diff --git a/.eslint-plugin-local/code-no-declare-const-enum.ts b/.eslint-plugin-local/code-no-declare-const-enum.ts new file mode 100644 index 0000000000000..b448adee89c53 --- /dev/null +++ b/.eslint-plugin-local/code-no-declare-const-enum.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; + +/** + * Disallows `declare const enum` declarations. esbuild does not inline + * `declare const enum` values, leaving the enum identifier in the output + * which causes a ReferenceError at runtime. + * + * Use `const enum` (without `declare`) instead. + * + * See https://github.com/evanw/esbuild/issues/4394 + */ +export default new class NoDeclareConstEnum implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noDeclareConstEnum: '"declare const enum" is not supported by esbuild. Use "const enum" instead. See https://github.com/evanw/esbuild/issues/4394', + }, + schema: false, + fixable: 'code', + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + return { + TSEnumDeclaration(node: any) { + if (node.const && node.declare) { + context.report({ + node, + messageId: 'noDeclareConstEnum', + fix: (fixer) => { + // Remove "declare " from "declare const enum" + const sourceCode = context.sourceCode; + const text = sourceCode.getText(node); + const declareIndex = text.indexOf('declare'); + if (declareIndex !== -1) { + return fixer.removeRange([ + node.range[0] + declareIndex, + node.range[0] + declareIndex + 'declare '.length + ]); + } + return null; + } + }); + } + } + }; + } +}; diff --git a/.eslint-plugin-local/code-no-native-private.ts b/.eslint-plugin-local/code-no-native-private.ts deleted file mode 100644 index 5d945ec34f7a5..0000000000000 --- a/.eslint-plugin-local/code-no-native-private.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as eslint from 'eslint'; -import type * as ESTree from 'estree'; - -export default new class ApiProviderNaming implements eslint.Rule.RuleModule { - - readonly meta: eslint.Rule.RuleMetaData = { - messages: { - slow: 'Native private fields are much slower and should only be used when needed. Ignore this warning if you know what you are doing, use compile-time private otherwise. See https://github.com/microsoft/vscode/issues/185991#issuecomment-1614468158 for details', - }, - schema: false, - }; - - create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - - return { - ['PropertyDefinition PrivateIdentifier']: (node: ESTree.Node) => { - context.report({ - node, - messageId: 'slow' - }); - }, - ['MethodDefinition PrivateIdentifier']: (node: ESTree.Node) => { - context.report({ - node, - messageId: 'slow' - }); - } - }; - } -}; diff --git a/.github/agents/data.md b/.github/agents/data.md index 5809fb06d861a..605bd276ef9a3 100644 --- a/.github/agents/data.md +++ b/.github/agents/data.md @@ -1,8 +1,7 @@ --- name: Data description: Answer telemetry questions with data queries using Kusto Query Language (KQL) -tools: - ['vscode/extensions', 'execute/runInTerminal', 'read/readFile', 'search', 'web/githubRepo', 'azure-mcp/kusto_query', 'todo'] +tools: [vscode/extensions, execute/runInTerminal, read/readFile, search, azure-mcp/kusto_query, todo, ms-vscode.kusto-client/kusto, ms-vscode.kusto-client/kustoQueryExecution] --- # Role and Objective @@ -14,7 +13,9 @@ You are a Azure Data Explorer data analyst with expert knowledge in Kusto Query 1. Read `vscode-telemetry-docs/.github/copilot-instructions.md` to understand how to access VS Code's telemetry - If the `vscode-telemetry-docs` folder doesn't exist (just check your workspace_info, no extra tool call needed), run `npm run mixin-telemetry-docs` to clone the telemetry documentation. 2. Analyze data using kusto queries: Don't just describe what could be queried - actually execute Kusto queries to provide real data and insights: - - If the `kusto_query` tool doesn't exist (just check your provided tools, no need to run it!), install the `ms-azuretools.vscode-azure-mcp-server` VS Code extension + - You need either the **Kusto Explorer** extension (`ms-vscode.kusto-client`) or the **Azure MCP** extension (`ms-azuretools.vscode-azure-mcp-server`) installed to run queries. + - **Prefer Kusto Explorer** (`kusto_runQuery` / `kusto_checkQueryExecution` tools) over Azure MCP (`kusto_query` tool) when both are available. + - If neither tool is available (just check your provided tools, no need to run them!), install the Kusto Explorer extension (`ms-vscode.kusto-client`). If that is not an option, fall back to installing the Azure MCP extension (`ms-azuretools.vscode-azure-mcp-server`). - Use the appropriate Kusto cluster and database for the data type - Always include proper time filtering to limit data volume - Default to a rolling 28-day window if no specific timeframe is requested diff --git a/.github/instructions/ai-customization.instructions.md b/.github/instructions/ai-customization.instructions.md new file mode 100644 index 0000000000000..8fe29e1663d06 --- /dev/null +++ b/.github/instructions/ai-customization.instructions.md @@ -0,0 +1,224 @@ +--- +description: Architecture documentation for VS Code AI Customization view. Use when working in `src/vs/workbench/contrib/chat/browser/aiCustomization` +applyTo: 'src/vs/workbench/contrib/chat/browser/aiCustomization/**' +--- + +# AI Customization View + +The AI Customization view provides a unified view for discovering and managing AI customization 'artifacts' (customizations that augment LLM prompts or behavior). + +Examples of these include: Custom Agents, Skills, Instructions, and Prompts. It surfaces prompt files that are typically hidden in `.github/` folders, user data directories, workspace settings, or exposed via extensions. + +## Overview + +The view displays a hierarchical tree structure: + +``` +AI Customization (View Container) +└── AI Customization (Tree View) + ├── Custom Agents (.agent.md files) + │ ├── Workspace + │ │ └── agent files... + │ ├── User + │ │ └── agent files... + │ └── Extensions + │ └── agent files... + ├── Skills (SKILL.md files) + │ └── (same storage structure) + ├── Instructions (.instructions.md files) + │ └── (same storage structure) + └── Prompts (.prompt.md files) + └── (same storage structure) +``` + +**Key Features:** +- 3-level tree hierarchy: Category → Storage Group → Files +- Auto-expands category nodes on initial load and refresh to show storage groups +- Symbol-based root element for type safety +- Double-click to open files in editor +- Context menu support with Open and Run Prompt actions +- Toolbar actions: New dropdown, Refresh, Collapse All +- Skill names parsed from frontmatter with fallback to folder name +- Responsive to IPromptsService change events + +## File Structure + +All files are located in `src/vs/workbench/contrib/chat/browser/aiCustomization/`: + +``` +aiCustomization/ +├── aiCustomization.ts # Constants, IDs, and MenuIds +├── aiCustomization.contribution.ts # View registration and actions +├── aiCustomizationViews.ts # Tree view pane implementation +├── aiCustomizationIcons.ts # Icon registrations +└── media/ + └── aiCustomization.css # Styling +``` + +## Key Constants (aiCustomization.ts) + +- `AI_CUSTOMIZATION_VIEWLET_ID`: View container ID for sidebar +- `AI_CUSTOMIZATION_VIEW_ID`: Unified tree view ID +- `AI_CUSTOMIZATION_STORAGE_ID`: State persistence key +- `AICustomizationItemMenuId`: Context menu ID +- `AICustomizationNewMenuId`: New item submenu ID + +## View Registration (aiCustomization.contribution.ts) + +### View Container + +Register sidebar container with: +- ViewPaneContainer with `mergeViewWithContainerWhenSingleView: true` +- Keyboard shortcut: Cmd+Shift+I +- Location: Sidebar +- Visibility: `when: ChatContextKeys.enabled` (respects AI disable setting) + +### View Descriptor + +Register single unified tree view: +- Constructor: `AICustomizationViewPane` +- Toggleable and moveable +- Gated by `ChatContextKeys.enabled` + +### Welcome Content + +Shows markdown links to create new items when tree is empty. + +## Toolbar Actions + +**New Item Dropdown** - Submenu in view title: +- Add icon in navigation group +- Submenu contains: New Agent, New Skill, New Instructions, New Prompt +- Each opens PromptFilePickers to guide user through creation + +**Refresh** - ViewAction that calls `view.refresh()` + +**Collapse All** - ViewAction that calls `view.collapseAll()` + +All actions use `ViewAction` pattern and are gated by `when: view === AI_CUSTOMIZATION_VIEW_ID`. + +## Tree View Implementation (aiCustomizationViews.ts) + +### Tree Item Types + +Discriminated union with `type` field: + +**ROOT_ELEMENT** - Symbol marker for type-safe root + +**IAICustomizationTypeItem** (`type: 'category'`) +- Represents: Custom Agents, Skills, Instructions, Prompts +- Contains: label, promptType, icon + +**IAICustomizationGroupItem** (`type: 'group'`) +- Represents: Workspace, User, Extensions +- Contains: label, storage, promptType, icon + +**IAICustomizationFileItem** (`type: 'file'`) +- Represents: Individual prompt files +- Contains: uri, name, description, storage, promptType + +### Data Source + +`UnifiedAICustomizationDataSource` implements `IAsyncDataSource`: + +**getChildren logic:** +- ROOT → 4 categories (agent, skill, instructions, prompt) +- category → storage groups (workspace, user, extensions) that have items +- group → files from `promptsService.listPromptFilesForStorage()` or `findAgentSkills()` + +**Skills special handling:** Uses `findAgentSkills()` to get names from frontmatter instead of filenames + +### Tree Renderers + +Three specialized renderers for category/group/file items: +- **Category**: Icon + bold label +- **Group**: Uppercase label with descriptionForeground color +- **File**: Icon + name with tooltip + +### View Pane + +`AICustomizationViewPane extends ViewPane`: + +**Injected services:** +- IPromptsService - data source +- IEditorService - open files +- IMenuService - context menus + +**Initialization:** +1. Subscribe to `onDidChangeCustomAgents` and `onDidChangeSlashCommands` events +2. Create WorkbenchAsyncDataTree with 3 renderers and data source +3. Register handlers: `onDidOpen` (double-click) → open file, `onContextMenu` → show menu +4. Set input to ROOT_ELEMENT and auto-expand categories + +**Auto-expansion:** +- After setInput, iterate root children and expand each category +- Reveals storage groups without user interaction +- Applied on both initial load and refresh + +**Public API:** +- `refresh()` - Reload tree and re-expand categories +- `collapseAll()` - Collapse all nodes +- `expandAll()` - Expand all nodes + +## Context Menu Actions + +Menu ID: `AICustomizationItemMenuId` + +**Actions:** +- **Open** - Opens file in editor using IEditorService +- **Run Prompt** - Only for prompt files, invokes chat with prompt + +**URI handling:** Actions must handle both URI objects and serialized strings +- Check `URI.isUri(context)` first +- Parse string variants with `URI.parse()` + +**Context passing:** +- Serialize context as `{ uri: string, name: string, promptType: PromptsType }` +- Use `shouldForwardArgs: true` in getMenuActions +- Only show context menu for file items (not categories/groups) + +## Icons (aiCustomizationIcons.ts) + +Themed icons using `registerIcon(id, codicon, label)`: + +**View/Types:** +- aiCustomizationViewIcon - Codicon.sparkle +- agentIcon - Codicon.copilot +- skillIcon - Codicon.lightbulb +- instructionsIcon - Codicon.book +- promptIcon - Codicon.bookmark + +**Storage:** +- workspaceIcon - Codicon.folder +- userIcon - Codicon.account +- extensionIcon - Codicon.extensions + +## Styling (media/aiCustomization.css) + +**Layout:** Full height view and tree container + +**Tree items:** Flex layout with 16px icon + text, ellipsis overflow + +**Categories:** Bold font-weight + +**Groups:** Uppercase, small font (11px), letter-spacing, descriptionForeground color + +## Integration Points + +**IPromptsService:** +- `listPromptFilesForStorage(type, storage)` - Get files for a type/storage combo +- `findAgentSkills()` - Get skills with names parsed from frontmatter +- `onDidChangeCustomAgents` - Refresh on agent changes +- `onDidChangeSlashCommands` - Refresh on command changes + +**PromptsType enum:** `instructions | prompt | agent | skill` + +**PromptsStorage enum:** `local` (workspace) | `user` | `extension` + +**AI Feature Gating:** View gated by `ChatContextKeys.enabled` (respects `chat.disableAIFeatures` setting) + +**Registration:** Import `./aiCustomization/aiCustomization.contribution.js` in `chat.contribution.ts` + +--- + +*Update this file when making architectural changes to the AI Customization view.* diff --git a/.github/instructions/sessions.instructions.md b/.github/instructions/sessions.instructions.md new file mode 100644 index 0000000000000..dc3f187e96c80 --- /dev/null +++ b/.github/instructions/sessions.instructions.md @@ -0,0 +1,13 @@ +--- +description: Architecture documentation for the Agent Sessions window — a sessions-first app built as a new top-level layer alongside vs/workbench. Covers layout, parts, chat widget, contributions, entry points, and development guidelines. Use when working in `src/vs/sessions` +applyTo: src/vs/sessions/** +--- + +# Agent Sessions Window + +The Agent Sessions window is a **standalone application** built as a new top-level layer (`vs/sessions`) in the VS Code architecture. It provides a sessions-first experience optimized for agent workflows — a simplified, fixed-layout workbench where chat is the primary interaction surface and editors appear as modal overlays. + +When working on files under `src/vs/sessions/`, use these skills for detailed guidance: + +- **`sessions`** skill — covers the full architecture: layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines +- **`agent-sessions-layout`** skill — covers the fixed layout structure, grid configuration, part visibility, editor modal, titlebar, sidebar footer, and implementation requirements diff --git a/.github/skills/agent-sessions-layout/SKILL.md b/.github/skills/agent-sessions-layout/SKILL.md new file mode 100644 index 0000000000000..f818e2dc75e08 --- /dev/null +++ b/.github/skills/agent-sessions-layout/SKILL.md @@ -0,0 +1,80 @@ +--- +name: agent-sessions-layout +description: Agent Sessions workbench layout — covers the fixed layout structure, grid configuration, part visibility, editor modal, titlebar, sidebar footer, and implementation requirements. Use when implementing features or fixing issues in the Agent Sessions workbench layout. +--- + +When working on the Agent Sessions workbench layout, always follow these guidelines: + +## 1. Read the Specification First + +The authoritative specification for the Agent Sessions layout lives at: + +**`src/vs/sessions/LAYOUT.md`** + +Before making any changes to the layout code, read and understand the current spec. It defines: + +- The fixed layout structure (grid tree, part positions, default sizes) +- Which parts are included/excluded and their visibility defaults +- Titlebar configuration and custom menu IDs +- Editor modal overlay behavior and sizing +- Part visibility API and events +- Agent session part classes and storage keys +- Workbench contributions and lifecycle +- CSS classes and file structure + +## 2. Keep the Spec in Sync + +If you modify the layout implementation, you **must** update `LAYOUT.md` to reflect those changes. The spec should always match the code. This includes: + +- Adding/removing parts or changing their positions +- Changing default visibility or sizing +- Adding new actions, menus, or contributions +- Modifying the grid structure +- Changing titlebar configuration +- Adding new CSS classes or file structure changes + +Update the **Revision History** table at the bottom of `LAYOUT.md` with a dated entry describing what changed. + +## 3. Implementation Principles + +When proposing or implementing changes, follow these rules from the spec: + +1. **Maintain fixed positions** — Do not add settings-based position customization +2. **Panel must span the right section width** — The grid structure places the panel below Chat Bar and Auxiliary Bar only +3. **Sidebar spans full height** — Sidebar is in the main content branch, spanning from top to bottom +4. **New parts go in the right section** — Any new parts should be added to the horizontal branch alongside Chat Bar and Auxiliary Bar +5. **Preserve no-op methods** — Unsupported features (zen mode, centered layout, etc.) should remain as no-ops, not throw errors +6. **Handle pane composite lifecycle** — When hiding/showing parts, manage the associated pane composites +7. **Use agent session parts** — New part functionality goes in the agent session part classes (`SidebarPart`, `AuxiliaryBarPart`, `PanelPart`, `ChatBarPart`), not the standard workbench parts +8. **Use separate storage keys** — Agent session parts use their own storage keys (prefixed with `workbench.agentsession.` or `workbench.chatbar.`) to avoid conflicts with regular workbench state +9. **Use agent session menu IDs** — Actions should use `Menus.*` menu IDs (from `sessions/browser/menus.ts`), not shared `MenuId.*` constants + +## 4. Key Files + +| File | Purpose | +|------|---------| +| `sessions/LAYOUT.md` | Authoritative specification | +| `sessions/browser/workbench.ts` | Main layout implementation (`Workbench` class) | +| `sessions/browser/menus.ts` | Agent sessions menu IDs (`Menus` export) | +| `sessions/browser/layoutActions.ts` | Layout actions (toggle sidebar, panel, secondary sidebar) | +| `sessions/browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService` | +| `sessions/browser/style.css` | Layout-specific styles | +| `sessions/browser/parts/` | Agent session part implementations | +| `sessions/browser/parts/titlebarPart.ts` | Titlebar part, MainTitlebarPart, AuxiliaryTitlebarPart, TitleService | +| `sessions/browser/parts/editorModal.ts` | Editor modal overlay | +| `sessions/browser/parts/sidebarPart.ts` | Sidebar part (with footer) | +| `sessions/browser/parts/chatBarPart.ts` | Chat Bar part | +| `sessions/browser/widget/` | Agent sessions chat widget | +| `sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts` | Title bar widget and session picker | +| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script contribution | +| `sessions/contrib/accountMenu/browser/account.contribution.ts` | Account widget for sidebar footer | + +## 5. Testing Changes + +After modifying layout code: + +1. Verify the build compiles without errors via the `VS Code - Build` task +2. Ensure the grid structure matches the spec's visual representation +3. Confirm part visibility toggling works correctly (show/hide/maximize) +4. Test the editor modal opens/closes properly on editor events +5. Verify sidebar footer renders with account widget diff --git a/.github/skills/author-contributions/SKILL.md b/.github/skills/author-contributions/SKILL.md new file mode 100644 index 0000000000000..540becc35ef1b --- /dev/null +++ b/.github/skills/author-contributions/SKILL.md @@ -0,0 +1,186 @@ +--- +name: author-contributions +description: Identify all files a specific author contributed to on a branch vs its upstream, tracing code through renames. Use when asked who edited what, what code an author contributed, or to audit authorship before a merge. This skill should be run as a subagent — it performs many git operations and returns a concise table. +--- + +When asked to find all files a specific author contributed to on a branch (compared to main or another upstream), follow this procedure. The goal is to produce a simple table that both humans and LLMs can consume. + +## Run as a Subagent + +This skill involves many sequential git commands. Delegate it to a subagent with a prompt like: + +> Find every file that author "Full Name" contributed to on branch `` compared to ``. Trace contributions through file renames. Return a markdown table with columns: Status (DIRECT or VIA_RENAME), File Path, and Lines (+/-). Include a summary line at the end. + +## Procedure + +### 1. Identify the author's exact git identity + +```bash +git log --format="%an <%ae>" .. | sort -u +``` + +Match the requested person to their exact `--author=` string. Do not guess — short usernames won't match full display names (resolve via `git log` or the GitHub MCP `get_me` tool). + +### 2. Collect all files the author directly committed to + +```bash +git log --author="" --format="%H" .. +``` + +For each commit hash, extract touched files: + +```bash +git diff-tree --no-commit-id --name-only -r +``` + +Union all results into a set (`author_files`). + +### 3. Build rename map across the entire branch + +For **every** commit on the branch (not just the author's), extract renames: + +```bash +git diff-tree --no-commit-id -r -M +``` + +Parse lines with `R` status to build a map: `new_path → {old_paths}`. + +### 4. Get the merge diff file list + +```bash +git diff --name-only .. +``` + +These are the files that will actually land when the branch merges. + +### 5. Classify each file in the merge diff + +For each file in step 4: +- If it's in `author_files` → **DIRECT** +- Else, walk the rename map transitively (follow chains: current → old → older) and check if any ancestor is in `author_files` → **VIA_RENAME** +- Otherwise → not this author's contribution + +### 6. Get diff stats + +```bash +git diff --stat .. -- ... +``` + +### 7. Return the table + +Format the result as a markdown table: + +``` +| Status | File | +/- | +|--------|------|-----| +| DIRECT | src/vs/foo/bar.ts | +120/-5 | +| VIA_RENAME | src/vs/baz/qux.ts | +300 | +| ... | ... | ... | + +**Total: N files, +X/-Y lines** +``` + +## Important Notes + +- **Use Python for the heavy lifting.** Shell loops with inline comments break in zsh. Write a temp `.py` script, run it, then delete it. +- **Author matching is exact.** Always run step 1 first. `--author` does substring matching but you must verify the right person is matched (e.g., don't match "Joshua Smith" when looking for "Josh S."). Use the GitHub MCP `get_me` tool or `git log` output to resolve the correct full name. +- **Renames can be multi-hop.** A file may have moved `contrib/chat/` → `agentSessions/` → `sessions/`. The rename map must be walked transitively. +- **Only report files in the merge diff** (step 4). Files the author touched that were later deleted entirely should not appear — they won't land in the upstream. +- **The rename map must include all authors' commits**, not just the target author's. Other people often do the rename commits (e.g., bulk refactors/moves). + +## Example Python Script + +```python +import subprocess, os + +os.chdir('') +UPSTREAM = 'main' +AUTHOR = '' # Resolve via `git log` or GitHub MCP `get_me` + +# Step 2: author's files +commits = subprocess.check_output( + ['git', 'log', f'--author={AUTHOR}', '--format=%H', f'{UPSTREAM}..HEAD'], + text=True).strip().split('\n') +author_files = set() +for h in (c for c in commits if c): + files = subprocess.check_output( + ['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', h], + text=True).strip().split('\n') + author_files.update(f for f in files if f) + +# Step 3: rename map from ALL commits +all_commits = subprocess.check_output( + ['git', 'log', '--format=%H', f'{UPSTREAM}..HEAD'], + text=True).strip().split('\n') +rename_map = {} # new_name -> set(old_names) +for h in (c for c in all_commits if c): + out = subprocess.check_output( + ['git', 'diff-tree', '--no-commit-id', '-r', '-M', h], + text=True, timeout=5).strip() + for line in out.split('\n'): + if not line: + continue + parts = line.split('\t') + if len(parts) >= 3 and 'R' in parts[0]: + rename_map.setdefault(parts[2], set()).add(parts[1]) + +# Step 4: merge diff +diff_files = subprocess.check_output( + ['git', 'diff', '--name-only', f'{UPSTREAM}..HEAD'], + text=True).strip().split('\n') + +# Step 5: classify +results = [] +for f in (x for x in diff_files if x): + if f in author_files: + results.append(('DIRECT', f)) + else: + # walk rename chain + chain, to_check = set(), [f] + while to_check: + cur = to_check.pop() + if cur in chain: + continue + chain.add(cur) + to_check.extend(rename_map.get(cur, [])) + chain.discard(f) + if chain & author_files: + results.append(('VIA_RENAME', f)) + +# Step 6: stats +if results: + stat = subprocess.check_output( + ['git', 'diff', '--stat', f'{UPSTREAM}..HEAD', '--'] + + [f for _, f in results], text=True) + print(stat) + +# Step 7: table +for kind, f in sorted(results, key=lambda x: x[1]): + print(f'| {kind:12s} | {f} |') +print(f'\nTotal: {len(results)} files') +``` + +### Alternative Script + +After following the process above, run this script to cross-check files touched by an author against the branch diff. You can do this both with an without src/vs/sessions. + +``` +AUTHOR="" + +# 1. Find commits by author on this branch (not on main) +git log main...HEAD --author="$AUTHOR" --format="%H" + +# 2. Get unique files touched across all those commits, excluding src/vs/sessions/ +git log main...HEAD --author="$AUTHOR" --format="%H" \ + | xargs -I{} git diff-tree --no-commit-id -r --name-only {} \ + | sort -u \ + | grep -v '^src/vs/sessions/' + +# 3. Cross-reference with branch diff to keep only files still changed vs main +git log main...HEAD --author="$AUTHOR" --format="%H" \ + | xargs -I{} git diff-tree --no-commit-id -r --name-only {} \ + | sort -u \ + | grep -v '^src/vs/sessions/' \ + | while read f; do git diff main...HEAD --name-only -- "$f" 2>/dev/null; done \ + | sort -u +``` diff --git a/.github/skills/hygiene/SKILL.md b/.github/skills/hygiene/SKILL.md new file mode 100644 index 0000000000000..084b76c719c8d --- /dev/null +++ b/.github/skills/hygiene/SKILL.md @@ -0,0 +1,25 @@ +# Hygiene Checks + +VS Code runs a hygiene check as a git pre-commit hook. Commits will be rejected if hygiene fails. + +## What it checks + +The hygiene linter scans all staged `.ts` files for issues including (but not limited to): + +- **Unicode characters**: Non-ASCII characters (em-dashes, curly quotes, emoji, etc.) are rejected. Use ASCII equivalents in comments and code. +- **Double-quoted strings**: Only use `"double quotes"` for externalized (localized) strings. Use `'single quotes'` everywhere else. +- **Copyright headers**: All files must include the Microsoft copyright header. + +## How it runs + +The git pre-commit hook (via husky) runs `npm run precommit`, which executes: + +```bash +node --experimental-strip-types build/hygiene.ts +``` + +This scans only **staged files** (from `git diff --cached`). To run it manually: + +```bash +npm run precommit +``` diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md new file mode 100644 index 0000000000000..010c3779a7253 --- /dev/null +++ b/.github/skills/sessions/SKILL.md @@ -0,0 +1,277 @@ +--- +name: sessions +description: Agent Sessions window architecture — covers the sessions-first app, layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines. Use when implementing features or fixing issues in the Agent Sessions window. +--- + +When working on the Agent Sessions window (`src/vs/sessions/`), always follow these guidelines: + +## 1. Read the Specification Documents First + +The `src/vs/sessions/` directory contains authoritative specification documents. **Always read the relevant spec before making changes.** + +| Document | Path | Covers | +|----------|------|--------| +| Layer spec | `src/vs/sessions/README.md` | Layering rules, dependency constraints, folder conventions | +| Layout spec | `src/vs/sessions/LAYOUT.md` | Grid structure, part positions, sizing, CSS classes, API reference | +| AI Customizations | `src/vs/sessions/AI_CUSTOMIZATIONS.md` | AI customization editor and tree view design | +| Chat Widget | `src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md` | Chat widget wrapper architecture, deferred session creation, option delivery | +| AI Customization Mgmt | `src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md` | Management editor specification | +| AI Customization Tree | `src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md` | Tree view specification | + +If you modify the implementation, you **must** update the corresponding spec to keep it in sync. Update the Revision History table at the bottom of `LAYOUT.md` with a dated entry. + +## 2. Architecture Overview + +### 2.1 Layering + +``` +vs/base ← Foundation utilities +vs/platform ← Platform services +vs/editor ← Text editor core +vs/workbench ← Standard VS Code workbench +vs/sessions ← Agent Sessions window (this layer) +``` + +**Key constraint:** `vs/sessions` may import from `vs/workbench` and all layers below it. `vs/workbench` must **never** import from `vs/sessions`. + +### 2.2 Dependency Rules + +- ✅ Import from `vs/base`, `vs/platform`, `vs/editor`, `vs/workbench` +- ✅ Import within `vs/sessions` (internal) +- ❌ Never import `vs/sessions` from `vs/workbench` +- Run `npm run valid-layers-check` to verify layering + +### 2.3 How It Differs from VS Code + +| Aspect | VS Code Workbench | Agent Sessions Window | +|--------|-------------------|----------------------| +| Layout | Configurable part positions | Fixed layout, no settings customization | +| Chrome | Activity bar, status bar, banner | Simplified — none of these | +| Primary UX | Editor-centric | Chat-first (Chat Bar is a primary part) | +| Editors | In the grid layout | Modal overlay above the workbench | +| Titlebar | Menubar, editor actions, layout controls | Session picker, run script, toggle sidebar/panel | +| Navigation | Activity bar with viewlets | Sidebar (views) + sidebar footer (account) | +| Entry point | `vs/workbench` workbench class | `vs/sessions/browser/workbench.ts` `Workbench` class | + +## 3. Folder Structure + +``` +src/vs/sessions/ +├── README.md # Layer specification (read first) +├── LAYOUT.md # Authoritative layout specification +├── AI_CUSTOMIZATIONS.md # AI customization design document +├── sessions.common.main.ts # Common (browser + desktop) entry point +├── sessions.desktop.main.ts # Desktop entry point (imports all contributions) +├── common/ # Shared types and context keys +│ └── contextkeys.ts # ChatBar context keys +├── browser/ # Core workbench implementation +│ ├── workbench.ts # Main Workbench class (implements IWorkbenchLayoutService) +│ ├── menus.ts # Agent sessions menu IDs (Menus export) +│ ├── layoutActions.ts # Layout toggle actions (sidebar, panel, auxiliary bar) +│ ├── paneCompositePartService.ts # AgenticPaneCompositePartService +│ ├── style.css # Layout-specific styles +│ ├── widget/ # Agent sessions chat widget +│ │ ├── AGENTS_CHAT_WIDGET.md # Chat widget architecture doc +│ │ ├── agentSessionsChatWidget.ts # Main wrapper around ChatWidget +│ │ ├── agentSessionsChatTargetConfig.ts # Observable target state +│ │ ├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar +│ │ └── media/ +│ └── parts/ # Workbench part implementations +│ ├── parts.ts # AgenticParts enum +│ ├── titlebarPart.ts # Titlebar (3-section toolbar layout) +│ ├── sidebarPart.ts # Sidebar (with footer for account widget) +│ ├── chatBarPart.ts # Chat Bar (primary chat surface) +│ ├── auxiliaryBarPart.ts # Auxiliary Bar (with run script dropdown) +│ ├── panelPart.ts # Panel (terminal, output, etc.) +│ ├── projectBarPart.ts # Project bar (folder entries) +│ ├── editorModal.ts # Editor modal overlay +│ ├── agentSessionsChatInputPart.ts # Chat input part adapter +│ ├── agentSessionsChatWelcomePart.ts # Welcome view (mascot + target buttons + pickers) +│ └── media/ # Part CSS files +├── electron-browser/ # Desktop-specific entry points +│ ├── sessions.main.ts # Desktop main bootstrap +│ ├── sessions.ts # Electron process entry +│ ├── sessions.html # Production HTML shell +│ └── sessions-dev.html # Development HTML shell +└── contrib/ # Feature contributions + ├── accountMenu/browser/ # Account widget for sidebar footer + ├── aiCustomizationManagement/browser/ # AI customization management editor + ├── aiCustomizationTreeView/browser/ # AI customization tree view sidebar + ├── changesView/browser/ # File changes view + ├── chat/browser/ # Chat actions (run script, branch, prompts) + ├── configuration/browser/ # Configuration overrides + └── sessions/browser/ # Sessions view, title bar widget, active session service +``` + +## 4. Layout + +Use the `agent-sessions-layout` skill for detailed guidance on the layout. Key points: + +### 4.1 Visual Layout + +``` +┌─────────┬───────────────────────────────────────────────────────┐ +│ │ Titlebar │ +│ ├────────────────────────────────────┬──────────────────┤ +│ Sidebar │ Chat Bar │ Auxiliary Bar │ +│ ├────────────────────────────────────┴──────────────────┤ +│ │ Panel │ +└─────────┴───────────────────────────────────────────────────────┘ +``` + +- **Sidebar** spans full window height (root grid level) +- **Titlebar** is inside the right section +- **Chat Bar** is the primary interaction surface +- **Panel** is hidden by default (terminal, output, etc.) +- **Editor** appears as a **modal overlay**, not in the grid + +### 4.2 Parts + +| Part | Default Visibility | Notes | +|------|-------------------|-------| +| Titlebar | Always visible | 3-section toolbar (left/center/right) | +| Sidebar | Visible | Sessions view, AI customization tree | +| Chat Bar | Visible | Primary chat widget | +| Auxiliary Bar | Visible | Changes view, etc. | +| Panel | Hidden | Terminal, output | +| Editor | Hidden | Modal overlay, auto-shows on editor open | + +**Not included:** Activity Bar, Status Bar, Banner. + +### 4.3 Editor Modal + +Editors appear as modal overlays (80% of workbench, min 400×300, max 1200×900). The modal auto-shows when an editor opens and auto-hides when all editors close. Click backdrop, press Escape, or click X to dismiss. + +## 5. Chat Widget + +The Agent Sessions chat experience is built around `AgentSessionsChatWidget` — a wrapper around the core `ChatWidget` that adds: + +- **Deferred session creation** — the UI is interactive before any session resource exists; sessions are created on first message send +- **Target configuration** — observable state tracking which agent provider (Local, Cloud) is selected +- **Welcome view** — branded empty state with mascot, target buttons, option pickers, and input slot +- **Initial session options** — option selections travel atomically with the first request +- **Configurable picker placement** — pickers can appear in welcome view, input toolbar, or both + +Read `browser/widget/AGENTS_CHAT_WIDGET.md` for the full architecture. + +### Key classes: +- `AgentSessionsChatWidget` (`browser/widget/agentSessionsChatWidget.ts`) — main wrapper +- `AgentSessionsChatTargetConfig` (`browser/widget/agentSessionsChatTargetConfig.ts`) — reactive target state +- `AgentSessionsChatWelcomePart` (`browser/parts/agentSessionsChatWelcomePart.ts`) — welcome view +- `AgentSessionsChatInputPart` (`browser/parts/agentSessionsChatInputPart.ts`) — standalone input adapter + +## 6. Menus + +The agent sessions window uses **its own menu IDs** defined in `browser/menus.ts` via the `Menus` export. **Never use shared `MenuId.*` constants** from `vs/platform/actions` for agent sessions UI — use the `Menus.*` equivalents instead. + +| Menu ID | Purpose | +|---------|---------| +| `Menus.TitleBarLeft` | Left toolbar (toggle sidebar) | +| `Menus.TitleBarCenter` | Not used directly (see CommandCenter) | +| `Menus.TitleBarRight` | Right toolbar (run script, open, toggle auxiliary bar) | +| `Menus.CommandCenter` | Center toolbar with session picker widget | +| `Menus.TitleBarControlMenu` | Submenu intercepted to render `SessionsTitleBarWidget` | +| `Menus.PanelTitle` | Panel title bar actions | +| `Menus.SidebarTitle` | Sidebar title bar actions | +| `Menus.SidebarFooter` | Sidebar footer (account widget) | +| `Menus.AuxiliaryBarTitle` | Auxiliary bar title actions | +| `Menus.AuxiliaryBarTitleLeft` | Auxiliary bar left title actions | +| `Menus.OpenSubMenu` | "Open..." split button (Open Terminal, Open in VS Code) | +| `Menus.ChatBarTitle` | Chat bar title actions | + +## 7. Context Keys + +Defined in `common/contextkeys.ts`: + +| Context Key | Type | Purpose | +|-------------|------|---------| +| `activeChatBar` | `string` | ID of the active chat bar panel | +| `chatBarFocus` | `boolean` | Whether chat bar has keyboard focus | +| `chatBarVisible` | `boolean` | Whether chat bar is visible | + +## 8. Contributions + +Feature contributions live under `contrib//browser/` and are registered via imports in `sessions.desktop.main.ts` (desktop) or `sessions.common.main.ts` (browser-compatible). + +### 8.1 Key Contributions + +| Contribution | Location | Purpose | +|-------------|----------|---------| +| **Sessions View** | `contrib/sessions/browser/` | Sessions list in sidebar, session picker, active session service | +| **Title Bar Widget** | `contrib/sessions/browser/sessionsTitleBarWidget.ts` | Session picker in titlebar center | +| **Account Widget** | `contrib/accountMenu/browser/` | Account button in sidebar footer | +| **Run Script** | `contrib/chat/browser/runScriptAction.ts` | Run configured script in terminal | +| **Branch Chat Session** | `contrib/chat/browser/branchChatSessionAction.ts` | Branch a chat session | +| **Open in VS Code / Terminal** | `contrib/chat/browser/chat.contribution.ts` | Open worktree in VS Code or terminal | +| **Prompts Service** | `contrib/chat/browser/promptsService.ts` | Agentic prompts service override | +| **Changes View** | `contrib/changesView/browser/` | File changes in auxiliary bar | +| **AI Customization Editor** | `contrib/aiCustomizationManagement/browser/` | Management editor for prompts, hooks, MCP, etc. | +| **AI Customization Tree** | `contrib/aiCustomizationTreeView/browser/` | Sidebar tree for AI customizations | +| **Configuration** | `contrib/configuration/browser/` | Configuration overrides | + +### 8.2 Service Overrides + +The agent sessions window registers its own implementations for: + +- `IPaneCompositePartService` → `AgenticPaneCompositePartService` (creates agent-specific parts) +- `IPromptsService` → `AgenticPromptsService` (scopes prompt discovery to active session worktree) +- `IActiveSessionService` → `ActiveSessionService` (tracks active session) + +### 8.3 `WindowVisibility.Sessions` + +Views and contributions that should only appear in the agent sessions window (not in regular VS Code) use `WindowVisibility.Sessions` in their registration. + +## 9. Entry Points + +| File | Purpose | +|------|---------| +| `sessions.common.main.ts` | Common entry — imports browser-compatible services, workbench contributions | +| `sessions.desktop.main.ts` | Desktop entry — imports desktop services, electron contributions, all `contrib/` modules | +| `electron-browser/sessions.main.ts` | Desktop bootstrap | +| `electron-browser/sessions.ts` | Electron process entry | +| `electron-browser/sessions.html` | Production HTML shell | +| `electron-browser/sessions-dev.html` | Development HTML shell | + +## 10. Development Guidelines + +### 10.1 Adding New Features + +1. **Core workbench code** (layout, parts, services) → `browser/` +2. **Feature contributions** (views, actions, editors) → `contrib//browser/` +3. Register by importing in `sessions.desktop.main.ts` (or `sessions.common.main.ts` for browser-compatible) +4. Use `Menus.*` from `browser/menus.ts` for menu registrations — never shared `MenuId.*` +5. Use separate storage keys prefixed with `workbench.agentsession.*` or `workbench.chatbar.*` +6. Use agent session part classes, not standard workbench parts +7. Mark views with `WindowVisibility.Sessions` so they only appear in this window + +### 10.2 Layout Changes + +1. **Read `LAYOUT.md` first** — it's the authoritative spec +2. Use the `agent-sessions-layout` skill for detailed implementation guidance +3. Maintain fixed positions — no settings-based customization +4. Update `LAYOUT.md` and its Revision History after any changes +5. Preserve no-op methods for unsupported features (zen mode, centered layout, etc.) +6. Handle pane composite lifecycle when hiding/showing parts + +### 10.3 Chat Widget Changes + +1. **Read `browser/widget/AGENTS_CHAT_WIDGET.md` first** +2. Prefer composition over modifying core `ChatWidget` — add behavior in the wrapper +3. Use `IAgentChatTargetConfig` observable for target state, not direct session creation +4. Ensure `initialSessionOptions` travel atomically with the first request +5. Test both first-load (extension not yet activated) and new-session flows + +### 10.4 AI Customization Changes + +1. **Read `AI_CUSTOMIZATIONS.md` first** — it covers the management editor and tree view design +2. Lean on existing VS Code services (`IPromptsService`, `IMcpService`, `IChatService`) +3. Browser compatibility required — no Node.js APIs +4. Active worktree comes from `IActiveSessionService` + +### 10.5 Validation + +1. Check `VS Code - Build` task output for compilation errors before declaring work complete +2. Run `npm run valid-layers-check` for layering violations +3. Verify part visibility toggling (show/hide/maximize) +4. Test editor modal open/close behavior +5. Test sidebar footer renders with account widget diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c6af7adaa7ae4..831a02068fb3d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -86,7 +86,7 @@ jobs: uses: ./.github/workflows/pr-linux-cli-test.yml with: job_name: CLI - rustup_toolchain: 1.85 + rustup_toolchain: 1.88 linux-electron-tests: name: Linux diff --git a/.npmrc b/.npmrc index 97baacfce6f4c..e2305b402ea33 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="39.5.2" -ms_build_id="13298971" +target="39.6.0" +ms_build_id="13312042" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/.vscode/settings.json b/.vscode/settings.json index 3e903f646758c..0a58b8a5bf849 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,7 +49,7 @@ "build/**/*.js.map": true, "build/**/*.js": { "when": "$(basename).ts" - } + }, }, "files.associations": { "cglicenses.json": "jsonc", @@ -69,7 +69,8 @@ "extensions/terminal-suggest/src/completions/upstream/**": true, "test/smoke/out/**": true, "test/automation/out/**": true, - "test/integration/browser/out/**": true + "test/integration/browser/out/**": true, + "src/vs/sessions/**": true }, // --- Search --- "search.exclude": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 14e637aaf2d1d..3e7b3c6765f24 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -73,10 +73,11 @@ "owner": "typescript", "applyTo": "closedDocuments", "fileLocation": [ - "absolute" + "relative", + "${workspaceFolder}" ], "pattern": { - "regexp": "Error: ([^(]+)\\((\\d+|\\d+,\\d+|\\d+,\\d+,\\d+,\\d+)\\): (.*)$", + "regexp": "\\] ([^(]+)\\((\\d+,\\d+)\\): (.*)$", "file": 1, "location": 2, "message": 3 diff --git a/build/azure-pipelines/cli/cli-compile.yml b/build/azure-pipelines/cli/cli-compile.yml index 2abefa7b6a438..c3565a9bf4b23 100644 --- a/build/azure-pipelines/cli/cli-compile.yml +++ b/build/azure-pipelines/cli/cli-compile.yml @@ -20,12 +20,55 @@ steps: - script: echo "##vso[task.setvariable variable=VSCODE_CLI_PRODUCT_JSON]$(Build.SourcesDirectory)/.build/distro/mixin/${{ parameters.VSCODE_QUALITY }}/product.json" displayName: Set product.json path + - task: Cache@2 + displayName: Restore sccache cache + inputs: + key: 'sccache | "$(Agent.OS)" | "${{ parameters.VSCODE_CLI_TARGET }}" | "${{ parameters.VSCODE_CHECK_ONLY }}" | $(Build.SourcesDirectory)/cli/Cargo.toml | $(Build.SourcesDirectory)/build/.cachesalt' + path: $(Pipeline.Workspace)/sccache + + - ${{ if contains(parameters.VSCODE_CLI_TARGET, '-windows-') }}: + - pwsh: | + $version = "0.14.0" + $url = "https://github.com/mozilla/sccache/releases/download/v$version/sccache-v$version-x86_64-pc-windows-msvc.zip" + Invoke-WebRequest -Uri $url -OutFile "$env:TEMP\sccache.zip" + Expand-Archive -Path "$env:TEMP\sccache.zip" -DestinationPath "$env:TEMP\sccache" -Force + $sccacheDir = Get-ChildItem -Path "$env:TEMP\sccache" -Directory | Select-Object -First 1 + Copy-Item "$($sccacheDir.FullName)\sccache.exe" -Destination "$env:USERPROFILE\.cargo\bin\sccache.exe" + sccache --version + displayName: Install sccache + - ${{ else }}: + - script: | + set -e + SCCACHE_VERSION="0.14.0" + ARCH=$(uname -m) + OS=$(uname -s) + if [ "$OS" = "Darwin" ]; then + TARGET="aarch64-apple-darwin" + elif [ "$OS" = "Linux" ]; then + if [ "$ARCH" = "aarch64" ]; then + TARGET="aarch64-unknown-linux-musl" + else + TARGET="x86_64-unknown-linux-musl" + fi + fi + FILENAME="sccache-v${SCCACHE_VERSION}-${TARGET}" + URL="https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${FILENAME}.tar.gz" + echo "Downloading sccache from $URL" + curl -fsSL "$URL" -o /tmp/sccache.tar.gz + tar -xzf /tmp/sccache.tar.gz -C /tmp + sudo cp "/tmp/${FILENAME}/sccache" /usr/local/bin/sccache + sudo chmod +x /usr/local/bin/sccache + sccache --version + displayName: Install sccache + - ${{ if parameters.VSCODE_CHECK_ONLY }}: - script: cargo clippy --target ${{ parameters.VSCODE_CLI_TARGET }} --bin=code displayName: Lint ${{ parameters.VSCODE_CLI_TARGET }} workingDirectory: $(Build.SourcesDirectory)/cli env: CARGO_NET_GIT_FETCH_WITH_CLI: true + RUSTC_WRAPPER: sccache + SCCACHE_DIR: $(Pipeline.Workspace)/sccache ${{ each pair in parameters.VSCODE_CLI_ENV }}: ${{ pair.key }}: ${{ pair.value }} @@ -93,6 +136,8 @@ steps: CARGO_NET_GIT_FETCH_WITH_CLI: true VSCODE_CLI_COMMIT: $(Build.SourceVersion) GITHUB_TOKEN: "$(github-distro-mixin-password)" + RUSTC_WRAPPER: sccache + SCCACHE_DIR: $(Pipeline.Workspace)/sccache ${{ each pair in parameters.VSCODE_CLI_ENV }}: ${{ pair.key }}: ${{ pair.value }} @@ -103,6 +148,8 @@ steps: env: CARGO_NET_GIT_FETCH_WITH_CLI: true VSCODE_CLI_COMMIT: $(Build.SourceVersion) + RUSTC_WRAPPER: sccache + SCCACHE_DIR: $(Pipeline.Workspace)/sccache ${{ each pair in parameters.VSCODE_CLI_ENV }}: ${{ pair.key }}: ${{ pair.value }} @@ -161,3 +208,9 @@ steps: archiveType: tar tarCompression: gz archiveFile: $(Build.ArtifactStagingDirectory)/${{ parameters.VSCODE_CLI_ARTIFACT }}.tar.gz + + - script: sccache --show-stats + displayName: sccache stats + condition: succeededOrFailed() + env: + SCCACHE_DIR: $(Pipeline.Workspace)/sccache diff --git a/build/buildfile.ts b/build/buildfile.ts index 168539f4cae5f..47b0476892cb7 100644 --- a/build/buildfile.ts +++ b/build/buildfile.ts @@ -25,7 +25,8 @@ export const workbenchDesktop = [ createModuleDescription('vs/platform/files/node/watcher/watcherMain'), createModuleDescription('vs/platform/terminal/node/ptyHostMain'), createModuleDescription('vs/workbench/api/node/extensionHostProcess'), - createModuleDescription('vs/workbench/workbench.desktop.main') + createModuleDescription('vs/workbench/workbench.desktop.main'), + createModuleDescription('vs/sessions/sessions.desktop.main') ]; export const workbenchWeb = createModuleDescription('vs/workbench/workbench.web.main.internal'); @@ -42,6 +43,7 @@ export const code = [ createModuleDescription('vs/code/node/cliProcessMain'), createModuleDescription('vs/code/electron-utility/sharedProcess/sharedProcessMain'), createModuleDescription('vs/code/electron-browser/workbench/workbench'), + createModuleDescription('vs/sessions/electron-browser/sessions'), ]; export const codeWeb = createModuleDescription('vs/code/browser/workbench/workbench'); diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 68a137efaa2eb..3df57a48a97d2 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -a87acdd1cb412151f71c25154dccdf26744b80471acbef03dd20cfea2e0bd2d1 *chromedriver-v39.5.2-darwin-arm64.zip -2181b4bfb4c9e543013bd08e0d6d1e1b3b80a7d99e1589a7e75089760d1d46c2 *chromedriver-v39.5.2-darwin-x64.zip -f18c47c5544f158eaa1a3705bbcaab7a879c1829926310ebfd876749393ed2a0 *chromedriver-v39.5.2-linux-arm64.zip -74435311880444074e0fedd2a52ecdf9a18f13689e9da700f6fae4c5b553e4d5 *chromedriver-v39.5.2-linux-armv7l.zip -dd240f331c4ae2dd8e83aa517bc17a107f0747d62bb4321901c18ad4e48d8ea6 *chromedriver-v39.5.2-linux-x64.zip -fa02c2e7f7b37b0944f796297545227e65b0ed09914b30a7469560db631e0b92 *chromedriver-v39.5.2-mas-arm64.zip -b74448c03567849ec9226ddcb66b4c6f538608e73adc02fe52bdac740ecd8836 *chromedriver-v39.5.2-mas-x64.zip -e8677c94445aaf9093b2dcdcaca6d95502001986cf2def6831f27d821997783e *chromedriver-v39.5.2-win32-arm64.zip -7151dfd5c39e2e9e974e2039916d27250b9d6e4f9acbc77abd8a3957c64a5e73 *chromedriver-v39.5.2-win32-ia32.zip -3cc66a7d98d67835b01c6732ad5f6083a30d8d91c6256dd1b1b6aee89df3b07e *chromedriver-v39.5.2-win32-x64.zip -ed614289f57a5240db7c6ae20725468f4ffaaddd9d2a3472db2637af7cf73c52 *electron-api.json -f6a4d5f3f66b200aa71ea10526ad6a0963d6595f90121cea148a93312d1e9478 *electron-v39.5.2-darwin-arm64-dsym-snapshot.zip -885cec937ece1e1b975e3b21ad01481e6fea89906a83fc88ff303fb8e5111117 *electron-v39.5.2-darwin-arm64-dsym.zip -9425e8286707e6c3cc280e8d415c9f1d7655e59c2e5c9e43866285a81cf8e5cf *electron-v39.5.2-darwin-arm64-symbols.zip -618ada5c3ea7fdd3b2405d6e2b62c895d6531b9b0e12120e26a2e20e58d68e67 *electron-v39.5.2-darwin-arm64.zip -4e311c4f48aed9d9c21ef99f39a70ce77bda85c7a419f604fc02c278b8d3f30f *electron-v39.5.2-darwin-x64-dsym-snapshot.zip -55e4be9743fb9269161db3c6a57b2684d2d47f65d945fd03958fec58e7d081b5 *electron-v39.5.2-darwin-x64-dsym.zip -8705af037514b7fd336ea90ef8990acbc5cce7d16f02925fd77e7f1b30ab0e5a *electron-v39.5.2-darwin-x64-symbols.zip -5f51d106849b2afaa0a233f487485f0a484ac9dcb57c432feb6ae2731ed5cdfd *electron-v39.5.2-darwin-x64.zip -829f10fad95dee51c74b221ab2569fea39102e27f2a00647bf04da6e8a90d386 *electron-v39.5.2-linux-arm64-debug.zip -bd88a964bde7aec2490e599c32d8a872653a80fa07ae06f89b8b68bebb4a678b *electron-v39.5.2-linux-arm64-symbols.zip -c1ed7b797fa534931b36cdd6a69ea6ed5de351d5cc4086aa155a8ed92fb518ed *electron-v39.5.2-linux-arm64.zip -de56f65b0d9543a92230b9484de58797602a6008625d429415bc8221cf45525f *electron-v39.5.2-linux-armv7l-debug.zip -dd6cecefe3fa2dd4ff8c97595bcb962814c92c989ae0a70dae73479709c5fe63 *electron-v39.5.2-linux-armv7l-symbols.zip -0aefd0e66439ea4eb3db3e57742ca88988bb8d694eaa9c7d332c733c373ee32d *electron-v39.5.2-linux-armv7l.zip -3c70967c5ec60a5bd3feba64052276e6920d659af87e994fc3b908e21cd93d33 *electron-v39.5.2-linux-x64-debug.zip -51a567542bc8556ef82bfc7d3de4badb44ddbf8f240e4482be77eb30e95ad67b *electron-v39.5.2-linux-x64-symbols.zip -1fddb169ed3a342ddaff1807c82c388966dbf4697349dd4902f2987152257337 *electron-v39.5.2-linux-x64.zip -84d32cbf59f3fd2fc8d83c0dbe66b08b3a5f408f229066a2a4e71db5194e69c2 *electron-v39.5.2-mas-arm64-dsym-snapshot.zip -f3a2701498c4b1ae3d59105225b4ba2b9a0fb0725d5f0591049462e643b63822 *electron-v39.5.2-mas-arm64-dsym.zip -32ebb3e5a653f2042697fb411b4e4d52fdbd2af959472aa8b51e79420fbf9c78 *electron-v39.5.2-mas-arm64-symbols.zip -92b9d5f559aed75931c744dd2c5a7124befe6c5d12e4cb777586107412375a11 *electron-v39.5.2-mas-arm64.zip -1fc599b7efa4017d441c68795df78d6d83ff952aee42efe5de2d3eb1c964d240 *electron-v39.5.2-mas-x64-dsym-snapshot.zip -af86806db4e0ee81e6949560d8a98ed56dbaf88698a7b9d15a30c64be05909f7 *electron-v39.5.2-mas-x64-dsym.zip -1f81e8e29125f5f693eb3b1cec18ee16487123319add8d19f3f0327d1b16a8f6 *electron-v39.5.2-mas-x64-symbols.zip -6d14692b43d0525f7fc1ce33485e586d1f1d6de68c2e7f4304917beea84bf576 *electron-v39.5.2-mas-x64.zip -287fbc58d5635423e5762f37f551d039d4aebcf14b3ef58d27da67a8f448d344 *electron-v39.5.2-win32-arm64-pdb.zip -6af15c530a187075418ff095c946f3fdb7b45709f402a61e7f9c93bc094f6bc7 *electron-v39.5.2-win32-arm64-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.5.2-win32-arm64-toolchain-profile.zip -13db5c25d118485e8edc6ecaa9b239a57403e728db1a085de9441b35543f398b *electron-v39.5.2-win32-arm64.zip -83fe5123b5810ee624ee5717d1466612a8b378a79c789f8963ac5d314efbc8d2 *electron-v39.5.2-win32-ia32-pdb.zip -1abf6ac223b42e755486248218b91ebc5a8d862cea9c0bafe8abeca7c0cfce2e *electron-v39.5.2-win32-ia32-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.5.2-win32-ia32-toolchain-profile.zip -9a5026091de20d4959b941ba594353ee5e8c7d7b542e5e729525d73c5cb67458 *electron-v39.5.2-win32-ia32.zip -b36c477788cc258f14faf9dd35a16d46512605e0378305ecbab8f587d16ca985 *electron-v39.5.2-win32-x64-pdb.zip -eba02395e7c3e6e508747800e36e529c000a6087f3f62a9c7a52328b3d91f378 *electron-v39.5.2-win32-x64-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.5.2-win32-x64-toolchain-profile.zip -f3658e4e9eb7585f9646db2469f6d4c30837bd524013ef677bd305cc90f4fb11 *electron-v39.5.2-win32-x64.zip -78d7e90d7dc1f753868011256075afcd24cbac74971f30d769105cff516f49fb *electron.d.ts -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.5.2-darwin-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.5.2-darwin-x64.zip -52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.5.2-linux-arm64.zip -622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.5.2-linux-armv7l.zip -ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.5.2-linux-x64.zip -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.5.2-mas-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.5.2-mas-x64.zip -6aabdb0f00966fb37d41a5ab193b0fdabb32a9556eae3c847ea22b946cd67f0d *ffmpeg-v39.5.2-win32-arm64.zip -b3af48c3fdbccc0e0d97abd569892eaf871a844c7308de8a583ea8e7541491d2 *ffmpeg-v39.5.2-win32-ia32.zip -8b53aae4bc3ae924e6d9e4b006708b53a335e7853c44db24ef49c982ab35acd4 *ffmpeg-v39.5.2-win32-x64.zip -cc48430bbe7690d7e8117b10edaf34587ef664012f04152158aa1c75852737dc *hunspell_dictionaries.zip -3c365db75791b6c6273d9b182698966194e0a1706286e1114c5846972efa92e9 *libcxx-objects-v39.5.2-linux-arm64.zip -e674701d184288a61d2fa4920ef3c9cd9ceac1e5b8df87694c3db6599abc02bc *libcxx-objects-v39.5.2-linux-armv7l.zip -998552c78147aeac569c1e7a5cb3fda94f6f6690aca105198280a28c7feb662b *libcxx-objects-v39.5.2-linux-x64.zip -8082c0d7d5f00631e57139a4dddfbfd9bfe80656abf78737fc3f5e729bd110e5 *libcxx_headers.zip -edf24979ef27199ad5cd9de9b52a56ef9bad022c7d9daff59c81a873bf0185cc *libcxxabi_headers.zip -9dafc93aa576649d79d7df97c21d1644783c3a522d2cef3f7a484f7b02e67d9c *mksnapshot-v39.5.2-darwin-arm64.zip -924fafb200efd7375cedd83e25202309229afe19e1df6efc4e2c8a1f344f1510 *mksnapshot-v39.5.2-darwin-x64.zip -274ed43bbe1af8cd822ad13bfc670ada8ff9b0a604b25e13b0aa26f4e519099d *mksnapshot-v39.5.2-linux-arm64-x64.zip -0a741013de490fb53fb230e812ae332dc9cebc93614c340abe5c427acae124bb *mksnapshot-v39.5.2-linux-armv7l-x64.zip -ce12c4dea98046cbe39cf1393983e34784f5d7722fd920f01ea59697fc192cc3 *mksnapshot-v39.5.2-linux-x64.zip -bdd5d316d30605d93527473d343fd4f7661ebaa415d07342be3d7450a9e46ade *mksnapshot-v39.5.2-mas-arm64.zip -250c8e1736e7da045db478cb85ddc0ba8ae02ca22b2fe5fc033c444335734f3a *mksnapshot-v39.5.2-mas-x64.zip -aeaf6d7f67dfd6b28e6c8be5a0d147e11bbb843c2b953c20fd8fcbc16178b015 *mksnapshot-v39.5.2-win32-arm64-x64.zip -583a12e704ae437cefbfc3bda11a413535d92bc598133d32517db1e9bb406566 *mksnapshot-v39.5.2-win32-ia32.zip -8d730a8ede9874203449b1d7f4486582714ebed3b00e7c5c2158926d26cec040 *mksnapshot-v39.5.2-win32-x64.zip +1a1bb622d9788793310458b7bf9eedcea8347da9556dd1d7661b757c15ebfdd5 *chromedriver-v39.6.0-darwin-arm64.zip +c84565c127adeca567ca69e85bbd8f387fff1f83c09e69f6f851528f5602dc4e *chromedriver-v39.6.0-darwin-x64.zip +f50df11f99a2e3df84560d5331608cd0a9d7a147a1490f25edfd8a95531918a2 *chromedriver-v39.6.0-linux-arm64.zip +a571fd25e33f3b3bded91506732a688319d93eb652e959bb19a09cd3f67f9e5f *chromedriver-v39.6.0-linux-armv7l.zip +2a50751190bbfe07984f7d8cbf2f12c257a4c132a36922a78c4e320169b8f498 *chromedriver-v39.6.0-linux-x64.zip +cf6034c20b727c48a6f44bb87b1ec89fd4189f56200a32cd39cedaab3f19e007 *chromedriver-v39.6.0-mas-arm64.zip +d2107db701c41fa5f3aaa04c279275ac4dcffde4542c032c806939acd8c6cd6c *chromedriver-v39.6.0-mas-x64.zip +1593ed5550fa11c549fd4ff5baea5cb7806548bff15b79340343ac24a86d6de3 *chromedriver-v39.6.0-win32-arm64.zip +deee89cbeed935a57551294fbc59f6a346b76769e27dd78a59a35a82ae3037d9 *chromedriver-v39.6.0-win32-ia32.zip +f88a23ebc246ed2a506d6d172eb9ffbb4c9d285103285a735e359268fcd08895 *chromedriver-v39.6.0-win32-x64.zip +2e1ec8568f4fda21dc4bb7231cdb0427fa31bb03c4bc39f8aa36659894f2d23e *electron-api.json +03e743428685b44beeab9aa51bad7437387dc2ce299b94745ed8fb0923dd9a07 *electron-v39.6.0-darwin-arm64-dsym-snapshot.zip +723d64530286ebd58539bc29deb65e9334ae8450a714b075d369013b4bbfdce0 *electron-v39.6.0-darwin-arm64-dsym.zip +8f529fbbed8c386f3485614fa059ea9408ebe17d3f0c793269ea52ef3efdf8df *electron-v39.6.0-darwin-arm64-symbols.zip +dace1f9e5c49f4f63f32341f8b0fb7f16b8cf07ce5fcb17abcc0b33782966b8c *electron-v39.6.0-darwin-arm64.zip +e2425514469c4382be374e676edff6779ef98ca1c679b1500337fa58aa863e98 *electron-v39.6.0-darwin-x64-dsym-snapshot.zip +877e72afd7d8695e8a4420a74765d45c30fad30606d3dbab07a0e88fe600e3f6 *electron-v39.6.0-darwin-x64-dsym.zip +ae958c150c6fe76fc7989a28ddb6104851f15d2e24bd32fe60f51e308954a816 *electron-v39.6.0-darwin-x64-symbols.zip +bed88dac3ac28249a020397d83f3f61871c7eaea2099d5bf6b1e92878cb14f19 *electron-v39.6.0-darwin-x64.zip +a86e9470d6084611f38849c9f9b3311584393fa81b55d0bbf7e284a649b729cf *electron-v39.6.0-linux-arm64-debug.zip +e7d7aec3873a6d2f2c9fe406a27a8668910f8b4fdf55a36b5302d9db3ec390db *electron-v39.6.0-linux-arm64-symbols.zip +d6ded47a49046eb031800cf70f2b5d763ccac11dac64e70a874c62aaa115ccba *electron-v39.6.0-linux-arm64.zip +2bf6a75c9f3c2400698c325e48c9b6444d108e4d76544fb130d04605002ae084 *electron-v39.6.0-linux-armv7l-debug.zip +421d02c8a063602b22e4f16a2614fe6cc13e07f9d4ead309fe40aeac296fe951 *electron-v39.6.0-linux-armv7l-symbols.zip +ee34896d1317f1572ed4f3ed8eb1719f599f250d442fc6afb6ec40091c4f4cdc *electron-v39.6.0-linux-armv7l.zip +233f55caae4514144310928248a96bd3a3ce7ac6dc1ff99e7531737a579793b1 *electron-v39.6.0-linux-x64-debug.zip +eca69e741b00ce141b9c2e6e63c1f77cd834a85aa095385f032fdb58d3154fff *electron-v39.6.0-linux-x64-symbols.zip +94bf4bee48f3c657edffd4556abbe62556ca8225cbb4528d62eb858233a3c34b *electron-v39.6.0-linux-x64.zip +6dfebeb760627df74c65ff8da7088fb77e0ae222cab5590fea4cdd37c060ea06 *electron-v39.6.0-mas-arm64-dsym-snapshot.zip +b327d41507546799451a684b6061caed10f1c16ee39a7e686aac71187f8b7afe *electron-v39.6.0-mas-arm64-dsym.zip +02a56a9c3c3522ebc653f03ad88be9a2f46594c730a767a28e7322ddb7a789b7 *electron-v39.6.0-mas-arm64-symbols.zip +2fe93cd39521371bb5722c358feebadc5e79d79628b07a79a00a9d918e261de4 *electron-v39.6.0-mas-arm64.zip +f25ddc8a9b2b699d6d9e54fdf66220514e387ae36e45efeb4d8217b1462503f6 *electron-v39.6.0-mas-x64-dsym-snapshot.zip +6732026b6a3728bea928af0c5928bf82d565eebeb3f5dc5b6991639d27e7c457 *electron-v39.6.0-mas-x64-dsym.zip +5260dabf5b0fc369e0f69d3286fbcce9d67bc65e3364e17f7bb13dd49e320422 *electron-v39.6.0-mas-x64-symbols.zip +905f7cf95270afa92972b6c9242fc50c0afd65ffd475a81ded6033588f27a613 *electron-v39.6.0-mas-x64.zip +9204c9844e89f5ca0b32a8347cf9141d8dcb66671906e299afa06004f464d9b0 *electron-v39.6.0-win32-arm64-pdb.zip +6778c54d8cf7a0d305e4334501c3b877daf4737197187120ac18064f4e093b23 *electron-v39.6.0-win32-arm64-symbols.zip +efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-arm64-toolchain-profile.zip +22b96aca4cf8f7823b98e3b20b6131e521e0100c5cd03ab76f106eefbd0399cf *electron-v39.6.0-win32-arm64.zip +f5b69c8c1c9349a1f3b4309fb3fa1cf6326953e0807d2063fc27ba9f1400232e *electron-v39.6.0-win32-ia32-pdb.zip +1d6e103869acdeb0330b26ee08089667e0b5afc506efcd7021ba761ed8b786b5 *electron-v39.6.0-win32-ia32-symbols.zip +efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-ia32-toolchain-profile.zip +2b30e5bc923fff1443e2a4d1971cb9b26f61bd6a454cfbb991042457bab4d623 *electron-v39.6.0-win32-ia32.zip +5f93924c317206a2a4800628854e44e68662a9c40b3457c9e72690d6fff884d3 *electron-v39.6.0-win32-x64-pdb.zip +eab07439f0a21210cd560c1169c04ea5e23c6fe0ab65bd60cffce2b9f69fd36e *electron-v39.6.0-win32-x64-symbols.zip +efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-x64-toolchain-profile.zip +e8eee36be3bb85ba6fd8fcd26cf3a264bc946ac0717762c64e168896695c8e34 *electron-v39.6.0-win32-x64.zip +2e84c606e40c7bab5530e4c83bbf3a24c28143b0a768dafa5ecf78b18d889297 *electron.d.ts +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.6.0-darwin-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.6.0-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.6.0-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.6.0-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.6.0-linux-x64.zip +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.6.0-mas-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.6.0-mas-x64.zip +2a358c2dbeeb259c0b6a18057b52ffb0109de69112086cb2ce02f3a79bd70cee *ffmpeg-v39.6.0-win32-arm64.zip +4555510880a7b8dff5d5d0520f641665c62494689782adbed67fa0e24b45ae67 *ffmpeg-v39.6.0-win32-ia32.zip +091ab3c97d5a1cda1e04c6bd263a2c07ea63ed7ec3fd06600af6d7e23bbbbe15 *ffmpeg-v39.6.0-win32-x64.zip +650fb5fbc7e6cc27e5caeb016f72aba756469772bbfdfb3ec0b229f973d8ad46 *hunspell_dictionaries.zip +669ef1bf8ed0f6378e67f4f8bc23d2907d7cc1db7369dbdf468e164f4ef49365 *libcxx-objects-v39.6.0-linux-arm64.zip +996d81ad796524246144e15e22ffef75faff055a102c49021d70b03f039c3541 *libcxx-objects-v39.6.0-linux-armv7l.zip +1ffb610613c11169640fa76e4790137034a0deb3b48e2aef51a01c9b96b7700a *libcxx-objects-v39.6.0-linux-x64.zip +6dd8db57473992367c7914b50d06cae3a1b713cc09ceebecfcd4107df333e759 *libcxx_headers.zip +e5c18f813cc64a7d3b0404ee9adeb9cbb49e7ee5e1054b62c71fa7d1a448ad1b *libcxxabi_headers.zip +7f58d6e1d8c75b990f7d2259de8d0896414d0f2cff2f0fe4e5c7f8037d8fe879 *mksnapshot-v39.6.0-darwin-arm64.zip +be1178e4aa1f4910ba2b8f35b5655e12182657b9e32d509b47f0b2db033f0ac5 *mksnapshot-v39.6.0-darwin-x64.zip +5e36a594067fea08bb3d7bcd60873c3e240ebcee2208bcebfbc9f77d3075cc0d *mksnapshot-v39.6.0-linux-arm64-x64.zip +2db9196d2af0148ebb7b6f1f597f46a535b7af482f95739bd1ced78e1ebf39e7 *mksnapshot-v39.6.0-linux-armv7l-x64.zip +cd673e0a908fc950e0b4246e2b099018a8ee879d12a62973a01cb7de522f5bcf *mksnapshot-v39.6.0-linux-x64.zip +0749d8735a1fd8c666862cd7020b81317c45203d01319c9be089d1e750cb2c15 *mksnapshot-v39.6.0-mas-arm64.zip +81ae98e064485f8c6c69cd6c875ee72666c0cc801a8549620d382c2d0cea3b5c *mksnapshot-v39.6.0-mas-x64.zip +2e44f75df797922e7c8bad61a1b41fed14b070a54257a6a751892b2b8b9dfe29 *mksnapshot-v39.6.0-win32-arm64-x64.zip +fb5d73a8bf4b8db80f61b7073aa8458b5c46cce5c2a4b23591e851c6fcbd0144 *mksnapshot-v39.6.0-win32-ia32.zip +118ae88dbcd6b260cfa370e46ccfb0ab00af5efbf59495aaeea56a2831f604b2 *mksnapshot-v39.6.0-win32-x64.zip diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index cae158ea59014..74d58f3840699 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -8,6 +8,7 @@ import { EventEmitter } from 'events'; EventEmitter.defaultMaxListeners = 100; import es from 'event-stream'; +import fancyLog from 'fancy-log'; import glob from 'glob'; import gulp from 'gulp'; import filter from 'gulp-filter'; @@ -27,6 +28,25 @@ import watcher from './lib/watch/index.ts'; const root = path.dirname(import.meta.dirname); const commit = getVersion(root); +// Tracks active extension compilations to emit aggregate +// "Starting compilation" / "Finished compilation" messages +// that the problem matcher in tasks.json relies on. +let activeExtensionCompilations = 0; + +function onExtensionCompilationStart(): void { + if (activeExtensionCompilations === 0) { + fancyLog('Starting compilation'); + } + activeExtensionCompilations++; +} + +function onExtensionCompilationEnd(): void { + activeExtensionCompilations--; + if (activeExtensionCompilations === 0) { + fancyLog('Finished compilation'); + } +} + // To save 250ms for each gulp startup, we are caching the result here // const compilations = glob.sync('**/tsconfig.json', { // cwd: extensionsPath, @@ -175,7 +195,25 @@ const tasks = compilations.map(function (tsconfigFile) { const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); const watchInput = watcher(src, { ...srcOpts, ...{ readDelay: 200 } }); const watchNonTs = watchInput.pipe(filter(['**', '!**/*.ts'], { dot: true })).pipe(gulp.dest(out)); - const tsgoStream = watchInput.pipe(util.debounce(() => createTsgoStream(absolutePath, { taskName: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)), 200)); + const tsgoStream = watchInput.pipe(util.debounce(() => { + onExtensionCompilationStart(); + const stream = createTsgoStream(absolutePath, { taskName: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)); + // Wrap in a result stream that always emits 'end' (even on + // error) so the debounce resets to idle and can process future + // file changes. Errors from tsgo (e.g. type errors causing a + // non-zero exit code) are already reported by spawnTsgo's + // runReporter, so swallowing the stream error is safe. + const result = es.through(); + stream.on('end', () => { + onExtensionCompilationEnd(); + result.emit('end'); + }); + stream.on('error', () => { + onExtensionCompilationEnd(); + result.emit('end'); + }); + return result; + }, 200)); const watchStream = es.merge(nonts.pipe(gulp.dest(out)), watchNonTs, tsgoStream); return watchStream; diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index f6e4e7afe49fb..31c31f58406ab 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -67,6 +67,7 @@ const vscodeResourceIncludes = [ // Workbench 'out-build/vs/code/electron-browser/workbench/workbench.html', + 'out-build/vs/sessions/electron-browser/sessions.html', // Electron Preload 'out-build/vs/base/parts/sandbox/electron-browser/preload.js', @@ -96,6 +97,9 @@ const vscodeResourceIncludes = [ // Welcome 'out-build/vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.{svg,png}', + // Sessions + 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', + // Extensions 'out-build/vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', 'out-build/vs/workbench/services/extensionManagement/common/media/*.{svg,png}', @@ -148,7 +152,7 @@ const bundleVSCodeTask = task.define('bundle-vscode', task.series( ...bootstrapEntryPoints ], resources: vscodeResources, - skipTSBoilerplateRemoval: entryPoint => entryPoint === 'vs/code/electron-browser/workbench/workbench' + skipTSBoilerplateRemoval: entryPoint => entryPoint === 'vs/code/electron-browser/workbench/workbench' || entryPoint === 'vs/sessions/electron-browser/sessions' } } ) @@ -187,6 +191,7 @@ function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean, target: const args = [scriptPath, 'bundle', '--out', outDir, '--target', target]; if (minify) { args.push('--minify'); + args.push('--mangle-privates'); } if (nls) { args.push('--nls'); @@ -326,7 +331,11 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d 'vs/workbench/workbench.desktop.main.css', 'vs/workbench/api/node/extensionHostProcess.js', 'vs/code/electron-browser/workbench/workbench.html', - 'vs/code/electron-browser/workbench/workbench.js' + 'vs/code/electron-browser/workbench/workbench.js', + 'vs/sessions/sessions.desktop.main.js', + 'vs/sessions/sessions.desktop.main.css', + 'vs/sessions/electron-browser/sessions.html', + 'vs/sessions/electron-browser/sessions.js' ]); const src = gulp.src(out + '/**', { base: '.' }) @@ -377,6 +386,34 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d this.emit('data', file); })); + + const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; + const embedded = isInsiderOrExploration + ? (product as typeof product & { embedded?: { nameShort: string; nameLong: string; applicationName: string; dataFolderName: string; darwinBundleIdentifier: string } }).embedded + : undefined; + + const packageSubJsonStream = isInsiderOrExploration + ? gulp.src(['package.json'], { base: '.' }) + .pipe(jsonEditor((json: Record) => { + json.name = `sessions-${quality || 'oss-dev'}`; + return json; + })) + .pipe(rename('package.sub.json')) + : undefined; + + const productSubJsonStream = embedded + ? gulp.src(['product.json'], { base: '.' }) + .pipe(jsonEditor((json: Record) => { + json.nameShort = embedded.nameShort; + json.nameLong = embedded.nameLong; + json.applicationName = embedded.applicationName; + json.dataFolderName = embedded.dataFolderName; + json.darwinBundleIdentifier = embedded.darwinBundleIdentifier; + return json; + })) + .pipe(rename('product.sub.json')) + : undefined; + const license = gulp.src([product.licenseFileName, 'ThirdPartyNotices.txt', 'licenses/**'], { base: '.', allowEmpty: true }); // TODO the API should be copied to `out` during compile, not here @@ -412,7 +449,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d 'node_modules/vsda/**' // retain copy of `vsda` in node_modules for internal use ], 'node_modules.asar')); - let all = es.merge( + const mergeStreams = [ packageJsonStream, productJsonStream, license, @@ -420,7 +457,14 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d telemetry, sources, deps - ); + ]; + if (packageSubJsonStream) { + mergeStreams.push(packageSubJsonStream); + } + if (productSubJsonStream) { + mergeStreams.push(productSubJsonStream); + } + let all = es.merge(...mergeStreams); if (platform === 'win32') { all = es.merge(all, gulp.src([ @@ -469,12 +513,24 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d all = es.merge(all, shortcut, policyDest); } + const electronConfig = { + ...config, + platform, + arch: arch === 'armhf' ? 'arm' : arch, + ffmpegChromium: false, + ...(embedded ? { + darwinMiniAppName: embedded.nameShort, + darwinMiniAppBundleIdentifier: embedded.darwinBundleIdentifier, + darwinMiniAppIcon: 'resources/darwin/sessions.icns', + } : {}) + }; + let result: NodeJS.ReadWriteStream = all .pipe(util.skipDirectories()) .pipe(util.fixWin32DirectoryPermissions()) .pipe(filter(['**', '!**/.github/**'], { dot: true })) // https://github.com/microsoft/vscode/issues/116523 - .pipe(electron({ ...config, platform, arch: arch === 'armhf' ? 'arm' : arch, ffmpegChromium: false })) - .pipe(filter(['**', '!LICENSE', '!version', ...(platform === 'darwin' ? ['!**/Contents/Applications/**'] : [])], { dot: true })); + .pipe(electron(electronConfig)) + .pipe(filter(['**', '!LICENSE', '!version'], { dot: true })); if (platform === 'linux') { result = es.merge(result, gulp.src('resources/completions/bash/code', { base: '.' }) diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index cdaf57000fac1..e9cc3720fcf7f 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -39,6 +39,7 @@ function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean): Promis const args = [scriptPath, 'bundle', '--out', outDir, '--target', 'web']; if (minify) { args.push('--minify'); + args.push('--mangle-privates'); } if (nls) { args.push('--nls'); diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index fac7946fc98af..6c1e65b0f57f3 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -441,7 +441,7 @@ interface IExtensionManifest { /** * Loosely based on `getExtensionKind` from `src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts` */ -function isWebExtension(manifest: IExtensionManifest): boolean { +export function isWebExtension(manifest: IExtensionManifest): boolean { if (Boolean(manifest.browser)) { return true; } @@ -578,11 +578,11 @@ export function packageMarketplaceExtensionsStream(forWeb: boolean): Stream { } export interface IScannedBuiltinExtension { - extensionPath: string; - packageJSON: any; - packageNLS?: any; - readmePath?: string; - changelogPath?: string; + readonly extensionPath: string; + readonly packageJSON: unknown; + readonly packageNLS: unknown | undefined; + readonly readmePath: string | undefined; + readonly changelogPath: string | undefined; } export function scanBuiltinExtensions(extensionsRoot: string, exclude: string[] = []): IScannedBuiltinExtension[] { diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index a03d24470c7e7..a913a9534fcfc 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -383,6 +383,9 @@ "--vscode-gauge-foreground", "--vscode-gauge-warningBackground", "--vscode-gauge-warningForeground", + "--vscode-gitDecoration-addedResourceForeground", + "--vscode-gitDecoration-deletedResourceForeground", + "--vscode-gitDecoration-modifiedResourceForeground", "--vscode-icon-foreground", "--vscode-inlineChat-background", "--vscode-inlineChat-border", @@ -914,6 +917,7 @@ "--inline-chat-frame-progress", "--insert-border-color", "--last-tab-margin-right", + "--list-scroll-right-offset", "--monaco-monospace-font", "--monaco-monospace-font", "--notebook-cell-input-preview-font-family", @@ -941,6 +945,9 @@ "--testMessageDecorationFontSize", "--title-border-bottom-color", "--title-wco-width", + "--reveal-button-size", + "--part-background", + "--part-border-color", "--vscode-chat-list-background", "--vscode-editorCodeLens-fontFamily", "--vscode-editorCodeLens-fontFamilyDefault", @@ -1007,7 +1014,10 @@ "--comment-thread-state-background-color", "--inline-edit-border-radius", "--chat-subagent-last-item-height", - "--vscode-inline-chat-affordance-height" + "--vscode-inline-chat-affordance-height", + "--collapse-from-width", + "--slide-from-x", + "--slide-from-y" ], "sizes": [ "--vscode-bodyFontSize", diff --git a/build/lib/tsgo.ts b/build/lib/tsgo.ts index 421f4c1cc1b17..36d925d43139a 100644 --- a/build/lib/tsgo.ts +++ b/build/lib/tsgo.ts @@ -12,13 +12,17 @@ import * as path from 'path'; const root = path.dirname(path.dirname(import.meta.dirname)); const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; +const timestampRegex = /^\[\d{2}:\d{2}:\d{2}\]\s*/; export function spawnTsgo(projectPath: string, config: { taskName: string; noEmit?: boolean }, onComplete?: () => Promise | void): Promise { - function reporter(stdError: string) { - const matches = (stdError || '').match(/^error \w+: (.+)?/g); - fancyLog(`Finished ${ansiColors.green(config.taskName)} ${projectPath} with ${matches ? matches.length : 0} errors.`); - for (const match of matches || []) { - fancyLog.error(match); + function runReporter(output: string) { + const lines = (output || '').split('\n'); + const errorLines = lines.filter(line => /error \w+:/.test(line)); + if (errorLines.length > 0) { + fancyLog(`Finished ${ansiColors.green(config.taskName)} ${projectPath} with ${errorLines.length} errors.`); + for (const line of errorLines) { + fancyLog(line); + } } } @@ -34,40 +38,27 @@ export function spawnTsgo(projectPath: string, config: { taskName: string; noEmi shell: true }); - let buffer = ''; - const handleLine = (line: string) => { - const trimmed = line.replace(ansiRegex, '').trim(); - if (!trimmed) { - return; - } - if (/Starting compilation|File change detected/i.test(trimmed)) { - return; - } - if (/Compilation complete/i.test(trimmed)) { - return; - } - - reporter(trimmed); - }; - - const handleData = (data: Buffer) => { - buffer += data.toString('utf8'); - const lines = buffer.split(/\r?\n/); - buffer = lines.pop() ?? ''; - for (const line of lines) { - handleLine(line); - } - }; + let stdoutData = ''; + let stderrData = ''; - child.stdout?.on('data', handleData); - child.stderr?.on('data', handleData); + child.stdout?.on('data', (data: Buffer) => { + stdoutData += data.toString(); + }); + child.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString(); + }); return new Promise((resolve, reject) => { child.on('exit', code => { - if (buffer.trim()) { - handleLine(buffer); - buffer = ''; - } + const allOutput = stdoutData + '\n' + stderrData; + const lines = allOutput + .split(/\r?\n/) + .map(line => line.replace(ansiRegex, '').trim()) + .map(line => line.replace(timestampRegex, '')) + .filter(line => line.length > 0) + .filter(line => !/Starting compilation|File change detected|Compilation complete/i.test(line)); + + runReporter(lines.join('\n')); if (code === 0) { Promise.resolve(onComplete?.()).then(() => resolve(), reject); @@ -84,12 +75,11 @@ export function spawnTsgo(projectPath: string, config: { taskName: string; noEmi export function createTsgoStream(projectPath: string, config: { taskName: string; noEmit?: boolean }, onComplete?: () => Promise | void): NodeJS.ReadWriteStream { const stream = es.through(); + spawnTsgo(projectPath, config, onComplete).then(() => { stream.emit('end'); - }).catch(() => { - // Errors are already reported by spawnTsgo via the reporter. - // Don't emit 'error' on the stream as that would exit the watch process. - stream.emit('end'); + }).catch(err => { + stream.emit('error', err); }); return stream; diff --git a/build/linux/rpm/dep-lists.ts b/build/linux/rpm/dep-lists.ts index 783923f34d94f..0424c8d37a62d 100644 --- a/build/linux/rpm/dep-lists.ts +++ b/build/linux/rpm/dep-lists.ts @@ -52,6 +52,7 @@ export const referenceGeneratedDepsByArch = { 'libc.so.6(GLIBC_2.3.3)(64bit)', 'libc.so.6(GLIBC_2.3.4)(64bit)', 'libc.so.6(GLIBC_2.4)(64bit)', + 'libc.so.6(GLIBC_2.5)(64bit)', 'libc.so.6(GLIBC_2.6)(64bit)', 'libc.so.6(GLIBC_2.7)(64bit)', 'libc.so.6(GLIBC_2.8)(64bit)', @@ -144,6 +145,7 @@ export const referenceGeneratedDepsByArch = { 'libc.so.6(GLIBC_2.27)', 'libc.so.6(GLIBC_2.28)', 'libc.so.6(GLIBC_2.4)', + 'libc.so.6(GLIBC_2.5)', 'libc.so.6(GLIBC_2.6)', 'libc.so.6(GLIBC_2.7)', 'libc.so.6(GLIBC_2.8)', diff --git a/build/next/index.ts b/build/next/index.ts index 2d30bbf737a51..e8f5b1f72d1c1 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -10,10 +10,12 @@ import { promisify } from 'util'; import glob from 'glob'; import gulpWatch from '../lib/watch/index.ts'; import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from './nls-plugin.ts'; +import { convertPrivateFields, type ConvertPrivateFieldsResult } from './private-to-property.ts'; import { getVersion } from '../lib/getVersion.ts'; import product from '../../product.json' with { type: 'json' }; import packageJson from '../../package.json' with { type: 'json' }; import { useEsbuildTranspile } from '../buildConfig.ts'; +import { isWebExtension, type IScannedBuiltinExtension } from '../lib/extensions.ts'; const globAsync = promisify(glob); @@ -41,6 +43,7 @@ const options = { watch: process.argv.includes('--watch'), minify: process.argv.includes('--minify'), nls: process.argv.includes('--nls'), + manglePrivates: process.argv.includes('--mangle-privates'), excludeTests: process.argv.includes('--exclude-tests'), out: getArgValue('--out'), target: getArgValue('--target') ?? 'desktop', // 'desktop' | 'server' | 'server-web' | 'web' @@ -61,6 +64,17 @@ const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]); // Entry Points (from build/buildfile.ts) // ============================================================================ +// Extension host bundles are excluded from private field mangling because they +// expose API surface to extensions where encapsulation matters. +const extensionHostEntryPoints = [ + 'vs/workbench/api/node/extensionHostProcess', + 'vs/workbench/api/worker/extensionHostWorkerMain', +]; + +function isExtensionHostBundle(filePath: string): boolean { + return extensionHostEntryPoints.some(ep => filePath.endsWith(`${ep}.js`)); +} + // Workers - shared between targets const workerEntryPoints = [ 'vs/editor/common/services/editorWebWorkerMain', @@ -80,6 +94,7 @@ const desktopWorkerEntryPoints = [ // Desktop workbench and code entry points const desktopEntryPoints = [ 'vs/workbench/workbench.desktop.main', + 'vs/sessions/sessions.desktop.main', 'vs/workbench/contrib/debug/node/telemetryApp', 'vs/platform/files/node/watcher/watcherMain', 'vs/platform/terminal/node/ptyHostMain', @@ -90,6 +105,7 @@ const codeEntryPoints = [ 'vs/code/node/cliProcessMain', 'vs/code/electron-utility/sharedProcess/sharedProcessMain', 'vs/code/electron-browser/workbench/workbench', + 'vs/sessions/electron-browser/sessions', ]; // Web entry points (used in server-web and vscode-web) @@ -184,6 +200,8 @@ function getCssBundleEntryPointsForTarget(target: BuildTarget): Set { return new Set([ 'vs/workbench/workbench.desktop.main', 'vs/code/electron-browser/workbench/workbench', + 'vs/sessions/sessions.desktop.main', + 'vs/sessions/electron-browser/sessions', ]); case 'server': return new Set(); // Server has no UI @@ -210,18 +228,11 @@ const commonResourcePatterns = [ // Tree-sitter queries 'vs/editor/common/languages/highlights/*.scm', 'vs/editor/common/languages/injections/*.scm', -]; -// Resources only needed for dev/transpile builds (these get bundled into the main -// JS/CSS bundles for production, so separate copies are redundant) -const devOnlyResourcePatterns = [ - // Fonts (esbuild file loader copies to media/codicon.ttf for production) - 'vs/base/browser/ui/codicons/codicon/codicon.ttf', - - // Vendor JavaScript libraries (bundled into workbench main JS for production) - 'vs/base/common/marked/marked.js', - 'vs/base/common/semver/semver.js', - 'vs/base/browser/dompurify/dompurify.js', + // SVGs referenced from CSS (needed for transpile/dev builds where CSS is copied as-is) + 'vs/workbench/browser/media/code-icon.svg', + 'vs/workbench/browser/parts/editor/media/letterpress*.svg', + 'vs/sessions/contrib/chat/browser/media/*.svg' ]; // Resources for desktop target @@ -231,6 +242,8 @@ const desktopResourcePatterns = [ // HTML 'vs/code/electron-browser/workbench/workbench.html', 'vs/code/electron-browser/workbench/workbench-dev.html', + 'vs/sessions/electron-browser/sessions.html', + 'vs/sessions/electron-browser/sessions-dev.html', 'vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html', 'vs/workbench/contrib/webview/browser/pre/*.html', @@ -351,28 +364,6 @@ function getResourcePatternsForTarget(target: BuildTarget): string[] { } } -// Test fixtures (only copied for development builds, not production) -const testFixturePatterns = [ - '**/test/**/*.json', - '**/test/**/*.txt', - '**/test/**/*.snap', - '**/test/**/*.tst', - '**/test/**/*.html', - '**/test/**/*.js', - '**/test/**/*.jxs', - '**/test/**/*.tsx', - '**/test/**/*.css', - '**/test/**/*.png', - '**/test/**/*.md', - '**/test/**/*.zip', - '**/test/**/*.pdf', - '**/test/**/*.qwoff', - '**/test/**/*.wuff', - '**/test/**/*.less', - // Files without extensions (executables, etc.) - '**/test/**/fixtures/executable/*', -]; - // ============================================================================ // Utilities // ============================================================================ @@ -388,33 +379,43 @@ async function cleanDir(dir: string): Promise { * Scan for built-in extensions in the given directory. * Returns an array of extension entries for the builtinExtensionsScannerService. */ -function scanBuiltinExtensions(extensionsRoot: string): Array<{ extensionPath: string; packageJSON: unknown }> { - const result: Array<{ extensionPath: string; packageJSON: unknown }> = []; +function scanBuiltinExtensions(extensionsRoot: string): Array { + const scannedExtensions: Array = []; const extensionsPath = path.join(REPO_ROOT, extensionsRoot); if (!fs.existsSync(extensionsPath)) { - return result; + return scannedExtensions; } - for (const entry of fs.readdirSync(extensionsPath, { withFileTypes: true })) { - if (!entry.isDirectory()) { + for (const extensionFolder of fs.readdirSync(extensionsPath)) { + const packageJSONPath = path.join(extensionsPath, extensionFolder, 'package.json'); + if (!fs.existsSync(packageJSONPath)) { continue; } - const packageJsonPath = path.join(extensionsPath, entry.name, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - try { - const packageJSON = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - result.push({ - extensionPath: entry.name, - packageJSON - }); - } catch (e) { - // Skip invalid extensions + try { + const packageJSON = JSON.parse(fs.readFileSync(packageJSONPath, 'utf8')); + if (!isWebExtension(packageJSON)) { + continue; } + const children = fs.readdirSync(path.join(extensionsPath, extensionFolder)); + const packageNLSPath = children.filter(child => child === 'package.nls.json')[0]; + const packageNLS = packageNLSPath ? JSON.parse(fs.readFileSync(path.join(extensionsPath, extensionFolder, packageNLSPath), 'utf8')) : undefined; + const readme = children.filter(child => /^readme(\.txt|\.md|)$/i.test(child))[0]; + const changelog = children.filter(child => /^changelog(\.txt|\.md|)$/i.test(child))[0]; + + scannedExtensions.push({ + extensionPath: extensionFolder, + packageJSON, + packageNLS, + readmePath: readme ? path.join(extensionFolder, readme) : undefined, + changelogPath: changelog ? path.join(extensionFolder, changelog) : undefined, + }); + } catch (e) { + // Skip invalid extensions } } - return result; + return scannedExtensions; } /** @@ -483,7 +484,7 @@ async function compileStandaloneFiles(outDir: string, doMinify: boolean, target: format: 'cjs', // CommonJS for Electron preload platform: 'node', target: ['es2024'], - sourcemap: 'external', + sourcemap: 'linked', sourcesContent: false, minify: doMinify, banner: { js: banner }, @@ -494,34 +495,57 @@ async function compileStandaloneFiles(outDir: string, doMinify: boolean, target: console.log(`[standalone] Done`); } -async function copyCssFiles(outDir: string, excludeTests = false): Promise { - // Copy all CSS files from src to output (they're imported by JS) - const cssFiles = await globAsync('**/*.css', { +/** + * Copy ALL non-TypeScript files from src/ to the output directory. + * This matches the old gulp build behavior where `gulp.src('src/**')` streams + * every file and non-TS files bypass the compiler via tsFilter.restore. + * Used for development/transpile builds only - production bundles use + * copyResources() with curated per-target patterns instead. + */ +async function copyAllNonTsFiles(outDir: string, excludeTests: boolean): Promise { + console.log(`[resources] Copying all non-TS files to ${outDir}...`); + + const ignorePatterns = [ + // Exclude .ts files but keep .d.ts files (they're needed at runtime for type references) + '**/*.ts', + ]; + if (excludeTests) { + ignorePatterns.push('**/test/**'); + } + + const files = await globAsync('**/*', { + cwd: path.join(REPO_ROOT, SRC_DIR), + nodir: true, + ignore: ignorePatterns, + }); + + // Re-include .d.ts files that were excluded by the *.ts ignore + const dtsFiles = await globAsync('**/*.d.ts', { cwd: path.join(REPO_ROOT, SRC_DIR), ignore: excludeTests ? ['**/test/**'] : [], }); - for (const file of cssFiles) { + const allFiles = [...new Set([...files, ...dtsFiles])]; + + await Promise.all(allFiles.map(file => { const srcPath = path.join(REPO_ROOT, SRC_DIR, file); const destPath = path.join(REPO_ROOT, outDir, file); + return copyFile(srcPath, destPath); + })); - await copyFile(srcPath, destPath); - } - - return cssFiles.length; + console.log(`[resources] Copied ${allFiles.length} files`); } -async function copyResources(outDir: string, target: BuildTarget, excludeDevFiles = false, excludeTests = false): Promise { +/** + * Copy curated resource files for production bundles. + * Uses specific per-target patterns matching the old build's vscodeResourceIncludes, + * serverResourceIncludes, etc. Only called by bundle() - transpile uses copyAllNonTsFiles(). + */ +async function copyResources(outDir: string, target: BuildTarget): Promise { console.log(`[resources] Copying to ${outDir} for target '${target}'...`); let copied = 0; - const ignorePatterns: string[] = []; - if (excludeTests) { - ignorePatterns.push('**/test/**'); - } - if (excludeDevFiles) { - ignorePatterns.push('**/*-dev.html'); - } + const ignorePatterns = ['**/test/**', '**/*-dev.html']; const resourcePatterns = getResourcePatternsForTarget(target); for (const pattern of resourcePatterns) { @@ -539,47 +563,7 @@ async function copyResources(outDir: string, target: BuildTarget, excludeDevFile } } - // Copy test fixtures (only for development builds) - if (!excludeTests) { - for (const pattern of testFixturePatterns) { - const files = await globAsync(pattern, { - cwd: path.join(REPO_ROOT, SRC_DIR), - }); - - for (const file of files) { - const srcPath = path.join(REPO_ROOT, SRC_DIR, file); - const destPath = path.join(REPO_ROOT, outDir, file); - - await copyFile(srcPath, destPath); - copied++; - } - } - } - - // Copy dev-only resources (vendor JS, codicon font) - only for development/transpile - // builds. In production bundles these are inlined by esbuild. - if (!excludeDevFiles) { - for (const pattern of devOnlyResourcePatterns) { - const files = await globAsync(pattern, { - cwd: path.join(REPO_ROOT, SRC_DIR), - ignore: ignorePatterns, - }); - for (const file of files) { - await copyFile(path.join(REPO_ROOT, SRC_DIR, file), path.join(REPO_ROOT, outDir, file)); - copied++; - } - } - } - - // Copy CSS files (only for development/transpile builds, not production bundles - // where CSS is already bundled into combined files like workbench.desktop.main.css) - if (!excludeDevFiles) { - const cssCount = await copyCssFiles(outDir, excludeTests); - copied += cssCount; - console.log(`[resources] Copied ${copied} files (${cssCount} CSS)`); - } else { - console.log(`[resources] Copied ${copied} files (CSS skipped - bundled)`); - } + console.log(`[resources] Copied ${copied} files`); } // ============================================================================ @@ -736,7 +720,7 @@ async function transpile(outDir: string, excludeTests: boolean): Promise { // Bundle (Goal 2: JS → bundled JS) // ============================================================================ -async function bundle(outDir: string, doMinify: boolean, doNls: boolean, target: BuildTarget, sourceMapBaseUrl?: string): Promise { +async function bundle(outDir: string, doMinify: boolean, doNls: boolean, doManglePrivates: boolean, target: BuildTarget, sourceMapBaseUrl?: string): Promise { await cleanDir(outDir); // Write build date file (used by packaging to embed in product.json) @@ -744,7 +728,7 @@ async function bundle(outDir: string, doMinify: boolean, doNls: boolean, target: await fs.promises.mkdir(outDirPath, { recursive: true }); await fs.promises.writeFile(path.join(outDirPath, 'date'), new Date().toISOString(), 'utf8'); - console.log(`[bundle] ${SRC_DIR} → ${outDir} (target: ${target})${doMinify ? ' (minify)' : ''}${doNls ? ' (nls)' : ''}`); + console.log(`[bundle] ${SRC_DIR} → ${outDir} (target: ${target})${doMinify ? ' (minify)' : ''}${doNls ? ' (nls)' : ''}${doManglePrivates ? ' (mangle-privates)' : ''}`); const t1 = Date.now(); // Read TSLib for banner @@ -815,7 +799,7 @@ ${tslib}`, platform: 'neutral', target: ['es2024'], packages: 'external', - sourcemap: 'external', + sourcemap: 'linked', sourcesContent: true, minify: doMinify, treeShaking: true, @@ -867,7 +851,7 @@ ${tslib}`, platform: 'node', target: ['es2024'], packages: 'external', - sourcemap: 'external', + sourcemap: 'linked', sourcesContent: true, minify: doMinify, treeShaking: true, @@ -898,6 +882,7 @@ ${tslib}`, // Post-process and write all output files let bundled = 0; + const mangleStats: { file: string; result: ConvertPrivateFieldsResult }[] = []; for (const { result } of buildResults) { if (!result.outputFiles) { continue; @@ -914,6 +899,17 @@ ${tslib}`, content = postProcessNLS(content, indexMap, preserveEnglish); } + // Convert native #private fields to regular properties. + // Skip extension host bundles - they expose API surface to extensions + // where true encapsulation matters more than the perf gain. + if (file.path.endsWith('.js') && doManglePrivates && !isExtensionHostBundle(file.path)) { + const mangleResult = convertPrivateFields(content, file.path); + content = mangleResult.code; + if (mangleResult.editCount > 0) { + mangleStats.push({ file: path.relative(path.join(REPO_ROOT, outDir), file.path), result: mangleResult }); + } + } + // Rewrite sourceMappingURL to CDN URL if configured if (sourceMapBaseUrl) { const relativePath = path.relative(path.join(REPO_ROOT, outDir), file.path); @@ -936,8 +932,21 @@ ${tslib}`, bundled++; } - // Copy resources (exclude dev files and tests for production) - await copyResources(outDir, target, true, true); + // Log mangle-privates stats + if (doManglePrivates && mangleStats.length > 0) { + let totalClasses = 0, totalFields = 0, totalEdits = 0, totalElapsed = 0; + for (const { file, result } of mangleStats) { + console.log(`[mangle-privates] ${file}: ${result.classCount} classes, ${result.fieldCount} fields, ${result.editCount} edits, ${result.elapsed}ms`); + totalClasses += result.classCount; + totalFields += result.fieldCount; + totalEdits += result.editCount; + totalElapsed += result.elapsed; + } + console.log(`[mangle-privates] Total: ${totalClasses} classes, ${totalFields} fields, ${totalEdits} edits, ${totalElapsed}ms`); + } + + // Copy resources (curated per-target patterns for production) + await copyResources(outDir, target); // Compile standalone TypeScript files (like Electron preload scripts) that cannot be bundled await compileStandaloneFiles(outDir, doMinify, target); @@ -970,7 +979,7 @@ async function watch(): Promise { const t1 = Date.now(); try { await transpile(outDir, false); - await copyResources(outDir, 'desktop', false, false); + await copyAllNonTsFiles(outDir, false); console.log(`Finished transpilation with 0 errors after ${Date.now() - t1} ms`); } catch (err) { console.error('[watch] Initial build failed:', err); @@ -1021,9 +1030,6 @@ async function watch(): Promise { } }; - // Extensions to watch and copy (non-TypeScript resources) - const copyExtensions = ['.css', '.html', '.js', '.json', '.ttf', '.svg', '.png', '.mp3', '.scm', '.sh', '.ps1', '.psm1', '.fish', '.zsh', '.scpt']; - // Watch src directory using existing gulp-watch based watcher let debounceTimer: ReturnType | undefined; const srcDir = path.join(REPO_ROOT, SRC_DIR); @@ -1032,7 +1038,8 @@ async function watch(): Promise { watchStream.on('data', (file: { path: string }) => { if (file.path.endsWith('.ts') && !file.path.endsWith('.d.ts')) { pendingTsFiles.add(file.path); - } else if (copyExtensions.some(ext => file.path.endsWith(ext))) { + } else { + // Copy any non-TS file (matches old gulp build's `src/**` behavior) pendingCopyFiles.add(file.path); } @@ -1071,6 +1078,7 @@ Options for 'transpile': Options for 'bundle': --minify Minify the output bundles --nls Process NLS (localization) strings + --mangle-privates Convert native #private fields to regular properties --out Output directory (default: out-vscode) --target Build target: desktop (default), server, server-web, web --source-map-base-url Rewrite sourceMappingURL to CDN URL @@ -1108,13 +1116,13 @@ async function main(): Promise { console.log(`[transpile] ${SRC_DIR} → ${outDir}${options.excludeTests ? ' (excluding tests)' : ''}`); const t1 = Date.now(); await transpile(outDir, options.excludeTests); - await copyResources(outDir, 'desktop', false, options.excludeTests); + await copyAllNonTsFiles(outDir, options.excludeTests); console.log(`[transpile] Done in ${Date.now() - t1}ms`); } break; case 'bundle': - await bundle(options.out ?? OUT_VSCODE_DIR, options.minify, options.nls, options.target as BuildTarget, options.sourceMapBaseUrl); + await bundle(options.out ?? OUT_VSCODE_DIR, options.minify, options.nls, options.manglePrivates, options.target as BuildTarget, options.sourceMapBaseUrl); break; default: diff --git a/build/next/private-to-property.ts b/build/next/private-to-property.ts new file mode 100644 index 0000000000000..64f1c4e74bf97 --- /dev/null +++ b/build/next/private-to-property.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as ts from 'typescript'; + +/** + * Converts native ES private fields (`#foo`) into regular JavaScript properties with short, + * globally unique names (e.g., `$a`, `$b`). This achieves two goals: + * + * 1. **Performance**: Native private fields are slower than regular properties in V8. + * 2. **Mangling**: Short replacement names reduce bundle size. + * + * ## Why not simply strip `#`? + * + * - **Inheritance collision**: If `class B extends A` and both declare `#x`, stripping `#` + * yields `x` on both - collision on child instances. + * - **Public property shadowing**: `class Foo extends Error { static #name = ... }` - stripping + * `#` produces `name` which shadows `Error.name`. + * + * ## Strategy: Globally unique names with `$` prefix + * + * Each (class, privateFieldName) pair gets a unique name from a global counter: `$a`, `$b`, ... + * This guarantees no inheritance collision and no shadowing of public properties. + * + * ## Why this is safe with syntax-only analysis + * + * Native `#` fields are **lexically scoped** to their declaring class body. Every declaration + * and every usage site is syntactically inside the class body. A single AST walk is sufficient + * to find all sites - no cross-file analysis or type checker needed. + */ + +// Short name generator: $a, $b, ..., $z, $A, ..., $Z, $aa, $ab, ... +const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +function generateShortName(index: number): string { + let name = ''; + do { + name = CHARS[index % CHARS.length] + name; + index = Math.floor(index / CHARS.length) - 1; + } while (index >= 0); + return '$' + name; +} + +interface Edit { + start: number; + end: number; + newText: string; +} + +// Private name → replacement name per class (identified by position in file) +type ClassScope = Map; + +export interface ConvertPrivateFieldsResult { + readonly code: string; + readonly classCount: number; + readonly fieldCount: number; + readonly editCount: number; + readonly elapsed: number; +} + +/** + * Converts all native `#` private fields/methods in the given JavaScript source to regular + * properties with short, globally unique names. + * + * @param code The JavaScript source code (typically a bundled output file). + * @param filename Used for TypeScript parser diagnostics only. + * @returns The transformed source code with `#` fields replaced, plus stats. + */ +export function convertPrivateFields(code: string, filename: string): ConvertPrivateFieldsResult { + const t1 = Date.now(); + // Quick bail-out: if there are no `#` characters, nothing to do + if (!code.includes('#')) { + return { code, classCount: 0, fieldCount: 0, editCount: 0, elapsed: Date.now() - t1 }; + } + + const sourceFile = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, false, ts.ScriptKind.JS); + + // Global counter for unique name generation + let nameCounter = 0; + let classCount = 0; + + // Collect all edits + const edits: Edit[] = []; + + // Class stack for resolving private names in nested classes. + // When a PrivateIdentifier is encountered, we search from innermost to outermost + // class scope - matching JS lexical resolution semantics. + const classStack: ClassScope[] = []; + + visit(sourceFile); + + if (edits.length === 0) { + return { code, classCount: 0, fieldCount: 0, editCount: 0, elapsed: Date.now() - t1 }; + } + + // Apply edits using substring concatenation (O(N+K), not O(N*K) like char-array splice) + edits.sort((a, b) => a.start - b.start); + const parts: string[] = []; + let lastEnd = 0; + for (const edit of edits) { + parts.push(code.substring(lastEnd, edit.start)); + parts.push(edit.newText); + lastEnd = edit.end; + } + parts.push(code.substring(lastEnd)); + return { code: parts.join(''), classCount, fieldCount: nameCounter, editCount: edits.length, elapsed: Date.now() - t1 }; + + // --- AST walking --- + + function visit(node: ts.Node): void { + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { + visitClass(node); + return; + } + ts.forEachChild(node, visit); + } + + function visitClass(node: ts.ClassDeclaration | ts.ClassExpression): void { + // 1) Collect all private field/method/accessor declarations in THIS class + const scope: ClassScope = new Map(); + for (const member of node.members) { + if (member.name && ts.isPrivateIdentifier(member.name)) { + const name = member.name.text; + if (!scope.has(name)) { + scope.set(name, generateShortName(nameCounter++)); + } + } + } + + if (scope.size > 0) { + classCount++; + } + classStack.push(scope); + + // 2) Walk the class body, replacing PrivateIdentifier nodes + ts.forEachChild(node, function walkInClass(child: ts.Node): void { + // Nested class: process independently with its own scope + if ((ts.isClassDeclaration(child) || ts.isClassExpression(child)) && child !== node) { + visitClass(child); + return; + } + + // Handle `#field in expr` (ergonomic brand check) - needs string literal replacement + if (ts.isBinaryExpression(child) && + child.operatorToken.kind === ts.SyntaxKind.InKeyword && + ts.isPrivateIdentifier(child.left)) { + const resolved = resolvePrivateName(child.left.text); + if (resolved !== undefined) { + edits.push({ + start: child.left.getStart(sourceFile), + end: child.left.getEnd(), + newText: `'${resolved}'` + }); + } + // Still need to walk the right-hand side for any private field usages + ts.forEachChild(child.right, walkInClass); + return; + } + + // Normal PrivateIdentifier usage (declaration, property access, method call) + if (ts.isPrivateIdentifier(child)) { + const resolved = resolvePrivateName(child.text); + if (resolved !== undefined) { + edits.push({ + start: child.getStart(sourceFile), + end: child.getEnd(), + newText: resolved + }); + } + return; + } + + ts.forEachChild(child, walkInClass); + }); + + classStack.pop(); + } + + function resolvePrivateName(name: string): string | undefined { + // Walk from innermost to outermost class scope (matches JS lexical resolution) + for (let i = classStack.length - 1; i >= 0; i--) { + const resolved = classStack[i].get(name); + if (resolved !== undefined) { + return resolved; + } + } + return undefined; + } +} diff --git a/build/next/test/private-to-property.test.ts b/build/next/test/private-to-property.test.ts new file mode 100644 index 0000000000000..3cde63a4bdaf4 --- /dev/null +++ b/build/next/test/private-to-property.test.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { convertPrivateFields } from '../private-to-property.ts'; + +suite('convertPrivateFields', () => { + + test('no # characters — quick bail-out', () => { + const result = convertPrivateFields('const x = 1; function foo() { return x; }', 'test.js'); + assert.strictEqual(result.code, 'const x = 1; function foo() { return x; }'); + assert.strictEqual(result.editCount, 0); + assert.strictEqual(result.classCount, 0); + assert.strictEqual(result.fieldCount, 0); + }); + + test('class without private fields — identity', () => { + const code = 'class Plain { x = 1; get() { return this.x; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.strictEqual(result.code, code); + assert.strictEqual(result.editCount, 0); + }); + + test('basic private field', () => { + const code = 'class Foo { #x = 1; get() { return this.#x; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#x'), 'should not contain #x'); + assert.ok(result.code.includes('$a'), 'should contain replacement $a'); + assert.strictEqual(result.classCount, 1); + assert.strictEqual(result.fieldCount, 1); + assert.strictEqual(result.editCount, 2); + }); + + test('multiple private fields in one class', () => { + const code = 'class Foo { #x = 1; #y = 2; get() { return this.#x + this.#y; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#x')); + assert.ok(!result.code.includes('#y')); + assert.strictEqual(result.fieldCount, 2); + assert.strictEqual(result.editCount, 4); + }); + + test('inheritance — same private name in parent and child get different replacements', () => { + const code = [ + 'class Parent { #a = 1; getA() { return this.#a; } }', + 'class Child extends Parent { #a = 2; getChildA() { return this.#a; } }', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#a')); + assert.ok(result.code.includes('$a'), 'Parent should get $a'); + assert.ok(result.code.includes('$b'), 'Child should get $b'); + }); + + test('static private field — no clash with inherited public property', () => { + const code = [ + 'class MyError extends Error {', + ' static #name = "MyError";', + ' check(data) { return data.name !== MyError.#name; }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#name')); + assert.ok(result.code.includes('$a')); + assert.ok(result.code.includes('data.name'), 'public property should be preserved'); + }); + + test('private method', () => { + const code = [ + 'class Bar {', + ' #normalize(s) { return s.toLowerCase(); }', + ' process(s) { return this.#normalize(s); }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#normalize')); + assert.strictEqual(result.fieldCount, 1); + }); + + test('getter/setter pair', () => { + const code = [ + 'class WithAccessors {', + ' #_val;', + ' get #val() { return this.#_val; }', + ' set #val(v) { this.#_val = v; }', + ' init() { this.#val = 42; }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#_val')); + assert.ok(!result.code.includes('#val')); + assert.strictEqual(result.fieldCount, 2); + }); + + test('nested classes — separate scopes', () => { + const code = [ + 'class Outer {', + ' #x = 1;', + ' method() {', + ' class Inner {', + ' #y = 2;', + ' foo() { return this.#y; }', + ' }', + ' return this.#x;', + ' }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#x')); + assert.ok(!result.code.includes('#y')); + assert.strictEqual(result.classCount, 2); + }); + + test('nested class accessing outer private field', () => { + const code = [ + 'class Outer {', + ' #x = 1;', + ' method() {', + ' class Inner {', + ' foo(o) { return o.#x; }', + ' }', + ' return this.#x;', + ' }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#x')); + const matches = result.code.match(/\$a/g); + assert.strictEqual(matches?.length, 3, 'decl + this.#x + o.#x = 3'); + }); + + test('nested classes — same private name get different replacements', () => { + const code = [ + 'class Outer {', + ' #x = 1;', + ' m() {', + ' class Inner {', + ' #x = 2;', + ' f() { return this.#x; }', + ' }', + ' return this.#x;', + ' }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#x')); + assert.ok(result.code.includes('$a'), 'Outer.#x → $a'); + assert.ok(result.code.includes('$b'), 'Inner.#x → $b'); + }); + + test('unrelated classes with same private name', () => { + const code = [ + 'class A { #data = 1; get() { return this.#data; } }', + 'class B { #data = 2; get() { return this.#data; } }', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#data')); + assert.ok(result.code.includes('$a')); + assert.ok(result.code.includes('$b')); + }); + + test('cross-instance access', () => { + const code = [ + 'class Foo {', + ' #secret = 42;', + ' equals(other) { return this.#secret === other.#secret; }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#secret')); + const matches = result.code.match(/\$a/g); + assert.strictEqual(matches?.length, 3); + }); + + test('string containing # is not modified', () => { + const code = [ + 'class Foo {', + ' #x = 1;', + ' label = "use #x for private";', + ' get() { return this.#x; }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(result.code.includes('"use #x for private"'), 'string preserved'); + assert.ok(!result.code.includes('this.#x'), 'usage replaced'); + }); + + test('#field in expr — brand check uses quoted string', () => { + const code = 'class Foo { #brand; static check(x) { if (#brand in x) return true; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#brand')); + assert.ok(result.code.includes('\'$a\' in x'), 'quoted string for in-check'); + }); + + test('string #brand in obj is not treated as private field', () => { + const code = 'class Foo { #brand = true; isFoo(obj) { return "#brand" in obj; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(result.code.includes('"#brand" in obj'), 'string literal preserved'); + }); + + test('transformed code is valid JavaScript', () => { + const code = [ + 'class Base { #id = 0; getId() { return this.#id; } }', + 'class Derived extends Base { #name; constructor(n) { super(); this.#name = n; } getName() { return this.#name; } }', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.doesNotThrow(() => new Function(result.code)); + }); + + test('transformed code executes correctly', () => { + const code = [ + 'class Counter {', + ' #count = 0;', + ' increment() { this.#count++; }', + ' get value() { return this.#count; }', + '}', + 'const c = new Counter();', + 'c.increment(); c.increment(); c.increment();', + 'return c.value;', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.strictEqual(new Function(result.code)(), 3); + }); + + test('transformed code executes correctly with inheritance', () => { + const code = [ + 'class Animal {', + ' #sound;', + ' constructor(s) { this.#sound = s; }', + ' speak() { return this.#sound; }', + '}', + 'class Dog extends Animal {', + ' #tricks = [];', + ' constructor() { super("woof"); }', + ' learn(trick) { this.#tricks.push(trick); }', + ' show() { return this.#tricks.join(","); }', + '}', + 'const d = new Dog();', + 'd.learn("sit"); d.learn("shake");', + 'return d.speak() + ":" + d.show();', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.strictEqual(new Function(result.code)(), 'woof:sit,shake'); + }); + + suite('name generation', () => { + + test('generates $a through $Z for 52 fields', () => { + const fields = []; + const usages = []; + for (let i = 0; i < 52; i++) { + fields.push(`#f${i};`); + usages.push(`this.#f${i}`); + } + const code = `class Big { ${fields.join(' ')} get() { return ${usages.join(' + ')}; } }`; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(result.code.includes('$a')); + assert.ok(result.code.includes('$Z')); + assert.strictEqual(result.fieldCount, 52); + }); + + test('wraps to $aa after $Z', () => { + const fields = []; + const usages = []; + for (let i = 0; i < 53; i++) { + fields.push(`#f${i};`); + usages.push(`this.#f${i}`); + } + const code = `class Big { ${fields.join(' ')} get() { return ${usages.join(' + ')}; } }`; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(result.code.includes('$aa')); + }); + }); +}); diff --git a/build/next/working.md b/build/next/working.md index bbf23a99806b1..71aac3fbdaf4c 100644 --- a/build/next/working.md +++ b/build/next/working.md @@ -97,6 +97,20 @@ Two placeholders that need injection: **Lesson:** Don't add new output file formats that create parity differences with the old build. The old build is the reference. +### 7. Resource Copying: Transpile vs Bundle + +**Problem:** The new build used curated, specific resource pattern lists (e.g., `desktopResourcePatterns`) for **both** transpile/dev and production/bundle builds. Team members kept discovering missing resources because every new non-TS file in `src/` required manually adding its pattern. + +**Root cause:** The old gulp build uses `gulp.src('src/**')` for dev/transpile — a catch-all glob that streams **every file** in `src/`. Non-TS files bypass the compiler via `tsFilter` + `tsFilter.restore` and land in `out/` untouched. This is inherently complete. The old build only uses curated resource lists for **production packaging** (`vscodeResourceIncludes`, `serverResourceIncludes` in the gulpfiles). + +**Fix:** +- **Transpile/dev path** (`transpile` command, `--watch` mode): Now uses `copyAllNonTsFiles()` which copies ALL non-TS files from `src/` to the output, matching old `gulp.src('src/**')` behavior. No curated patterns needed. +- **Bundle/production path** (`bundle` command): Continues using `copyResources()` with curated per-target patterns, matching old `vscodeResourceIncludes` etc. +- Removed `devOnlyResourcePatterns` and `testFixturePatterns` — no longer needed since the broad copy handles all dev resources. +- Watch mode incremental copy now accepts **any** non-`.ts` file change (removed the `copyExtensions` allowlist). + +**Lesson:** Dev builds should copy everything (completeness matters); production builds should be selective (size matters). Don't mix the two strategies. + --- ## Testing the Fix diff --git a/build/package-lock.json b/build/package-lock.json index 92ebc368df200..ffcaa1455e746 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -7945,9 +7945,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/build/package.json b/build/package.json index b6ccfdfc302dc..e889b6ac54d7b 100644 --- a/build/package.json +++ b/build/package.json @@ -70,7 +70,7 @@ "pretypecheck": "npm run copy-policy-dto", "typecheck": "cd .. && npx tsgo --project build/tsconfig.json", "watch": "npm run typecheck -- --watch", - "test": "mocha --ui tdd 'lib/**/*.test.ts'" + "test": "mocha --ui tdd '{lib,next}/**/*.test.ts'" }, "optionalDependencies": { "tree-sitter-typescript": "^0.23.2", diff --git a/build/win32/code.iss b/build/win32/code.iss index 0e2b143f3b8d9..f7091b28e5597 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1508,7 +1508,7 @@ var begin // Check if the user has forced Windows 10 style context menus on Windows 11 SubKey := 'Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32'; - Result := RegKeyExists(HKEY_CURRENT_USER, SubKey) or RegKeyExists(HKEY_LOCAL_MACHINE, SubKey); + Result := RegKeyExists(HKEY_CURRENT_USER, SubKey); end; function ShouldUseWindows11ContextMenu(): Boolean; @@ -1675,6 +1675,12 @@ begin if CurStep = ssPostInstall then begin #ifdef AppxPackageName + // Remove the appx package when user has forced Windows 10 context menus via + // registry. This handles the case where the user previously had the appx + // installed but now wants the classic context menu style. + if IsWindows10ContextMenuForced() then begin + RemoveAppxPackage(); + end; // Remove the old context menu registry keys if ShouldUseWindows11ContextMenu() then begin RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}'); diff --git a/cgmanifest.json b/cgmanifest.json index 402496fb37438..21554434500a7 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "c49899af4c7079170a189a43c533b17a11808631", - "tag": "39.5.2" + "commitHash": "a229dbf7a56336b847b34dfff1bac79afc311eee", + "tag": "39.6.0" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.5.2" + "version": "39.6.0" }, { "component": { diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 5209f73ad91a6..cd9b8de6afba6 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -3,35 +3,20 @@ version = 4 [[package]] -name = "addr2line" -version = "0.21.0" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -43,9 +28,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.14" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -58,38 +43,45 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.7" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.3" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.3" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + [[package]] name = "async-broadcast" version = "0.5.1" @@ -102,12 +94,12 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", - "event-listener-strategy 0.5.2", + "event-listener-strategy", "futures-core", "pin-project-lite", ] @@ -126,7 +118,7 @@ dependencies = [ "log", "parking", "polling 2.8.0", - "rustix 0.37.27", + "rustix 0.37.28", "slab", "socket2 0.4.10", "waker-fn", @@ -134,21 +126,20 @@ dependencies = [ [[package]] name = "async-io" -version = "2.3.2" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcccb0f599cfa2f8ace422d3555572f47424da5648a4382a9dd0310ff8210884" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "async-lock 3.3.0", + "autocfg", "cfg-if", "concurrent-queue", "futures-io", - "futures-lite 2.3.0", + "futures-lite 2.6.1", "parking", - "polling 3.7.0", - "rustix 0.38.34", + "polling 3.11.0", + "rustix 1.1.3", "slab", - "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -162,12 +153,12 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.3.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d034b430882f8381900d3fe6f0aaa3ad94f2cb4ac519b429692a1bc2dda4ae7b" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener 4.0.3", - "event-listener-strategy 0.4.0", + "event-listener 5.4.1", + "event-listener-strategy", "pin-project-lite", ] @@ -184,7 +175,7 @@ dependencies = [ "cfg-if", "event-listener 3.1.0", "futures-lite 1.13.0", - "rustix 0.38.34", + "rustix 0.38.44", "windows-sys 0.48.0", ] @@ -196,25 +187,25 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] name = "async-signal" -version = "0.2.6" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afe66191c335039c7bb78f99dc7520b0cbb166b3a1cb33a03f53d8a1c6f2afda" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ - "async-io 2.3.2", - "async-lock 3.3.0", + "async-io 2.6.0", + "async-lock 3.4.2", "atomic-waker", "cfg-if", "futures-core", "futures-io", - "rustix 0.38.34", + "rustix 1.1.3", "signal-hook-registry", "slab", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -225,13 +216,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.80" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] @@ -242,24 +233,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" - -[[package]] -name = "backtrace" -version = "0.3.71" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "base64" @@ -281,9 +257,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "block-buffer" @@ -303,25 +279,33 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" -version = "1.6.0" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "495f7104e962b7356f0aeb34247aca1fe7d2e783b346582db7f2904cb5717e88" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ "async-channel", - "async-lock 3.3.0", "async-task", "futures-io", - "futures-lite 2.3.0", + "futures-lite 2.6.1", "piper", ] [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byteorder" @@ -337,34 +321,43 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.0.98" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41c270e7540d725e65ac7f1b212ac8ce349719624d7bcff99f8e2e488e8cf03f" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ - "android-tzdata", "iana-time-zone", "num-traits", "serde", - "windows-targets 0.52.5", + "windows-link", ] [[package]] name = "clap" -version = "4.5.4" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -372,33 +365,39 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.2" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", - "clap_lex", + "clap_lex 1.0.0", "strsim", ] [[package]] name = "clap_derive" -version = "4.5.4" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] name = "clap_lex" -version = "0.7.0" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "clap_lex" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "code-cli" @@ -410,7 +409,7 @@ dependencies = [ "cfg-if", "chrono", "clap", - "clap_lex", + "clap_lex 0.7.7", "console", "const_format", "core-foundation", @@ -454,9 +453,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "concurrent-queue" @@ -469,31 +468,31 @@ dependencies = [ [[package]] name = "console" -version = "0.15.8" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" dependencies = [ "encode_unicode", - "lazy_static", "libc", + "once_cell", "unicode-width", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "const_format" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a214c7af3d04997541b18d432afaff4c455e79e2029079647e72fc2bd27673" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" dependencies = [ "const_format_proc_macros", ] [[package]] name = "const_format_proc_macros" -version = "0.2.32" +version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f6ff08fd20f4f299298a28e2dfa8a8ba1036e6cd2460ac1de7b425d76f2500" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" dependencies = [ "proc-macro2", "quote", @@ -512,24 +511,24 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.12" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58ebf8d6963185c7625d2c3c3962d99eb8936637b1427536d21dc36ae402ebad" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -545,15 +544,15 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -561,15 +560,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.6.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", ] @@ -649,6 +648,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -657,29 +666,29 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] name = "encode_unicode" -version = "0.3.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.34" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] [[package]] name = "enumflags2" -version = "0.7.9" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3278c9d5fb675e0a51dabcf4c0d355f692b064171535ba72361be1528a9d8e8d" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", "serde", @@ -687,29 +696,29 @@ dependencies = [ [[package]] name = "enumflags2_derive" -version = "0.7.9" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c785274071b1b420972453b306eeca06acf4633829db4223b58a2a8c5953bc4" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -731,20 +740,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "4.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b215c49b2b248c855fb73579eb1f4f26c38ffdc12973e20e07b91d78d5646e" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener" -version = "5.3.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9944b8ca13534cdfb2800775f8dd4902ff3fc75a50101466decadfdf322a24" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -753,21 +751,11 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958e4d70b6d5e81971bebec42271ec641e7ff4e170a6fa605f2b8a8b65cb97d3" -dependencies = [ - "event-listener 4.0.3", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.3.0", + "event-listener 5.4.1", "pin-project-lite", ] @@ -782,27 +770,32 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "filetime" -version = "0.2.23" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.4.1", - "windows-sys 0.52.0", + "libredox", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" -version = "1.0.30" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "libz-sys", @@ -815,6 +808,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -832,18 +831,18 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -856,9 +855,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -866,15 +865,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -883,9 +882,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" @@ -904,9 +903,9 @@ dependencies = [ [[package]] name = "futures-lite" -version = "2.3.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "futures-core", "pin-project-lite", @@ -914,32 +913,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -986,26 +985,45 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "r-efi", + "wasip2", ] [[package]] -name = "gimli" -version = "0.28.1" +name = "getrandom" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes", "fnv", @@ -1013,7 +1031,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap 2.2.6", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1028,9 +1046,18 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -1044,6 +1071,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1089,9 +1122,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -1101,9 +1134,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "0.14.28" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ "bytes", "futures-channel", @@ -1116,7 +1149,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.7", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1138,14 +1171,15 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.60" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", "windows-core", ] @@ -1161,21 +1195,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -1184,104 +1219,72 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] [[package]] -name = "icu_provider_macros" -version = "1.5.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.65", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1290,9 +1293,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -1310,32 +1313,34 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] name = "indicatif" -version = "0.17.8" +version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" dependencies = [ "console", - "instant", "number_prefix", "portable-atomic", "unicode-width", + "web-time", ] [[package]] name = "inout" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding", "generic-array", @@ -1356,16 +1361,16 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", "windows-sys 0.48.0", ] [[package]] name = "ipnet" -version = "2.9.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "is-docker" @@ -1388,22 +1393,23 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.0" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -1423,31 +1429,38 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.10.0", "libc", + "redox_syscall 0.7.1", ] [[package]] name = "libz-sys" -version = "1.1.16" +version = "1.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" dependencies = [ "cc", "pkg-config", @@ -1460,7 +1473,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.10.0", "libc", ] @@ -1472,31 +1485,36 @@ checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.7.4" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.21" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "md5" @@ -1506,9 +1524,9 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" -version = "2.7.2" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -1536,31 +1554,31 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.7.3" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87dfd01fe195c66b572b37921ad8803d010623c0aca821bea2302239d155cdae" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "adler", + "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "0.8.11" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", ] [[package]] name = "native-tls" -version = "0.2.11" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +checksum = "6cdede44f9a69cab2899a2049e2c3bd49bf911a157f6a3353d4a91c61abbce44" dependencies = [ - "lazy_static", "libc", "log", "openssl", @@ -1584,11 +1602,23 @@ dependencies = [ "memoffset 0.7.1", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "ntapi" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] @@ -1609,9 +1639,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c165a9ab64cf766f73521c0dd2cfdff64f488b8f0b3e621face3462d3db536d7" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -1629,9 +1659,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -1673,16 +1703,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "number_prefix" version = "0.4.0" @@ -1690,70 +1710,226 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" [[package]] -name = "object" -version = "0.32.2" +name = "objc2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ - "memchr", + "objc2-encode", ] [[package]] -name = "once_cell" -version = "1.19.0" +name = "objc2-cloud-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] [[package]] -name = "open" -version = "4.2.0" +name = "objc2-core-data" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a083c0c7e5e4a8ec4176346cf61f67ac674e8bfb059d9226e1c54a96b377c12" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "is-wsl", - "libc", - "pathdiff", + "objc2", + "objc2-foundation", ] [[package]] -name = "openssl" -version = "0.10.72" +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.5.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", + "bitflags 2.10.0", + "dispatch2", + "objc2", ] [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "objc2-core-graphics" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.65", + "bitflags 2.10.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] -name = "openssl-probe" -version = "0.1.5" +name = "objc2-core-image" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] [[package]] -name = "openssl-sys" -version = "0.9.107" +name = "objc2-core-location" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ - "cc", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a083c0c7e5e4a8ec4176346cf61f67ac674e8bfb059d9226e1c54a96b377c12" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", "libc", "pkg-config", "vcpkg", @@ -1822,25 +1998,30 @@ dependencies = [ [[package]] name = "os_info" -version = "3.8.2" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae99c7fa6dd38c7cafe1ec085e804f8f555a2f8659b0dbe03f1f9963a9b51092" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" dependencies = [ + "android_system_properties", "log", - "windows-sys 0.52.0", + "nix 0.30.1", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "windows-sys 0.61.2", ] [[package]] name = "parking" -version = "2.2.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.2" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -1848,60 +2029,54 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.1", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.5", + "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pathdiff" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -1911,20 +2086,20 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464db0c665917b13ebb5d453ccdec4add5658ee1adc7affc7677615356a8afaf" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", - "fastrand 2.1.0", + "fastrand 2.3.0", "futures-io", ] [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "polling" @@ -1944,24 +2119,32 @@ dependencies = [ [[package]] name = "polling" -version = "3.7.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645493cf344456ef24219d02a768cf1fb92ddf8c92161679ae3d91b91a637be3" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.2", "pin-project-lite", - "rustix 0.38.34", - "tracing", - "windows-sys 0.52.0", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] name = "portable-atomic" -version = "1.6.0" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] [[package]] name = "powerfmt" @@ -1971,9 +2154,22 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.17" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.115", +] [[package]] name = "proc-macro-crate" @@ -1987,22 +2183,28 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.83" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b33eb56c327dec362a9e55b3ad14f9d2f0904fb5a5b03b513ab5465399e9f43" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.7.3" @@ -2062,7 +2264,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", ] [[package]] @@ -2076,38 +2278,38 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.4.1" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", ] [[package]] name = "redox_syscall" -version = "0.5.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.10.0", ] [[package]] name = "redox_users" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "libredox", "thiserror", ] [[package]] name = "regex" -version = "1.10.4" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2117,9 +2319,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.6" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2128,9 +2330,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.3" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" @@ -2176,22 +2378,19 @@ dependencies = [ [[package]] name = "rmp" -version = "0.8.14" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" dependencies = [ - "byteorder", "num-traits", - "paste", ] [[package]] name = "rmp-serde" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" dependencies = [ - "byteorder", "rmp", "serde", ] @@ -2261,17 +2460,11 @@ dependencies = [ "yasna", ] -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - [[package]] name = "rustix" -version = "0.37.27" +version = "0.37.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" dependencies = [ "bitflags 1.3.2", "errno", @@ -2283,15 +2476,28 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.4.14", - "windows-sys 0.52.0", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] @@ -2303,19 +2509,25 @@ dependencies = [ "base64", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schannel" -version = "0.1.23" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2326,9 +2538,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secret-service" -version = "3.0.1" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5da1a5ad4d28c03536f82f77d9f36603f5e37d8869ac98f0a750d5b5686d8d95" +checksum = "b5204d39df37f06d1944935232fd2dfe05008def7ca599bf28c0800366c8a8f9" dependencies = [ "futures-util", "generic-array", @@ -2342,11 +2554,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -2355,63 +2567,82 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" -version = "1.0.202" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] [[package]] name = "serde_bytes" -version = "0.11.14" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b8497c313fd43ab992087548117643f6fcd935cbf36f176ffda0aacf9591734" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" dependencies = [ "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.202" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] name = "serde_json" -version = "1.0.117" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", - "ryu", + "memchr", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_repr" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] @@ -2439,9 +2670,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -2456,33 +2687,43 @@ checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" @@ -2496,19 +2737,29 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -2524,9 +2775,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -2541,9 +2792,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.65" +version = "2.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2863d96a84c6439701d7a38f9de935ec562c8832cc55d1dde0f513b52fad106" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" dependencies = [ "proc-macro2", "quote", @@ -2558,13 +2809,13 @@ checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] @@ -2604,9 +2855,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.40" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" dependencies = [ "filetime", "libc", @@ -2615,60 +2866,61 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.10.1" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ - "cfg-if", - "fastrand 2.1.0", - "rustix 0.38.34", - "windows-sys 0.52.0", + "fastrand 2.3.0", + "getrandom 0.4.1", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] name = "thiserror" -version = "1.0.61" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.61" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] name = "time" -version = "0.3.36" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -2676,33 +2928,31 @@ dependencies = [ [[package]] name = "tokio" -version = "1.38.2" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68722da18b0fc4a05fdc1120b302b82051265792a1e1b399086e9b204b10ad3d" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ - "backtrace", "bytes", "libc", "mio", - "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.7", + "socket2 0.6.2", "tokio-macros", "tracing", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] @@ -2717,9 +2967,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.15" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -2742,9 +2992,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -2756,9 +3006,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.6" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" [[package]] name = "toml_edit" @@ -2766,22 +3016,22 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.13.0", "toml_datetime", "winnow", ] [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -2790,20 +3040,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -2864,9 +3114,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.17.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uds_windows" @@ -2881,31 +3131,32 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" [[package]] name = "unicode-width" -version = "0.1.12" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2920,12 +3171,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2934,18 +3179,20 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.8.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ - "getrandom 0.2.15", - "serde", + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", ] [[package]] @@ -2956,9 +3203,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "waker-fn" @@ -2983,52 +3230,60 @@ checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasm-bindgen" -version = "0.2.92" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "cfg-if", - "wasm-bindgen-macro", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.92" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "bumpalo", - "log", + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", "once_cell", - "proc-macro2", - "quote", - "syn 2.0.65", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.42" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3036,28 +3291,53 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.65", - "wasm-bindgen-backend", + "syn 2.0.115", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] [[package]] name = "wasm-streams" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" dependencies = [ "futures-util", "js-sys", @@ -3066,11 +3346,33 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.69" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -3100,11 +3402,61 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.52.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-targets 0.52.5", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", ] [[package]] @@ -3122,7 +3474,34 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.5", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -3142,18 +3521,35 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.5" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows_aarch64_gnullvm 0.52.5", - "windows_aarch64_msvc 0.52.5", - "windows_i686_gnu 0.52.5", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.5", - "windows_x86_64_gnu 0.52.5", - "windows_x86_64_gnullvm 0.52.5", - "windows_x86_64_msvc 0.52.5", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -3164,9 +3560,15 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -3176,9 +3578,15 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.5" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -3188,15 +3596,27 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -3206,9 +3626,15 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -3218,9 +3644,15 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -3230,9 +3662,15 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -3242,9 +3680,15 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" @@ -3275,36 +3719,117 @@ dependencies = [ ] [[package]] -name = "write16" -version = "1.0.0" +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.115", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.115", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "xattr" -version = "1.3.1" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "linux-raw-sys 0.4.14", - "rustix 0.38.34", + "rustix 1.1.3", ] [[package]] name = "xdg-home" -version = "1.1.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e5a325c3cb8398ad6cf859c1135b25dd29e186679cf2da7581d9679f63b38e" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" dependencies = [ "libc", - "winapi", + "windows-sys 0.59.0", ] [[package]] @@ -3319,11 +3844,10 @@ dependencies = [ [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -3331,13 +3855,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", "synstructure", ] @@ -3359,7 +3883,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.26.4", "once_cell", "ordered-stream", "rand 0.8.5", @@ -3402,38 +3926,69 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "zerofrom" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", "synstructure", ] [[package]] name = "zeroize" -version = "1.7.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -3442,13 +3997,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.65", + "syn 2.0.115", ] [[package]] @@ -3464,6 +4019,12 @@ dependencies = [ "time", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zvariant" version = "3.15.2" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 1c1f8d93b57c8..175069f448a50 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -76,7 +76,7 @@ russh-keys = { git = "https://github.com/microsoft/vscode-russh", branch = "main [profile.release] strip = true -lto = true +lto = "thin" [features] default = [] diff --git a/cli/src/json_rpc.rs b/cli/src/json_rpc.rs index 57baac01c5e41..1ef0ecf01ffda 100644 --- a/cli/src/json_rpc.rs +++ b/cli/src/json_rpc.rs @@ -69,7 +69,7 @@ pub async fn start_json_rpc( n = read.read_line(&mut read_buf) => { let r = match n { Ok(0) => return Ok(None), - Ok(n) => dispatcher.dispatch(read_buf[..n].as_bytes()), + Ok(n) => dispatcher.dispatch(&read_buf.as_bytes()[..n]), Err(e) => return Err(e) }; diff --git a/cli/src/util/http.rs b/cli/src/util/http.rs index e49120578a77c..9658ec1fcbd62 100644 --- a/cli/src/util/http.rs +++ b/cli/src/util/http.rs @@ -66,7 +66,7 @@ impl SimpleResponse { pub fn url_path_basename(&self) -> Option { self.url.as_ref().and_then(|u| { u.path_segments() - .and_then(|s| s.last().map(|s| s.to_owned())) + .and_then(|mut s| s.next_back().map(|s| s.to_owned())) }) } } @@ -176,7 +176,7 @@ impl SimpleHttp for ReqwestSimpleHttp { url: Some(res.url().clone()), read: Box::pin( res.bytes_stream() - .map_err(|e| futures::io::Error::new(futures::io::ErrorKind::Other, e)) + .map_err(futures::io::Error::other) .into_async_read() .compat(), ), diff --git a/eslint.config.js b/eslint.config.js index dfb489e0f0bc6..bc3c698a129f2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -77,7 +77,7 @@ export default tseslint.config( 'no-var': 'warn', 'semi': 'warn', 'local/code-translation-remind': 'warn', - 'local/code-no-native-private': 'warn', + 'local/code-no-declare-const-enum': 'warn', 'local/code-parameter-properties-must-have-explicit-accessibility': 'warn', 'local/code-no-nls-in-standalone-editor': 'warn', 'local/code-no-potentially-unsafe-disposables': 'warn', @@ -1686,6 +1686,7 @@ export default tseslint.config( 'vs/workbench/~', 'vs/workbench/services/*/~', 'vs/workbench/contrib/*/~', + 'vs/sessions/~', 'vs/workbench/contrib/terminal/terminalContribChatExports*', 'vs/workbench/contrib/terminal/terminalContribExports*', 'vscode-notebook-renderer', // Type only import @@ -1762,6 +1763,17 @@ export default tseslint.config( } ] }, + { + 'target': 'src/vs/sessions/electron-browser/sessions.ts', + 'layer': 'electron-browser', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/sessions/~', + 'vs/sessions/sessions.desktop.main.js' + ] + }, { 'target': 'src/vs/server/~', 'restrictions': [ @@ -1894,7 +1906,74 @@ export default tseslint.config( 'src/*.js', '*' // node.js ] - } + }, + { + 'target': 'src/vs/sessions/sessions.common.main.ts', + 'layer': 'browser', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/editor/editor.all.js', + 'vs/workbench/~', + 'vs/workbench/api/~', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~', + 'vs/workbench/contrib/terminal/terminal.all.js' + ] + }, + { + 'target': 'src/vs/sessions/sessions.desktop.main.ts', + 'layer': 'electron-browser', + 'restrictions': [ + 'vs/base/*/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/editor/editor.all.js', + 'vs/sessions/~', + 'vs/sessions/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/api/~', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~', + 'vs/sessions/sessions.common.main.js' + ] + }, + { + 'target': 'src/vs/sessions/~', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/browser/**', + 'vs/workbench/contrib/**', + 'vs/workbench/services/*/~', + 'vs/sessions/~' + ] + }, + { + 'target': 'src/vs/sessions/contrib/*/~', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/browser/**', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~', + 'vs/sessions/~', + 'vs/sessions/contrib/*/~' + ] + }, ] } }, diff --git a/extensions/configuration-editing/.vscodeignore b/extensions/configuration-editing/.vscodeignore index 679a6d6859f0a..7c246a3d95f20 100644 --- a/extensions/configuration-editing/.vscodeignore +++ b/extensions/configuration-editing/.vscodeignore @@ -1,9 +1,8 @@ test/** src/** -tsconfig.json +tsconfig*.json out/** -extension.webpack.config.js -extension-browser.webpack.config.js +esbuild* package-lock.json build/** schemas/devContainer.codespaces.schema.json diff --git a/extensions/configuration-editing/esbuild.browser.mts b/extensions/configuration-editing/esbuild.browser.mts new file mode 100644 index 0000000000000..347aadc7c9229 --- /dev/null +++ b/extensions/configuration-editing/esbuild.browser.mts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import type { Plugin } from 'esbuild'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +/** + * Plugin to redirect `./node/net` imports to `./browser/net` for the browser build. + */ +const browserNetPlugin: Plugin = { + name: 'browser-net-redirect', + setup(build) { + build.onResolve({ filter: /\/node\/net$/ }, args => { + return { path: path.join(path.dirname(args.resolveDir), 'src', 'browser', 'net.ts') }; + }); + }, +}; + +run({ + platform: 'browser', + entryPoints: { + 'configurationEditingMain': path.join(srcDir, 'configurationEditingMain.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + plugins: [browserNetPlugin], + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv); diff --git a/extensions/configuration-editing/esbuild.mts b/extensions/configuration-editing/esbuild.mts new file mode 100644 index 0000000000000..a0d03289442b4 --- /dev/null +++ b/extensions/configuration-editing/esbuild.mts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'configurationEditingMain': path.join(srcDir, 'configurationEditingMain.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/configuration-editing/extension-browser.webpack.config.js b/extensions/configuration-editing/extension-browser.webpack.config.js deleted file mode 100644 index 1136c92520837..0000000000000 --- a/extensions/configuration-editing/extension-browser.webpack.config.js +++ /dev/null @@ -1,23 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import path from 'path'; -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/configurationEditingMain.ts' - }, - output: { - filename: 'configurationEditingMain.js' - }, - resolve: { - alias: { - './node/net': path.resolve(import.meta.dirname, 'src', 'browser', 'net'), - } - } -}); - diff --git a/extensions/configuration-editing/package.json b/extensions/configuration-editing/package.json index 33156c201dc0c..42c4a708c0cdc 100644 --- a/extensions/configuration-editing/package.json +++ b/extensions/configuration-editing/package.json @@ -22,7 +22,13 @@ "browser": "./dist/browser/configurationEditingMain", "scripts": { "compile": "gulp compile-extension:configuration-editing", - "watch": "gulp watch-extension:configuration-editing" + "watch": "gulp watch-extension:configuration-editing", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch" }, "dependencies": { "@octokit/rest": "^21.1.1", diff --git a/extensions/configuration-editing/tsconfig.browser.json b/extensions/configuration-editing/tsconfig.browser.json new file mode 100644 index 0000000000000..f609992ab5893 --- /dev/null +++ b/extensions/configuration-editing/tsconfig.browser.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "exclude": [ + "./src/test/**" + ], + "files": [ + "./src/configurationEditingMain.ts" + ] +} diff --git a/extensions/emmet/.vscodeignore b/extensions/emmet/.vscodeignore index ccf478fb97f7b..c63ff6f6805d2 100644 --- a/extensions/emmet/.vscodeignore +++ b/extensions/emmet/.vscodeignore @@ -2,9 +2,8 @@ test/** test-workspace/** src/** out/** -tsconfig.json -extension.webpack.config.js -extension-browser.webpack.config.js +tsconfig*.json +esbuild* CONTRIBUTING.md cgmanifest.json package-lock.json diff --git a/extensions/emmet/esbuild.browser.mts b/extensions/emmet/esbuild.browser.mts new file mode 100644 index 0000000000000..5436791dde64d --- /dev/null +++ b/extensions/emmet/esbuild.browser.mts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + platform: 'browser', + entryPoints: { + 'emmetBrowserMain': path.join(srcDir, 'browser', 'emmetBrowserMain.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv); diff --git a/extensions/emmet/esbuild.mts b/extensions/emmet/esbuild.mts new file mode 100644 index 0000000000000..1bc372fa8c6d0 --- /dev/null +++ b/extensions/emmet/esbuild.mts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'node'); + +run({ + platform: 'node', + entryPoints: { + 'emmetNodeMain': path.join(srcDir, 'node', 'emmetNodeMain.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/emmet/package.json b/extensions/emmet/package.json index dce1af57a1b93..67f834f913920 100644 --- a/extensions/emmet/package.json +++ b/extensions/emmet/package.json @@ -474,8 +474,14 @@ } }, "scripts": { - "watch": "gulp watch-extension:emmet", "compile": "gulp compile-extension:emmet", + "watch": "gulp watch-extension:emmet", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch", "deps": "npm install @vscode/emmet-helper" }, "devDependencies": { diff --git a/extensions/emmet/tsconfig.browser.json b/extensions/emmet/tsconfig.browser.json new file mode 100644 index 0000000000000..781e4ba631c54 --- /dev/null +++ b/extensions/emmet/tsconfig.browser.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "exclude": [ + "./src/test/**" + ], + "files": [ + "./src/browser/emmetBrowserMain.ts" + ] +} diff --git a/extensions/esbuild-extension-common.mts b/extensions/esbuild-extension-common.mts index 513656ae89fb3..da028ca7e02db 100644 --- a/extensions/esbuild-extension-common.mts +++ b/extensions/esbuild-extension-common.mts @@ -16,17 +16,7 @@ type BuildOptions = Partial & { * Build the source code once using esbuild. */ async function build(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { - await esbuild.build({ - bundle: true, - minify: true, - sourcemap: false, - format: 'cjs', - platform: 'node', - target: ['es2024'], - external: ['vscode'], - ...options, - }); - + await esbuild.build(options); await didBuild?.(options.outdir); } @@ -42,10 +32,46 @@ async function tryBuild(options: BuildOptions, didBuild?: (outDir: string) => un } interface RunConfig { - srcDir: string; - outdir: string; - entryPoints: string[] | Record | { in: string; out: string }[]; - additionalOptions?: Partial; + readonly platform: 'node' | 'browser'; + readonly srcDir: string; + readonly outdir: string; + readonly entryPoints: string[] | Record | { in: string; out: string }[]; + readonly additionalOptions?: Partial; +} + +function resolveOptions(config: RunConfig, outdir: string): BuildOptions { + const options: BuildOptions = { + platform: config.platform, + bundle: true, + minify: true, + sourcemap: true, + target: ['es2024'], + external: ['vscode'], + entryPoints: config.entryPoints, + outdir, + logOverride: { + 'import-is-undefined': 'error', + }, + ...(config.additionalOptions || {}), + }; + + if (config.platform === 'node') { + options.format = 'cjs'; + options.mainFields = ['module', 'main']; + } else if (config.platform === 'browser') { + options.format = 'cjs'; + options.mainFields = ['browser', 'module', 'main']; + options.alias = { + 'path': 'path-browserify', + }; + options.define = { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }; + } + + return options; } export async function run(config: RunConfig, args: string[], didBuild?: (outDir: string) => unknown): Promise { @@ -57,14 +83,7 @@ export async function run(config: RunConfig, args: string[], didBuild?: (outDir: outdir = path.join(outputRoot, outputDirName); } - const resolvedOptions: BuildOptions = { - entryPoints: config.entryPoints, - outdir, - logOverride: { - 'import-is-undefined': 'error', - }, - ...(config.additionalOptions || {}), - }; + const resolvedOptions = resolveOptions(config, outdir); const isWatch = args.indexOf('--watch') >= 0; if (isWatch) { diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 80aebb0616af1..0791401665ec0 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import { Model } from '../model'; import { Repository as BaseRepository, Resource } from '../repository'; import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, GitErrorCodes, CloneOptions, CommitShortStat, DiffChange, Worktree, RepositoryKind, RepositoryAccessDetails } from './git'; diff --git a/extensions/git/src/quickDiffProvider.ts b/extensions/git/src/quickDiffProvider.ts new file mode 100644 index 0000000000000..3b1aa64c8faae --- /dev/null +++ b/extensions/git/src/quickDiffProvider.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FileType, l10n, LogOutputChannel, QuickDiffProvider, Uri, workspace } from 'vscode'; +import { IRepositoryResolver, Repository } from './repository'; +import { isDescendant, pathEquals } from './util'; +import { toGitUri } from './uri'; +import { Status } from './api/git'; + +export class GitQuickDiffProvider implements QuickDiffProvider { + readonly label = l10n.t('Git Local Changes (Working Tree)'); + + constructor( + private readonly repository: Repository, + private readonly repositoryResolver: IRepositoryResolver, + private readonly logger: LogOutputChannel + ) { } + + async provideOriginalResource(uri: Uri): Promise { + this.logger.trace(`[Repository][provideOriginalResource] Resource: ${uri.toString()}`); + + if (uri.scheme !== 'file') { + this.logger.trace(`[Repository][provideOriginalResource] Resource is not a file: ${uri.scheme}`); + return undefined; + } + + // Ignore path that is inside the .git directory (ex: COMMIT_EDITMSG) + if (isDescendant(this.repository.dotGit.commonPath ?? this.repository.dotGit.path, uri.fsPath)) { + this.logger.trace(`[Repository][provideOriginalResource] Resource is inside .git directory: ${uri.toString()}`); + return undefined; + } + + // Ignore symbolic links + const stat = await workspace.fs.stat(uri); + if ((stat.type & FileType.SymbolicLink) !== 0) { + this.logger.trace(`[Repository][provideOriginalResource] Resource is a symbolic link: ${uri.toString()}`); + return undefined; + } + + // Ignore path that is not inside the current repository + if (this.repositoryResolver.getRepository(uri) !== this.repository) { + this.logger.trace(`[Repository][provideOriginalResource] Resource is not part of the repository: ${uri.toString()}`); + return undefined; + } + + // Ignore path that is inside a hidden repository + if (this.repository.isHidden === true) { + this.logger.trace(`[Repository][provideOriginalResource] Repository is hidden: ${uri.toString()}`); + return undefined; + } + + // Ignore path that is inside a merge group + if (this.repository.mergeGroup.resourceStates.some(r => pathEquals(r.resourceUri.fsPath, uri.fsPath))) { + this.logger.trace(`[Repository][provideOriginalResource] Resource is part of a merge group: ${uri.toString()}`); + return undefined; + } + + // Ignore path that is untracked + if (this.repository.untrackedGroup.resourceStates.some(r => pathEquals(r.resourceUri.path, uri.path)) || + this.repository.workingTreeGroup.resourceStates.some(r => pathEquals(r.resourceUri.path, uri.path) && r.type === Status.UNTRACKED)) { + this.logger.trace(`[Repository][provideOriginalResource] Resource is untracked: ${uri.toString()}`); + return undefined; + } + + // Ignore path that is git ignored + const ignored = await this.repository.checkIgnore([uri.fsPath]); + if (ignored.size > 0) { + this.logger.trace(`[Repository][provideOriginalResource] Resource is git ignored: ${uri.toString()}`); + return undefined; + } + + const originalResource = toGitUri(uri, '', { replaceFileExtension: true }); + this.logger.trace(`[Repository][provideOriginalResource] Original resource: ${originalResource.toString()}`); + + return originalResource; + } +} + +export class StagedResourceQuickDiffProvider implements QuickDiffProvider { + readonly label = l10n.t('Git Local Changes (Index)'); + + constructor( + private readonly _repository: Repository, + private readonly logger: LogOutputChannel + ) { } + + async provideOriginalResource(uri: Uri): Promise { + this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource: ${uri.toString()}`); + + if (uri.scheme !== 'file') { + this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource is not a file: ${uri.scheme}`); + return undefined; + } + + // Ignore path that is inside a hidden repository + if (this._repository.isHidden === true) { + this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Repository is hidden: ${uri.toString()}`); + return undefined; + } + + // Ignore symbolic links + const stat = await workspace.fs.stat(uri); + if ((stat.type & FileType.SymbolicLink) !== 0) { + this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource is a symbolic link: ${uri.toString()}`); + return undefined; + } + + // Ignore resources that are not in the index group + if (!this._repository.indexGroup.resourceStates.some(r => pathEquals(r.resourceUri.fsPath, uri.fsPath))) { + this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource is not part of a index group: ${uri.toString()}`); + return undefined; + } + + const originalResource = toGitUri(uri, 'HEAD', { replaceFileExtension: true }); + this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Original resource: ${originalResource.toString()}`); + return originalResource; + } +} diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 8175e9b576a52..24a6dca48c388 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import * as fsPromises from 'fs/promises'; import * as path from 'path'; import picomatch from 'picomatch'; -import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, Disposable, Event, EventEmitter, ExcludeSettingOptions, FileDecoration, FileType, l10n, LogLevel, LogOutputChannel, Memento, ProgressLocation, ProgressOptions, QuickDiffProvider, RelativePattern, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit } from 'vscode'; +import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, Disposable, Event, EventEmitter, ExcludeSettingOptions, FileDecoration, l10n, LogLevel, LogOutputChannel, Memento, ProgressLocation, ProgressOptions, RelativePattern, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit } from 'vscode'; import { ActionButton } from './actionButton'; import { ApiRepository } from './api/api1'; import { Branch, BranchQuery, Change, CommitOptions, DiffChange, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, RepositoryKind, Status } from './api/git'; @@ -28,6 +28,7 @@ import { IFileWatcher, watch } from './watch'; import { ISourceControlHistoryItemDetailsProviderRegistry } from './historyItemDetailsProvider'; import { GitArtifactProvider } from './artifactProvider'; import { RepositoryCache } from './repositoryCache'; +import { GitQuickDiffProvider, StagedResourceQuickDiffProvider } from './quickDiffProvider'; const timeout = (millis: number) => new Promise(c => setTimeout(c, millis)); @@ -970,7 +971,7 @@ export class Repository implements Disposable { this._sourceControl = scm.createSourceControl('git', 'Git', root, icon, this._isHidden, parent); this._sourceControl.contextValue = repository.kind; - this._sourceControl.quickDiffProvider = this; + this._sourceControl.quickDiffProvider = new GitQuickDiffProvider(this, this.repositoryResolver, logger); this._sourceControl.secondaryQuickDiffProvider = new StagedResourceQuickDiffProvider(this, logger); this._historyProvider = new GitHistoryProvider(historyItemDetailProviderRegistry, this, logger); @@ -1102,72 +1103,6 @@ export class Repository implements Disposable { return undefined; } - /** - * Quick diff label - */ - get label(): string { - return l10n.t('Git Local Changes (Working Tree)'); - } - - async provideOriginalResource(uri: Uri): Promise { - this.logger.trace(`[Repository][provideOriginalResource] Resource: ${uri.toString()}`); - - if (uri.scheme !== 'file') { - this.logger.trace(`[Repository][provideOriginalResource] Resource is not a file: ${uri.scheme}`); - return undefined; - } - - // Ignore path that is inside the .git directory (ex: COMMIT_EDITMSG) - if (isDescendant(this.dotGit.commonPath ?? this.dotGit.path, uri.fsPath)) { - this.logger.trace(`[Repository][provideOriginalResource] Resource is inside .git directory: ${uri.toString()}`); - return undefined; - } - - // Ignore symbolic links - const stat = await workspace.fs.stat(uri); - if ((stat.type & FileType.SymbolicLink) !== 0) { - this.logger.trace(`[Repository][provideOriginalResource] Resource is a symbolic link: ${uri.toString()}`); - return undefined; - } - - // Ignore path that is not inside the current repository - if (this.repositoryResolver.getRepository(uri) !== this) { - this.logger.trace(`[Repository][provideOriginalResource] Resource is not part of the repository: ${uri.toString()}`); - return undefined; - } - - // Ignore path that is inside a hidden repository - if (this.isHidden === true) { - this.logger.trace(`[Repository][provideOriginalResource] Repository is hidden: ${uri.toString()}`); - return undefined; - } - - // Ignore path that is inside a merge group - if (this.mergeGroup.resourceStates.some(r => pathEquals(r.resourceUri.fsPath, uri.fsPath))) { - this.logger.trace(`[Repository][provideOriginalResource] Resource is part of a merge group: ${uri.toString()}`); - return undefined; - } - - // Ignore path that is untracked - if (this.untrackedGroup.resourceStates.some(r => pathEquals(r.resourceUri.path, uri.path)) || - this.workingTreeGroup.resourceStates.some(r => pathEquals(r.resourceUri.path, uri.path) && r.type === Status.UNTRACKED)) { - this.logger.trace(`[Repository][provideOriginalResource] Resource is untracked: ${uri.toString()}`); - return undefined; - } - - // Ignore path that is git ignored - const ignored = await this.checkIgnore([uri.fsPath]); - if (ignored.size > 0) { - this.logger.trace(`[Repository][provideOriginalResource] Resource is git ignored: ${uri.toString()}`); - return undefined; - } - - const originalResource = toGitUri(uri, '', { replaceFileExtension: true }); - this.logger.trace(`[Repository][provideOriginalResource] Original resource: ${originalResource.toString()}`); - - return originalResource; - } - async getInputTemplate(): Promise { const commitMessage = (await Promise.all([this.repository.getMergeMessage(), this.repository.getSquashMessage()])).find(msg => !!msg); @@ -3286,44 +3221,3 @@ export class Repository implements Disposable { this.disposables = dispose(this.disposables); } } - -export class StagedResourceQuickDiffProvider implements QuickDiffProvider { - readonly label = l10n.t('Git Local Changes (Index)'); - - constructor( - private readonly _repository: Repository, - private readonly logger: LogOutputChannel - ) { } - - async provideOriginalResource(uri: Uri): Promise { - this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource: ${uri.toString()}`); - - if (uri.scheme !== 'file') { - this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource is not a file: ${uri.scheme}`); - return undefined; - } - - // Ignore path that is inside a hidden repository - if (this._repository.isHidden === true) { - this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Repository is hidden: ${uri.toString()}`); - return undefined; - } - - // Ignore symbolic links - const stat = await workspace.fs.stat(uri); - if ((stat.type & FileType.SymbolicLink) !== 0) { - this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource is a symbolic link: ${uri.toString()}`); - return undefined; - } - - // Ignore resources that are not in the index group - if (!this._repository.indexGroup.resourceStates.some(r => pathEquals(r.resourceUri.fsPath, uri.fsPath))) { - this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource is not part of a index group: ${uri.toString()}`); - return undefined; - } - - const originalResource = toGitUri(uri, 'HEAD', { replaceFileExtension: true }); - this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Original resource: ${originalResource.toString()}`); - return originalResource; - } -} diff --git a/extensions/github/package.json b/extensions/github/package.json index cd70cfea26b65..a9ba2e87d3087 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -29,6 +29,7 @@ }, "enabledApiProposals": [ "canonicalUriProvider", + "chatSessionsProvider", "contribEditSessions", "contribShareMenu", "contribSourceControlHistoryItemMenu", @@ -68,6 +69,11 @@ "command": "github.timeline.openOnGitHub", "title": "%command.openOnGitHub%", "icon": "$(github)" + }, + { + "command": "github.createPullRequest", + "title": "%command.createPullRequest%", + "icon": "$(git-pull-request)" } ], "continueEditSession": [ @@ -85,6 +91,10 @@ "command": "github.publish", "when": "git-base.gitEnabled && workspaceFolderCount != 0 && remoteName != 'codespaces'" }, + { + "command": "github.createPullRequest", + "when": "false" + }, { "command": "github.graph.openOnGitHub", "when": "false" @@ -163,6 +173,14 @@ "group": "1_actions@3", "when": "github.hasGitHubRepo && timelineItem =~ /git:file:commit\\b/" } + ], + "chat/input/editing/sessionToolbar": [ + { + "command": "github.createPullRequest", + "group": "navigation", + "order": 1, + "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli" + } ] }, "configuration": [ diff --git a/extensions/github/package.nls.json b/extensions/github/package.nls.json index 40271bea980e8..ced536e4bd7c6 100644 --- a/extensions/github/package.nls.json +++ b/extensions/github/package.nls.json @@ -5,6 +5,7 @@ "command.publish": "Publish to GitHub", "command.openOnGitHub": "Open on GitHub", "command.openOnVscodeDev": "Open in vscode.dev", + "command.createPullRequest": "Create Pull Request", "config.branchProtection": "Controls whether to query repository rules for GitHub repositories", "config.gitAuthentication": "Controls whether to enable automatic GitHub authentication for git commands within VS Code.", "config.gitProtocol": "Controls which protocol is used to clone a GitHub repository", diff --git a/extensions/github/src/commands.ts b/extensions/github/src/commands.ts index 48e9574c708c1..496772ededf82 100644 --- a/extensions/github/src/commands.ts +++ b/extensions/github/src/commands.ts @@ -34,6 +34,68 @@ async function openVscodeDevLink(gitAPI: GitAPI): Promise { + if (!sessionResource || !sessionMetadata?.worktreePath) { + return; + } + + const worktreeUri = vscode.Uri.file(sessionMetadata.worktreePath); + const repository = gitAPI.getRepository(worktreeUri); + + if (!repository) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not find a git repository for the session worktree.')); + return; + } + + // Find the GitHub remote + const remotes = repository.state.remotes + .filter(remote => remote.fetchUrl && getRepositoryFromUrl(remote.fetchUrl)); + + if (remotes.length === 0) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not find a GitHub remote for this repository.')); + return; + } + + // Prefer upstream -> origin -> first + const gitRemote = remotes.find(r => r.name === 'upstream') + ?? remotes.find(r => r.name === 'origin') + ?? remotes[0]; + + const remoteInfo = getRepositoryFromUrl(gitRemote.fetchUrl!); + if (!remoteInfo) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not parse GitHub remote URL.')); + return; + } + + // Get the current branch (the worktree branch) + const head = repository.state.HEAD; + if (!head?.name) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not determine the current branch.')); + return; + } + + // Ensure the branch is published to the remote + if (!head.upstream) { + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Publishing branch to {0}...', gitRemote.name) }, + async () => { + await repository.push(gitRemote.name, head.name, true); + } + ); + } catch (err) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to publish branch: {0}', err instanceof Error ? err.message : String(err))); + return; + } + } + + // Build the GitHub PR creation URL + // Format: https://github.com/owner/repo/compare/base...head + const prUrl = `https://github.com/${remoteInfo.owner}/${remoteInfo.repo}/compare/${head.name}?expand=1`; + + vscode.env.openExternal(vscode.Uri.parse(prUrl)); +} + async function openOnGitHub(repository: Repository, commit: string): Promise { // Get the unique remotes that contain the commit const branches = await repository.getBranches({ contains: commit, remote: true }); @@ -115,5 +177,9 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable { return openVscodeDevLink(gitAPI); })); + disposables.add(vscode.commands.registerCommand('github.createPullRequest', async (sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined) => { + return createPullRequest(gitAPI, sessionResource, sessionMetadata); + })); + return disposables; } diff --git a/extensions/grunt/.vscodeignore b/extensions/grunt/.vscodeignore index 698898bf9dfb1..e6cd7f0ed82e6 100644 --- a/extensions/grunt/.vscodeignore +++ b/extensions/grunt/.vscodeignore @@ -1,6 +1,6 @@ test/** src/** -tsconfig.json +tsconfig*.json out/** -extension.webpack.config.js +esbuild* package-lock.json diff --git a/extensions/emmet/extension.webpack.config.js b/extensions/grunt/esbuild.mts similarity index 52% rename from extensions/emmet/extension.webpack.config.js rename to extensions/grunt/esbuild.mts index 2c6094112e17b..9be4332b6d832 100644 --- a/extensions/emmet/extension.webpack.config.js +++ b/extensions/grunt/esbuild.mts @@ -2,18 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import path from 'path'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -import withDefaults from '../shared.webpack.config.mjs'; +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/node/emmetNodeMain.ts', +run({ + platform: 'node', + entryPoints: { + 'main': path.join(srcDir, 'main.ts'), }, - output: { - path: path.join(import.meta.dirname, 'dist', 'node'), - filename: 'emmetNodeMain.js' - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/gulp/.vscodeignore b/extensions/gulp/.vscodeignore index 360fcfd1c990b..58e98fc51822d 100644 --- a/extensions/gulp/.vscodeignore +++ b/extensions/gulp/.vscodeignore @@ -1,5 +1,5 @@ src/** -tsconfig.json +tsconfig*.json out/** -extension.webpack.config.js +esbuild* package-lock.json diff --git a/extensions/grunt/extension.webpack.config.js b/extensions/gulp/esbuild.mts similarity index 52% rename from extensions/grunt/extension.webpack.config.js rename to extensions/gulp/esbuild.mts index 1e221c2fa8500..9be4332b6d832 100644 --- a/extensions/grunt/extension.webpack.config.js +++ b/extensions/gulp/esbuild.mts @@ -2,15 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -export default withDefaults({ - context: import.meta.dirname, - entry: { - main: './src/main.ts', +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'main': path.join(srcDir, 'main.ts'), }, - resolve: { - mainFields: ['module', 'main'] - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/gulp/extension.webpack.config.js b/extensions/gulp/extension.webpack.config.js deleted file mode 100644 index 1e221c2fa8500..0000000000000 --- a/extensions/gulp/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - entry: { - main: './src/main.ts', - }, - resolve: { - mainFields: ['module', 'main'] - } -}); diff --git a/extensions/jake/.vscodeignore b/extensions/jake/.vscodeignore index 360fcfd1c990b..58e98fc51822d 100644 --- a/extensions/jake/.vscodeignore +++ b/extensions/jake/.vscodeignore @@ -1,5 +1,5 @@ src/** -tsconfig.json +tsconfig*.json out/** -extension.webpack.config.js +esbuild* package-lock.json diff --git a/extensions/configuration-editing/extension.webpack.config.js b/extensions/jake/esbuild.mts similarity index 52% rename from extensions/configuration-editing/extension.webpack.config.js rename to extensions/jake/esbuild.mts index 519fc2e359f44..9be4332b6d832 100644 --- a/extensions/configuration-editing/extension.webpack.config.js +++ b/extensions/jake/esbuild.mts @@ -2,18 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/configurationEditingMain.ts', - }, - output: { - filename: 'configurationEditingMain.js' +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'main': path.join(srcDir, 'main.ts'), }, - resolve: { - mainFields: ['module', 'main'] - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/jake/extension.webpack.config.js b/extensions/jake/extension.webpack.config.js deleted file mode 100644 index 1e221c2fa8500..0000000000000 --- a/extensions/jake/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - entry: { - main: './src/main.ts', - }, - resolve: { - mainFields: ['module', 'main'] - } -}); diff --git a/extensions/markdown-language-features/esbuild.browser.mts b/extensions/markdown-language-features/esbuild.browser.mts index ddf0c5a99dc4e..ece1769bcdc25 100644 --- a/extensions/markdown-language-features/esbuild.browser.mts +++ b/extensions/markdown-language-features/esbuild.browser.mts @@ -19,22 +19,13 @@ async function copyServerWorkerMain(outDir: string): Promise { } run({ + platform: 'browser', entryPoints: { 'extension': path.join(srcDir, 'extension.browser.ts'), }, srcDir, outdir: outDir, additionalOptions: { - platform: 'browser', - format: 'cjs', - alias: { - 'path': 'path-browserify', - }, - define: { - 'process.platform': JSON.stringify('web'), - 'process.env': JSON.stringify({}), - 'process.env.BROWSER_ENV': JSON.stringify('true'), - }, tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, }, process.argv, copyServerWorkerMain); diff --git a/extensions/markdown-language-features/esbuild.mts b/extensions/markdown-language-features/esbuild.mts index a1cf6eb5fa8de..2a7eda8c18328 100644 --- a/extensions/markdown-language-features/esbuild.mts +++ b/extensions/markdown-language-features/esbuild.mts @@ -19,6 +19,7 @@ async function copyServerWorkerMain(outDir: string): Promise { } run({ + platform: 'node', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, diff --git a/extensions/markdown-math/esbuild.browser.mts b/extensions/markdown-math/esbuild.browser.mts index e3fa7792d056d..9ea4d5f68401e 100644 --- a/extensions/markdown-math/esbuild.browser.mts +++ b/extensions/markdown-math/esbuild.browser.mts @@ -9,18 +9,13 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); run({ + platform: 'browser', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, srcDir, outdir: outDir, additionalOptions: { - platform: 'browser', - format: 'cjs', - define: { - 'process.platform': JSON.stringify('web'), - 'process.env': JSON.stringify({}), - 'process.env.BROWSER_ENV': JSON.stringify('true'), - }, + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, }, process.argv); diff --git a/extensions/markdown-math/esbuild.mts b/extensions/markdown-math/esbuild.mts index 5fafb57ab75a8..2b75ca703da06 100644 --- a/extensions/markdown-math/esbuild.mts +++ b/extensions/markdown-math/esbuild.mts @@ -9,6 +9,7 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); run({ + platform: 'node', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, diff --git a/extensions/media-preview/esbuild.browser.mts b/extensions/media-preview/esbuild.browser.mts index e3fa7792d056d..9ea4d5f68401e 100644 --- a/extensions/media-preview/esbuild.browser.mts +++ b/extensions/media-preview/esbuild.browser.mts @@ -9,18 +9,13 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); run({ + platform: 'browser', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, srcDir, outdir: outDir, additionalOptions: { - platform: 'browser', - format: 'cjs', - define: { - 'process.platform': JSON.stringify('web'), - 'process.env': JSON.stringify({}), - 'process.env.BROWSER_ENV': JSON.stringify('true'), - }, + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, }, process.argv); diff --git a/extensions/media-preview/esbuild.mts b/extensions/media-preview/esbuild.mts index 5fafb57ab75a8..2b75ca703da06 100644 --- a/extensions/media-preview/esbuild.mts +++ b/extensions/media-preview/esbuild.mts @@ -9,6 +9,7 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); run({ + platform: 'node', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, diff --git a/extensions/media-preview/tsconfig.browser.json b/extensions/media-preview/tsconfig.browser.json index 3694afc77ee63..ea6be8e9a508f 100644 --- a/extensions/media-preview/tsconfig.browser.json +++ b/extensions/media-preview/tsconfig.browser.json @@ -1,3 +1,3 @@ { - "extends": "./tsconfig" + "extends": "./tsconfig.json" } diff --git a/extensions/merge-conflict/.vscodeignore b/extensions/merge-conflict/.vscodeignore index 3a8a2a96a6cd7..0628555db0021 100644 --- a/extensions/merge-conflict/.vscodeignore +++ b/extensions/merge-conflict/.vscodeignore @@ -1,6 +1,5 @@ src/** -tsconfig.json +tsconfig*.json out/** -extension.webpack.config.js -extension-browser.webpack.config.js +esbuild*.mts package-lock.json diff --git a/extensions/merge-conflict/esbuild.browser.mts b/extensions/merge-conflict/esbuild.browser.mts new file mode 100644 index 0000000000000..cb1249901465a --- /dev/null +++ b/extensions/merge-conflict/esbuild.browser.mts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + platform: 'browser', + entryPoints: { + 'mergeConflictMain': path.join(srcDir, 'mergeConflictMain.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + } +}, process.argv); diff --git a/extensions/npm/extension-browser.webpack.config.js b/extensions/merge-conflict/esbuild.mts similarity index 50% rename from extensions/npm/extension-browser.webpack.config.js rename to extensions/merge-conflict/esbuild.mts index 6ec242a87a221..4b450a4bafaa6 100644 --- a/extensions/npm/extension-browser.webpack.config.js +++ b/extensions/merge-conflict/esbuild.mts @@ -2,22 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -const config = withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/npmBrowserMain.ts' - }, - output: { - filename: 'npmBrowserMain.js' - }, - resolve: { - fallback: { - 'child_process': false - } - } -}); +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); -export default config; +run({ + platform: 'node', + entryPoints: { + 'mergeConflictMain': path.join(srcDir, 'mergeConflictMain.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/merge-conflict/extension-browser.webpack.config.js b/extensions/merge-conflict/extension-browser.webpack.config.js deleted file mode 100644 index 7054f22b86803..0000000000000 --- a/extensions/merge-conflict/extension-browser.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/mergeConflictMain.ts' - }, - output: { - filename: 'mergeConflictMain.js' - } -}); diff --git a/extensions/merge-conflict/extension.webpack.config.js b/extensions/merge-conflict/extension.webpack.config.js deleted file mode 100644 index c927dcaf3719e..0000000000000 --- a/extensions/merge-conflict/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/mergeConflictMain.ts' - }, - output: { - filename: 'mergeConflictMain.js' - }, -}); diff --git a/extensions/merge-conflict/package.json b/extensions/merge-conflict/package.json index 5b0eaa1b29c8f..42fb877b5763b 100644 --- a/extensions/merge-conflict/package.json +++ b/extensions/merge-conflict/package.json @@ -26,7 +26,13 @@ "browser": "./dist/browser/mergeConflictMain", "scripts": { "compile": "gulp compile-extension:merge-conflict", - "watch": "gulp watch-extension:merge-conflict" + "watch": "gulp watch-extension:merge-conflict", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.json --noEmit --watch" }, "contributes": { "commands": [ diff --git a/extensions/merge-conflict/tsconfig.browser.json b/extensions/merge-conflict/tsconfig.browser.json new file mode 100644 index 0000000000000..ea6be8e9a508f --- /dev/null +++ b/extensions/merge-conflict/tsconfig.browser.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/extensions/mermaid-chat-features/esbuild.browser.mts b/extensions/mermaid-chat-features/esbuild.browser.mts index e3fa7792d056d..9ea4d5f68401e 100644 --- a/extensions/mermaid-chat-features/esbuild.browser.mts +++ b/extensions/mermaid-chat-features/esbuild.browser.mts @@ -9,18 +9,13 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); run({ + platform: 'browser', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, srcDir, outdir: outDir, additionalOptions: { - platform: 'browser', - format: 'cjs', - define: { - 'process.platform': JSON.stringify('web'), - 'process.env': JSON.stringify({}), - 'process.env.BROWSER_ENV': JSON.stringify('true'), - }, + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, }, process.argv); diff --git a/extensions/mermaid-chat-features/esbuild.mts b/extensions/mermaid-chat-features/esbuild.mts index 5fafb57ab75a8..2b75ca703da06 100644 --- a/extensions/mermaid-chat-features/esbuild.mts +++ b/extensions/mermaid-chat-features/esbuild.mts @@ -9,6 +9,7 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); run({ + platform: 'node', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, diff --git a/extensions/mermaid-chat-features/tsconfig.browser.json b/extensions/mermaid-chat-features/tsconfig.browser.json index 3694afc77ee63..ea6be8e9a508f 100644 --- a/extensions/mermaid-chat-features/tsconfig.browser.json +++ b/extensions/mermaid-chat-features/tsconfig.browser.json @@ -1,3 +1,3 @@ { - "extends": "./tsconfig" + "extends": "./tsconfig.json" } diff --git a/extensions/microsoft-authentication/src/node/authServer.ts b/extensions/microsoft-authentication/src/node/authServer.ts deleted file mode 100644 index 2d6a8d03861e7..0000000000000 --- a/extensions/microsoft-authentication/src/node/authServer.ts +++ /dev/null @@ -1,207 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import * as http from 'http'; -import { URL } from 'url'; -import * as fs from 'fs'; -import * as path from 'path'; -import { randomBytes } from 'crypto'; - -function sendFile(res: http.ServerResponse, filepath: string) { - fs.readFile(filepath, (err, body) => { - if (err) { - console.error(err); - res.writeHead(404); - res.end(); - } else { - res.writeHead(200, { - 'content-length': body.length, - }); - res.end(body); - } - }); -} - -interface IOAuthResult { - code: string; - state: string; -} - -interface ILoopbackServer { - /** - * If undefined, the server is not started yet. - */ - port: number | undefined; - - /** - * The nonce used - */ - nonce: string; - - /** - * The state parameter used in the OAuth flow. - */ - state: string | undefined; - - /** - * Starts the server. - * @returns The port to listen on. - * @throws If the server fails to start. - * @throws If the server is already started. - */ - start(): Promise; - /** - * Stops the server. - * @throws If the server is not started. - * @throws If the server fails to stop. - */ - stop(): Promise; - /** - * Returns a promise that resolves to the result of the OAuth flow. - */ - waitForOAuthResponse(): Promise; -} - -export class LoopbackAuthServer implements ILoopbackServer { - private readonly _server: http.Server; - private readonly _resultPromise: Promise; - private _startingRedirect: URL; - - public nonce = randomBytes(16).toString('base64'); - public port: number | undefined; - - public set state(state: string | undefined) { - if (state) { - this._startingRedirect.searchParams.set('state', state); - } else { - this._startingRedirect.searchParams.delete('state'); - } - } - public get state(): string | undefined { - return this._startingRedirect.searchParams.get('state') ?? undefined; - } - - constructor(serveRoot: string, startingRedirect: string) { - if (!serveRoot) { - throw new Error('serveRoot must be defined'); - } - if (!startingRedirect) { - throw new Error('startingRedirect must be defined'); - } - this._startingRedirect = new URL(startingRedirect); - let deferred: { resolve: (result: IOAuthResult) => void; reject: (reason: any) => void }; - this._resultPromise = new Promise((resolve, reject) => deferred = { resolve, reject }); - - this._server = http.createServer((req, res) => { - const reqUrl = new URL(req.url!, `http://${req.headers.host}`); - switch (reqUrl.pathname) { - case '/signin': { - const receivedNonce = (reqUrl.searchParams.get('nonce') ?? '').replace(/ /g, '+'); - if (receivedNonce !== this.nonce) { - res.writeHead(302, { location: `/?error=${encodeURIComponent('Nonce does not match.')}` }); - res.end(); - } - res.writeHead(302, { location: this._startingRedirect.toString() }); - res.end(); - break; - } - case '/callback': { - const code = reqUrl.searchParams.get('code') ?? undefined; - const state = reqUrl.searchParams.get('state') ?? undefined; - const nonce = (reqUrl.searchParams.get('nonce') ?? '').replace(/ /g, '+'); - const error = reqUrl.searchParams.get('error') ?? undefined; - if (error) { - res.writeHead(302, { location: `/?error=${reqUrl.searchParams.get('error_description')}` }); - res.end(); - deferred.reject(new Error(error)); - break; - } - if (!code || !state || !nonce) { - res.writeHead(400); - res.end(); - break; - } - if (this.state !== state) { - res.writeHead(302, { location: `/?error=${encodeURIComponent('State does not match.')}` }); - res.end(); - deferred.reject(new Error('State does not match.')); - break; - } - if (this.nonce !== nonce) { - res.writeHead(302, { location: `/?error=${encodeURIComponent('Nonce does not match.')}` }); - res.end(); - deferred.reject(new Error('Nonce does not match.')); - break; - } - deferred.resolve({ code, state }); - res.writeHead(302, { location: '/' }); - res.end(); - break; - } - // Serve the static files - case '/': - sendFile(res, path.join(serveRoot, 'index.html')); - break; - default: - // substring to get rid of leading '/' - sendFile(res, path.join(serveRoot, reqUrl.pathname.substring(1))); - break; - } - }); - } - - public start(): Promise { - return new Promise((resolve, reject) => { - if (this._server.listening) { - throw new Error('Server is already started'); - } - const portTimeout = setTimeout(() => { - reject(new Error('Timeout waiting for port')); - }, 5000); - this._server.on('listening', () => { - const address = this._server.address(); - if (typeof address === 'string') { - this.port = parseInt(address); - } else if (address instanceof Object) { - this.port = address.port; - } else { - throw new Error('Unable to determine port'); - } - - clearTimeout(portTimeout); - - // set state which will be used to redirect back to vscode - this.state = `http://127.0.0.1:${this.port}/callback?nonce=${encodeURIComponent(this.nonce)}`; - - resolve(this.port); - }); - this._server.on('error', err => { - reject(new Error(`Error listening to server: ${err}`)); - }); - this._server.on('close', () => { - reject(new Error('Closed')); - }); - this._server.listen(0, '127.0.0.1'); - }); - } - - public stop(): Promise { - return new Promise((resolve, reject) => { - if (!this._server.listening) { - throw new Error('Server is not started'); - } - this._server.close((err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); - } - - public waitForOAuthResponse(): Promise { - return this._resultPromise; - } -} diff --git a/extensions/npm/.vscodeignore b/extensions/npm/.vscodeignore index f05a79416be02..7e9dd51ede2aa 100644 --- a/extensions/npm/.vscodeignore +++ b/extensions/npm/.vscodeignore @@ -1,7 +1,6 @@ src/** out/** -tsconfig.json +tsconfig*.json .vscode/** -extension.webpack.config.js -extension-browser.webpack.config.js +esbuild* package-lock.json diff --git a/extensions/npm/esbuild.browser.mts b/extensions/npm/esbuild.browser.mts new file mode 100644 index 0000000000000..852700edd07a5 --- /dev/null +++ b/extensions/npm/esbuild.browser.mts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + platform: 'browser', + entryPoints: { + 'npmBrowserMain': path.join(srcDir, 'npmBrowserMain.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + external: [ + 'vscode', + 'child_process', + ] + }, +}, process.argv); diff --git a/extensions/npm/esbuild.mts b/extensions/npm/esbuild.mts new file mode 100644 index 0000000000000..be92f45b26cc7 --- /dev/null +++ b/extensions/npm/esbuild.mts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'npmMain': path.join(srcDir, 'npmMain.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/npm/extension.webpack.config.js b/extensions/npm/extension.webpack.config.js deleted file mode 100644 index 0dcad6f8b143f..0000000000000 --- a/extensions/npm/extension.webpack.config.js +++ /dev/null @@ -1,20 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/npmMain.ts', - }, - output: { - filename: 'npmMain.js', - }, - resolve: { - mainFields: ['module', 'main'], - extensions: ['.ts', '.js'] // support ts-files and js-files - } -}); diff --git a/extensions/npm/package.json b/extensions/npm/package.json index 9a67c3a83ecfa..bba6a23b8ac99 100644 --- a/extensions/npm/package.json +++ b/extensions/npm/package.json @@ -18,7 +18,13 @@ ], "scripts": { "compile": "npx gulp compile-extension:npm", - "watch": "npx gulp watch-extension:npm" + "watch": "npx gulp watch-extension:npm", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch" }, "dependencies": { "find-up": "^5.0.0", diff --git a/extensions/npm/src/features/packageJSONContribution.ts b/extensions/npm/src/features/packageJSONContribution.ts index 999f39664f1c2..d90a6b189f533 100644 --- a/extensions/npm/src/features/packageJSONContribution.ts +++ b/extensions/npm/src/features/packageJSONContribution.ts @@ -8,7 +8,7 @@ import { IJSONContribution, ISuggestionsCollector } from './jsonContributions'; import { XHRRequest } from 'request-light'; import { Location } from 'jsonc-parser'; -import * as cp from 'child_process'; +import type * as cp from 'child_process'; import { dirname } from 'path'; import { fromNow } from './date'; @@ -283,7 +283,8 @@ export class PackageJSONContribution implements IJSONContribution { return info; } - private npmView(npmCommandPath: string, pack: string, resource: Uri | undefined): Promise { + private async npmView(npmCommandPath: string, pack: string, resource: Uri | undefined): Promise { + const cp = await import('child_process'); return new Promise((resolve, _reject) => { const args = ['view', '--json', '--', pack, 'description', 'dist-tags.latest', 'homepage', 'version', 'time']; const cwd = resource && resource.scheme === 'file' ? dirname(resource.fsPath) : undefined; diff --git a/extensions/npm/tsconfig.browser.json b/extensions/npm/tsconfig.browser.json new file mode 100644 index 0000000000000..4e9b89120f863 --- /dev/null +++ b/extensions/npm/tsconfig.browser.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "exclude": [ + "./src/test/**" + ], + "files": [ + "./src/npmBrowserMain.ts" + ] +} diff --git a/extensions/references-view/.vscodeignore b/extensions/references-view/.vscodeignore index 4d2ffa699e4f4..4a97d0f9e7c4b 100644 --- a/extensions/references-view/.vscodeignore +++ b/extensions/references-view/.vscodeignore @@ -1,6 +1,6 @@ .vscode/** src/** out/** -tsconfig.json -*.webpack.config.js +tsconfig*.json +esbuild*.mts package-lock.json diff --git a/extensions/references-view/esbuild.browser.mts b/extensions/references-view/esbuild.browser.mts new file mode 100644 index 0000000000000..9ea4d5f68401e --- /dev/null +++ b/extensions/references-view/esbuild.browser.mts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + platform: 'browser', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv); diff --git a/extensions/references-view/esbuild.mts b/extensions/references-view/esbuild.mts new file mode 100644 index 0000000000000..2b75ca703da06 --- /dev/null +++ b/extensions/references-view/esbuild.mts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/references-view/extension-browser.webpack.config.js b/extensions/references-view/extension-browser.webpack.config.js deleted file mode 100644 index 1e0caad7e7297..0000000000000 --- a/extensions/references-view/extension-browser.webpack.config.js +++ /dev/null @@ -1,18 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; -import path from 'path'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' - }, - output: { - filename: 'extension.js', - path: path.join(import.meta.dirname, 'dist') - } -}); diff --git a/extensions/references-view/extension.webpack.config.js b/extensions/references-view/extension.webpack.config.js deleted file mode 100644 index 4928186ae556c..0000000000000 --- a/extensions/references-view/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] - }, - entry: { - extension: './src/extension.ts', - } -}); diff --git a/extensions/references-view/package.json b/extensions/references-view/package.json index b5ac88950703d..ae87e5944dc5c 100644 --- a/extensions/references-view/package.json +++ b/extensions/references-view/package.json @@ -27,7 +27,7 @@ "onCommand:editor.action.showReferences" ], "main": "./out/extension", - "browser": "./dist/extension.js", + "browser": "./dist/browser/extension", "contributes": { "configuration": { "properties": { @@ -396,7 +396,13 @@ }, "scripts": { "compile": "npx gulp compile-extension:references-view", - "watch": "npx gulp watch-extension:references-view" + "watch": "npx gulp watch-extension:references-view", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.json --noEmit --watch" }, "devDependencies": { "@types/node": "22.x" diff --git a/extensions/references-view/tsconfig.browser.json b/extensions/references-view/tsconfig.browser.json new file mode 100644 index 0000000000000..ea6be8e9a508f --- /dev/null +++ b/extensions/references-view/tsconfig.browser.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/extensions/search-result/.vscodeignore b/extensions/search-result/.vscodeignore index 35b808e16f7ee..50bbe59eb5209 100644 --- a/extensions/search-result/.vscodeignore +++ b/extensions/search-result/.vscodeignore @@ -1,7 +1,6 @@ src/** out/** -tsconfig.json -extension.webpack.config.js -extension-browser.webpack.config.js +tsconfig*.json +esbuild*.mts package-lock.json syntaxes/generateTMLanguage.js diff --git a/extensions/search-result/esbuild.browser.mts b/extensions/search-result/esbuild.browser.mts new file mode 100644 index 0000000000000..9ea4d5f68401e --- /dev/null +++ b/extensions/search-result/esbuild.browser.mts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + platform: 'browser', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv); diff --git a/extensions/search-result/esbuild.mts b/extensions/search-result/esbuild.mts new file mode 100644 index 0000000000000..2b75ca703da06 --- /dev/null +++ b/extensions/search-result/esbuild.mts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/search-result/extension-browser.webpack.config.js b/extensions/search-result/extension-browser.webpack.config.js deleted file mode 100644 index 1e0caad7e7297..0000000000000 --- a/extensions/search-result/extension-browser.webpack.config.js +++ /dev/null @@ -1,18 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; -import path from 'path'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' - }, - output: { - filename: 'extension.js', - path: path.join(import.meta.dirname, 'dist') - } -}); diff --git a/extensions/search-result/extension.webpack.config.js b/extensions/search-result/extension.webpack.config.js deleted file mode 100644 index 4928186ae556c..0000000000000 --- a/extensions/search-result/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] - }, - entry: { - extension: './src/extension.ts', - } -}); diff --git a/extensions/search-result/package.json b/extensions/search-result/package.json index bcd0656eb6147..5e6c74618e034 100644 --- a/extensions/search-result/package.json +++ b/extensions/search-result/package.json @@ -10,13 +10,19 @@ "vscode": "^1.39.0" }, "main": "./out/extension.js", - "browser": "./dist/extension.js", + "browser": "./dist/browser/extension", "activationEvents": [ "onLanguage:search-result" ], "scripts": { "generate-grammar": "node ./syntaxes/generateTMLanguage.js", - "vscode:prepublish": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:search-result ./tsconfig.json" + "vscode:prepublish": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:search-result ./tsconfig.json", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.json --noEmit --watch" }, "capabilities": { "virtualWorkspaces": true, diff --git a/extensions/search-result/tsconfig.browser.json b/extensions/search-result/tsconfig.browser.json new file mode 100644 index 0000000000000..ea6be8e9a508f --- /dev/null +++ b/extensions/search-result/tsconfig.browser.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/extensions/simple-browser/.vscodeignore b/extensions/simple-browser/.vscodeignore index ef5dc0365fa33..e2740fcfba08f 100644 --- a/extensions/simple-browser/.vscodeignore +++ b/extensions/simple-browser/.vscodeignore @@ -1,14 +1,11 @@ test/** test-workspace/** src/** -tsconfig.json +tsconfig*.json out/test/** out/** -extension.webpack.config.js -extension-browser.webpack.config.js +esbuild*.mts cgmanifest.json .gitignore package-lock.json preview-src/** -webpack.config.js -esbuild-* diff --git a/extensions/simple-browser/esbuild.browser.mts b/extensions/simple-browser/esbuild.browser.mts new file mode 100644 index 0000000000000..9ea4d5f68401e --- /dev/null +++ b/extensions/simple-browser/esbuild.browser.mts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + platform: 'browser', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv); diff --git a/extensions/simple-browser/esbuild.mts b/extensions/simple-browser/esbuild.mts new file mode 100644 index 0000000000000..2b75ca703da06 --- /dev/null +++ b/extensions/simple-browser/esbuild.mts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/simple-browser/extension.webpack.config.js b/extensions/simple-browser/extension.webpack.config.js deleted file mode 100644 index 4928186ae556c..0000000000000 --- a/extensions/simple-browser/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] - }, - entry: { - extension: './src/extension.ts', - } -}); diff --git a/extensions/simple-browser/package.json b/extensions/simple-browser/package.json index 691e72f248c41..18bd0027fdc59 100644 --- a/extensions/simple-browser/package.json +++ b/extensions/simple-browser/package.json @@ -72,8 +72,12 @@ "vscode:prepublish": "npm run build-ext && npm run build-webview", "build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:simple-browser ./tsconfig.json", "build-webview": "node ./esbuild.webview.mts", - "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", - "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose" + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.json --noEmit --watch" }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8" diff --git a/extensions/simple-browser/tsconfig.browser.json b/extensions/simple-browser/tsconfig.browser.json new file mode 100644 index 0000000000000..ea6be8e9a508f --- /dev/null +++ b/extensions/simple-browser/tsconfig.browser.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 44d0d3273f359..4f25b3268fc3e 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -7,7 +7,7 @@ "foreground": "#bfbfbf", "disabledForeground": "#666666", "errorForeground": "#f48771", - "descriptionForeground": "#999999", + "descriptionForeground": "#888888", "icon.foreground": "#888888", "focusBorder": "#3994BCB3", "textBlockQuote.background": "#242526", @@ -53,7 +53,7 @@ "badge.background": "#3994BCF0", "badge.foreground": "#FFFFFF", "progressBar.background": "#878889", - "list.activeSelectionBackground": "#3994BC55", + "list.activeSelectionBackground": "#3994BC26", "list.activeSelectionForeground": "#bfbfbf", "list.inactiveSelectionBackground": "#2C2D2E", "list.inactiveSelectionForeground": "#bfbfbf", @@ -104,6 +104,7 @@ "editor.background": "#121314", "editor.foreground": "#BBBEBF", "editorStickyScroll.background": "#121314", + "editorStickyScrollHover.background": "#202122", "editorLineNumber.foreground": "#858889", "editorLineNumber.activeForeground": "#BBBEBF", "editorCursor.foreground": "#BBBEBF", @@ -136,6 +137,7 @@ "editorSuggestWidget.selectedBackground": "#3994BC26", "editorHoverWidget.background": "#202122", "editorHoverWidget.border": "#2A2B2CFF", + "widget.border": "#2A2B2CFF", "peekView.border": "#2A2B2CFF", "peekViewEditor.background": "#191A1B", "peekViewEditor.matchHighlightBackground": "#3994BC33", diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 3ed64c055bf87..95d65795f8b79 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -20,10 +20,10 @@ "button.background": "#0069CC", "button.foreground": "#FFFFFF", "button.hoverBackground": "#0063C1", - "button.border": "#F2F3F4FF", + "button.border": "#EEEEF1", "button.secondaryBackground": "#EDEDED", "button.secondaryForeground": "#202020", - "button.secondaryHoverBackground": "#F3F3F3", + "button.secondaryHoverBackground": "#EEEEEE", "checkbox.background": "#EDEDED", "checkbox.border": "#D8D8D8", "checkbox.foreground": "#202020", @@ -51,6 +51,7 @@ "widget.shadow": "#00000000", "widget.border": "#EEEEF1", "editorStickyScroll.shadow": "#00000000", + "editorStickyScrollHover.background": "#F0F0F3", "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", "listFilterWidget.shadow": "#00000000", @@ -64,7 +65,7 @@ "list.activeSelectionForeground": "#202020", "list.inactiveSelectionBackground": "#E0E0E0", "list.inactiveSelectionForeground": "#202020", - "list.hoverBackground": "#F3F3F3", + "list.hoverBackground": "#EEEEEE", "list.hoverForeground": "#202020", "list.dropBackground": "#0069CC15", "list.focusBackground": "#0069CC1A", @@ -101,12 +102,12 @@ "menu.foreground": "#202020", "menu.selectionBackground": "#0069CC1A", "menu.selectionForeground": "#202020", - "menu.separatorBackground": "#F7F7F7", + "menu.separatorBackground": "#EEEEF1", "menu.border": "#F2F3F4FF", "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", "commandCenter.background": "#FAFAFD", - "commandCenter.activeBackground": "#F3F3F3", + "commandCenter.activeBackground": "#DADADA4f", "commandCenter.border": "#D8D8D8", "editor.background": "#FFFFFF", "editor.foreground": "#202020", @@ -127,7 +128,7 @@ "editorLink.activeForeground": "#0069CC", "editorWhitespace.foreground": "#66666640", "editorIndentGuide.background": "#F7F7F740", - "editorIndentGuide.activeBackground": "#F3F3F3", + "editorIndentGuide.activeBackground": "#EEEEEE", "editorRuler.foreground": "#F7F7F7", "editorCodeLens.foreground": "#666666", "editorBracketMatch.background": "#0069CC40", @@ -179,12 +180,13 @@ "statusBar.debuggingForeground": "#FFFFFF", "statusBar.noFolderBackground": "#F0F0F3", "statusBar.noFolderForeground": "#666666", - "statusBarItem.activeBackground": "#F3F3F3", - "statusBarItem.hoverBackground": "#F3F3F3", + "statusBarItem.activeBackground": "#EEEEEE", + "statusBarItem.hoverBackground": "#EEEEEE", "statusBarItem.focusBorder": "#0069CCFF", "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", "statusBarItem.prominentHoverBackground": "#0069CC", + "toolbar.hoverBackground": "#DADADA4f", "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", "tab.inactiveBackground": "#FAFAFD", @@ -193,7 +195,7 @@ "tab.lastPinnedBorder": "#F2F3F4FF", "tab.activeBorder": "#FAFAFD", "tab.activeBorderTop": "#000000", - "tab.hoverBackground": "#F3F3F3", + "tab.hoverBackground": "#EEEEEE", "tab.hoverForeground": "#202020", "tab.unfocusedActiveBackground": "#FAFAFD", "tab.unfocusedActiveForeground": "#666666", @@ -224,7 +226,7 @@ "extensionButton.prominentBackground": "#0069CC", "extensionButton.prominentForeground": "#FFFFFF", "extensionButton.prominentHoverBackground": "#0064CC", - "pickerGroup.border": "#F2F3F4FF", + "pickerGroup.border": "#EEEEF1", "pickerGroup.foreground": "#202020", "quickInput.background": "#F0F0F3", "quickInput.foreground": "#202020", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 35fe38ae75a50..6dfbb2888a496 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -7,28 +7,22 @@ --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; - /* --radius-lg: 12px; */ - --shadow-xs: 0 0 2px rgba(0, 0, 0, 0.06); --shadow-sm: 0 0 4px rgba(0, 0, 0, 0.08); --shadow-md: 0 0 6px rgba(0, 0, 0, 0.08); --shadow-lg: 0 0 12px rgba(0, 0, 0, 0.14); --shadow-xl: 0 0 20px rgba(0, 0, 0, 0.15); - --shadow-2xl: 0 0 20px rgba(0, 0, 0, 0.18); --shadow-hover: 0 0 8px rgba(0, 0, 0, 0.12); --shadow-sm-strong: 0 0 4px rgba(0, 0, 0, 0.18); --shadow-button-active: inset 0 1px 2px rgba(0, 0, 0, 0.1); - --shadow-inset-white: inset 0 0 4px rgba(255, 255, 255, 0.1); --shadow-active-tab: 0 8px 12px rgba(0, 0, 0, 0.02); - --backdrop-blur-sm: blur(12px); --backdrop-blur-md: blur(20px) saturate(180%); --backdrop-blur-lg: blur(40px) saturate(180%); } /* Dark theme: add brightness reduction for contrast-safe luminosity blending over bright backgrounds */ .monaco-workbench.vs-dark { - --backdrop-blur-sm: blur(12px) brightness(0.55); --backdrop-blur-md: blur(20px) saturate(180%) brightness(0.55); --backdrop-blur-lg: blur(40px) saturate(180%) brightness(0.55); } @@ -131,7 +125,6 @@ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { box-shadow: inset var(--shadow-active-tab); - /* background: var(--vs) */ position: relative; z-index: 5; border-radius: 0; @@ -194,15 +187,48 @@ .monaco-workbench .quick-input-widget .quick-input-action, .monaco-workbench .quick-input-widget .quick-input-message, .monaco-workbench .quick-input-widget .monaco-list, -.monaco-workbench .quick-input-widget .monaco-list-row { +.monaco-workbench .quick-input-widget .monaco-list-row:not(:has(.quick-input-list-separator-border)) { border-color: transparent !important; outline: none !important; } +.monaco-workbench .quick-input-widget .quick-input-list .monaco-list-rows { + background: transparent !important; +} + +.monaco-workbench.vs .quick-input-widget .quick-input-list .monaco-list-row:hover:not(.selected):not(.focused) { + background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 95%, black) !important; +} + +.quick-input-list .quick-input-list-entry .quick-input-list-separator { + height: 16px; + margin-top: 2px; + display: flex; + align-items: center; + font-size: 11px; + padding: 0 4px 1px 4px; + border-radius: var(--vscode-cornerRadius-small) !important; + background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; + color: var(--vscode-badge-foreground) !important; + margin-right: 8px; +} + +.monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator, +.monaco-list-row.selected .quick-input-list-entry .quick-input-list-separator, +.monaco-list-row:hover .quick-input-list-entry .quick-input-list-separator { + background: transparent !important; + color: inherit !important; + padding: 0; +} + .monaco-workbench .monaco-editor .suggest-widget .monaco-list { border-radius: var(--radius-lg); } +.monaco-workbench .quick-input-widget .monaco-list-rows { + background: transparent !important; +} + .monaco-workbench .quick-input-widget .monaco-inputbox { box-shadow: none !important; background: transparent !important; @@ -216,16 +242,16 @@ /* Chat Widget */ .monaco-workbench .interactive-session .chat-input-container { box-shadow: inset var(--shadow-sm); - border-radius: var(--radius-md); + border-radius: var(--radius-lg); } .monaco-workbench .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor, .monaco-workbench .interactive-session .chat-editing-session .chat-editing-session-container { - border-radius: var(--radius-sm) var(--radius-sm) 0 0; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; } .monaco-workbench .interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container { - border-radius: 0 0 var(--radius-md) var(--radius-md); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); } .monaco-workbench .part.panel .interactive-session, @@ -339,7 +365,7 @@ /* Dialog */ .monaco-workbench .monaco-dialog-box { - box-shadow: var(--shadow-2xl); + box-shadow: var(--shadow-xl); border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-lg); -webkit-backdrop-filter: var(--backdrop-blur-lg); @@ -354,8 +380,8 @@ .monaco-workbench .monaco-editor .peekview-widget { box-shadow: var(--shadow-hover); background: color-mix(in srgb, var(--vscode-peekViewEditor-background) 60%, transparent) !important; - backdrop-filter: var(--backdrop-blur-sm); - -webkit-backdrop-filter: var(--backdrop-blur-sm); + backdrop-filter: var(--backdrop-blur-md); + -webkit-backdrop-filter: var(--backdrop-blur-md); } .monaco-workbench.vs-dark .monaco-editor .peekview-widget { @@ -467,13 +493,6 @@ } /* Buttons */ -.monaco-workbench .monaco-button { - box-shadow: var(--shadow-xs); -} - -.monaco-workbench .monaco-button:hover { - box-shadow: var(--shadow-sm); -} .monaco-workbench .monaco-button:active { box-shadow: var(--shadow-button-active); @@ -508,11 +527,6 @@ border-radius: var(--radius-lg); } -/* Terminal */ -.monaco-workbench.vs .pane-body.integrated-terminal { - box-shadow: var(--shadow-inset-white); -} - /* SCM */ .monaco-workbench .scm-view .scm-provider { box-shadow: var(--shadow-sm); @@ -572,13 +586,20 @@ left: 0; } +/* Minimap autohide: ensure opacity:0 overrides the 0.85 above */ +.monaco-workbench .monaco-editor .minimap:is(.minimap-autohide-mouseover, .minimap-autohide-scroll) { + opacity: 0; +} + +.monaco-workbench .monaco-editor .minimap:is(.minimap-autohide-mouseover:hover, .minimap-autohide-scroll.active) { + opacity: 0.85; +} + /* Sticky Scroll */ .monaco-workbench .monaco-editor .sticky-widget { box-shadow: var(--shadow-md) !important; border-bottom: var(--vscode-editorWidget-border) !important; background: transparent !important; - backdrop-filter: var(--backdrop-blur-md) !important; - -webkit-backdrop-filter: var(--backdrop-blur-md) !important; } .monaco-workbench .monaco-editor .sticky-widget > * { @@ -589,10 +610,15 @@ border-bottom: none !important; } -.monaco-workbench .monaco-editor .sticky-widget .sticky-widget-lines { - background: color-mix(in srgb, var(--vscode-editor-background) 40%, transparent) !important; +.monaco-workbench .monaco-editor .sticky-widget .sticky-widget-line-numbers, +.monaco-editor .sticky-widget .sticky-line-content { backdrop-filter: var(--backdrop-blur-md) !important; -webkit-backdrop-filter: var(--backdrop-blur-md) !important; + background: color-mix(in srgb, var(--vscode-editor-background) 40%, transparent) !important; +} + +.monaco-workbench .monaco-editor .sticky-widget .sticky-line-content:hover { + background: var(--vscode-editorStickyScrollHover-background) !important; } .monaco-editor .rename-box.preview { @@ -673,12 +699,174 @@ background: transparent; } -.monaco-workbench .quick-input-list .quick-input-list-entry.quick-input-list-separator-border { - border-top-width: 0; -} - /* Quick Input List - use descriptionForeground color for descriptions */ .monaco-workbench .quick-input-list .monaco-icon-label .label-description { opacity: 1; color: var(--vscode-descriptionForeground); } + +/* ============================================================================================ + * Reduced Transparency - disable backdrop-filter blur and color-mix transparency effects + * for improved rendering performance. Controlled by workbench.reduceTransparency setting. + * ============================================================================================ */ + +/* Reset blur variables to none */ +.monaco-workbench.monaco-reduce-transparency { + --backdrop-blur-sm: none; + --backdrop-blur-md: none; + --backdrop-blur-lg: none; +} + +/* Quick Input (Command Palette) */ +.monaco-workbench.monaco-reduce-transparency .quick-input-widget { + background-color: var(--vscode-quickInput-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Notifications */ +.monaco-workbench.monaco-reduce-transparency .notification-toast-container { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-notifications-background) !important; +} + +.monaco-workbench.monaco-reduce-transparency .notifications-center { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-notifications-background) !important; +} + +/* Context Menu / Action Widget */ +.monaco-workbench.monaco-reduce-transparency .action-widget { + background: var(--vscode-menu-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Suggest Widget */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .suggest-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-editorSuggestWidget-background) !important; +} + +/* Find Widget */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .find-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.monaco-workbench.monaco-reduce-transparency .inline-chat-gutter-menu { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Dialog */ +.monaco-workbench.monaco-reduce-transparency .monaco-dialog-box { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-editor-background) !important; +} + +/* Peek View */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .peekview-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-peekViewEditor-background) !important; +} + +/* Hover */ +.monaco-reduce-transparency .monaco-hover { + background-color: var(--vscode-editorHoverWidget-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.monaco-reduce-transparency .monaco-hover.workbench-hover, +.monaco-reduce-transparency .workbench-hover { + background-color: var(--vscode-editorHoverWidget-background) !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +/* Keybinding Widget */ +.monaco-workbench.monaco-reduce-transparency .defineKeybindingWidget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Chat Editor Overlay */ +.monaco-workbench.monaco-reduce-transparency .chat-editor-overlay-widget, +.monaco-workbench.monaco-reduce-transparency .chat-diff-change-content-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Debug Toolbar */ +.monaco-workbench.monaco-reduce-transparency .debug-toolbar { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +.monaco-workbench.monaco-reduce-transparency .debug-hover-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Parameter Hints */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .parameter-hints-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-editorWidget-background) !important; +} + +/* Sticky Scroll */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background: var(--vscode-editor-background) !important; +} + +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-widget-line-numbers, +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-widget-lines, +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-line-content { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background: var(--vscode-editor-background) !important; +} + +/* Rename Box */ +.monaco-reduce-transparency .monaco-editor .rename-box.preview { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +/* Notebook */ +.monaco-workbench.monaco-reduce-transparency .notebookOverlay .monaco-list-row .cell-title-toolbar { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Command Center */ +.monaco-workbench.monaco-reduce-transparency .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { + background: var(--vscode-commandCenter-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.monaco-workbench.monaco-reduce-transparency .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:hover { + background: var(--vscode-commandCenter-activeBackground) !important; +} + +/* Breadcrumbs */ +.monaco-workbench.monaco-reduce-transparency .breadcrumbs-picker-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-breadcrumbPicker-background) !important; +} + +/* Quick Input filter input */ +.monaco-workbench.monaco-reduce-transparency .quick-input-widget .quick-input-filter .monaco-inputbox { + background: var(--vscode-input-background) !important; +} diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 097d69d644a7c..70e0d21044499 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -798,6 +798,26 @@ "title": "%configuration.preferences%", "order": 21, "properties": { + "js/ts.preferences.quoteStyle": { + "type": "string", + "enum": [ + "auto", + "single", + "double" + ], + "default": "auto", + "markdownDescription": "%typescript.preferences.quoteStyle%", + "markdownEnumDescriptions": [ + "%typescript.preferences.quoteStyle.auto%", + "%typescript.preferences.quoteStyle.single%", + "%typescript.preferences.quoteStyle.double%" + ], + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.preferences.quoteStyle": { "type": "string", "enum": [ @@ -812,6 +832,7 @@ "%typescript.preferences.quoteStyle.single%", "%typescript.preferences.quoteStyle.double%" ], + "markdownDeprecationMessage": "%configuration.preferences.quoteStyle.unifiedDeprecationMessage%", "scope": "language-overridable" }, "typescript.preferences.quoteStyle": { @@ -828,8 +849,31 @@ "%typescript.preferences.quoteStyle.single%", "%typescript.preferences.quoteStyle.double%" ], + "markdownDeprecationMessage": "%configuration.preferences.quoteStyle.unifiedDeprecationMessage%", "scope": "language-overridable" }, + "js/ts.preferences.importModuleSpecifier": { + "type": "string", + "enum": [ + "shortest", + "relative", + "non-relative", + "project-relative" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.importModuleSpecifier.shortest%", + "%typescript.preferences.importModuleSpecifier.relative%", + "%typescript.preferences.importModuleSpecifier.nonRelative%", + "%typescript.preferences.importModuleSpecifier.projectRelative%" + ], + "default": "shortest", + "description": "%typescript.preferences.importModuleSpecifier%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.preferences.importModuleSpecifier": { "type": "string", "enum": [ @@ -846,6 +890,7 @@ ], "default": "shortest", "description": "%typescript.preferences.importModuleSpecifier%", + "markdownDeprecationMessage": "%configuration.preferences.importModuleSpecifier.unifiedDeprecationMessage%", "scope": "language-overridable" }, "typescript.preferences.importModuleSpecifier": { @@ -864,8 +909,37 @@ ], "default": "shortest", "description": "%typescript.preferences.importModuleSpecifier%", + "markdownDeprecationMessage": "%configuration.preferences.importModuleSpecifier.unifiedDeprecationMessage%", "scope": "language-overridable" }, + "js/ts.preferences.importModuleSpecifierEnding": { + "type": "string", + "enum": [ + "auto", + "minimal", + "index", + "js" + ], + "enumItemLabels": [ + null, + null, + null, + "%typescript.preferences.importModuleSpecifierEnding.label.js%" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.importModuleSpecifierEnding.auto%", + "%typescript.preferences.importModuleSpecifierEnding.minimal%", + "%typescript.preferences.importModuleSpecifierEnding.index%", + "%typescript.preferences.importModuleSpecifierEnding.js%" + ], + "default": "auto", + "description": "%typescript.preferences.importModuleSpecifierEnding%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.preferences.importModuleSpecifierEnding": { "type": "string", "enum": [ @@ -888,6 +962,7 @@ ], "default": "auto", "description": "%typescript.preferences.importModuleSpecifierEnding%", + "markdownDeprecationMessage": "%configuration.preferences.importModuleSpecifierEnding.unifiedDeprecationMessage%", "scope": "language-overridable" }, "typescript.preferences.importModuleSpecifierEnding": { @@ -912,8 +987,29 @@ ], "default": "auto", "description": "%typescript.preferences.importModuleSpecifierEnding%", + "markdownDeprecationMessage": "%configuration.preferences.importModuleSpecifierEnding.unifiedDeprecationMessage%", "scope": "language-overridable" }, + "js/ts.preferences.jsxAttributeCompletionStyle": { + "type": "string", + "enum": [ + "auto", + "braces", + "none" + ], + "markdownEnumDescriptions": [ + "%configuration.preferences.jsxAttributeCompletionStyle.auto%", + "%typescript.preferences.jsxAttributeCompletionStyle.braces%", + "%typescript.preferences.jsxAttributeCompletionStyle.none%" + ], + "default": "auto", + "description": "%typescript.preferences.jsxAttributeCompletionStyle%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.preferences.jsxAttributeCompletionStyle": { "type": "string", "enum": [ @@ -928,6 +1024,7 @@ ], "default": "auto", "description": "%typescript.preferences.jsxAttributeCompletionStyle%", + "markdownDeprecationMessage": "%configuration.preferences.jsxAttributeCompletionStyle.unifiedDeprecationMessage%", "scope": "language-overridable" }, "typescript.preferences.jsxAttributeCompletionStyle": { @@ -944,8 +1041,28 @@ ], "default": "auto", "description": "%typescript.preferences.jsxAttributeCompletionStyle%", + "markdownDeprecationMessage": "%configuration.preferences.jsxAttributeCompletionStyle.unifiedDeprecationMessage%", "scope": "language-overridable" }, + "js/ts.preferences.includePackageJsonAutoImports": { + "type": "string", + "enum": [ + "auto", + "on", + "off" + ], + "enumDescriptions": [ + "%typescript.preferences.includePackageJsonAutoImports.auto%", + "%typescript.preferences.includePackageJsonAutoImports.on%", + "%typescript.preferences.includePackageJsonAutoImports.off%" + ], + "default": "auto", + "markdownDescription": "%typescript.preferences.includePackageJsonAutoImports%", + "scope": "window", + "tags": [ + "TypeScript" + ] + }, "typescript.preferences.includePackageJsonAutoImports": { "type": "string", "enum": [ @@ -960,14 +1077,28 @@ ], "default": "auto", "markdownDescription": "%typescript.preferences.includePackageJsonAutoImports%", + "markdownDeprecationMessage": "%configuration.preferences.includePackageJsonAutoImports.unifiedDeprecationMessage%", "scope": "window" }, + "js/ts.preferences.autoImportFileExcludePatterns": { + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "%typescript.preferences.autoImportFileExcludePatterns%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.preferences.autoImportFileExcludePatterns": { "type": "array", "items": { "type": "string" }, "markdownDescription": "%typescript.preferences.autoImportFileExcludePatterns%", + "markdownDeprecationMessage": "%configuration.preferences.autoImportFileExcludePatterns.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.preferences.autoImportFileExcludePatterns": { @@ -976,14 +1107,28 @@ "type": "string" }, "markdownDescription": "%typescript.preferences.autoImportFileExcludePatterns%", + "markdownDeprecationMessage": "%configuration.preferences.autoImportFileExcludePatterns.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.preferences.autoImportSpecifierExcludeRegexes": { + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "%typescript.preferences.autoImportSpecifierExcludeRegexes%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.preferences.autoImportSpecifierExcludeRegexes": { "type": "array", "items": { "type": "string" }, "markdownDescription": "%typescript.preferences.autoImportSpecifierExcludeRegexes%", + "markdownDeprecationMessage": "%configuration.preferences.autoImportSpecifierExcludeRegexes.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.preferences.autoImportSpecifierExcludeRegexes": { @@ -992,41 +1137,159 @@ "type": "string" }, "markdownDescription": "%typescript.preferences.autoImportSpecifierExcludeRegexes%", + "markdownDeprecationMessage": "%configuration.preferences.autoImportSpecifierExcludeRegexes.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.preferences.preferTypeOnlyAutoImports": { + "type": "boolean", + "default": false, + "markdownDescription": "%typescript.preferences.preferTypeOnlyAutoImports%", + "scope": "resource", + "tags": [ + "TypeScript" + ] + }, "typescript.preferences.preferTypeOnlyAutoImports": { "type": "boolean", "default": false, "markdownDescription": "%typescript.preferences.preferTypeOnlyAutoImports%", + "markdownDeprecationMessage": "%configuration.preferences.preferTypeOnlyAutoImports.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.preferences.useAliasesForRenames": { + "type": "boolean", + "default": true, + "description": "%typescript.preferences.useAliasesForRenames%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.preferences.useAliasesForRenames": { "type": "boolean", "default": true, "description": "%typescript.preferences.useAliasesForRenames%", + "markdownDeprecationMessage": "%configuration.preferences.useAliasesForRenames.unifiedDeprecationMessage%", "scope": "language-overridable" }, "typescript.preferences.useAliasesForRenames": { "type": "boolean", "default": true, "description": "%typescript.preferences.useAliasesForRenames%", + "markdownDeprecationMessage": "%configuration.preferences.useAliasesForRenames.unifiedDeprecationMessage%", "scope": "language-overridable" }, + "js/ts.preferences.renameMatchingJsxTags": { + "type": "boolean", + "default": true, + "description": "%typescript.preferences.renameMatchingJsxTags%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.preferences.renameMatchingJsxTags": { "type": "boolean", "default": true, "description": "%typescript.preferences.renameMatchingJsxTags%", + "markdownDeprecationMessage": "%configuration.preferences.renameMatchingJsxTags.unifiedDeprecationMessage%", "scope": "language-overridable" }, "typescript.preferences.renameMatchingJsxTags": { "type": "boolean", "default": true, "description": "%typescript.preferences.renameMatchingJsxTags%", + "markdownDeprecationMessage": "%configuration.preferences.renameMatchingJsxTags.unifiedDeprecationMessage%", "scope": "language-overridable" }, + "js/ts.preferences.organizeImports": { + "type": "object", + "markdownDescription": "%typescript.preferences.organizeImports%", + "properties": { + "caseSensitivity": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.caseSensitivity%", + "enum": [ + "auto", + "caseInsensitive", + "caseSensitive" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.caseSensitivity.auto%", + "%typescript.preferences.organizeImports.caseSensitivity.insensitive", + "%typescript.preferences.organizeImports.caseSensitivity.sensitive%" + ], + "default": "auto" + }, + "typeOrder": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.typeOrder%", + "enum": [ + "auto", + "last", + "inline", + "first" + ], + "default": "auto", + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.typeOrder.auto%", + "%typescript.preferences.organizeImports.typeOrder.last%", + "%typescript.preferences.organizeImports.typeOrder.inline%", + "%typescript.preferences.organizeImports.typeOrder.first%" + ] + }, + "unicodeCollation": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.unicodeCollation%", + "enum": [ + "ordinal", + "unicode" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.unicodeCollation.ordinal%", + "%typescript.preferences.organizeImports.unicodeCollation.unicode%" + ], + "default": "ordinal" + }, + "locale": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.locale%" + }, + "numericCollation": { + "type": "boolean", + "markdownDescription": "%typescript.preferences.organizeImports.numericCollation%" + }, + "accentCollation": { + "type": "boolean", + "markdownDescription": "%typescript.preferences.organizeImports.accentCollation%" + }, + "caseFirst": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.caseFirst%", + "enum": [ + "default", + "upper", + "lower" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.caseFirst.default%", + "%typescript.preferences.organizeImports.caseFirst.upper%", + "%typescript.preferences.organizeImports.caseFirst.lower%" + ], + "default": "default" + } + }, + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.preferences.organizeImports": { "type": "object", "markdownDescription": "%typescript.preferences.organizeImports%", + "markdownDeprecationMessage": "%configuration.preferences.organizeImports.unifiedDeprecationMessage%", "properties": { "caseSensitivity": { "type": "string", @@ -1105,6 +1368,7 @@ "typescript.preferences.organizeImports": { "type": "object", "markdownDescription": "%typescript.preferences.organizeImports%", + "markdownDeprecationMessage": "%configuration.preferences.organizeImports.unifiedDeprecationMessage%", "properties": { "caseSensitivity": { "type": "string", diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index cb65d0c94177d..59a9ea92331bb 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -155,6 +155,18 @@ "typescript.suggestionActions.enabled": "Enable/disable suggestion diagnostics for TypeScript files in the editor.", "configuration.suggestionActions.enabled": "Enable/disable suggestion diagnostics for JavaScript and TypeScript files in the editor.", "configuration.suggestionActions.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.suggestionActions.enabled#` instead.", + "configuration.preferences.quoteStyle.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.quoteStyle#` instead.", + "configuration.preferences.importModuleSpecifier.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.importModuleSpecifier#` instead.", + "configuration.preferences.importModuleSpecifierEnding.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.importModuleSpecifierEnding#` instead.", + "configuration.preferences.jsxAttributeCompletionStyle.auto": "Insert `={}` or `=\"\"` after attribute names based on the prop type. See `#js/ts.preferences.quoteStyle#` to control the type of quotes used for string attributes.", + "configuration.preferences.jsxAttributeCompletionStyle.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.jsxAttributeCompletionStyle#` instead.", + "configuration.preferences.includePackageJsonAutoImports.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.includePackageJsonAutoImports#` instead.", + "configuration.preferences.autoImportFileExcludePatterns.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.autoImportFileExcludePatterns#` instead.", + "configuration.preferences.autoImportSpecifierExcludeRegexes.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.autoImportSpecifierExcludeRegexes#` instead.", + "configuration.preferences.preferTypeOnlyAutoImports.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.preferTypeOnlyAutoImports#` instead.", + "configuration.preferences.useAliasesForRenames.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.useAliasesForRenames#` instead.", + "configuration.preferences.renameMatchingJsxTags.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.renameMatchingJsxTags#` instead.", + "configuration.preferences.organizeImports.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.preferences.organizeImports#` instead.", "typescript.preferences.quoteStyle": "Preferred quote style to use for Quick Fixes.", "typescript.preferences.quoteStyle.single": "Always use single quotes: `'`", "typescript.preferences.quoteStyle.double": "Always use double quotes: `\"`", diff --git a/extensions/typescript-language-features/src/configuration/configuration.ts b/extensions/typescript-language-features/src/configuration/configuration.ts index 54fa02eff748e..211beb52f4099 100644 --- a/extensions/typescript-language-features/src/configuration/configuration.ts +++ b/extensions/typescript-language-features/src/configuration/configuration.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import * as Proto from '../tsServer/protocol/protocol'; +import { readUnifiedConfig } from '../utils/configuration'; import * as objects from '../utils/objects'; export enum TsServerLogLevel { @@ -268,8 +269,8 @@ export abstract class BaseServiceConfigurationProvider implements ServiceConfigu return { ...(watchOptions ?? {}) }; } - protected readIncludePackageJsonAutoImports(configuration: vscode.WorkspaceConfiguration): 'auto' | 'on' | 'off' | undefined { - return configuration.get<'auto' | 'on' | 'off'>('typescript.preferences.includePackageJsonAutoImports'); + protected readIncludePackageJsonAutoImports(_configuration: vscode.WorkspaceConfiguration): 'auto' | 'on' | 'off' | undefined { + return readUnifiedConfig<'auto' | 'on' | 'off' | undefined>('preferences.includePackageJsonAutoImports', undefined, { fallbackSection: 'typescript' }); } protected readMaxTsServerMemory(configuration: vscode.WorkspaceConfiguration): number { diff --git a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts index a79d086344ab3..5b89c4340f329 100644 --- a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts +++ b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts @@ -178,20 +178,16 @@ export default class FileConfigurationManager extends Disposable { isTypeScriptDocument(document) ? 'typescript' : 'javascript', document); - const preferencesConfig = vscode.workspace.getConfiguration( - isTypeScriptDocument(document) ? 'typescript.preferences' : 'javascript.preferences', - document); - const fallbackSection = isTypeScriptDocument(document) ? 'typescript' : 'javascript'; const preferences: Proto.UserPreferences = { ...config.get('unstable'), - quotePreference: this.getQuoteStylePreference(preferencesConfig), - importModuleSpecifierPreference: getImportModuleSpecifierPreference(preferencesConfig), - importModuleSpecifierEnding: getImportModuleSpecifierEndingPreference(preferencesConfig), - jsxAttributeCompletionStyle: getJsxAttributeCompletionStyle(preferencesConfig), + quotePreference: getQuoteStylePreference(document, fallbackSection), + importModuleSpecifierPreference: getImportModuleSpecifierPreference(document, fallbackSection), + importModuleSpecifierEnding: getImportModuleSpecifierEndingPreference(document, fallbackSection), + jsxAttributeCompletionStyle: getJsxAttributeCompletionStyle(document, fallbackSection), allowTextChangesInNewFiles: document.uri.scheme === fileSchemes.file, - providePrefixAndSuffixTextForRename: preferencesConfig.get('useAliasesForRenames', true), + providePrefixAndSuffixTextForRename: readUnifiedConfig('preferences.useAliasesForRenames', true, { scope: document, fallbackSection }), allowRenameOfImportPath: true, includeAutomaticOptionalChainCompletions: readUnifiedConfig('suggest.includeAutomaticOptionalChainCompletions', true, { scope: document, fallbackSection }), provideRefactorNotApplicableReason: true, @@ -200,9 +196,9 @@ export default class FileConfigurationManager extends Disposable { includeCompletionsWithSnippetText: true, includeCompletionsWithClassMemberSnippets: readUnifiedConfig('suggest.classMemberSnippets.enabled', true, { scope: document, fallbackSection }), includeCompletionsWithObjectLiteralMethodSnippets: readUnifiedConfig('suggest.objectLiteralMethodSnippets.enabled', true, { scope: document, fallbackSection }), - autoImportFileExcludePatterns: this.getAutoImportFileExcludePatternsPreference(preferencesConfig, vscode.workspace.getWorkspaceFolder(document.uri)?.uri), - autoImportSpecifierExcludeRegexes: preferencesConfig.get('autoImportSpecifierExcludeRegexes'), - preferTypeOnlyAutoImports: preferencesConfig.get('preferTypeOnlyAutoImports', false), + autoImportFileExcludePatterns: this.getAutoImportFileExcludePatternsPreference(document, fallbackSection, vscode.workspace.getWorkspaceFolder(document.uri)?.uri), + autoImportSpecifierExcludeRegexes: readUnifiedConfig('preferences.autoImportSpecifierExcludeRegexes', undefined, { scope: document, fallbackSection }), + preferTypeOnlyAutoImports: readUnifiedConfig('preferences.preferTypeOnlyAutoImports', false, { scope: document, fallbackSection }), useLabelDetailsInCompletionEntries: true, allowIncompleteCompletions: true, displayPartsForJSDoc: true, @@ -210,23 +206,16 @@ export default class FileConfigurationManager extends Disposable { interactiveInlayHints: true, includeCompletionsForModuleExports: readUnifiedConfig('suggest.autoImports', true, { scope: document, fallbackSection }), ...getInlayHintsPreferences(document, fallbackSection), - ...this.getOrganizeImportsPreferences(preferencesConfig), + ...getOrganizeImportsPreferences(document, fallbackSection), maximumHoverLength: this.getMaximumHoverLength(document), }; return preferences; } - private getQuoteStylePreference(config: vscode.WorkspaceConfiguration) { - switch (config.get('quoteStyle')) { - case 'single': return 'single'; - case 'double': return 'double'; - default: return 'auto'; - } - } - - private getAutoImportFileExcludePatternsPreference(config: vscode.WorkspaceConfiguration, workspaceFolder: vscode.Uri | undefined): string[] | undefined { - return workspaceFolder && config.get('autoImportFileExcludePatterns')?.map(p => { + private getAutoImportFileExcludePatternsPreference(scope: vscode.ConfigurationScope, fallbackSection: string, workspaceFolder: vscode.Uri | undefined): string[] | undefined { + const patterns = readUnifiedConfig('preferences.autoImportFileExcludePatterns', undefined, { scope, fallbackSection }); + return workspaceFolder && patterns?.map(p => { // Normalization rules: https://github.com/microsoft/TypeScript/pull/49578 const isRelative = /^\.\.?($|[\/\\])/.test(p); // In TypeScript < 5.3, the first path component cannot be a wildcard, so we need to prefix @@ -241,27 +230,6 @@ export default class FileConfigurationManager extends Disposable { }); } - private getOrganizeImportsPreferences(config: vscode.WorkspaceConfiguration): Proto.UserPreferences { - const organizeImportsCollation = config.get<'ordinal' | 'unicode'>('organizeImports.unicodeCollation'); - const organizeImportsCaseSensitivity = config.get<'auto' | 'caseInsensitive' | 'caseSensitive'>('organizeImports.caseSensitivity'); - return { - // More specific settings - organizeImportsTypeOrder: withDefaultAsUndefined(config.get<'auto' | 'last' | 'inline' | 'first'>('organizeImports.typeOrder', 'auto'), 'auto'), - organizeImportsIgnoreCase: organizeImportsCaseSensitivity === 'caseInsensitive' ? true - : organizeImportsCaseSensitivity === 'caseSensitive' ? false - : 'auto', - organizeImportsCollation, - - // The rest of the settings are only applicable when using unicode collation - ...(organizeImportsCollation === 'unicode' ? { - organizeImportsCaseFirst: organizeImportsCaseSensitivity === 'caseInsensitive' ? undefined : withDefaultAsUndefined(config.get<'default' | 'upper' | 'lower' | false>('organizeImports.caseFirst', false), 'default'), - organizeImportsAccentCollation: config.get('organizeImports.accentCollation'), - organizeImportsLocale: config.get('organizeImports.locale'), - organizeImportsNumericCollation: config.get('organizeImports.numericCollation'), - } : {}), - }; - } - private getMaximumHoverLength(document: vscode.TextDocument): number { const defaultMaxLength = 500; @@ -310,8 +278,16 @@ function getInlayParameterNameHintsPreference(scope: vscode.ConfigurationScope, } } -function getImportModuleSpecifierPreference(config: vscode.WorkspaceConfiguration) { - switch (config.get('importModuleSpecifier')) { +function getQuoteStylePreference(scope: vscode.ConfigurationScope, fallbackSection: string) { + switch (readUnifiedConfig('preferences.quoteStyle', 'auto', { scope, fallbackSection })) { + case 'single': return 'single'; + case 'double': return 'double'; + default: return 'auto'; + } +} + +function getImportModuleSpecifierPreference(scope: vscode.ConfigurationScope, fallbackSection: string) { + switch (readUnifiedConfig('preferences.importModuleSpecifier', 'shortest', { scope, fallbackSection })) { case 'project-relative': return 'project-relative'; case 'relative': return 'relative'; case 'non-relative': return 'non-relative'; @@ -319,8 +295,8 @@ function getImportModuleSpecifierPreference(config: vscode.WorkspaceConfiguratio } } -function getImportModuleSpecifierEndingPreference(config: vscode.WorkspaceConfiguration) { - switch (config.get('importModuleSpecifierEnding')) { +function getImportModuleSpecifierEndingPreference(scope: vscode.ConfigurationScope, fallbackSection: string) { + switch (readUnifiedConfig('preferences.importModuleSpecifierEnding', 'auto', { scope, fallbackSection })) { case 'minimal': return 'minimal'; case 'index': return 'index'; case 'js': return 'js'; @@ -328,10 +304,31 @@ function getImportModuleSpecifierEndingPreference(config: vscode.WorkspaceConfig } } -function getJsxAttributeCompletionStyle(config: vscode.WorkspaceConfiguration) { - switch (config.get('jsxAttributeCompletionStyle')) { +function getJsxAttributeCompletionStyle(scope: vscode.ConfigurationScope, fallbackSection: string) { + switch (readUnifiedConfig('preferences.jsxAttributeCompletionStyle', 'auto', { scope, fallbackSection })) { case 'braces': return 'braces'; case 'none': return 'none'; default: return 'auto'; } } + +function getOrganizeImportsPreferences(scope: vscode.ConfigurationScope, fallbackSection: string): Proto.UserPreferences { + const organizeImportsCollation = readUnifiedConfig<'ordinal' | 'unicode'>('preferences.organizeImports.unicodeCollation', 'ordinal', { scope, fallbackSection }); + const organizeImportsCaseSensitivity = readUnifiedConfig<'auto' | 'caseInsensitive' | 'caseSensitive'>('preferences.organizeImports.caseSensitivity', 'auto', { scope, fallbackSection }); + return { + // More specific settings + organizeImportsTypeOrder: withDefaultAsUndefined(readUnifiedConfig<'auto' | 'last' | 'inline' | 'first'>('preferences.organizeImports.typeOrder', 'auto', { scope, fallbackSection }), 'auto'), + organizeImportsIgnoreCase: organizeImportsCaseSensitivity === 'caseInsensitive' ? true + : organizeImportsCaseSensitivity === 'caseSensitive' ? false + : 'auto', + organizeImportsCollation, + + // The rest of the settings are only applicable when using unicode collation + ...(organizeImportsCollation === 'unicode' ? { + organizeImportsCaseFirst: organizeImportsCaseSensitivity === 'caseInsensitive' ? undefined : withDefaultAsUndefined(readUnifiedConfig<'default' | 'upper' | 'lower' | false>('preferences.organizeImports.caseFirst', false, { scope, fallbackSection }), 'default'), + organizeImportsAccentCollation: readUnifiedConfig('preferences.organizeImports.accentCollation', undefined, { scope, fallbackSection }), + organizeImportsLocale: readUnifiedConfig('preferences.organizeImports.locale', undefined, { scope, fallbackSection }), + organizeImportsNumericCollation: readUnifiedConfig('preferences.organizeImports.numericCollation', undefined, { scope, fallbackSection }), + } : {}), + }; +} diff --git a/extensions/typescript-language-features/src/languageFeatures/rename.ts b/extensions/typescript-language-features/src/languageFeatures/rename.ts index 5c86190a53e74..3629ff7790dcf 100644 --- a/extensions/typescript-language-features/src/languageFeatures/rename.ts +++ b/extensions/typescript-language-features/src/languageFeatures/rename.ts @@ -11,6 +11,7 @@ import { API } from '../tsServer/api'; import type * as Proto from '../tsServer/protocol/protocol'; import * as typeConverters from '../typeConverters'; import { ClientCapability, ITypeScriptServiceClient } from '../typescriptService'; +import { readUnifiedConfig } from '../utils/configuration'; import FileConfigurationManager from './fileConfigurationManager'; import { conditionalRegistration, requireSomeCapability } from './util/dependentRegistration'; import { LanguageDescription } from '../configuration/languageDescription'; @@ -112,7 +113,7 @@ class TypeScriptRenameProvider implements vscode.RenameProvider { // Prefer renaming matching jsx tag when available if (this.client.apiVersion.gte(API.v510) && - vscode.workspace.getConfiguration(this.language.id).get('preferences.renameMatchingJsxTags', true) && + readUnifiedConfig('preferences.renameMatchingJsxTags', true, { scope: document, fallbackSection: this.language.id }) && this.looksLikePotentialJsxTagContext(document, position) ) { const args = typeConverters.Position.toFileLocationRequestArgs(file, position); diff --git a/extensions/typescript-language-features/src/test/smoke/completions.test.ts b/extensions/typescript-language-features/src/test/smoke/completions.test.ts index deea8f9bdacd3..e7559a28180b8 100644 --- a/extensions/typescript-language-features/src/test/smoke/completions.test.ts +++ b/extensions/typescript-language-features/src/test/smoke/completions.test.ts @@ -20,8 +20,7 @@ suite.skip('TypeScript Completions', () => { [Config.insertMode]: 'insert', [Config.snippetSuggestions]: 'none', [Config.suggestSelection]: 'first', - [Config.javascriptQuoteStyle]: 'double', - [Config.typescriptQuoteStyle]: 'double', + [Config.quoteStyle]: 'double', }); const _disposables: vscode.Disposable[] = []; diff --git a/extensions/typescript-language-features/src/test/testUtils.ts b/extensions/typescript-language-features/src/test/testUtils.ts index fc65d68944f03..16a2b37cbdb5b 100644 --- a/extensions/typescript-language-features/src/test/testUtils.ts +++ b/extensions/typescript-language-features/src/test/testUtils.ts @@ -123,8 +123,7 @@ export const Config = Object.freeze({ insertMode: 'editor.suggest.insertMode', snippetSuggestions: 'editor.snippetSuggestions', suggestSelection: 'editor.suggestSelection', - javascriptQuoteStyle: 'javascript.preferences.quoteStyle', - typescriptQuoteStyle: 'typescript.preferences.quoteStyle', + quoteStyle: 'js/ts.preferences.quoteStyle', } as const); export const insertModesValues = Object.freeze(['insert', 'replace']); diff --git a/package-lock.json b/package-lock.json index 6bc08ff095670..32f7e1bae0a78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-8", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -50,6 +50,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", + "playwright-core": "^1.58.2", "tas-client": "0.3.1", "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", @@ -83,7 +84,7 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", "@typescript/native-preview": "^7.0.0-dev.20260130", - "@vscode/gulp-electron": "^1.38.2", + "@vscode/gulp-electron": "https://github.com/microsoft/vscode-gulp-electron.git#405e3df0e4e9c37fcf549cbe6f5cef8d5ba5ddff", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.10.2", "@vscode/test-cli": "^0.0.6", @@ -100,7 +101,7 @@ "css-loader": "^6.9.1", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.5.2", + "electron": "39.6.0", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -2044,6 +2045,19 @@ "node": ">=18" } }, + "node_modules/@playwright/browser-chromium/node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@playwright/test": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", @@ -2947,9 +2961,9 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.45-6", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-6.tgz", - "integrity": "sha512-HjJmIxw6anUPk/yiQTyF60ERjARNfc/A11kKoiO7jg2bzNeaCexunu4oUo/W8lHGr/dvHxYcruM1V3ZoGxyFNQ==", + "version": "0.0.45-8", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-8.tgz", + "integrity": "sha512-5MfjQ+LBXnzLB/+nfpB8EpvHPdUkoW57cFcrIAHz52L/sBjwOxZER3+K2+nwb+/ejAiPmogTBDoJP/NM85uBtQ==", "license": "CC-BY-4.0" }, "node_modules/@vscode/deviceid": { @@ -2965,8 +2979,8 @@ }, "node_modules/@vscode/gulp-electron": { "version": "1.38.2", - "resolved": "https://registry.npmjs.org/@vscode/gulp-electron/-/gulp-electron-1.38.2.tgz", - "integrity": "sha512-uFMp6Utz2kf62NMXVIht09FfIcuAFLuw7b9xhJNm2iGaaAI3b2BBHP05cKG3LYIPGvkWoC7UNk4EjyQDO7T/ZA==", + "resolved": "git+ssh://git@github.com/microsoft/vscode-gulp-electron.git#405e3df0e4e9c37fcf549cbe6f5cef8d5ba5ddff", + "integrity": "sha512-SHFKIq0Gr8WOeVn9QOACkbxX5lsaj96Ux2npBHSb/a7S6ykyDD0Im1i+xCT96WimWLRQV0X20sK9IFli8I2Mkg==", "dev": true, "license": "MIT", "dependencies": { @@ -6586,9 +6600,9 @@ "dev": true }, "node_modules/electron": { - "version": "39.5.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.5.2.tgz", - "integrity": "sha512-EiGFKoTjCuJTsdNxSwOiKJvRbWOFHTmqnnVftpUUZf7rdMkvM6yn9i55uLNDKefvTE69M+vfMgGLa7HuY94WZg==", + "version": "39.6.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.6.0.tgz", + "integrity": "sha512-KQK3sJ6JCyymY3HQxV0N/bVBQwKQETRW0N/+OYcrL9H6tZhpmTSaZY3qSxcruWrPIuouvoiP3Vk/JKUpw05ZIw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -13958,6 +13972,18 @@ } }, "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/playwright-core": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", diff --git a/package.json b/package.json index a43cf973e41c8..20b139291a3a7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "c3c943b0a748aa10a5d6818ab8e7c9e34e458d83", + "distro": "16be71799bd7ef33ea9b0206fb548ce74a47daa4", "author": { "name": "Microsoft Corporation" }, @@ -30,7 +30,7 @@ "watch-client": "npm run gulp watch-client", "watch-clientd": "deemon npm run watch-client", "kill-watch-clientd": "deemon --kill npm run watch-client", - "watch-client-transpile": "npx tsx build/next/index.ts transpile --watch", + "watch-client-transpile": "node build/next/index.ts transpile --watch", "watch-client-transpiled": "deemon npm run watch-client-transpile", "kill-watch-client-transpiled": "deemon --kill npm run watch-client-transpile", "watch-extensions": "npm run gulp watch-extensions watch-extension-media", @@ -80,7 +80,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-8", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -115,6 +115,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", + "playwright-core": "^1.58.2", "tas-client": "0.3.1", "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", @@ -148,7 +149,7 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", "@typescript/native-preview": "^7.0.0-dev.20260130", - "@vscode/gulp-electron": "^1.38.2", + "@vscode/gulp-electron": "https://github.com/microsoft/vscode-gulp-electron.git#405e3df0e4e9c37fcf549cbe6f5cef8d5ba5ddff", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.10.2", "@vscode/test-cli": "^0.0.6", @@ -165,7 +166,7 @@ "css-loader": "^6.9.1", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.5.2", + "electron": "39.6.0", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 48c97fba4c5fd..f443df36ae51d 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-8", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.45-6", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-6.tgz", - "integrity": "sha512-HjJmIxw6anUPk/yiQTyF60ERjARNfc/A11kKoiO7jg2bzNeaCexunu4oUo/W8lHGr/dvHxYcruM1V3ZoGxyFNQ==", + "version": "0.0.45-8", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-8.tgz", + "integrity": "sha512-5MfjQ+LBXnzLB/+nfpB8EpvHPdUkoW57cFcrIAHz52L/sBjwOxZER3+K2+nwb+/ejAiPmogTBDoJP/NM85uBtQ==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 4f591ed99068f..fa7ad6cca706f 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-8", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", diff --git a/scripts/code-cli.bat b/scripts/code-cli.bat index e28f03f6cdcb8..543106f45c836 100644 --- a/scripts/code-cli.bat +++ b/scripts/code-cli.bat @@ -8,7 +8,8 @@ pushd %~dp0.. :: Get electron, compile, built-in extensions if "%VSCODE_SKIP_PRELAUNCH%"=="" node build/lib/preLaunch.ts -for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do set NAMESHORT=%%~a +set "NAMESHORT=" +for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do if not defined NAMESHORT set "NAMESHORT=%%~a" set NAMESHORT=%NAMESHORT: "=% set NAMESHORT=%NAMESHORT:"=%.exe set CODE=".build\electron\%NAMESHORT%" diff --git a/scripts/code.bat b/scripts/code.bat index 784efeaecaf78..62cfd0b4c4908 100644 --- a/scripts/code.bat +++ b/scripts/code.bat @@ -10,7 +10,8 @@ if "%VSCODE_SKIP_PRELAUNCH%"=="" ( node build/lib/preLaunch.ts ) -for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do set NAMESHORT=%%~a +set "NAMESHORT=" +for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do if not defined NAMESHORT set "NAMESHORT=%%~a" set NAMESHORT=%NAMESHORT: "=% set NAMESHORT=%NAMESHORT:"=%.exe set CODE=".build\electron\%NAMESHORT%" diff --git a/scripts/node-electron.bat b/scripts/node-electron.bat index ee24da09accd6..33d4fdd71fc44 100644 --- a/scripts/node-electron.bat +++ b/scripts/node-electron.bat @@ -5,7 +5,8 @@ set ELECTRON_RUN_AS_NODE=1 pushd %~dp0\.. -for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do set NAMESHORT=%%~a +set "NAMESHORT=" +for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do if not defined NAMESHORT set "NAMESHORT=%%~a" set NAMESHORT=%NAMESHORT: "=% set NAMESHORT=%NAMESHORT:"=%.exe set CODE=".build\electron\%NAMESHORT%" diff --git a/scripts/test.bat b/scripts/test.bat index 4e0f89fb501b0..dee40972d809b 100644 --- a/scripts/test.bat +++ b/scripts/test.bat @@ -6,7 +6,8 @@ set ELECTRON_RUN_AS_NODE= pushd %~dp0\.. :: Get Code.exe location -for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do set NAMESHORT=%%~a +set "NAMESHORT=" +for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do if not defined NAMESHORT set "NAMESHORT=%%~a" set NAMESHORT=%NAMESHORT: "=% set NAMESHORT=%NAMESHORT:"=%.exe set CODE=".build\electron\%NAMESHORT%" diff --git a/src/bootstrap-meta.ts b/src/bootstrap-meta.ts index 1e5affb0a9754..51c7066d8c6f8 100644 --- a/src/bootstrap-meta.ts +++ b/src/bootstrap-meta.ts @@ -5,6 +5,7 @@ import { createRequire } from 'node:module'; import type { IProductConfiguration } from './vs/base/common/product.js'; +import type { INodeProcess } from './vs/base/common/platform.js'; const require = createRequire(import.meta.url); @@ -18,6 +19,18 @@ if (pkgObj['BUILD_INSERT_PACKAGE_CONFIGURATION']) { pkgObj = require('../package.json'); // Running out of sources } +// Load sub files +if ((process as INodeProcess).isEmbeddedApp) { + try { + const productSubObj = require('../product.sub.json'); + productObj = Object.assign(productObj, productSubObj); + } catch (error) { /* ignore */ } + try { + const pkgSubObj = require('../package.sub.json'); + pkgObj = Object.assign(pkgObj, pkgSubObj); + } catch (error) { /* ignore */ } +} + let productOverridesObj = {}; if (process.env['VSCODE_DEV']) { try { diff --git a/src/tsec.exemptions.json b/src/tsec.exemptions.json index 83691e2de5ab8..e290af37d5c42 100644 --- a/src/tsec.exemptions.json +++ b/src/tsec.exemptions.json @@ -13,6 +13,7 @@ ], "ban-trustedtypes-createpolicy": [ "vs/code/electron-browser/workbench/workbench.ts", + "vs/sessions/electron-browser/sessions.ts", "vs/amdX.ts", "vs/base/browser/trustedTypes.ts", "vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts" @@ -37,6 +38,7 @@ "**/*.ts" ], "ban-script-content-assignments": [ - "vs/code/electron-browser/workbench/workbench.ts" + "vs/code/electron-browser/workbench/workbench.ts", + "vs/sessions/electron-browser/sessions.ts" ] } diff --git a/src/vs/base/browser/ui/button/button.ts b/src/vs/base/browser/ui/button/button.ts index 8b7fc2d9b33cc..78c7a71963ae5 100644 --- a/src/vs/base/browser/ui/button/button.ts +++ b/src/vs/base/browser/ui/button/button.ts @@ -91,6 +91,15 @@ const buttonSanitizerConfig = Object.freeze({ }, }); +// Markdown render options that allow class attributes to pass through +const buttonMarkdownRenderOptions = Object.freeze({ + sanitizerConfig: { + allowedAttributes: { + override: ['class'], + } + } +}); + export class Button extends Disposable implements IButton { protected options: IButtonOptions; @@ -262,7 +271,7 @@ export class Button extends Disposable implements IButton { const labelElement = this.options.supportShortLabel ? this._labelElement! : this._element; if (isMarkdownString(value)) { - const rendered = renderMarkdown(value, undefined, document.createElement('span')); + const rendered = renderMarkdown(value, buttonMarkdownRenderOptions, document.createElement('span')); rendered.dispose(); // Don't include outer `

` @@ -673,7 +682,7 @@ export class ButtonWithIcon extends Button { this._element.classList.add('monaco-text-button'); if (isMarkdownString(value)) { - const rendered = renderMarkdown(value, undefined, document.createElement('span')); + const rendered = renderMarkdown(value, buttonMarkdownRenderOptions, document.createElement('span')); rendered.dispose(); // eslint-disable-next-line no-restricted-syntax diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index 6c972e7866b02..fceb9c852cce1 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -58,6 +58,7 @@ export interface IDialogOptions { readonly disableCloseAction?: boolean; readonly disableCloseButton?: boolean; readonly disableDefaultAction?: boolean; + readonly onVisibilityChange?: (window: Window, visible: boolean) => void; readonly buttonStyles: IButtonStyles; readonly checkboxStyles: ICheckboxStyles; readonly inputBoxStyles: IInputBoxStyles; @@ -536,6 +537,10 @@ export class Dialog extends Disposable { this.element.setAttribute('aria-describedby', 'monaco-dialog-icon monaco-dialog-message-text monaco-dialog-message-detail monaco-dialog-message-body monaco-dialog-footer'); show(this.element); + // Notify visibility change + this.options.onVisibilityChange?.(window, true); + this._register(toDisposable(() => this.options.onVisibilityChange?.(window, false))); + // Focus first element (input or button) if (this.inputs.length > 0) { this.inputs[0].focus(); diff --git a/src/vs/base/browser/ui/grid/grid.ts b/src/vs/base/browser/ui/grid/grid.ts index f667738b74818..ef9b1ba246278 100644 --- a/src/vs/base/browser/ui/grid/grid.ts +++ b/src/vs/base/browser/ui/grid/grid.ts @@ -9,10 +9,9 @@ import { Event } from '../../../common/event.js'; import { Disposable } from '../../../common/lifecycle.js'; import './gridview.css'; import { Box, GridView, IGridViewOptions, IGridViewStyles, IView as IGridViewView, IViewSize, orthogonal, Sizing as GridViewSizing, GridLocation } from './gridview.js'; -import type { SplitView, AutoSizing as SplitViewAutoSizing, IViewVisibilityAnimationOptions } from '../splitview/splitview.js'; +import type { SplitView, AutoSizing as SplitViewAutoSizing } from '../splitview/splitview.js'; export type { IViewSize }; -export type { IViewVisibilityAnimationOptions } from '../splitview/splitview.js'; export { LayoutPriority, Orientation, orthogonal } from './gridview.js'; export const enum Direction { @@ -651,12 +650,10 @@ export class Grid extends Disposable { * Set the visibility state of a {@link IView view}. * * @param view The {@link IView view}. - * @param visible Whether the view should be visible. - * @param animation Optional animation options. */ - setViewVisible(view: T, visible: boolean, animation?: IViewVisibilityAnimationOptions): void { + setViewVisible(view: T, visible: boolean): void { const location = this.getViewLocation(view); - this.gridview.setViewVisible(location, visible, animation); + this.gridview.setViewVisible(location, visible); } /** diff --git a/src/vs/base/browser/ui/grid/gridview.ts b/src/vs/base/browser/ui/grid/gridview.ts index 03c416cfd896f..47de4f1de301e 100644 --- a/src/vs/base/browser/ui/grid/gridview.ts +++ b/src/vs/base/browser/ui/grid/gridview.ts @@ -5,7 +5,7 @@ import { $ } from '../../dom.js'; import { IBoundarySashes, Orientation, Sash } from '../sash/sash.js'; -import { DistributeSizing, ISplitViewStyles, IView as ISplitView, IViewVisibilityAnimationOptions, LayoutPriority, Sizing, AutoSizing, SplitView } from '../splitview/splitview.js'; +import { DistributeSizing, ISplitViewStyles, IView as ISplitView, LayoutPriority, Sizing, AutoSizing, SplitView } from '../splitview/splitview.js'; import { equals as arrayEquals, tail } from '../../../common/arrays.js'; import { Color } from '../../../common/color.js'; import { Emitter, Event, Relay } from '../../../common/event.js'; @@ -615,7 +615,7 @@ class BranchNode implements ISplitView, IDisposable { return this.splitview.isViewVisible(index); } - setChildVisible(index: number, visible: boolean, animation?: IViewVisibilityAnimationOptions): void { + setChildVisible(index: number, visible: boolean): void { index = validateIndex(index, this.children.length); if (this.splitview.isViewVisible(index) === visible) { @@ -623,7 +623,7 @@ class BranchNode implements ISplitView, IDisposable { } const wereAllChildrenHidden = this.splitview.contentSize === 0; - this.splitview.setViewVisible(index, visible, animation); + this.splitview.setViewVisible(index, visible); const areAllChildrenHidden = this.splitview.contentSize === 0; // If all children are hidden then the parent should hide the entire splitview @@ -738,6 +738,7 @@ class BranchNode implements ISplitView, IDisposable { } this._onDidChange.dispose(); + this._onDidScroll.dispose(); this._onDidSashReset.dispose(); this._onDidVisibilityChange.dispose(); @@ -933,6 +934,7 @@ class LeafNode implements ISplitView, IDisposable { } dispose(): void { + this._onDidSetLinkedNode.dispose(); this.disposables.dispose(); } } @@ -1663,7 +1665,7 @@ export class GridView implements IDisposable { * * @param location The {@link GridLocation location} of the view. */ - setViewVisible(location: GridLocation, visible: boolean, animation?: IViewVisibilityAnimationOptions): void { + setViewVisible(location: GridLocation, visible: boolean): void { if (this.hasMaximizedView()) { this.exitMaximizedView(); return; @@ -1676,7 +1678,7 @@ export class GridView implements IDisposable { throw new Error('Invalid from location'); } - parent.setChildVisible(index, visible, animation); + parent.setChildVisible(index, visible); } /** @@ -1832,6 +1834,7 @@ export class GridView implements IDisposable { } dispose(): void { + this._onDidChangeViewMaximized.dispose(); this.onDidSashResetRelay.dispose(); this.root.dispose(); this.element.remove(); diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 512430ae805de..6e29b67c503a5 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -332,8 +332,8 @@ export class ListView implements IListView { private readonly disposables: DisposableStore = new DisposableStore(); - private readonly _onDidChangeContentHeight = new Emitter(); - private readonly _onDidChangeContentWidth = new Emitter(); + private readonly _onDidChangeContentHeight = this.disposables.add(new Emitter()); + private readonly _onDidChangeContentWidth = this.disposables.add(new Emitter()); readonly onDidChangeContentHeight: Event = Event.latch(this._onDidChangeContentHeight.event, undefined, this.disposables); readonly onDidChangeContentWidth: Event = Event.latch(this._onDidChangeContentWidth.event, undefined, this.disposables); get contentHeight(): number { return this.rangeMap.size; } @@ -370,6 +370,7 @@ export class ListView implements IListView { this.scrollableElementWidthDelayer.cancel(); this.scrollableElement.setScrollDimensions({ width: this.renderWidth, scrollWidth: this.renderWidth }); this.rowsContainer.style.width = ''; + this.domNode.style.removeProperty('--list-scroll-right-offset'); } } @@ -894,6 +895,11 @@ export class ListView implements IListView { this.scrollableElement.setScrollDimensions({ width: typeof width === 'number' ? width : getContentWidth(this.domNode) }); + + const scrollPos = this.scrollableElement.getScrollPosition(); + const scrollDims = this.scrollableElement.getScrollDimensions(); + const rightOffset = Math.max(0, scrollDims.scrollWidth - scrollPos.scrollLeft - this.renderWidth); + this.domNode.style.setProperty('--list-scroll-right-offset', `${Math.max(rightOffset - 12, 0)}px`); } } @@ -935,6 +941,8 @@ export class ListView implements IListView { if (this.horizontalScrolling && scrollWidth !== undefined) { this.rowsContainer.style.width = `${Math.max(scrollWidth, this.renderWidth)}px`; + const rightOffset = Math.max(0, scrollWidth - (renderLeft ?? 0) - this.renderWidth); + this.domNode.style.setProperty('--list-scroll-right-offset', `${Math.max(rightOffset - 12, 0)}px`); } this.lastRenderTop = renderTop; diff --git a/src/vs/base/browser/ui/motion/motion.ts b/src/vs/base/browser/ui/motion/motion.ts deleted file mode 100644 index c2e8a045d417e..0000000000000 --- a/src/vs/base/browser/ui/motion/motion.ts +++ /dev/null @@ -1,155 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './motion.css'; - -//#region Easing Curves - -/** - * A pre-parsed cubic bezier easing curve that can be evaluated directly - * without reparsing a CSS string on every frame. - * - * Given control points `(x1, y1)` and `(x2, y2)` (the CSS `cubic-bezier` - * parameters), {@link solve} finds the bezier parameter `u` such that - * `Bx(u) = t` using Newton's method, then returns `By(u)`. - */ -export class CubicBezierCurve { - - constructor( - readonly x1: number, - readonly y1: number, - readonly x2: number, - readonly y2: number, - ) { } - - /** - * Evaluate the curve at time `t` (0-1), returning the eased value. - */ - solve(t: number): number { - if (t <= 0) { - return 0; - } - if (t >= 1) { - return 1; - } - - // Newton's method to find u where Bx(u) = t - let u = t; // initial guess - for (let i = 0; i < 8; i++) { - const currentX = bezierComponent(u, this.x1, this.x2); - const error = currentX - t; - if (Math.abs(error) < 1e-6) { - break; - } - const dx = bezierComponentDerivative(u, this.x1, this.x2); - if (Math.abs(dx) < 1e-6) { - break; - } - u -= error / dx; - } - - u = Math.max(0, Math.min(1, u)); - return bezierComponent(u, this.y1, this.y2); - } - - /** - * Returns the CSS `cubic-bezier(…)` string representation, for use in - * CSS `transition` or `animation` properties. - */ - toCssString(): string { - return `cubic-bezier(${this.x1}, ${this.y1}, ${this.x2}, ${this.y2})`; - } -} - -/** - * Fluent 2 ease-out curve - default for entrances and expansions. - * Starts fast and decelerates to a stop. - */ -export const EASE_OUT = new CubicBezierCurve(0.1, 0.9, 0.2, 1); - -/** - * Fluent 2 ease-in curve - for exits and collapses. - * Starts slow and accelerates out. - */ -export const EASE_IN = new CubicBezierCurve(0.9, 0.1, 1, 0.2); - -//#endregion - -//#region Cubic Bezier Evaluation - -/** - * Parses a CSS `cubic-bezier(x1, y1, x2, y2)` string into a - * {@link CubicBezierCurve}. Returns a linear curve on parse failure. - */ -export function parseCubicBezier(css: string): CubicBezierCurve { - const match = css.match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/); - if (!match) { - return new CubicBezierCurve(0, 0, 1, 1); - } - return new CubicBezierCurve(parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4])); -} - -/** Evaluates one component of a cubic bezier: B(u) with control points p1, p2, endpoints 0 and 1. */ -function bezierComponent(u: number, p1: number, p2: number): number { - // B(u) = 3(1-u)^2*u*p1 + 3(1-u)*u^2*p2 + u^3 - const oneMinusU = 1 - u; - return 3 * oneMinusU * oneMinusU * u * p1 + 3 * oneMinusU * u * u * p2 + u * u * u; -} - -/** First derivative of a bezier component: B'(u). */ -function bezierComponentDerivative(u: number, p1: number, p2: number): number { - // B'(u) = 3(1-u)^2*p1 + 6(1-u)*u*(p2-p1) + 3*u^2*(1-p2) - const oneMinusU = 1 - u; - return 3 * oneMinusU * oneMinusU * p1 + 6 * oneMinusU * u * (p2 - p1) + 3 * u * u * (1 - p2); -} - -//#endregion - -//#region Duration Scaling - -/** - * Reference pixel distance at which the base duration constants apply. - * Duration scales linearly: a 600px animation takes twice as long as a 300px - * one, keeping perceived velocity constant. - */ -const REFERENCE_DISTANCE = 300; - -/** Minimum animation duration in milliseconds (avoids sub-frame flickers). */ -const MIN_DURATION = 50; - -/** Maximum animation duration in milliseconds (avoids sluggish feel). */ -const MAX_DURATION = 300; - -/** - * Scales a base animation duration proportionally to the pixel distance - * being animated, so that perceived velocity stays constant regardless of - * panel width. - * - * @param baseDuration The duration (ms) that applies at {@link REFERENCE_DISTANCE} pixels. - * @param pixelDistance The actual number of pixels the view will resize. - * @returns The scaled duration, clamped to [{@link MIN_DURATION}, {@link MAX_DURATION}]. - */ -export function scaleDuration(baseDuration: number, pixelDistance: number): number { - if (pixelDistance <= 0) { - return baseDuration; - } - const scaled = baseDuration * (pixelDistance / REFERENCE_DISTANCE); - return Math.round(Math.max(MIN_DURATION, Math.min(MAX_DURATION, scaled))); -} - -//#endregion - -//#region Utility Functions - -/** - * Checks whether motion is reduced by looking for the `monaco-reduce-motion` - * class on an ancestor element. This integrates with VS Code's existing - * accessibility infrastructure in {@link AccessibilityService}. - */ -export function isMotionReduced(element: HTMLElement): boolean { - return element.closest('.monaco-reduce-motion') !== null; -} - -//#endregion diff --git a/src/vs/base/browser/ui/splitview/splitview.ts b/src/vs/base/browser/ui/splitview/splitview.ts index 2c72bd8d86976..35f2724c1a8e4 100644 --- a/src/vs/base/browser/ui/splitview/splitview.ts +++ b/src/vs/base/browser/ui/splitview/splitview.ts @@ -8,41 +8,15 @@ import { DomEmitter } from '../../event.js'; import { ISashEvent as IBaseSashEvent, Orientation, Sash, SashState } from '../sash/sash.js'; import { SmoothScrollableElement } from '../scrollbar/scrollableElement.js'; import { pushToEnd, pushToStart, range } from '../../../common/arrays.js'; -import { CancellationToken } from '../../../common/cancellation.js'; import { Color } from '../../../common/color.js'; import { Emitter, Event } from '../../../common/event.js'; import { combinedDisposable, Disposable, dispose, IDisposable, toDisposable } from '../../../common/lifecycle.js'; import { clamp } from '../../../common/numbers.js'; import { Scrollable, ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js'; import * as types from '../../../common/types.js'; -import { CubicBezierCurve, isMotionReduced, scaleDuration } from '../motion/motion.js'; import './splitview.css'; export { Orientation } from '../sash/sash.js'; -/** - * Options for animating a view visibility change in a {@link SplitView}. - */ -export interface IViewVisibilityAnimationOptions { - - /** Transition duration in milliseconds. */ - readonly duration: number; - - /** The easing curve applied to the animation. */ - readonly easing: CubicBezierCurve; - - /** - * Optional callback invoked when the animation finishes naturally. - * NOT called if the animation is cancelled via the {@link token}. - */ - readonly onComplete?: () => void; - - /** - * A cancellation token that allows the caller to stop the animation. - * When cancellation is requested the animation snaps to its final state. - */ - readonly token: CancellationToken; -} - export interface ISplitViewStyles { readonly separatorBorder: Color; } @@ -828,38 +802,14 @@ export class SplitView= this.viewItems.length) { throw new Error('Index out of bounds'); } - // Cancel any in-flight animation before changing visibility. - // An animated visibility change interpolates ALL view sizes each - // frame, so a concurrent change on a different view would be - // overwritten on the next frame. Snapping first prevents that. - this._cleanupMotion?.(); - this._cleanupMotion = undefined; - - if (animation && !animation.token.isCancellationRequested && !isMotionReduced(this.el) && this.viewItems[index].visible !== visible) { - this._setViewVisibleAnimated(index, visible, animation); - } else { - this._setViewVisibleInstant(index, visible); - } - } - - /** - * Apply the visibility change to the model without animation. - */ - private _setViewVisibleInstant(index: number, visible: boolean): void { const viewItem = this.viewItems[index]; viewItem.setVisible(visible); @@ -868,167 +818,6 @@ export class SplitView v.size); - - // 2. Apply the target visibility to the model instantly. - // This computes final sizes, fires events, updates sashes, etc. - this._setViewVisibleInstant(index, visible); - - // 3. Snapshot sizes AFTER the visibility change (the animation end state) - const finalSizes = this.viewItems.map(v => v.size); - - // 4. Restore start sizes so we can animate FROM them - for (let i = 0; i < this.viewItems.length; i++) { - this.viewItems[i].size = startSizes[i]; - } - - // 5. For hiding: the target container lost .visible class (→ display:none). - // Restore it so content stays visible during the animation. - if (!visible) { - container.classList.add('visible'); - } - - // 6. Clip overflow on the animating container so that the view - // content (rendered at its full target size) is revealed/hidden - // smoothly as the container width animates. - container.style.overflow = 'hidden'; - - // The target size is the full (non-zero) panel size. During - // animation we re-layout the animating view at this fixed size - // after each frame so its content never reflows at intermediate - // widths/heights (prevents chat, extension icons, etc. from - // jumping around). Sibling views still receive interpolated sizes - // so the editor / bottom panel remain responsive. - const viewTargetSize = visible ? finalSizes[index] : startSizes[index]; - - // 6b. Set initial opacity for fade effect - container.style.opacity = visible ? '0' : '1'; - - // 7. Scale duration based on pixel distance for consistent perceived velocity - const pixelDistance = Math.abs(finalSizes[index] - startSizes[index]); - const duration = scaleDuration(baseDuration, pixelDistance); - - // 8. Render the start state - this.layoutViews(); - try { - this.viewItems[index].view.layout(viewTargetSize, 0, this.layoutContext); - } catch (e) { - console.error('Splitview: Failed to layout view during animation'); - console.error(e); - } - - // 9. Easing curve is pre-parsed - ready for JS evaluation - - // Helper: snap all sizes to final state and clean up - const applyFinalState = () => { - for (let i = 0; i < this.viewItems.length; i++) { - this.viewItems[i].size = finalSizes[i]; - } - container.style.opacity = ''; - container.style.overflow = ''; - if (!visible) { - container.classList.remove('visible'); - } - this.layoutViews(); - this.saveProportions(); - }; - - const cleanup = (completed: boolean) => { - if (disposed) { - return; - } - disposed = true; - tokenListener.dispose(); - if (rafId !== undefined) { - window.cancelAnimationFrame(rafId); - rafId = undefined; - } - applyFinalState(); - this._cleanupMotion = undefined; - if (completed) { - onComplete?.(); - } - }; - this._cleanupMotion = () => cleanup(false); - - // Listen to the cancellation token so the caller can stop the animation - const tokenListener = token.onCancellationRequested(() => cleanup(false)); - - // 10. Animate via requestAnimationFrame - const startTime = performance.now(); - const totalSize = this.size; - - const animate = () => { - if (disposed) { - return; - } - - const elapsed = performance.now() - startTime; - const t = Math.min(elapsed / duration, 1); - const easedT = easing.solve(t); - - // Interpolate opacity for fade effect - container.style.opacity = String(visible ? easedT : 1 - easedT); - - // Interpolate all view sizes - let runningTotal = 0; - for (let i = 0; i < this.viewItems.length; i++) { - if (i === this.viewItems.length - 1) { - // Last item absorbs rounding errors to maintain total = this.size - this.viewItems[i].size = totalSize - runningTotal; - } else { - const size = Math.round( - startSizes[i] + (finalSizes[i] - startSizes[i]) * easedT - ); - this.viewItems[i].size = size; - runningTotal += size; - } - } - - this.layoutViews(); - - // Re-layout the animating view at its full target size so its - // content does not reflow at intermediate sizes. The container - // is already at the interpolated size with overflow:hidden. - try { - this.viewItems[index].view.layout(viewTargetSize, 0, this.layoutContext); - } catch (e) { - console.error('Splitview: Failed to layout view during animation'); - console.error(e); - } - - if (t < 1) { - rafId = window.requestAnimationFrame(animate); - } else { - cleanup(true); - } - }; - - rafId = window.requestAnimationFrame(animate); - } - - private _cleanupMotion: (() => void) | undefined; - /** * Returns the {@link IView view}'s size previously to being hidden. * diff --git a/src/vs/base/browser/ui/table/tableWidget.ts b/src/vs/base/browser/ui/table/tableWidget.ts index 0ea558d7ff7b9..27c924f150090 100644 --- a/src/vs/base/browser/ui/table/tableWidget.ts +++ b/src/vs/base/browser/ui/table/tableWidget.ts @@ -126,7 +126,7 @@ class ColumnHeader extends Disposable implements IView { get maximumSize() { return this.column.maximumWidth ?? Number.POSITIVE_INFINITY; } get onDidChange() { return this.column.onDidChangeWidthConstraints ?? Event.None; } - private _onDidLayout = new Emitter<[number, number]>(); + private _onDidLayout = this._register(new Emitter<[number, number]>()); readonly onDidLayout = this._onDidLayout.event; constructor(readonly column: ITableColumn, private index: number) { diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index ece9f2d016c0a..d3fc8596e457c 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -832,7 +832,7 @@ class FindWidget extends Disposable { private readonly actionbar: ActionBar; private readonly toggles: TreeFindToggle[] = []; - readonly _onDidDisable = new Emitter(); + readonly _onDidDisable = this._register(new Emitter()); readonly onDidDisable = this._onDidDisable.event; readonly onDidChangeValue: Event; readonly onDidToggleChange: Event; @@ -1431,7 +1431,7 @@ class StickyScrollController extends Disposable { const firstVisibleNode = this.getNodeAtHeight(this.paddingTop); // Don't render anything if there are no elements - if (!firstVisibleNode || this.tree.scrollTop <= this.paddingTop) { + if (!firstVisibleNode || this.tree.scrollTop <= this.paddingTop || this.view.renderHeight === 0) { this._widget.setState(undefined); return; } @@ -1901,10 +1901,10 @@ class StickyScrollFocus extends Disposable { private elements: HTMLElement[] = []; private state: StickyScrollState | undefined; - private _onDidChangeHasFocus = new Emitter(); + private _onDidChangeHasFocus = this._register(new Emitter()); readonly onDidChangeHasFocus = this._onDidChangeHasFocus.event; - private _onContextMenu = new Emitter>(); + private _onContextMenu = this._register(new Emitter>()); readonly onContextMenu: Event> = this._onContextMenu.event; private _domHasFocus: boolean = false; diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index 565340518ff9d..6c604269ac51a 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -1387,6 +1387,8 @@ export class AsyncDataTree implements IDisposable } dispose(): void { + this._onDidRender.dispose(); + this._onDidChangeNodeSlowState.dispose(); this.disposables.dispose(); this.tree.dispose(); } diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 0ff9490a05801..f541d4face8e6 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -654,4 +654,5 @@ export const codiconsLibrary = { ask: register('ask', 0xec80), openai: register('openai', 0xec81), claude: register('claude', 0xec82), + openInWindow: register('open-in-window', 0xec83), } as const; diff --git a/src/vs/base/common/platform.ts b/src/vs/base/common/platform.ts index 3013e09489b22..91831fa4b78b1 100644 --- a/src/vs/base/common/platform.ts +++ b/src/vs/base/common/platform.ts @@ -44,6 +44,7 @@ export interface INodeProcess { chrome?: string; }; type?: string; + isEmbeddedApp?: boolean; cwd: () => string; } diff --git a/src/vs/base/common/yaml.ts b/src/vs/base/common/yaml.ts index 6f2e801d6964e..1cb0388afea2e 100644 --- a/src/vs/base/common/yaml.ts +++ b/src/vs/base/common/yaml.ts @@ -3,890 +3,1592 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../nls.js'; + /** * Parses a simplified YAML-like input from a single string. * Supports objects, arrays, primitive types (string, number, boolean, null). * Tracks positions for error reporting and node locations. * * Limitations: - * - No multi-line strings or block literals * - No anchors or references * - No complex types (dates, binary) - * - No special handling for escape sequences in strings - * - Indentation must be consistent (spaces only, no tabs) - * - * Notes: - * - New line separators can be either "\n" or "\r\n". The input string is split into lines internally. + * - No single pair implicit entries * * @param input A string containing the YAML-like input * @param errors Array to collect parsing errors - * @param options Parsing options - * @returns The parsed representation (ObjectNode, ArrayNode, or primitive node) + * @returns The parsed representation (YamlMapNode, YamlSequenceNode, or YamlScalarNode) */ export function parse(input: string, errors: YamlParseError[] = [], options: ParseOptions = {}): YamlNode | undefined { - // Normalize both LF and CRLF by splitting on either; CR characters are not retained as part of line text. - // This keeps the existing line/character based lexer logic intact. - const lines = input.length === 0 ? [] : input.split(/\r\n|\n/); - const parser = new YamlParser(lines, errors, options); + const scanner = new YamlScanner(input); + const tokens = scanner.scan(); + const parser = new YamlParser(tokens, input, errors, options); return parser.parse(); } -export interface YamlParseError { - readonly message: string; - readonly start: Position; - readonly end: Position; - readonly code: string; -} - -export interface ParseOptions { - readonly allowDuplicateKeys?: boolean; -} - -export interface Position { - readonly line: number; - readonly character: number; -} +// -- AST Node Types ---------------------------------------------------------- -export interface YamlStringNode { - readonly type: 'string'; +export interface YamlScalarNode { + readonly type: 'scalar'; readonly value: string; - readonly start: Position; - readonly end: Position; + readonly rawValue: string; + readonly startOffset: number; + readonly endOffset: number; + readonly format: 'single' | 'double' | 'none' | 'literal' | 'folded'; } -export interface YamlNumberNode { - readonly type: 'number'; - readonly value: number; - readonly start: Position; - readonly end: Position; +export interface YamlMapNode { + readonly type: 'map'; + readonly properties: { key: YamlScalarNode; value: YamlNode }[]; + readonly style: 'block' | 'flow'; + readonly startOffset: number; + readonly endOffset: number; } -export interface YamlBooleanNode { - readonly type: 'boolean'; - readonly value: boolean; - readonly start: Position; - readonly end: Position; +export interface YamlSequenceNode { + readonly type: 'sequence'; + readonly items: YamlNode[]; + readonly style: 'block' | 'flow'; + readonly startOffset: number; + readonly endOffset: number; } -export interface YamlNullNode { - readonly type: 'null'; - readonly value: null; - readonly start: Position; - readonly end: Position; -} +export type YamlNode = YamlSequenceNode | YamlMapNode | YamlScalarNode; -export interface YamlObjectNode { - readonly type: 'object'; - readonly properties: { key: YamlStringNode; value: YamlNode }[]; - readonly start: Position; - readonly end: Position; +export interface YamlParseError { + readonly message: string; + readonly startOffset: number; + readonly endOffset: number; + readonly code: string; } -export interface YamlArrayNode { - readonly type: 'array'; - readonly items: YamlNode[]; - readonly start: Position; - readonly end: Position; +export interface ParseOptions { + readonly allowDuplicateKeys?: boolean; } -export type YamlNode = YamlStringNode | YamlNumberNode | YamlBooleanNode | YamlNullNode | YamlObjectNode | YamlArrayNode; - -// Helper functions for position and node creation -function createPosition(line: number, character: number): Position { - return { line, character }; +// -- Token Types ------------------------------------------------------------- + +const enum TokenType { + // Scalar values (unquoted, single-quoted, double-quoted) + Scalar, + // Structural tokens + Colon, // ':' + Dash, // '- ' + Comma, // ',' + FlowMapStart, // '{' + FlowMapEnd, // '}' + FlowSeqStart, // '[' + FlowSeqEnd, // ']' + // Whitespace / structure + Newline, + Indent, // leading whitespace at start of line (carries the indent level) + Comment, + DocumentStart, // '---' + DocumentEnd, // '...' + EOF, } -// Specialized node creation functions using a more concise approach -function createStringNode(value: string, start: Position, end: Position): YamlStringNode { - return { type: 'string', value, start, end }; +interface Token { + readonly type: TokenType; + readonly startOffset: number; + readonly endOffset: number; + /** For Scalar tokens: the raw text (including quotes). */ + readonly rawValue: string; + /** For Scalar tokens: the interpreted string value. */ + readonly value: string; + /** For Scalar tokens: quote style. */ + readonly format: 'single' | 'double' | 'none' | 'literal' | 'folded'; + /** For Indent tokens: the column (number of spaces). */ + readonly indent: number; } -function createNumberNode(value: number, start: Position, end: Position): YamlNumberNode { - return { type: 'number', value, start, end }; +function makeToken( + type: TokenType, + startOffset: number, + endOffset: number, + extra?: Partial> +): Token { + return { + type, + startOffset, + endOffset, + rawValue: extra?.rawValue ?? '', + value: extra?.value ?? '', + format: extra?.format ?? 'none' as Token['format'], + indent: extra?.indent ?? 0, + }; } -function createBooleanNode(value: boolean, start: Position, end: Position): YamlBooleanNode { - return { type: 'boolean', value, start, end }; -} +// -- Scanner ----------------------------------------------------------------- -function createNullNode(start: Position, end: Position): YamlNullNode { - return { type: 'null', value: null, start, end }; -} +class YamlScanner { + private pos = 0; + private readonly tokens: Token[] = []; + // Track flow nesting depth so commas and flow indicators are only special inside flow collections + private flowDepth = 0; -function createObjectNode(properties: { key: YamlStringNode; value: YamlNode }[], start: Position, end: Position): YamlObjectNode { - return { type: 'object', start, end, properties }; -} + constructor(private readonly input: string) { } -function createArrayNode(items: YamlNode[], start: Position, end: Position): YamlArrayNode { - return { type: 'array', start, end, items }; -} + scan(): Token[] { + while (this.pos < this.input.length) { + this.scanLine(); + } + this.tokens.push(makeToken(TokenType.EOF, this.pos, this.pos)); + return this.tokens; + } -// Utility functions for parsing -function isWhitespace(char: string): boolean { - return char === ' ' || char === '\t'; -} + // Scan a single logical line (up to and including the newline character) + private scanLine(): void { + // Handle blank lines / lines that are only whitespace + if (this.peekChar() === '\n') { + this.tokens.push(makeToken(TokenType.Newline, this.pos, this.pos + 1)); + this.pos++; + return; + } + if (this.peekChar() === '\r') { + const end = this.pos + (this.input[this.pos + 1] === '\n' ? 2 : 1); + this.tokens.push(makeToken(TokenType.Newline, this.pos, end)); + this.pos = end; + return; + } -// Simplified number validation using regex -function isValidNumber(value: string): boolean { - return /^-?\d*\.?\d+$/.test(value); -} + // Measure leading whitespace → Indent token + const indentStart = this.pos; + let indent = 0; + while (this.pos < this.input.length && (this.input[this.pos] === ' ' || this.input[this.pos] === '\t')) { + indent++; + this.pos++; + } + if (indent > 0) { + this.tokens.push(makeToken(TokenType.Indent, indentStart, this.pos, { indent })); + } -// Lexer/Tokenizer for YAML content -class YamlLexer { - private lines: string[]; - private currentLine: number = 0; - private currentChar: number = 0; + // If line is now empty (only whitespace before newline/EOF), emit newline + if (this.pos >= this.input.length || this.peekChar() === '\n' || this.peekChar() === '\r') { + if (this.pos < this.input.length) { + const nlStart = this.pos; + const end = this.peekChar() === '\r' && this.input[this.pos + 1] === '\n' ? this.pos + 2 : this.pos + 1; + this.tokens.push(makeToken(TokenType.Newline, nlStart, end)); + this.pos = end; + } + return; + } - constructor(lines: string[]) { - this.lines = lines; - } + // Check for document markers (--- / ...) at column 0 + if (indent === 0 && this.input.length - this.pos >= 3) { + const c0 = this.input[this.pos]; + const c1 = this.input[this.pos + 1]; + const c2 = this.input[this.pos + 2]; + const c3 = this.input[this.pos + 3]; + const isTerminator = c3 === undefined || c3 === ' ' || c3 === '\t' || c3 === '\n' || c3 === '\r'; + if (c0 === '-' && c1 === '-' && c2 === '-' && isTerminator) { + this.tokens.push(makeToken(TokenType.DocumentStart, this.pos, this.pos + 3)); + this.pos += 3; + this.scanLineContent(); + this.scanNewline(); + return; + } + if (c0 === '.' && c1 === '.' && c2 === '.' && isTerminator) { + this.tokens.push(makeToken(TokenType.DocumentEnd, this.pos, this.pos + 3)); + this.pos += 3; + this.scanLineContent(); + this.scanNewline(); + return; + } + } - getCurrentPosition(): Position { - return createPosition(this.currentLine, this.currentChar); - } + // Check for comment-only line + if (this.peekChar() === '#') { + this.scanComment(); + this.scanNewline(); + return; + } - getCurrentLineNumber(): number { - return this.currentLine; - } + // Skip directive lines (e.g., %YAML 1.2, %TAG) - consume rest of line + if (this.peekChar() === '%') { + while (this.pos < this.input.length && this.input[this.pos] !== '\n' && this.input[this.pos] !== '\r') { + this.pos++; + } + this.scanNewline(); + return; + } - getCurrentCharNumber(): number { - return this.currentChar; + // Scan the rest of the line for tokens + this.scanLineContent(); + this.scanNewline(); } - getCurrentLineText(): string { - return this.currentLine < this.lines.length ? this.lines[this.currentLine] : ''; - } + private scanLineContent(): void { + while (this.pos < this.input.length && this.peekChar() !== '\n' && this.peekChar() !== '\r') { + this.skipInlineWhitespace(); + if (this.pos >= this.input.length || this.peekChar() === '\n' || this.peekChar() === '\r') { + break; + } - savePosition(): { line: number; char: number } { - return { line: this.currentLine, char: this.currentChar }; + const ch = this.peekChar(); + + if (ch === '#') { + this.scanComment(); + break; // comment consumes rest of line + } else if (ch === '{') { + this.flowDepth++; + this.tokens.push(makeToken(TokenType.FlowMapStart, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === '}' && this.flowDepth > 0) { + this.flowDepth--; + this.tokens.push(makeToken(TokenType.FlowMapEnd, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === '[') { + this.flowDepth++; + this.tokens.push(makeToken(TokenType.FlowSeqStart, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === ']' && this.flowDepth > 0) { + this.flowDepth--; + this.tokens.push(makeToken(TokenType.FlowSeqEnd, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === ',' && this.flowDepth > 0) { + this.tokens.push(makeToken(TokenType.Comma, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === '-' && this.isBlockDash()) { + // Block sequence indicator: '- ' or '-' at end of line + this.tokens.push(makeToken(TokenType.Dash, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === ':' && this.isBlockColon()) { + this.tokens.push(makeToken(TokenType.Colon, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === ':' && this.flowDepth > 0 && this.lastTokenIsJsonLike()) { + // In flow context, ':' immediately following a JSON-like node (quoted scalar, + // flow mapping, or flow sequence) is a value indicator even without trailing space + this.tokens.push(makeToken(TokenType.Colon, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === '\'' || ch === '"') { + this.scanQuotedScalar(ch); + } else if ((ch === '|' || ch === '>') && this.flowDepth === 0 && this.isBlockScalarStart()) { + this.scanBlockScalar(ch as '|' | '>'); + break; // Block scalar consumed multiple lines; return to main scan loop + } else { + this.scanUnquotedScalar(); + } + } } - restorePosition(pos: { line: number; char: number }): void { - this.currentLine = pos.line; - this.currentChar = pos.char; + /** Check if '-' is a block sequence dash (followed by space, newline, or EOF) */ + private isBlockDash(): boolean { + const next = this.input[this.pos + 1]; + return next === undefined || next === ' ' || next === '\t' || next === '\n' || next === '\r'; } - isAtEnd(): boolean { - return this.currentLine >= this.lines.length; + /** Check if ':' acts as a mapping value indicator (followed by space, newline, EOF, or flow indicator) */ + private isBlockColon(): boolean { + const next = this.input[this.pos + 1]; + if (next === undefined || next === ' ' || next === '\t' || next === '\n' || next === '\r') { return true; } + // Flow indicators after colon only count inside flow context + if (this.flowDepth > 0 && (next === ',' || next === '}' || next === ']')) { return true; } + return false; } - getCurrentChar(): string { - if (this.isAtEnd() || this.currentChar >= this.lines[this.currentLine].length) { - return ''; + /** Check if the last non-whitespace token is a JSON-like node (quoted scalar or flow end) */ + private lastTokenIsJsonLike(): boolean { + for (let i = this.tokens.length - 1; i >= 0; i--) { + const t = this.tokens[i]; + if (t.type === TokenType.Newline || t.type === TokenType.Indent || t.type === TokenType.Comment) { + continue; + } + // Quoted scalar or flow collection end bracket + if (t.type === TokenType.Scalar && t.format !== 'none') { return true; } + if (t.type === TokenType.FlowMapEnd || t.type === TokenType.FlowSeqEnd) { return true; } + return false; } - return this.lines[this.currentLine][this.currentChar]; + return false; } - peek(offset: number = 1): string { - const newChar = this.currentChar + offset; - if (this.currentLine >= this.lines.length || newChar >= this.lines[this.currentLine].length) { - return ''; + private scanQuotedScalar(quote: '\'' | '"'): void { + const start = this.pos; + this.pos++; // skip opening quote + let value = ''; + // Track trailing literal whitespace count so flow folding only trims + // source-level whitespace, not whitespace produced by escape sequences + let trailingLiteralWs = 0; + + while (this.pos < this.input.length) { + const ch = this.input[this.pos]; + if (ch === quote) { + // In single-quoted strings, '' is an escaped single quote + if (quote === '\'' && this.input[this.pos + 1] === '\'') { + value += '\''; + this.pos += 2; + trailingLiteralWs = 0; + continue; + } + this.pos++; // skip closing quote + const rawValue = this.input.substring(start, this.pos); + this.tokens.push(makeToken(TokenType.Scalar, start, this.pos, { + rawValue, + value, + format: quote === '\'' ? 'single' : 'double', + })); + return; + } + + // Handle escape sequences in double-quoted strings + if (quote === '"' && ch === '\\') { + const next = this.input[this.pos + 1]; + // Escaped line break: \ + newline → join lines without inserting a space + if (next === '\n' || next === '\r') { + this.pos++; // skip '\' + this.consumeNewline(); + // Strip leading whitespace on continuation line + this.skipInlineWhitespace(); + trailingLiteralWs = 0; + continue; + } + switch (next) { + case 'n': value += '\n'; break; + case 't': value += '\t'; break; + case '\\': value += '\\'; break; + case '"': value += '"'; break; + case '/': value += '/'; break; + case 'r': value += '\r'; break; + case '0': value += '\0'; break; + case 'a': value += '\x07'; break; + case 'b': value += '\b'; break; + case 'e': value += '\x1b'; break; + case 'v': value += '\v'; break; + case 'f': value += '\f'; break; + case ' ': value += ' '; break; + case '_': value += '\xa0'; break; + case 'x': { + // \xNN - 2-digit hex escape + const hex = this.input.substring(this.pos + 2, this.pos + 4); + const code = parseInt(hex, 16); + if (hex.length === 2 && !isNaN(code)) { + value += String.fromCharCode(code); + this.pos += 4; + } else { + value += '\\x'; + this.pos += 2; + } + trailingLiteralWs = 0; + continue; + } + case 'u': { + // \uNNNN - 4-digit unicode escape + const hex = this.input.substring(this.pos + 2, this.pos + 6); + const code = parseInt(hex, 16); + if (hex.length === 4 && !isNaN(code)) { + value += String.fromCodePoint(code); + this.pos += 6; + } else { + value += '\\u'; + this.pos += 2; + } + trailingLiteralWs = 0; + continue; + } + case 'U': { + // \UNNNNNNNN - 8-digit unicode escape + const hex = this.input.substring(this.pos + 2, this.pos + 10); + const code = parseInt(hex, 16); + if (hex.length === 8 && !isNaN(code)) { + value += String.fromCodePoint(code); + this.pos += 10; + } else { + value += '\\U'; + this.pos += 2; + } + trailingLiteralWs = 0; + continue; + } + default: value += '\\' + (next ?? ''); break; + } + this.pos += 2; + trailingLiteralWs = 0; + continue; + } + + // Flow folding: handle newlines inside quoted scalars (both single and double) + if (ch === '\n' || ch === '\r') { + // Trim trailing literal whitespace (not escape-produced whitespace) + if (trailingLiteralWs > 0) { + value = value.substring(0, value.length - trailingLiteralWs); + } + trailingLiteralWs = 0; + + // Skip the newline + this.consumeNewline(); + + // Count empty lines (lines with only whitespace) + let emptyLineCount = 0; + while (this.pos < this.input.length) { + // Skip whitespace at start of line + this.skipInlineWhitespace(); + // Check if this line is empty (another newline follows) + const c = this.input[this.pos]; + if (c === '\n' || c === '\r') { + emptyLineCount++; + this.consumeNewline(); + } else { + break; + } + } + + // Apply folding: empty lines → \n each, otherwise single newline → space + if (emptyLineCount > 0) { + value += '\n'.repeat(emptyLineCount); + } else { + value += ' '; + } + continue; + } + + // Track literal whitespace for folding purposes + if (ch === ' ' || ch === '\t') { + trailingLiteralWs++; + } else { + trailingLiteralWs = 0; + } + value += ch; + this.pos++; } - return this.lines[this.currentLine][newChar]; + + // Unterminated string - emit what we have + const rawValue = this.input.substring(start, this.pos); + this.tokens.push(makeToken(TokenType.Scalar, start, this.pos, { + rawValue, + value, + format: quote === '\'' ? 'single' : 'double', + })); } - advance(): string { - const char = this.getCurrentChar(); - if (this.currentChar >= this.lines[this.currentLine].length && this.currentLine < this.lines.length - 1) { - this.currentLine++; - this.currentChar = 0; - } else { - this.currentChar++; + private scanUnquotedScalar(): void { + const start = this.pos; + let end = this.pos; + + while (this.pos < this.input.length) { + const ch = this.input[this.pos]; + // Stop at newline + if (ch === '\n' || ch === '\r') { break; } + // Stop at flow indicators (only inside flow collections) + if (this.flowDepth > 0 && (ch === ',' || ch === '}' || ch === ']')) { break; } + if (this.flowDepth > 0 && (ch === '{' || ch === '[')) { break; } + // Stop at ': ' or ':' at end-of-line (mapping value indicator) + if (ch === ':' && this.isBlockColon()) { break; } + // Stop at ' #' (comment) + if (ch === '#' && this.pos > start && (this.input[this.pos - 1] === ' ' || this.input[this.pos - 1] === '\t')) { break; } + + this.pos++; + // Track the last non-whitespace position to trim trailing whitespace + if (ch !== ' ' && ch !== '\t') { + end = this.pos; + } } - return char; - } - advanceLine(): void { - this.currentLine++; - this.currentChar = 0; + const rawValue = this.input.substring(start, end); + this.tokens.push(makeToken(TokenType.Scalar, start, end, { + rawValue, + value: rawValue, + format: 'none', + })); } - skipWhitespace(): void { - while (!this.isAtEnd() && this.currentChar < this.lines[this.currentLine].length && isWhitespace(this.getCurrentChar())) { - this.advance(); + /** + * Check if '|' or '>' at the current position is a block scalar indicator. + * Must be followed by optional indentation/chomping indicators, optional comment, then newline. + */ + private isBlockScalarStart(): boolean { + let p = this.pos + 1; + // Skip optional indentation indicator (digit 1-9) and chomping indicator (+/-) + while (p < this.input.length) { + const c = this.input[p]; + if (c >= '1' && c <= '9') { p++; continue; } + if (c === '+' || c === '-') { p++; continue; } + break; } + // Skip optional whitespace + while (p < this.input.length && (this.input[p] === ' ' || this.input[p] === '\t')) { p++; } + // Must be at newline, EOF, or comment + if (p >= this.input.length) { return true; } + const c = this.input[p]; + return c === '\n' || c === '\r' || c === '#'; } - skipToEndOfLine(): void { - this.currentChar = this.lines[this.currentLine].length; - } + /** + * Scan a block scalar (literal '|' or folded '>'). + * Parses the header line for indentation indicator and chomping mode, + * then collects all content lines that are indented beyond the detected indentation. + */ + private scanBlockScalar(style: '|' | '>'): void { + const start = this.pos; + this.pos++; // skip '|' or '>' + + // Parse header: optional indentation indicator (1-9) and chomping indicator (+/-) + let explicitIndent = 0; + let chomping: 'clip' | 'strip' | 'keep' = 'clip'; + + // The order of indent indicator and chomping indicator can vary (D83L test) + for (let i = 0; i < 2; i++) { + if (this.pos < this.input.length) { + const c = this.input[this.pos]; + if (c >= '1' && c <= '9' && explicitIndent === 0) { + explicitIndent = parseInt(c, 10); + this.pos++; + } else if (c === '-' && chomping === 'clip') { + chomping = 'strip'; + this.pos++; + } else if (c === '+' && chomping === 'clip') { + chomping = 'keep'; + this.pos++; + } + } + } - getIndentation(): number { - if (this.isAtEnd()) { - return 0; + // Skip any trailing whitespace on the header line + while (this.pos < this.input.length && (this.input[this.pos] === ' ' || this.input[this.pos] === '\t')) { + this.pos++; } - let indent = 0; - for (let i = 0; i < this.lines[this.currentLine].length; i++) { - if (this.lines[this.currentLine][i] === ' ') { - indent++; - } else if (this.lines[this.currentLine][i] === '\t') { - indent += 4; // Treat tab as 4 spaces - } else { - break; + + // Skip optional comment on header line + if (this.pos < this.input.length && this.input[this.pos] === '#') { + while (this.pos < this.input.length && this.input[this.pos] !== '\n' && this.input[this.pos] !== '\r') { + this.pos++; } } - return indent; - } - moveToNextNonEmptyLine(): void { - while (this.currentLine < this.lines.length) { - // First check current line from current position - if (this.currentChar < this.lines[this.currentLine].length) { - const remainingLine = this.lines[this.currentLine].substring(this.currentChar).trim(); - if (remainingLine.length > 0 && !remainingLine.startsWith('#')) { - this.skipWhitespace(); - return; + // Skip the header line's newline + this.consumeNewline(); + + // Determine the parent block's indentation level. + // Per YAML spec 8.1.1.1, content indentation = parent_block_indent + N + // where N is the explicit indent indicator (or auto-detected). + // Also used to establish a minimum content indent for auto-detection. + const parentBlockIndent = this.getParentBlockIndent(start); + + // Compute the content indentation level + let contentIndent = explicitIndent > 0 ? parentBlockIndent + explicitIndent : 0; + const lines: string[] = []; + let trailingNewlines = 0; + + while (this.pos < this.input.length) { + const lineStart = this.pos; + + // Count leading spaces on this line (tabs are not valid YAML indentation) + let lineIndent = 0; + while (this.pos < this.input.length && this.input[this.pos] === ' ') { + lineIndent++; + this.pos++; + } + + // Check if this is an empty or whitespace-only line + if (this.pos >= this.input.length || this.input[this.pos] === '\n' || this.input[this.pos] === '\r') { + if (contentIndent > 0 && lineIndent >= contentIndent) { + // Whitespace-only line with enough indent - preserve excess whitespace + const preserved = this.input.substring(lineStart + contentIndent, this.pos); + lines.push(preserved); + if (preserved === '') { + // Effectively an empty line - counts as trailing + trailingNewlines++; + } else { + trailingNewlines = 0; + } + } else { + // Truly empty line - part of scalar content + lines.push(''); + trailingNewlines++; } + // Skip newline + this.consumeNewline(); + continue; } - // Move to next line and check from beginning - this.currentLine++; - this.currentChar = 0; + // Check for document markers at column 0 - they terminate the block scalar + if (lineIndent === 0 && this.input.length - this.pos >= 3) { + const c0 = this.input[this.pos]; + const c1 = this.input[this.pos + 1]; + const c2 = this.input[this.pos + 2]; + const c3 = this.input[this.pos + 3]; + const isTerm = c3 === undefined || c3 === ' ' || c3 === '\t' || c3 === '\n' || c3 === '\r'; + if ((c0 === '-' && c1 === '-' && c2 === '-' && isTerm) || + (c0 === '.' && c1 === '.' && c2 === '.' && isTerm)) { + this.pos = lineStart; + break; + } + } - if (this.currentLine < this.lines.length) { - const line = this.lines[this.currentLine].trim(); - if (line.length > 0 && !line.startsWith('#')) { - this.skipWhitespace(); - return; + // Auto-detect content indent from first non-empty line. + // Content must be more indented than the parent block. + if (contentIndent === 0) { + if (lineIndent <= parentBlockIndent) { + // Not enough indentation - terminates the block scalar + this.pos = lineStart; + break; } + contentIndent = lineIndent; + } + + // If this line's indentation is less than the content indent, the block scalar is done + if (lineIndent < contentIndent) { + this.pos = lineStart; + break; } + + // Read the rest of the line (the content) + const contentStart = lineStart + contentIndent; + while (this.pos < this.input.length && this.input[this.pos] !== '\n' && this.input[this.pos] !== '\r') { + this.pos++; + } + // The line content includes any extra indentation beyond contentIndent + const lineContent = this.input.substring(contentStart, this.pos); + lines.push(lineContent); + trailingNewlines = 0; + + // Skip newline + this.consumeNewline(); } - } -} -// Parser class for handling YAML parsing -class YamlParser { - private lexer: YamlLexer; - private errors: YamlParseError[]; - private options: ParseOptions; - // Track nesting level of flow (inline) collections '[' ']' '{' '}' - private flowLevel: number = 0; + // Process the collected lines according to the block scalar style + let value: string; + if (style === '|') { + // Literal: join lines with newlines (preserving all line breaks as-is) + value = lines.join('\n'); + } else { + // Folded: per YAML spec, line breaks between adjacent non-more-indented + // content lines are folded into spaces. More-indented lines preserve breaks. + // Empty lines produce \n each. The break from content into an empty run + // is "trimmed" (absorbed) for non-more-indented lines, but preserved + // for more-indented lines. + value = ''; + let lastNonEmptyIsMoreIndented = false; + let inEmptyRun = false; + let seenNonEmpty = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const isMoreIndented = line.length > 0 && (line[0] === ' ' || line[0] === '\t'); + + if (line === '') { + // Empty line → contributes one \n + value += '\n'; + inEmptyRun = true; + } else if (i === 0) { + value = line; + lastNonEmptyIsMoreIndented = isMoreIndented; + seenNonEmpty = true; + } else if (inEmptyRun) { + // Transitioning from empty lines back to content. + // If the previous content or current line is more-indented + // AND we've seen content before, the break is preserved. + // Otherwise the empties already provided all needed line breaks. + if ((lastNonEmptyIsMoreIndented || isMoreIndented) && seenNonEmpty) { + value += '\n' + line; + } else { + value += line; + } + lastNonEmptyIsMoreIndented = isMoreIndented; + inEmptyRun = false; + seenNonEmpty = true; + } else if (isMoreIndented || lastNonEmptyIsMoreIndented) { + // More-indented line → preserve newline + value += '\n' + line; + lastNonEmptyIsMoreIndented = isMoreIndented; + seenNonEmpty = true; + } else { + // Normal adjacent non-more-indented lines → fold to space + value += ' ' + line; + lastNonEmptyIsMoreIndented = false; + seenNonEmpty = true; + } + } + } - constructor(lines: string[], errors: YamlParseError[], options: ParseOptions) { - this.lexer = new YamlLexer(lines); - this.errors = errors; - this.options = options; - } + // Apply chomping to trailing newlines + if (trailingNewlines > 0) { + // Strip all trailing newlines from the value + let end = value.length; + while (end > 0 && value[end - 1] === '\n') { + end--; + } + value = value.substring(0, end); + } - addError(message: string, code: string, start: Position, end: Position): void { - this.errors.push({ message, code, start, end }); + // Determine if there was any actual (non-empty) content + const hasContent = lines.some(l => l !== ''); + + switch (chomping) { + case 'clip': + if (hasContent) { + // Add exactly one trailing newline + value += '\n'; + } + break; + case 'keep': + if (hasContent) { + // Content + trailing: final line break + trailing empty line breaks + value += '\n'.repeat(trailingNewlines + 1); + } else { + // No content, only trailing empties + value = '\n'.repeat(trailingNewlines); + } + break; + case 'strip': + // No trailing newline + break; + } + + const rawValue = this.input.substring(start, this.pos); + this.tokens.push(makeToken(TokenType.Scalar, start, this.pos, { + rawValue, + value, + format: style === '|' ? 'literal' : 'folded', + })); } - parseValue(expectedIndent?: number): YamlNode { - this.lexer.skipWhitespace(); + /** + * Determine the parent block's indentation level for a block scalar. + * Looks at preceding tokens to find the context: + * - After Colon: the indentation of the line containing the mapping key + * - After Dash: the column of the dash + * - At document level: -1 (allows content at indent 0) + */ + private getParentBlockIndent(blockScalarPos: number): number { + for (let i = this.tokens.length - 1; i >= 0; i--) { + const t = this.tokens[i]; + if (t.type === TokenType.Newline || t.type === TokenType.Comment || t.type === TokenType.Indent) { continue; } + if (t.type === TokenType.Colon) { + // Block scalar is a mapping value. The parent indentation + // is the column of the mapping key (the scalar before the colon). + for (let j = i - 1; j >= 0; j--) { + const kt = this.tokens[j]; + if (kt.type === TokenType.Newline || kt.type === TokenType.Comment || kt.type === TokenType.Indent) { continue; } + // Found the key token - return its column + return this.getColumnAt(kt.startOffset); + } + return 0; + } + if (t.type === TokenType.Dash) { + // Block scalar is a sequence item. Parent indent = column of the dash. + return this.getColumnAt(t.startOffset); + } + // Document root - content at indent 0 is valid + if (t.type === TokenType.DocumentStart) { return -1; } + // For any other token, use 0 + break; + } + return 0; + } - if (this.lexer.isAtEnd()) { - const pos = this.lexer.getCurrentPosition(); - return createStringNode('', pos, pos); + /** + * Get the column (0-based offset from start of line) for a position in the input. + */ + private getColumnAt(offset: number): number { + let col = 0; + let p = offset - 1; + while (p >= 0 && this.input[p] !== '\n' && this.input[p] !== '\r') { + col++; + p--; } + return col; + } - const char = this.lexer.getCurrentChar(); + private scanComment(): void { + const start = this.pos; + while (this.pos < this.input.length && this.input[this.pos] !== '\n' && this.input[this.pos] !== '\r') { + this.pos++; + } + this.tokens.push(makeToken(TokenType.Comment, start, this.pos, { + rawValue: this.input.substring(start, this.pos), + value: this.input.substring(start, this.pos), + })); + } - // Handle quoted strings - if (char === '"' || char === `'`) { - return this.parseQuotedString(char); + private scanNewline(): void { + const start = this.pos; + if (this.consumeNewline()) { + this.tokens.push(makeToken(TokenType.Newline, start, this.pos)); } + } - // Handle inline arrays - if (char === '[') { - return this.parseInlineArray(); + private skipInlineWhitespace(): void { + while (this.pos < this.input.length) { + const ch = this.input[this.pos]; + if (ch === ' ' || ch === '\t') { + this.pos++; + } else { + break; + } } + } - // Handle inline objects - if (char === '{') { - return this.parseInlineObject(); + /** Advance past a newline sequence (\r\n, \n, or \r). Returns true if a newline was consumed. */ + private consumeNewline(): boolean { + if (this.pos >= this.input.length) { return false; } + if (this.input[this.pos] === '\r' && this.input[this.pos + 1] === '\n') { + this.pos += 2; + return true; + } + if (this.input[this.pos] === '\n' || this.input[this.pos] === '\r') { + this.pos++; + return true; } + return false; + } - // Handle unquoted values - return this.parseUnquotedValue(); + private peekChar(): string { + return this.input[this.pos]; } +} - parseQuotedString(quote: string): YamlNode { - const start = this.lexer.getCurrentPosition(); - this.lexer.advance(); // Skip opening quote +// -- Parser ------------------------------------------------------------------ - let value = ''; - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== quote) { - value += this.lexer.advance(); - } +class YamlParser { + private pos = 0; + + constructor( + private readonly tokens: Token[], + private readonly input: string, + private readonly errors: YamlParseError[], + private readonly options: ParseOptions, + ) { } - if (this.lexer.getCurrentChar() === quote) { - this.lexer.advance(); // Skip closing quote + parse(): YamlNode | undefined { + this.skipNewlinesAndComments(); + // Skip document start marker (---) if present + if (this.currentToken().type === TokenType.DocumentStart) { + this.advance(); + this.skipNewlinesAndComments(); } + if (this.currentToken().type === TokenType.EOF || this.currentToken().type === TokenType.DocumentEnd) { + return undefined; + } + const result = this.parseValue(-1); + return result; + } - const end = this.lexer.getCurrentPosition(); - return createStringNode(value, start, end); + // -- helpers ---------------------------------------------------------- + + private currentToken(): Token { + return this.tokens[this.pos]; } - parseUnquotedValue(): YamlNode { - const start = this.lexer.getCurrentPosition(); - let value = ''; - let endPos = start; + private peek(offset = 0): Token { + return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)]; + } - // Helper function to check for value terminators - const isTerminator = (char: string): boolean => { - if (char === '#') { return true; } - // Comma, ']' and '}' only terminate inside flow collections - if (this.flowLevel > 0 && (char === ',' || char === ']' || char === '}')) { return true; } - return false; - }; + private advance(): Token { + const t = this.tokens[this.pos]; + if (t.type !== TokenType.EOF) { + this.pos++; + } + return t; + } - // Handle opening quote that might not be closed - const firstChar = this.lexer.getCurrentChar(); - if (firstChar === '"' || firstChar === `'`) { - value += this.lexer.advance(); - endPos = this.lexer.getCurrentPosition(); - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { - const char = this.lexer.getCurrentChar(); - if (char === firstChar || isTerminator(char)) { - break; - } - value += this.lexer.advance(); - endPos = this.lexer.getCurrentPosition(); - } - } else { - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { - const char = this.lexer.getCurrentChar(); - if (isTerminator(char)) { - break; - } - value += this.lexer.advance(); - endPos = this.lexer.getCurrentPosition(); - } + private expect(type: TokenType): Token { + const t = this.currentToken(); + if (t.type === type) { + return this.advance(); } - const trimmed = value.trimEnd(); - const diff = value.length - trimmed.length; - if (diff) { - endPos = createPosition(start.line, endPos.character - diff); + return t; + } + + private emitError(message: string, startOffset: number, endOffset: number, code: string): void { + this.errors.push({ message, startOffset, endOffset, code }); + } + + private skipNewlinesAndComments(): void { + while ( + this.currentToken().type === TokenType.Newline || + this.currentToken().type === TokenType.Comment || + (this.currentToken().type === TokenType.Indent && this.isFollowedByNewlineOrComment()) + ) { + this.advance(); } - const finalValue = (firstChar === '"' || firstChar === `'`) ? trimmed.substring(1) : trimmed; - return this.createValueNode(finalValue, start, endPos); } - private createValueNode(value: string, start: Position, end: Position): YamlNode { - if (value === '') { - return createStringNode('', start, start); + /** Returns true if the current Indent token is followed immediately by Newline/Comment/EOF */ + private isFollowedByNewlineOrComment(): boolean { + const next = this.peek(1); + return next.type === TokenType.Newline || next.type === TokenType.Comment || next.type === TokenType.EOF; + } + + /** + * Determines the current indentation level. + * If the current token is an Indent, returns its indent value. + * Otherwise returns 0 (token is at column 0). + */ + private currentIndent(): number { + if (this.currentToken().type === TokenType.Indent) { + return this.currentToken().indent; } + return 0; + } + + // -- Main parse entry for a value at a given indentation -------------- - // Boolean values - if (value === 'true') { - return createBooleanNode(true, start, end); + private parseValue(parentIndent: number): YamlNode | undefined { + this.skipNewlinesAndComments(); + const token = this.currentToken(); + + // Flow collections (also check past indent) + const flowToken = token.type === TokenType.Indent ? this.peek(1) : token; + if (flowToken.type === TokenType.FlowMapStart || flowToken.type === TokenType.FlowSeqStart) { + if (token.type === TokenType.Indent) { this.advance(); } + if (flowToken.type === TokenType.FlowMapStart) { return this.parseFlowMap(); } + return this.parseFlowSeq(); } - if (value === 'false') { - return createBooleanNode(false, start, end); + + // Block-level: detect if this is a sequence or mapping + const indent = this.currentIndent(); + + // Determine what the first meaningful token is at this indent + const firstContentToken = this.peekPastIndent(); + + if (firstContentToken.type === TokenType.Dash) { + return this.parseBlockSequence(indent); } - // Null values - if (value === 'null' || value === '~') { - return createNullNode(start, end); + // Check if this looks like a mapping (scalar followed by colon) + if (this.looksLikeMapping()) { + return this.parseBlockMapping(indent); } - // Number values - const numberValue = Number(value); - if (!isNaN(numberValue) && isFinite(numberValue) && isValidNumber(value)) { - return createNumberNode(numberValue, start, end); + // Otherwise it's a scalar + if (token.type === TokenType.Scalar || token.type === TokenType.Indent) { + return this.parseScalar(parentIndent); } - // Default to string - return createStringNode(value, start, end); + return undefined; } - parseInlineArray(): YamlArrayNode { - const start = this.lexer.getCurrentPosition(); - this.lexer.advance(); // Skip '[' - this.flowLevel++; + /** Peek past an optional Indent token to see the first content token */ + private peekPastIndent(): Token { + if (this.currentToken().type === TokenType.Indent) { + return this.peek(1); + } + return this.currentToken(); + } - const items: YamlNode[] = []; + /** Check if tokens at current position look like a mapping entry (key: value) */ + private looksLikeMapping(): boolean { + let offset = 0; + if (this.peek(offset).type === TokenType.Indent) { offset++; } + if (this.peek(offset).type === TokenType.Scalar) { + offset++; + if (this.peek(offset).type === TokenType.Colon) { return true; } + } + return false; + } - while (!this.lexer.isAtEnd()) { - this.lexer.skipWhitespace(); + // -- Scalar ---------------------------------------------------------- - // Handle end of array - if (this.lexer.getCurrentChar() === ']') { - this.lexer.advance(); + private parseScalar(parentIndent: number = -1): YamlScalarNode { + // Skip indent if present + if (this.currentToken().type === TokenType.Indent) { + this.advance(); + } + const token = this.expect(TokenType.Scalar); + // Quoted scalars are complete as-is (scanner handles their multiline) + if (token.format !== 'none') { + return this.scalarFromToken(token); + } + // For unquoted (plain) scalars, check for multiline continuation + return this.parsePlainMultiline(token, parentIndent); + } + + /** + * Parse a multiline plain scalar. The first line's token is already consumed. + * Continuation lines must be indented deeper than `parentIndent`. + * Line folding rules: + * - Single line break → space + * - Each empty line → preserved as \n + */ + private parsePlainMultiline(firstToken: Token, parentIndent: number): YamlScalarNode { + let value = firstToken.value; + let endOffset = firstToken.endOffset; + + while (true) { + // Save position to backtrack if continuation is not valid + const savedPos = this.pos; + + // Count empty lines (newlines with only whitespace between) + let emptyLineCount = 0; + let foundContent = false; + + while (this.pos < this.tokens.length) { + const t = this.currentToken(); + if (t.type === TokenType.Comment) { + // Comment terminates a plain scalar + break; + } + if (t.type === TokenType.Newline) { + this.advance(); + // Check if the next thing after this newline is blank or content + const afterNewline = this.currentToken(); + if (afterNewline.type === TokenType.Newline) { + // Another newline means an empty line + emptyLineCount++; + continue; + } + if (afterNewline.type === TokenType.Indent) { + // Check what follows the indent + const afterIndent = this.peek(1); + if (afterIndent.type === TokenType.Newline || afterIndent.type === TokenType.EOF) { + // Indent followed by newline = empty line + emptyLineCount++; + this.advance(); // skip the indent + continue; + } + if (afterIndent.type === TokenType.Comment) { + // Comment terminates scalar + break; + } + // Content on this line - check indentation + if (afterNewline.indent > parentIndent) { + // Valid continuation line + foundContent = true; + break; + } else { + // Not deep enough - not a continuation + break; + } + } + if (afterNewline.type === TokenType.EOF) { + break; + } + // Document markers terminate plain scalars + if (afterNewline.type === TokenType.DocumentStart || afterNewline.type === TokenType.DocumentEnd) { + break; + } + // Content at column 0 + if (parentIndent < 0) { + // Top-level: column 0 is valid continuation for parentIndent = -1 + foundContent = true; + break; + } + break; + } + if (t.type === TokenType.Indent) { + // We should only get here at the very start of lookahead when + // the first token after the scalar's end is Indent (no newline before it), + // which shouldn't happen. Break to be safe. + break; + } + // Any other token (EOF, structural) = end of scalar break; } - // Handle end of line - continue to next line for multi-line arrays - if (this.lexer.getCurrentChar() === '') { - this.lexer.advanceLine(); - continue; + if (!foundContent) { + // No continuation found - restore position + this.pos = savedPos; + break; } - // Handle comments - comments should terminate the array parsing - if (this.lexer.getCurrentChar() === '#') { - // Skip the rest of the line (comment) - this.lexer.skipToEndOfLine(); - this.lexer.advanceLine(); - continue; + // We found a continuation line. Skip optional indent. + if (this.currentToken().type === TokenType.Indent) { + this.advance(); } - // Save position before parsing to detect if we're making progress - const positionBefore = this.lexer.savePosition(); - - // Parse array item - const item = this.parseValue(); - // Skip implicit empty items that arise from a leading comma at the beginning of a new line - // (e.g. a line starting with ",foo" after a comment). A legitimate empty string element - // would have quotes and thus a non-zero span. We only filter zero-length spans. - if (!(item.type === 'string' && item.value === '' && item.start.line === item.end.line && item.start.character === item.end.character)) { - items.push(item); + // The next token must be a Scalar for continuation + if (this.currentToken().type !== TokenType.Scalar) { + // A dash at a deeper indent than the parent is text content, not a sequence indicator + // (e.g., "- single multiline\n - sequence entry" → one scalar "single multiline - sequence entry") + if (this.currentToken().type === TokenType.Dash) { + const dashToken = this.advance(); + let lineText = '-'; + if (this.currentToken().type === TokenType.Scalar) { + const restToken = this.advance(); + lineText = '- ' + restToken.value; + endOffset = restToken.endOffset; + } else { + endOffset = dashToken.endOffset; + } + if (emptyLineCount > 0) { + value += '\n'.repeat(emptyLineCount); + } else { + value += ' '; + } + value += lineText; + continue; + } + // Not a scalar continuation (could be Colon, etc.) + this.pos = savedPos; + break; } - // Check if we made progress - if not, we're likely stuck - const positionAfter = this.lexer.savePosition(); - if (positionBefore.line === positionAfter.line && positionBefore.char === positionAfter.char) { - // No progress made, advance at least one character to prevent infinite loop - if (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { - this.lexer.advance(); - } else { - break; - } + // Check that this line doesn't look like a mapping key (scalar followed by colon) + // which would mean the scalar ended and a new mapping entry starts + if (this.peek(1).type === TokenType.Colon) { + this.pos = savedPos; + break; } - this.lexer.skipWhitespace(); + const contToken = this.advance(); - // Handle comma separator - if (this.lexer.getCurrentChar() === ',') { - this.lexer.advance(); + // Apply line folding: empty lines become \n, single line break becomes space + if (emptyLineCount > 0) { + value += '\n'.repeat(emptyLineCount); + } else { + value += ' '; } + value += contToken.value; + endOffset = contToken.endOffset; } - const end = this.lexer.getCurrentPosition(); - this.flowLevel--; - return createArrayNode(items, start, end); + return { + type: 'scalar', + value, + rawValue: this.input.substring(firstToken.startOffset, endOffset), + startOffset: firstToken.startOffset, + endOffset, + format: 'none', + }; } - parseInlineObject(): YamlObjectNode { - const start = this.lexer.getCurrentPosition(); - this.lexer.advance(); // Skip '{' - this.flowLevel++; - - const properties: { key: YamlStringNode; value: YamlNode }[] = []; + // -- Block mapping --------------------------------------------------- - while (!this.lexer.isAtEnd()) { - this.lexer.skipWhitespace(); + private parseBlockMapping(baseIndent: number, inlineFirstEntry = false): YamlMapNode { + const startOffset = this.currentToken().startOffset; + const properties: { key: YamlScalarNode; value: YamlNode }[] = []; + const seenKeys = new Set(); - // Handle end of object - if (this.lexer.getCurrentChar() === '}') { - this.lexer.advance(); - break; + // When called after a sequence dash, the first key is already at the current position + if (inlineFirstEntry) { + const firstEntry = this.parseMappingEntry(baseIndent); + if (firstEntry) { + seenKeys.add(firstEntry.key.value); + properties.push(firstEntry); } + } - // Handle comments - comments should terminate the object parsing - if (this.lexer.getCurrentChar() === '#') { - // Skip the rest of the line (comment) - this.lexer.skipToEndOfLine(); - this.lexer.advanceLine(); - continue; + while (this.currentToken().type !== TokenType.EOF) { + this.skipNewlinesAndComments(); + if (this.currentToken().type === TokenType.EOF) { break; } + + const indent = this.currentIndent(); + if (indent < baseIndent) { break; } + if (indent !== baseIndent) { + if (indent > baseIndent) { + this.emitError( + localize('unexpectedIndentation', 'Unexpected indentation (expected {0}, got {1})', baseIndent, indent), + this.currentToken().startOffset, + this.currentToken().endOffset, + 'unexpected-indentation', + ); + } else { + break; + } + } + if (!this.looksLikeMapping()) { break; } + + const entry = this.parseMappingEntry(baseIndent); + if (!entry) { break; } + + if (!this.options.allowDuplicateKeys && seenKeys.has(entry.key.value)) { + this.emitError( + localize('duplicateKey', 'Duplicate key: "{0}"', entry.key.value), + entry.key.startOffset, + entry.key.endOffset, + 'duplicate-key', + ); } + seenKeys.add(entry.key.value); + properties.push(entry); + } - // Save position before parsing to detect if we're making progress - const positionBefore = this.lexer.savePosition(); + const endOffset = properties.length > 0 ? properties[properties.length - 1].value.endOffset : startOffset; + return { type: 'map', properties, style: 'block', startOffset, endOffset }; + } - // Parse key - read until colon - const keyStart = this.lexer.getCurrentPosition(); - let keyValue = ''; + private parseMappingEntry(baseIndent: number): { key: YamlScalarNode; value: YamlNode } | undefined { + // Skip indent + if (this.currentToken().type === TokenType.Indent) { + this.advance(); + } - // Handle quoted keys - if (this.lexer.getCurrentChar() === '"' || this.lexer.getCurrentChar() === `'`) { - const quote = this.lexer.getCurrentChar(); - this.lexer.advance(); // Skip opening quote + // Parse key + const keyToken = this.expect(TokenType.Scalar); + const key = this.scalarFromToken(keyToken); - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== quote) { - keyValue += this.lexer.advance(); - } + // Expect colon + const colon = this.expect(TokenType.Colon); + if (colon.type !== TokenType.Colon) { + this.emitError(localize('expectedColon', 'Expected ":"'), colon.startOffset, colon.endOffset, 'expected-colon'); + return undefined; + } - if (this.lexer.getCurrentChar() === quote) { - this.lexer.advance(); // Skip closing quote - } - } else { - // Handle unquoted keys - read until colon - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== ':') { - keyValue += this.lexer.advance(); - } - } + // Parse value: could be on same line or next line (indented) + const value = this.parseMappingValue(baseIndent, colon); - keyValue = keyValue.trim(); - const keyEnd = this.lexer.getCurrentPosition(); - const key = createStringNode(keyValue, keyStart, keyEnd); + return { key, value }; + } + + private parseMappingValue(baseIndent: number, colonToken: Token): YamlNode { + // Check if there's a value on the same line after the colon + const next = this.currentToken(); - this.lexer.skipWhitespace(); + // Same-line flow collections + if (next.type === TokenType.FlowMapStart) { return this.parseFlowMap(); } + if (next.type === TokenType.FlowSeqStart) { return this.parseFlowSeq(); } - // Expect colon - if (this.lexer.getCurrentChar() === ':') { - this.lexer.advance(); + // Same-line scalar (may be multiline with continuation) + if (next.type === TokenType.Scalar) { + // Skip indent if present (shouldn't be here, but be safe) + if (this.currentToken().type === TokenType.Indent) { + this.advance(); + } + const token = this.advance(); + if (token.format !== 'none') { + return this.scalarFromToken(token); } + // Plain scalar - allow multiline continuation deeper than baseIndent + return this.parsePlainMultiline(token, baseIndent); + } - this.lexer.skipWhitespace(); + // Value is on the next line (skip newlines/comments and check indentation) + this.skipNewlinesAndComments(); + const afterNewline = this.currentToken(); - // Parse value - const value = this.parseValue(); + if (afterNewline.type === TokenType.EOF) { + // Missing value at end of input + this.emitError(localize('missingValue', 'Missing value'), colonToken.startOffset, colonToken.endOffset, 'missing-value'); + return this.makeEmptyScalar(colonToken.endOffset); + } - properties.push({ key, value }); + const nextIndent = this.currentIndent(); + + // Special case: a sequence at the same indent as the mapping key is allowed + // as the mapping value (e.g., "foo:\n- 42") + if (nextIndent === baseIndent && this.peekPastIndent().type === TokenType.Dash) { + return this.parseValue(baseIndent) ?? this.makeEmptyScalar(colonToken.endOffset); + } + + if (nextIndent <= baseIndent) { + // No deeper indentation → missing value + this.emitError(localize('missingValue', 'Missing value'), colonToken.startOffset, colonToken.endOffset, 'missing-value'); + return this.makeEmptyScalar(colonToken.endOffset); + } + + // Parse the nested value + return this.parseValue(baseIndent) ?? this.makeEmptyScalar(colonToken.endOffset); + } - // Check if we made progress - if not, we're likely stuck - const positionAfter = this.lexer.savePosition(); - if (positionBefore.line === positionAfter.line && positionBefore.char === positionAfter.char) { - // No progress made, advance at least one character to prevent infinite loop - if (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { - this.lexer.advance(); + // -- Block sequence -------------------------------------------------- + + private parseBlockSequence(baseIndent: number): YamlSequenceNode { + const items: YamlNode[] = []; + const startOffset = this.currentToken().startOffset; + let endOffset = startOffset; + let isFirstItem = true; + + while (this.currentToken().type !== TokenType.EOF) { + this.skipNewlinesAndComments(); + if (this.currentToken().type === TokenType.EOF) { break; } + + // For the first item, the dash may be on the same line (no Indent token). + // Compute the actual column to check against baseIndent. + let indent: number; + if (isFirstItem && this.currentToken().type === TokenType.Dash) { + indent = this.currentToken().startOffset - this.getLineStart(this.currentToken().startOffset); + } else { + indent = this.currentIndent(); + } + isFirstItem = false; + + if (indent < baseIndent) { break; } + + if (indent !== baseIndent) { + if (indent > baseIndent) { + this.emitError( + localize('unexpectedIndentation', 'Unexpected indentation (expected {0}, got {1})', baseIndent, indent), + this.currentToken().startOffset, + this.currentToken().endOffset, + 'unexpected-indentation', + ); } else { break; } } - this.lexer.skipWhitespace(); + const contentToken = this.peekPastIndent(); + if (contentToken.type !== TokenType.Dash) { break; } - // Handle comma separator - if (this.lexer.getCurrentChar() === ',') { - this.lexer.advance(); + // Skip indent + if (this.currentToken().type === TokenType.Indent) { + this.advance(); } + + // Consume the dash + const dashToken = this.advance(); + + // Parse the item value + const itemValue = this.parseSequenceItemValue(baseIndent, dashToken); + items.push(itemValue); + endOffset = itemValue.endOffset; } - const end = this.lexer.getCurrentPosition(); - this.flowLevel--; - return createObjectNode(properties, start, end); + return { type: 'sequence', items, style: 'block', startOffset, endOffset }; } - parseBlockArray(baseIndent: number): YamlArrayNode { - const start = this.lexer.getCurrentPosition(); - const items: YamlNode[] = []; + private parseSequenceItemValue(baseIndent: number, dashToken: Token): YamlNode { + const next = this.currentToken(); - while (!this.lexer.isAtEnd()) { - this.lexer.moveToNextNonEmptyLine(); + // Skip comment after dash + if (next.type === TokenType.Comment) { + this.advance(); + } - if (this.lexer.isAtEnd()) { - break; - } + // Flow collections on same line + if (next.type === TokenType.FlowMapStart) { return this.parseFlowMap(); } + if (next.type === TokenType.FlowSeqStart) { return this.parseFlowSeq(); } - const currentIndent = this.lexer.getIndentation(); + // Nested sequence on same line (e.g., '- - value') + if (next.type === TokenType.Dash) { + // The nested sequence's base indent is the column of the dash + const nestedIndent = next.startOffset - this.getLineStart(next.startOffset); + return this.parseBlockSequence(nestedIndent); + } - // If indentation is less than expected, we're done with this array - if (currentIndent < baseIndent) { - break; + // Inline scalar on same line + if (next.type === TokenType.Scalar) { + // Check if this is actually a mapping (key: value on same line after dash) + if (this.peek(1).type === TokenType.Colon) { + // It's an inline mapping after '- ' like '- name: John' + // The implicit indent for continuation lines is the column of the key + const itemIndent = next.startOffset - this.getLineStart(next.startOffset); + return this.parseBlockMapping(itemIndent, true); } + return this.parseScalar(baseIndent); + } - this.lexer.skipWhitespace(); - - // Check for array item marker - if (this.lexer.getCurrentChar() === '-') { - this.lexer.advance(); // Skip '-' - this.lexer.skipWhitespace(); - - const itemStart = this.lexer.getCurrentPosition(); - - // Check if this is a nested structure - if (this.lexer.getCurrentChar() === '' || this.lexer.getCurrentChar() === '#') { - // Empty item - check if next lines form a nested structure - this.lexer.advanceLine(); - - if (!this.lexer.isAtEnd()) { - const nextIndent = this.lexer.getIndentation(); - - if (nextIndent > currentIndent) { - // Check if the next line starts with a dash (nested array) or has properties (nested object) - this.lexer.skipWhitespace(); - if (this.lexer.getCurrentChar() === '-') { - // It's a nested array - const nestedArray = this.parseBlockArray(nextIndent); - items.push(nestedArray); - } else { - // Check if it looks like an object property (has a colon) - const currentLine = this.lexer.getCurrentLineText(); - const currentPos = this.lexer.getCurrentCharNumber(); - const remainingLine = currentLine.substring(currentPos); - - if (remainingLine.includes(':') && !remainingLine.trim().startsWith('#')) { - // It's a nested object - const nestedObject = this.parseBlockObject(nextIndent, this.lexer.getCurrentCharNumber()); - items.push(nestedObject); - } else { - // Not a nested structure, create empty string - items.push(createStringNode('', itemStart, itemStart)); - } - } - } else { - // No nested content, empty item - items.push(createStringNode('', itemStart, itemStart)); - } - } else { - // End of input, empty item - items.push(createStringNode('', itemStart, itemStart)); - } - } else { - // Parse the item value - // Check if this is a multi-line object by looking for a colon and checking next lines - const currentLine = this.lexer.getCurrentLineText(); - const currentPos = this.lexer.getCurrentCharNumber(); - const remainingLine = currentLine.substring(currentPos); - - // Check if there's a colon on this line (indicating object properties) - const hasColon = remainingLine.includes(':'); - - if (hasColon) { - // Any line with a colon should be treated as an object - // Parse as an object with the current item's indentation as the base - const item = this.parseBlockObject(itemStart.character, itemStart.character); - items.push(item); - } else { - // No colon, parse as regular value - const item = this.parseValue(); - items.push(item); - - // Skip to end of line - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== '#') { - this.lexer.advance(); - } - this.lexer.advanceLine(); - } - } - } else { - // No dash found at expected indent level, break - break; - } + // Value on next line + this.skipNewlinesAndComments(); + if (this.currentToken().type === TokenType.EOF) { + this.emitError(localize('missingSeqItemValue', 'Missing sequence item value'), dashToken.startOffset, dashToken.endOffset, 'missing-value'); + return this.makeEmptyScalar(dashToken.endOffset); } - // Calculate end position based on the last item - let end = start; - if (items.length > 0) { - const lastItem = items[items.length - 1]; - end = lastItem.end; - } else { - // If no items, end is right after the start - end = createPosition(start.line, start.character + 1); + const nextIndent = this.currentIndent(); + if (nextIndent <= baseIndent) { + // Empty item (just a dash) + this.emitError(localize('missingSeqItemValue', 'Missing sequence item value'), dashToken.startOffset, dashToken.endOffset, 'missing-value'); + return this.makeEmptyScalar(dashToken.endOffset); } - return createArrayNode(items, start, end); + return this.parseValue(baseIndent) ?? this.makeEmptyScalar(dashToken.endOffset); + } + + /** Calculate the start of the line containing the given offset */ + private getLineStart(offset: number): number { + let i = offset - 1; + while (i >= 0 && this.input[i] !== '\n' && this.input[i] !== '\r') { + i--; + } + return i + 1; } - parseBlockObject(baseIndent: number, baseCharPosition?: number): YamlObjectNode { - const start = this.lexer.getCurrentPosition(); - const properties: { key: YamlStringNode; value: YamlNode }[] = []; - const localKeysSeen = new Set(); + // -- Flow map -------------------------------------------------------- - // For parsing from current position (inline object parsing) - const fromCurrentPosition = baseCharPosition !== undefined; - let firstIteration = true; + private parseFlowMap(): YamlMapNode { + const startToken = this.advance(); // consume '{' + const properties: { key: YamlScalarNode; value: YamlNode }[] = []; - while (!this.lexer.isAtEnd()) { - if (!firstIteration || !fromCurrentPosition) { - this.lexer.moveToNextNonEmptyLine(); - } - firstIteration = false; + this.skipFlowWhitespace(); - if (this.lexer.isAtEnd()) { + while (this.currentToken().type !== TokenType.FlowMapEnd && this.currentToken().type !== TokenType.EOF) { + // Parse key (must be a scalar) + let key: YamlScalarNode; + if (this.currentToken().type === TokenType.Scalar) { + key = this.parseFlowScalar(); + } else { + this.emitError(localize('expectedMappingKey', 'Expected mapping key'), this.currentToken().startOffset, this.currentToken().endOffset, 'expected-key'); break; } - const currentIndent = this.lexer.getIndentation(); + this.skipFlowWhitespace(); - if (fromCurrentPosition) { - // For current position parsing, check character position alignment - this.lexer.skipWhitespace(); - const currentCharPosition = this.lexer.getCurrentCharNumber(); + // Check for colon - if missing, the key has an empty value (terminated by comma or }) + let value: YamlNode; + if (this.currentToken().type === TokenType.Colon) { + this.advance(); + this.skipFlowWhitespace(); - if (currentCharPosition < baseCharPosition) { - break; - } + // Parse value + value = this.parseFlowValue(); } else { - // For normal block parsing, check indentation level - if (currentIndent < baseIndent) { - break; - } - - // Check for incorrect indentation - if (currentIndent > baseIndent) { - const lineStart = createPosition(this.lexer.getCurrentLineNumber(), 0); - const lineEnd = createPosition(this.lexer.getCurrentLineNumber(), this.lexer.getCurrentLineText().length); - this.addError('Unexpected indentation', 'indentation', lineStart, lineEnd); - - // Try to recover by treating it as a property anyway - this.lexer.skipWhitespace(); - } else { - this.lexer.skipWhitespace(); - } - } - - // Parse key - const keyStart = this.lexer.getCurrentPosition(); - let keyValue = ''; - - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== ':') { - keyValue += this.lexer.advance(); + // Key without value (e.g., { key, other: val }) + value = this.makeEmptyScalar(key.endOffset); } - keyValue = keyValue.trim(); - const keyEnd = this.lexer.getCurrentPosition(); - const key = createStringNode(keyValue, keyStart, keyEnd); + properties.push({ key, value }); - // Check for duplicate keys - if (!this.options.allowDuplicateKeys && localKeysSeen.has(keyValue)) { - this.addError(`Duplicate key '${keyValue}'`, 'duplicateKey', keyStart, keyEnd); - } - localKeysSeen.add(keyValue); + this.skipFlowWhitespace(); - // Expect colon - if (this.lexer.getCurrentChar() === ':') { - this.lexer.advance(); + // Consume comma if present + if (this.currentToken().type === TokenType.Comma) { + this.advance(); + this.skipFlowWhitespace(); } + } - this.lexer.skipWhitespace(); - - // Determine if value is on same line or next line(s) - let value: YamlNode; - const valueStart = this.lexer.getCurrentPosition(); + const endToken = this.currentToken(); + if (endToken.type === TokenType.FlowMapEnd) { + this.advance(); + } else { + this.emitError(localize('expectedFlowMapEnd', 'Expected "}"'), endToken.startOffset, endToken.endOffset, 'expected-flow-map-end'); + } - if (this.lexer.getCurrentChar() === '' || this.lexer.getCurrentChar() === '#') { - // Value is on next line(s) or empty - this.lexer.advanceLine(); + return { + type: 'map', + properties, + style: 'flow', + startOffset: startToken.startOffset, + endOffset: endToken.type === TokenType.FlowMapEnd ? endToken.endOffset : endToken.startOffset, + }; + } - // Check next line for nested content - if (!this.lexer.isAtEnd()) { - const nextIndent = this.lexer.getIndentation(); + // -- Flow sequence --------------------------------------------------- - if (nextIndent > currentIndent) { - // Nested content - determine if it's an object, array, or just a scalar value - this.lexer.skipWhitespace(); + private parseFlowSeq(): YamlSequenceNode { + const startToken = this.advance(); // consume '[' + const items: YamlNode[] = []; - if (this.lexer.getCurrentChar() === '-') { - value = this.parseBlockArray(nextIndent); - } else { - // Check if this looks like an object property (has a colon) - const currentLine = this.lexer.getCurrentLineText(); - const currentPos = this.lexer.getCurrentCharNumber(); - const remainingLine = currentLine.substring(currentPos); - - if (remainingLine.includes(':') && !remainingLine.trim().startsWith('#')) { - // It's a nested object - value = this.parseBlockObject(nextIndent); - } else { - // It's just a scalar value on the next line - value = this.parseValue(); - } - } - } else if (!fromCurrentPosition && nextIndent === currentIndent) { - // Same indentation level - check if it's an array item - this.lexer.skipWhitespace(); + this.skipFlowWhitespace(); - if (this.lexer.getCurrentChar() === '-') { - value = this.parseBlockArray(currentIndent); - } else { - value = createStringNode('', valueStart, valueStart); - } - } else { - value = createStringNode('', valueStart, valueStart); - } - } else { - value = createStringNode('', valueStart, valueStart); - } + while (this.currentToken().type !== TokenType.FlowSeqEnd && this.currentToken().type !== TokenType.EOF) { + let item: YamlNode; + if (this.currentToken().type === TokenType.FlowMapStart) { + item = this.parseFlowMap(); + } else if (this.currentToken().type === TokenType.FlowSeqStart) { + item = this.parseFlowSeq(); + } else if (this.currentToken().type === TokenType.Scalar) { + item = this.parseFlowScalar(); } else { - // Value is on the same line - value = this.parseValue(); - - // Skip any remaining content on this line (comments, etc.) - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== '#') { - if (isWhitespace(this.lexer.getCurrentChar())) { - this.lexer.advance(); - } else { - break; - } - } + this.emitError(localize('unexpectedTokenInFlowSeq', 'Unexpected token in flow sequence'), this.currentToken().startOffset, this.currentToken().endOffset, 'unexpected-token'); + this.advance(); + continue; + } - // Skip to end of line if we hit a comment - if (this.lexer.getCurrentChar() === '#') { - this.lexer.skipToEndOfLine(); - } + items.push(item); + this.skipFlowWhitespace(); - // Move to next line for next iteration - if (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() === '') { - this.lexer.advanceLine(); - } + if (this.currentToken().type === TokenType.Comma) { + this.advance(); + this.skipFlowWhitespace(); } - - properties.push({ key, value }); } - // Calculate the end position based on the last property - let end = start; - if (properties.length > 0) { - const lastProperty = properties[properties.length - 1]; - end = lastProperty.value.end; + const endToken = this.currentToken(); + if (endToken.type === TokenType.FlowSeqEnd) { + this.advance(); + } else { + this.emitError(localize('expectedFlowSeqEnd', 'Expected "]"'), endToken.startOffset, endToken.endOffset, 'expected-flow-seq-end'); } - return createObjectNode(properties, start, end); + return { + type: 'sequence', + items, + style: 'flow', + startOffset: startToken.startOffset, + endOffset: endToken.type === TokenType.FlowSeqEnd ? endToken.endOffset : endToken.startOffset, + }; } - parse(): YamlNode | undefined { - if (this.lexer.isAtEnd()) { - return undefined; + /** + * Parse a scalar inside a flow collection, handling multiline plain scalars. + * In flow context, plain (unquoted) scalars can span multiple lines; + * line breaks are folded into spaces. + */ + private parseFlowScalar(): YamlScalarNode { + const token = this.advance(); + // Quoted scalars are complete as-is (scanner handles their multiline folding) + if (token.format !== 'none') { + return this.scalarFromToken(token); } + // For unquoted (plain) scalars, fold continuation lines across newlines + let value = token.value; + let endOffset = token.endOffset; + + while (true) { + // Look ahead for a newline followed by a plain scalar continuation + let hasNewline = false; + let p = this.pos; + while (p < this.tokens.length) { + const t = this.tokens[p]; + if (t.type === TokenType.Newline) { + hasNewline = true; + p++; + } else if (t.type === TokenType.Indent || t.type === TokenType.Comment) { + p++; + } else { + break; + } + } - this.lexer.moveToNextNonEmptyLine(); + if (!hasNewline || p >= this.tokens.length) { break; } - if (this.lexer.isAtEnd()) { - return undefined; + const nextToken = this.tokens[p]; + if (nextToken.type === TokenType.Scalar && nextToken.format === 'none') { + // Fold continuation line into the scalar + this.pos = p + 1; + value += ' ' + nextToken.value; + endOffset = nextToken.endOffset; + } else { + break; + } } - // Determine the root structure type - this.lexer.skipWhitespace(); + return { + type: 'scalar', + value, + rawValue: this.input.substring(token.startOffset, endOffset), + startOffset: token.startOffset, + endOffset, + format: 'none', + }; + } - if (this.lexer.getCurrentChar() === '-') { - // Check if this is an array item or a negative number - // Look at the character after the dash - const nextChar = this.lexer.peek(); - if (nextChar === ' ' || nextChar === '\t' || nextChar === '' || nextChar === '#') { - // It's an array item (dash followed by whitespace/end/comment) - return this.parseBlockArray(0); - } else { - // It's likely a negative number or other value, treat as single value - return this.parseValue(); - } - } else if (this.lexer.getCurrentChar() === '[') { - // Root is an inline array - return this.parseInlineArray(); - } else if (this.lexer.getCurrentChar() === '{') { - // Root is an inline object - return this.parseInlineObject(); + /** Parse a value in flow context (used after colon in flow mappings/implicit mappings) */ + private parseFlowValue(): YamlNode { + if (this.currentToken().type === TokenType.FlowMapStart) { + return this.parseFlowMap(); + } else if (this.currentToken().type === TokenType.FlowSeqStart) { + return this.parseFlowSeq(); + } else if (this.currentToken().type === TokenType.Scalar) { + return this.parseFlowScalar(); } else { - // Check if this looks like a key-value pair by looking for a colon - // For single values, there shouldn't be a colon - const currentLine = this.lexer.getCurrentLineText(); - const currentPos = this.lexer.getCurrentCharNumber(); - const remainingLine = currentLine.substring(currentPos); - - // Check if there's a colon that's not inside quotes - let hasColon = false; - let inQuotes = false; - let quoteChar = ''; - - for (let i = 0; i < remainingLine.length; i++) { - const char = remainingLine[i]; - - if (!inQuotes && (char === '"' || char === `'`)) { - inQuotes = true; - quoteChar = char; - } else if (inQuotes && char === quoteChar) { - inQuotes = false; - quoteChar = ''; - } else if (!inQuotes && char === ':') { - hasColon = true; - break; - } else if (!inQuotes && char === '#') { - // Comment starts, stop looking - break; - } - } + return this.makeEmptyScalar(this.currentToken().startOffset); + } + } - if (hasColon) { - // Root is an object - return this.parseBlockObject(0); + /** Skip whitespace, newlines, and comments inside flow collections */ + private skipFlowWhitespace(): void { + while (true) { + const t = this.currentToken().type; + if (t === TokenType.Newline || t === TokenType.Indent || t === TokenType.Comment) { + this.advance(); } else { - // Root is a single value - return this.parseValue(); + break; } } } -} + private scalarFromToken(token: Token): YamlScalarNode { + return { + type: 'scalar', + value: token.value, + rawValue: token.rawValue, + startOffset: token.startOffset, + endOffset: token.endOffset, + format: token.format, + }; + } + private makeEmptyScalar(offset: number): YamlScalarNode { + return { + type: 'scalar', + value: '', + rawValue: '', + startOffset: offset, + endOffset: offset, + format: 'none', + }; + } +} diff --git a/src/vs/base/parts/ipc/common/ipc.net.ts b/src/vs/base/parts/ipc/common/ipc.net.ts index 5908e831ba1c1..ccb2eca316f04 100644 --- a/src/vs/base/parts/ipc/common/ipc.net.ts +++ b/src/vs/base/parts/ipc/common/ipc.net.ts @@ -544,10 +544,10 @@ export class Protocol extends Disposable implements IMessagePassingProtocol { private _socketWriter: ProtocolWriter; private _socketReader: ProtocolReader; - private readonly _onMessage = new Emitter(); + private readonly _onMessage = this._register(new Emitter()); readonly onMessage: Event = this._onMessage.event; - private readonly _onDidDispose = new Emitter(); + private readonly _onDidDispose = this._register(new Emitter()); readonly onDidDispose: Event = this._onDidDispose.event; constructor(socket: ISocket) { diff --git a/src/vs/base/parts/ipc/common/ipc.ts b/src/vs/base/parts/ipc/common/ipc.ts index b22e9fd9e42b2..8e061681e5c51 100644 --- a/src/vs/base/parts/ipc/common/ipc.ts +++ b/src/vs/base/parts/ipc/common/ipc.ts @@ -780,6 +780,7 @@ export class ChannelClient implements IChannelClient, IDisposable { } dispose(this.activeRequests.values()); this.activeRequests.clear(); + this._onDidInitialize.dispose(); } } diff --git a/src/vs/base/test/common/yaml.test.ts b/src/vs/base/test/common/yaml.test.ts index c6e3a53e7cc66..3c78fd291d514 100644 --- a/src/vs/base/test/common/yaml.test.ts +++ b/src/vs/base/test/common/yaml.test.ts @@ -2,1173 +2,1131 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { deepStrictEqual, strictEqual, ok } from 'assert'; -import { parse, ParseOptions, YamlParseError, Position, YamlNode } from '../../common/yaml.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; +import * as assert from 'assert'; +import { parse, YamlNode, YamlScalarNode, YamlMapNode, YamlSequenceNode, YamlParseError } from '../../common/yaml.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; -function assertValidParse(input: string[], expected: YamlNode, expectedErrors: YamlParseError[], options?: ParseOptions): void { +// Helper to parse and assert no errors +function parseOk(input: string): YamlNode | undefined { const errors: YamlParseError[] = []; - const text = input.join('\n'); - const actual1 = parse(text, errors, options); - deepStrictEqual(actual1, expected); - deepStrictEqual(errors, expectedErrors); + const result = parse(input, errors); + assert.deepStrictEqual(errors, [], `Unexpected errors: ${JSON.stringify(errors)}`); + return result; +} + +// Helper to assert a scalar node and verify its offsets match the raw value in the input +function assertScalar(input: string, node: YamlNode | undefined, expected: { value: string; format?: 'single' | 'double' | 'none' | 'literal' | 'folded' }): void { + assert.ok(node, 'Expected a node but got undefined'); + assert.strictEqual(node.type, 'scalar'); + const scalar = node as YamlScalarNode; + assert.strictEqual(scalar.value, expected.value); + if (expected.format !== undefined) { + assert.strictEqual(scalar.format, expected.format); + } + // Verify that the offsets correctly correspond to the rawValue in the input + assert.strictEqual( + input.substring(scalar.startOffset, scalar.endOffset), + scalar.rawValue, + `Offset mismatch: input[${scalar.startOffset}..${scalar.endOffset}] is "${input.substring(scalar.startOffset, scalar.endOffset)}" but rawValue is "${scalar.rawValue}"` + ); +} + +// Helper to assert a map node and return properties for further assertions +function assertMap(node: YamlNode | undefined, expectedKeyCount: number): YamlMapNode { + assert.ok(node, 'Expected a node but got undefined'); + assert.strictEqual(node.type, 'map', `Expected map but got ${node.type}`); + const map = node as YamlMapNode; + assert.strictEqual(map.properties.length, expectedKeyCount, `Expected ${expectedKeyCount} properties but got ${map.properties.length}`); + return map; } -function pos(line: number, character: number): Position { - return { line, character }; +// Helper to assert a sequence node and return items +function assertSequence(node: YamlNode | undefined, expectedItemCount: number): YamlSequenceNode { + assert.ok(node, 'Expected a node but got undefined'); + assert.strictEqual(node.type, 'sequence', `Expected sequence but got ${node.type}`); + const seq = node as YamlSequenceNode; + assert.strictEqual(seq.items.length, expectedItemCount, `Expected ${expectedItemCount} items but got ${seq.items.length}`); + return seq; } suite('YAML Parser', () => { ensureNoDisposablesAreLeakedInTestSuite(); - suite('scalars', () => { + suite('Empty input', () => { + test('returns undefined for empty string', () => { + assert.strictEqual(parseOk(''), undefined); + }); + + test('returns undefined for whitespace-only input', () => { + assert.strictEqual(parseOk(' '), undefined); + }); + + test('returns undefined for newline-only input', () => { + assert.strictEqual(parseOk('\n\n'), undefined); + }); + }); + + suite('Scalars', () => { + test('unquoted scalar', () => { + const input = 'hello world'; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello world', format: 'none' }); + }); + + test('literal block scalar format', () => { + const input = [ + 'text: |', + ' line one', + ' line two', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one\nline two\n', format: 'literal' }); + }); + + test('folded block scalar format', () => { + const input = [ + 'text: >', + ' line one', + ' line two', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one line two\n', format: 'folded' }); + }); + + test('literal block scalar strip chomping (|-)', () => { + const input = [ + 'text: |-', + ' line one', + ' line two', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one\nline two', format: 'literal' }); + }); + + test('literal block scalar keep chomping (|+)', () => { + const input = [ + 'text: |+', + ' line one', + ' line two', + '', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one\nline two\n', format: 'literal' }); + }); + + test('folded block scalar strip chomping (>-)', () => { + const input = [ + 'text: >-', + ' line one', + ' line two', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one line two', format: 'folded' }); + }); + + test('folded block scalar keep chomping (>+)', () => { + const input = [ + 'text: >+', + ' line one', + ' line two', + '', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one line two\n', format: 'folded' }); + }); - test('numbers', () => { - assertValidParse(['1'], { type: 'number', start: pos(0, 0), end: pos(0, 1), value: 1 }, []); - assertValidParse(['1.234'], { type: 'number', start: pos(0, 0), end: pos(0, 5), value: 1.234 }, []); - assertValidParse(['-42'], { type: 'number', start: pos(0, 0), end: pos(0, 3), value: -42 }, []); + test('single-quoted scalar', () => { + const input = `'hello world'`; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello world', format: 'single' }); }); - test('boolean', () => { - assertValidParse(['true'], { type: 'boolean', start: pos(0, 0), end: pos(0, 4), value: true }, []); - assertValidParse(['false'], { type: 'boolean', start: pos(0, 0), end: pos(0, 5), value: false }, []); + test('double-quoted scalar', () => { + const input = '"hello world"'; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello world', format: 'double' }); }); - test('null', () => { - assertValidParse(['null'], { type: 'null', start: pos(0, 0), end: pos(0, 4), value: null }, []); - assertValidParse(['~'], { type: 'null', start: pos(0, 0), end: pos(0, 1), value: null }, []); + test('double-quoted scalar with escape sequences', () => { + const input = '"hello\\nworld"'; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello\nworld', format: 'double' }); }); - test('string', () => { - assertValidParse(['A Developer'], { type: 'string', start: pos(0, 0), end: pos(0, 11), value: 'A Developer' }, []); - assertValidParse(['\'A Developer\''], { type: 'string', start: pos(0, 0), end: pos(0, 13), value: 'A Developer' }, []); - assertValidParse(['"A Developer"'], { type: 'string', start: pos(0, 0), end: pos(0, 13), value: 'A Developer' }, []); - assertValidParse(['*.js,*.ts'], { type: 'string', start: pos(0, 0), end: pos(0, 9), value: '*.js,*.ts' }, []); + test('single-quoted scalar with escaped single quote', () => { + const input = `'it''s a test'`; + const node = parseOk(input); + assertScalar(input, node, { value: `it's a test`, format: 'single' }); + }); + + test('scalar offsets are correct', () => { + const node = parseOk('hello') as YamlScalarNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 5); }); }); - suite('objects', () => { - - test('simple properties', () => { - assertValidParse(['name: John Doe'], { - type: 'object', start: pos(0, 0), end: pos(0, 14), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 14), value: 'John Doe' } - } - ] - }, []); - assertValidParse(['age: 30'], { - type: 'object', start: pos(0, 0), end: pos(0, 7), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'age' }, - value: { type: 'number', start: pos(0, 5), end: pos(0, 7), value: 30 } - } - ] - }, []); - assertValidParse(['active: true'], { - type: 'object', start: pos(0, 0), end: pos(0, 12), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'active' }, - value: { type: 'boolean', start: pos(0, 8), end: pos(0, 12), value: true } - } - ] - }, []); - assertValidParse(['value: null'], { - type: 'object', start: pos(0, 0), end: pos(0, 11), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 5), value: 'value' }, - value: { type: 'null', start: pos(0, 7), end: pos(0, 11), value: null } - } - ] - }, []); - }); - - test('value on next line', () => { - assertValidParse( - [ - 'name:', - ' John Doe', - 'colors:', - ' [ Red, Green, Blue ]', - ], - { - type: 'object', start: pos(0, 0), end: pos(3, 22), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(1, 2), end: pos(1, 10), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(2, 0), end: pos(2, 6), value: 'colors' }, - value: { - type: 'array', start: pos(3, 2), end: pos(3, 22), items: [ - { type: 'string', start: pos(3, 4), end: pos(3, 7), value: 'Red' }, - { type: 'string', start: pos(3, 9), end: pos(3, 14), value: 'Green' }, - { type: 'string', start: pos(3, 16), end: pos(3, 20), value: 'Blue' } - ] - } - } - ] - }, - [] - ); - }); - - test('multiple properties', () => { - assertValidParse( - [ - 'name: John Doe', - 'age: 30' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 7), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 14), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 3), value: 'age' }, - value: { type: 'number', start: pos(1, 5), end: pos(1, 7), value: 30 } - } - ] - }, - [] - ); - }); - - test('nested object', () => { - assertValidParse( - [ - 'person:', - ' name: John Doe', - ' age: 30' - ], - { - type: 'object', start: pos(0, 0), end: pos(2, 9), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'person' }, - value: { - type: 'object', start: pos(1, 2), end: pos(2, 9), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, - value: { type: 'string', start: pos(1, 8), end: pos(1, 16), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(2, 2), end: pos(2, 5), value: 'age' }, - value: { type: 'number', start: pos(2, 7), end: pos(2, 9), value: 30 } - } - ] - } - } - ] - - }, - [] - ); - }); - - - test('nested objects with address', () => { - assertValidParse( - [ - 'person:', - ' name: John Doe', - ' age: 30', - ' address:', - ' street: 123 Main St', - ' city: Example City' - ], - { - type: 'object', start: pos(0, 0), end: pos(5, 22), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'person' }, - value: { - type: 'object', start: pos(1, 2), end: pos(5, 22), - properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, - value: { type: 'string', start: pos(1, 8), end: pos(1, 16), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(2, 2), end: pos(2, 5), value: 'age' }, - value: { type: 'number', start: pos(2, 7), end: pos(2, 9), value: 30 } - }, - { - key: { type: 'string', start: pos(3, 2), end: pos(3, 9), value: 'address' }, - value: { - type: 'object', start: pos(4, 4), end: pos(5, 22), properties: [ - { - key: { type: 'string', start: pos(4, 4), end: pos(4, 10), value: 'street' }, - value: { type: 'string', start: pos(4, 12), end: pos(4, 23), value: '123 Main St' } - }, - { - key: { type: 'string', start: pos(5, 4), end: pos(5, 8), value: 'city' }, - value: { type: 'string', start: pos(5, 10), end: pos(5, 22), value: 'Example City' } - } - ] - } - } - ] - } - } - ] - }, - [] - ); - }); - - test('properties without space after colon', () => { - assertValidParse( - ['name:John'], - { - type: 'object', start: pos(0, 0), end: pos(0, 9), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(0, 5), end: pos(0, 9), value: 'John' } - } - ] - }, - [] - ); - - // Test mixed: some properties with space, some without - assertValidParse( - [ - 'config:', - ' database:', - ' host:localhost', - ' port: 5432', - ' credentials:', - ' username:admin', - ' password: secret123' - ], - { - type: 'object', start: pos(0, 0), end: pos(6, 25), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'config' }, - value: { - type: 'object', start: pos(1, 2), end: pos(6, 25), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 10), value: 'database' }, - value: { - type: 'object', start: pos(2, 4), end: pos(6, 25), properties: [ - { - key: { type: 'string', start: pos(2, 4), end: pos(2, 8), value: 'host' }, - value: { type: 'string', start: pos(2, 9), end: pos(2, 18), value: 'localhost' } - }, - { - key: { type: 'string', start: pos(3, 4), end: pos(3, 8), value: 'port' }, - value: { type: 'number', start: pos(3, 10), end: pos(3, 14), value: 5432 } - }, - { - key: { type: 'string', start: pos(4, 4), end: pos(4, 15), value: 'credentials' }, - value: { - type: 'object', start: pos(5, 6), end: pos(6, 25), properties: [ - { - key: { type: 'string', start: pos(5, 6), end: pos(5, 14), value: 'username' }, - value: { type: 'string', start: pos(5, 15), end: pos(5, 20), value: 'admin' } - }, - { - key: { type: 'string', start: pos(6, 6), end: pos(6, 14), value: 'password' }, - value: { type: 'string', start: pos(6, 16), end: pos(6, 25), value: 'secret123' } - } - ] - } - } - ] - } - } - ] - } - } - ] - }, - [] - ); - }); - - test('inline objects', () => { - assertValidParse( - ['{name: John, age: 30}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 21), properties: [ - { - key: { type: 'string', start: pos(0, 1), end: pos(0, 5), value: 'name' }, - value: { type: 'string', start: pos(0, 7), end: pos(0, 11), value: 'John' } - }, - { - key: { type: 'string', start: pos(0, 13), end: pos(0, 16), value: 'age' }, - value: { type: 'number', start: pos(0, 18), end: pos(0, 20), value: 30 } - } - ] - }, - [] - ); - - // Test with different data types - assertValidParse( - ['{active: true, score: 85.5, role: null}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 39), properties: [ - { - key: { type: 'string', start: pos(0, 1), end: pos(0, 7), value: 'active' }, - value: { type: 'boolean', start: pos(0, 9), end: pos(0, 13), value: true } - }, - { - key: { type: 'string', start: pos(0, 15), end: pos(0, 20), value: 'score' }, - value: { type: 'number', start: pos(0, 22), end: pos(0, 26), value: 85.5 } - }, - { - key: { type: 'string', start: pos(0, 28), end: pos(0, 32), value: 'role' }, - value: { type: 'null', start: pos(0, 34), end: pos(0, 38), value: null } - } - ] - }, - [] - ); - - // Test empty inline object - assertValidParse( - ['{}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 2), properties: [] - }, - [] - ); - - // Test inline object with quoted keys and values - assertValidParse( - ['{"name": "John Doe", "age": 30}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 31), properties: [ - { - key: { type: 'string', start: pos(0, 1), end: pos(0, 7), value: 'name' }, - value: { type: 'string', start: pos(0, 9), end: pos(0, 19), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(0, 21), end: pos(0, 26), value: 'age' }, - value: { type: 'number', start: pos(0, 28), end: pos(0, 30), value: 30 } - } - ] - }, - [] - ); - - // Test inline object without spaces - assertValidParse( - ['{name:John,age:30}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 18), properties: [ - { - key: { type: 'string', start: pos(0, 1), end: pos(0, 5), value: 'name' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 10), value: 'John' } - }, - { - key: { type: 'string', start: pos(0, 11), end: pos(0, 14), value: 'age' }, - value: { type: 'number', start: pos(0, 15), end: pos(0, 17), value: 30 } - } - ] - }, - [] - ); - - // Test multi-line inline object with internal comment line between properties - assertValidParse( - ['{a:1, # comment about b', ' b:2, c:3}'], - { - type: 'object', start: pos(0, 0), end: pos(1, 10), properties: [ - { - key: { type: 'string', start: pos(0, 1), end: pos(0, 2), value: 'a' }, - value: { type: 'number', start: pos(0, 3), end: pos(0, 4), value: 1 } - }, - { - key: { type: 'string', start: pos(1, 1), end: pos(1, 2), value: 'b' }, - value: { type: 'number', start: pos(1, 3), end: pos(1, 4), value: 2 } - }, - { - key: { type: 'string', start: pos(1, 6), end: pos(1, 7), value: 'c' }, - value: { type: 'number', start: pos(1, 8), end: pos(1, 9), value: 3 } - } - ] - }, - [] - ); + suite('Block mappings', () => { + test('simple key-value pair', () => { + const input = 'name: John Doe'; + const node = parseOk(input); + const map = assertMap(node, 1); + assert.strictEqual(map.properties[0].key.value, 'name'); + assertScalar(input, map.properties[0].value, { value: 'John Doe' }); }); - test('special characters in values', () => { - // Test values with special characters - assertValidParse( - [`key: value with \t special chars`], - { - type: 'object', start: pos(0, 0), end: pos(0, 31), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'string', start: pos(0, 5), end: pos(0, 31), value: `value with \t special chars` } - } - ] - }, - [] - ); - }); - - test('various whitespace types', () => { - // Test different types of whitespace - assertValidParse( - [`key:\t \t \t value`], - { - type: 'object', start: pos(0, 0), end: pos(0, 15), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'string', start: pos(0, 10), end: pos(0, 15), value: 'value' } - } - ] - }, - [] - ); + test('multiple key-value pairs', () => { + const input = [ + 'name: John Doe', + 'age: 30', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + assert.strictEqual(map.properties[0].key.value, 'name'); + assertScalar(input, map.properties[0].value, { value: 'John Doe' }); + assert.strictEqual(map.properties[1].key.value, 'age'); + assertScalar(input, map.properties[1].value, { value: '30' }); + }); + + test('nested mappings', () => { + const input = [ + 'name: John Doe', + 'age: 30', + 'mother:', + ' name: Susi Doe', + ' age: 50', + ' address:', + ' street: 123 Main St', + ' city: Example City', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 3); + assert.strictEqual(map.properties[0].key.value, 'name'); + assert.strictEqual(map.properties[2].key.value, 'mother'); + const mother = assertMap(map.properties[2].value, 3); + assert.strictEqual(mother.properties[0].key.value, 'name'); + assertScalar(input, mother.properties[0].value, { value: 'Susi Doe' }); + const address = assertMap(mother.properties[2].value, 2); + assert.strictEqual(address.properties[0].key.value, 'street'); + assertScalar(input, address.properties[0].value, { value: '123 Main St' }); + }); + + test('mapping with quoted keys and values', () => { + const input = [ + '"name": \'John Doe\'', + '\'age\': "30"', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + assert.strictEqual(map.properties[0].key.format, 'double'); + assert.strictEqual((map.properties[0].value as YamlScalarNode).format, 'single'); + }); + + test('mapping offsets', () => { + const input = 'name: John'; + const node = parseOk(input) as YamlMapNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 10); }); }); - suite('arrays', () => { - - - test('arrays', () => { - assertValidParse( - [ - '- Boston Red Sox', - '- Detroit Tigers', - '- New York Yankees' - ], - { - type: 'array', start: pos(0, 0), end: pos(2, 18), items: [ - { type: 'string', start: pos(0, 2), end: pos(0, 16), value: 'Boston Red Sox' }, - { type: 'string', start: pos(1, 2), end: pos(1, 16), value: 'Detroit Tigers' }, - { type: 'string', start: pos(2, 2), end: pos(2, 18), value: 'New York Yankees' } - ] - - }, - [] - ); - }); - - - test('inline arrays', () => { - assertValidParse( - ['[Apple, Banana, Cherry]'], - { - type: 'array', start: pos(0, 0), end: pos(0, 23), items: [ - { type: 'string', start: pos(0, 1), end: pos(0, 6), value: 'Apple' }, - { type: 'string', start: pos(0, 8), end: pos(0, 14), value: 'Banana' }, - { type: 'string', start: pos(0, 16), end: pos(0, 22), value: 'Cherry' } - ] - - }, - [] - ); - }); - - test('inline array with internal comment line', () => { - assertValidParse( - ['[one # comment about two', ',two, three]'], - { - type: 'array', start: pos(0, 0), end: pos(1, 12), items: [ - { type: 'string', start: pos(0, 1), end: pos(0, 4), value: 'one' }, - { type: 'string', start: pos(1, 1), end: pos(1, 4), value: 'two' }, - { type: 'string', start: pos(1, 6), end: pos(1, 11), value: 'three' } - ] - }, - [] - ); - }); - - test('multi-line inline arrays', () => { - assertValidParse( - [ - '[', - ' geen, ', - ' yello, red]' - ], - { - type: 'array', start: pos(0, 0), end: pos(2, 15), items: [ - { type: 'string', start: pos(1, 4), end: pos(1, 8), value: 'geen' }, - { type: 'string', start: pos(2, 4), end: pos(2, 9), value: 'yello' }, - { type: 'string', start: pos(2, 11), end: pos(2, 14), value: 'red' } - ] - }, - [] - ); - }); - - test('arrays of arrays', () => { - assertValidParse( - [ - '-', - ' - Apple', - ' - Banana', - ' - Cherry' - ], - { - type: 'array', start: pos(0, 0), end: pos(3, 10), items: [ - { - type: 'array', start: pos(1, 2), end: pos(3, 10), items: [ - { type: 'string', start: pos(1, 4), end: pos(1, 9), value: 'Apple' }, - { type: 'string', start: pos(2, 4), end: pos(2, 10), value: 'Banana' }, - { type: 'string', start: pos(3, 4), end: pos(3, 10), value: 'Cherry' } - ] - } - ] - }, - [] - ); - }); - - test('inline arrays of inline arrays', () => { - assertValidParse( - [ - '[', - ' [ee], [ff, gg]', - ']', - ], - { - type: 'array', start: pos(0, 0), end: pos(2, 1), items: [ - { - type: 'array', start: pos(1, 2), end: pos(1, 6), items: [ - { type: 'string', start: pos(1, 3), end: pos(1, 5), value: 'ee' }, - ], - }, - { - type: 'array', start: pos(1, 8), end: pos(1, 16), items: [ - { type: 'string', start: pos(1, 9), end: pos(1, 11), value: 'ff' }, - { type: 'string', start: pos(1, 13), end: pos(1, 15), value: 'gg' }, - ], - } - ] - }, - [] - ); - }); - - test('object with array containing single object', () => { - assertValidParse( - [ - 'items:', - '- name: John', - ' age: 30' - ], - { - type: 'object', start: pos(0, 0), end: pos(2, 9), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 5), value: 'items' }, - value: { - type: 'array', start: pos(1, 0), end: pos(2, 9), items: [ - { - type: 'object', start: pos(1, 2), end: pos(2, 9), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, - value: { type: 'string', start: pos(1, 8), end: pos(1, 12), value: 'John' } - }, - { - key: { type: 'string', start: pos(2, 2), end: pos(2, 5), value: 'age' }, - value: { type: 'number', start: pos(2, 7), end: pos(2, 9), value: 30 } - } - ] - } - ] - } - } - ] - }, - [] - ); - }); - - test('arrays of objects', () => { - assertValidParse( - [ - '-', - ' name: one', - '- name: two', - '-', - ' name: three' - ], - { - type: 'array', start: pos(0, 0), end: pos(4, 13), items: [ - { - type: 'object', start: pos(1, 2), end: pos(1, 11), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, - value: { type: 'string', start: pos(1, 8), end: pos(1, 11), value: 'one' } - } - ] - }, - { - type: 'object', start: pos(2, 2), end: pos(2, 11), properties: [ - { - key: { type: 'string', start: pos(2, 2), end: pos(2, 6), value: 'name' }, - value: { type: 'string', start: pos(2, 8), end: pos(2, 11), value: 'two' } - } - ] - }, - { - type: 'object', start: pos(4, 2), end: pos(4, 13), properties: [ - { - key: { type: 'string', start: pos(4, 2), end: pos(4, 6), value: 'name' }, - value: { type: 'string', start: pos(4, 8), end: pos(4, 13), value: 'three' } - } - ] - } - ] - }, - [] - ); + suite('Block sequences', () => { + test('simple sequence', () => { + const input = [ + '- Apple', + '- Banana', + '- Cherry', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 3); + assertScalar(input, seq.items[0], { value: 'Apple' }); + assertScalar(input, seq.items[1], { value: 'Banana' }); + assertScalar(input, seq.items[2], { value: 'Cherry' }); + }); + + // Spec Example 2.4. Sequence of Mappings (229Q) + test('spec 2.4 - sequence of mappings (229Q)', () => { + const input = [ + '-', + ' name: Mark McGwire', + ' hr: 65', + ' avg: 0.278', + '-', + ' name: Sammy Sosa', + ' hr: 63', + ' avg: 0.288', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 2); + + const first = assertMap(seq.items[0], 3); + assert.strictEqual(first.properties[0].key.value, 'name'); + assertScalar(input, first.properties[0].value, { value: 'Mark McGwire' }); + assert.strictEqual(first.properties[1].key.value, 'hr'); + assertScalar(input, first.properties[1].value, { value: '65' }); + assert.strictEqual(first.properties[2].key.value, 'avg'); + assertScalar(input, first.properties[2].value, { value: '0.278' }); + + const second = assertMap(seq.items[1], 3); + assert.strictEqual(second.properties[0].key.value, 'name'); + assertScalar(input, second.properties[0].value, { value: 'Sammy Sosa' }); + assert.strictEqual(second.properties[1].key.value, 'hr'); + assertScalar(input, second.properties[1].value, { value: '63' }); + assert.strictEqual(second.properties[2].key.value, 'avg'); + assertScalar(input, second.properties[2].value, { value: '0.288' }); + }); + + test('sequence of mappings', () => { + const input = [ + '-', + ' name: Mark McGwire', + ' hr: 65', + ' avg: 0.278', + '-', + ' name: Sammy Sosa', + ' hr: 63', + ' avg: 0.288', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 2); + const first = assertMap(seq.items[0], 3); + assertScalar(input, first.properties[0].value, { value: 'Mark McGwire' }); + const second = assertMap(seq.items[1], 3); + assertScalar(input, second.properties[0].value, { value: 'Sammy Sosa' }); + }); + + test('map of sequences', () => { + const input = [ + 'american:', + ' - Boston Red Sox', + ' - Detroit Tigers', + ' - New York Yankees', + 'national:', + ' - New York Mets', + ' - Chicago Cubs', + ' - Atlanta Braves', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + const american = assertSequence(map.properties[0].value, 3); + assertScalar(input, american.items[0], { value: 'Boston Red Sox' }); + const national = assertSequence(map.properties[1].value, 3); + assertScalar(input, national.items[2], { value: 'Atlanta Braves' }); + }); + + test('inline mapping after dash', () => { + const input = [ + '- name: Mark McGwire', + ' hr: 65', + '- name: Sammy Sosa', + ' hr: 63', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 2); + const first = assertMap(seq.items[0], 2); + assertScalar(input, first.properties[0].value, { value: 'Mark McGwire' }); }); }); - suite('complex structures', () => { - - test('array of objects', () => { - assertValidParse( - [ - 'products:', - ' - name: Laptop', - ' price: 999.99', - ' in_stock: true', - ' - name: Mouse', - ' price: 25.50', - ' in_stock: false' - ], - { - type: 'object', start: pos(0, 0), end: pos(6, 19), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 8), value: 'products' }, - value: { - type: 'array', start: pos(1, 2), end: pos(6, 19), items: [ - { - type: 'object', start: pos(1, 4), end: pos(3, 18), properties: [ - { - key: { type: 'string', start: pos(1, 4), end: pos(1, 8), value: 'name' }, - value: { type: 'string', start: pos(1, 10), end: pos(1, 16), value: 'Laptop' } - }, - { - key: { type: 'string', start: pos(2, 4), end: pos(2, 9), value: 'price' }, - value: { type: 'number', start: pos(2, 11), end: pos(2, 17), value: 999.99 } - }, - { - key: { type: 'string', start: pos(3, 4), end: pos(3, 12), value: 'in_stock' }, - value: { type: 'boolean', start: pos(3, 14), end: pos(3, 18), value: true } - } - ] - }, - { - type: 'object', start: pos(4, 4), end: pos(6, 19), properties: [ - { - key: { type: 'string', start: pos(4, 4), end: pos(4, 8), value: 'name' }, - value: { type: 'string', start: pos(4, 10), end: pos(4, 15), value: 'Mouse' } - }, - { - key: { type: 'string', start: pos(5, 4), end: pos(5, 9), value: 'price' }, - value: { type: 'number', start: pos(5, 11), end: pos(5, 16), value: 25.50 } - }, - { - key: { type: 'string', start: pos(6, 4), end: pos(6, 12), value: 'in_stock' }, - value: { type: 'boolean', start: pos(6, 14), end: pos(6, 19), value: false } - } - ] - } - ] - } - } - ] - }, - [] - ); - }); - - test('inline array mixed primitives', () => { - assertValidParse( - ['vals: [1, true, null, "str"]'], - { - type: 'object', start: pos(0, 0), end: pos(0, 28), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'vals' }, - value: { - type: 'array', start: pos(0, 6), end: pos(0, 28), items: [ - { type: 'number', start: pos(0, 7), end: pos(0, 8), value: 1 }, - { type: 'boolean', start: pos(0, 10), end: pos(0, 14), value: true }, - { type: 'null', start: pos(0, 16), end: pos(0, 20), value: null }, - { type: 'string', start: pos(0, 22), end: pos(0, 27), value: 'str' } - ] - } - } - ] - }, - [] - ); - }); - - test('mixed inline structures', () => { - assertValidParse( - ['config: {env: "prod", settings: [true, 42], debug: false}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 57), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'config' }, - value: { - type: 'object', start: pos(0, 8), end: pos(0, 57), properties: [ - { - key: { type: 'string', start: pos(0, 9), end: pos(0, 12), value: 'env' }, - value: { type: 'string', start: pos(0, 14), end: pos(0, 20), value: 'prod' } - }, - { - key: { type: 'string', start: pos(0, 22), end: pos(0, 30), value: 'settings' }, - value: { - type: 'array', start: pos(0, 32), end: pos(0, 42), items: [ - { type: 'boolean', start: pos(0, 33), end: pos(0, 37), value: true }, - { type: 'number', start: pos(0, 39), end: pos(0, 41), value: 42 } - ] - } - }, - { - key: { type: 'string', start: pos(0, 44), end: pos(0, 49), value: 'debug' }, - value: { type: 'boolean', start: pos(0, 51), end: pos(0, 56), value: false } - } - ] - } - } - ] - }, - [] - ); - }); - - test('with comments', () => { - assertValidParse( - [ - `# This is a comment`, - 'name: John Doe # inline comment', - 'age: 30' - ], - { - type: 'object', start: pos(1, 0), end: pos(2, 7), properties: [ - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 4), value: 'name' }, - value: { type: 'string', start: pos(1, 6), end: pos(1, 14), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(2, 0), end: pos(2, 3), value: 'age' }, - value: { type: 'number', start: pos(2, 5), end: pos(2, 7), value: 30 } - } - ] - }, - [] - ); + suite('Flow mappings', () => { + test('simple flow mapping', () => { + const input = '{hr: 65, avg: 0.278}'; + const node = parseOk(input); + const map = assertMap(node, 2); + assert.strictEqual(map.properties[0].key.value, 'hr'); + assertScalar(input, map.properties[0].value, { value: '65' }); + assert.strictEqual(map.properties[1].key.value, 'avg'); + assertScalar(input, map.properties[1].value, { value: '0.278' }); + }); + + test('flow mapping offsets', () => { + const input = '{hr: 65}'; + const node = parseOk(input) as YamlMapNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 8); }); }); - suite('edge cases and error handling', () => { - - - // Edge cases - test('duplicate keys error', () => { - assertValidParse( - [ - 'key: 1', - 'key: 2' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 6), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'number', start: pos(0, 5), end: pos(0, 6), value: 1 } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 3), value: 'key' }, - value: { type: 'number', start: pos(1, 5), end: pos(1, 6), value: 2 } - } - ] - }, - [ - { - message: 'Duplicate key \'key\'', - code: 'duplicateKey', - start: pos(1, 0), - end: pos(1, 3) - } - ] - ); + suite('Flow sequences', () => { + test('simple flow sequence', () => { + const input = '[Sammy Sosa , 63, 0.288]'; + const node = parseOk(input); + const seq = assertSequence(node, 3); + assertScalar(input, seq.items[0], { value: 'Sammy Sosa' }); + assertScalar(input, seq.items[1], { value: '63' }); + assertScalar(input, seq.items[2], { value: '0.288' }); + }); + + test('flow sequence with quoted strings', () => { + const input = `[ 'Sammy Sosa', 63, 0.288]`; + const node = parseOk(input); + const seq = assertSequence(node, 3); + assertScalar(input, seq.items[0], { value: 'Sammy Sosa', format: 'single' }); + }); + + test('flow sequence offsets', () => { + const input = '[a, b]'; + const node = parseOk(input) as YamlSequenceNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 6); + }); + }); + + suite('Mixed structures', () => { + test('object with scalars, arrays, inline objects and arrays', () => { + const input = [ + 'object:', + ' street: 123 Main St', + ' city: "Example City"', + 'array:', + ' - Boston Red Sox', + ` - 'Detroit Tigers'`, + 'inline object: {hr: 65, avg: 0.278}', + `inline array: [ 'Sammy Sosa', 63, 0.288]`, + 'bool: false', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 5); + + // Nested object + const obj = assertMap(map.properties[0].value, 2); + assertScalar(input, obj.properties[0].value, { value: '123 Main St' }); + assertScalar(input, obj.properties[1].value, { value: 'Example City', format: 'double' }); + + // Array + const arr = assertSequence(map.properties[1].value, 2); + assertScalar(input, arr.items[0], { value: 'Boston Red Sox' }); + assertScalar(input, arr.items[1], { value: 'Detroit Tigers', format: 'single' }); + + // Inline object + const inlineObj = assertMap(map.properties[2].value, 2); + assertScalar(input, inlineObj.properties[0].value, { value: '65' }); + + // Inline array + const inlineArr = assertSequence(map.properties[3].value, 3); + assertScalar(input, inlineArr.items[0], { value: 'Sammy Sosa', format: 'single' }); + + // Boolean as scalar + assertScalar(input, map.properties[4].value, { value: 'false' }); + }); + + test('arrays of inline arrays', () => { + const input = [ + '- [name , hr, avg ]', + '- [Mark McGwire, 65, 0.278]', + '- [Sammy Sosa , 63, 0.288]', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 3); + + const header = assertSequence(seq.items[0], 3); + assertScalar(input, header.items[0], { value: 'name' }); + assertScalar(input, header.items[1], { value: 'hr' }); + assertScalar(input, header.items[2], { value: 'avg' }); + + const row1 = assertSequence(seq.items[1], 3); + assertScalar(input, row1.items[0], { value: 'Mark McGwire' }); + }); + }); + + suite('Comments', () => { + test('comment-only lines are ignored', () => { + const input = [ + '# This is a comment', + 'name: John', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assert.strictEqual(map.properties[0].key.value, 'name'); + }); + + test('inline comment after value', () => { + const input = [ + 'hr: # 1998 hr ranking', + ' - Mark McGwire', + ' - Sammy Sosa', + 'rbi:', + ' # 1998 rbi ranking', + ' - Sammy Sosa', + ' - Ken Griffey#part of the value, not a comment', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + + const hr = assertSequence(map.properties[0].value, 2); + assertScalar(input, hr.items[0], { value: 'Mark McGwire' }); + + const rbi = assertSequence(map.properties[1].value, 2); + // '#' without leading space is part of the value + assertScalar(input, rbi.items[1], { value: 'Ken Griffey#part of the value, not a comment' }); + }); + }); + + suite('Error handling', () => { + test('missing value emits error and creates empty scalar', () => { + const errors: YamlParseError[] = []; + const input = [ + 'name:', + 'age: 30', + ].join('\n'); + const node = parse(input, errors); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].value, { value: '' }); + assert.ok(errors.some(e => e.code === 'missing-value')); + }); + + test('duplicate keys emit errors', () => { + const errors: YamlParseError[] = []; + const input = [ + 'name: John', + 'name: Jane', + ].join('\n'); + const node = parse(input, errors); + assertMap(node, 2); + assert.ok(errors.some(e => e.code === 'duplicate-key')); }); test('duplicate keys allowed with option', () => { - assertValidParse( - [ - 'key: 1', - 'key: 2' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 6), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'number', start: pos(0, 5), end: pos(0, 6), value: 1 } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 3), value: 'key' }, - value: { type: 'number', start: pos(1, 5), end: pos(1, 6), value: 2 } - } - ] - }, - [], - { allowDuplicateKeys: true } - ); - }); - - test('unexpected indentation error with recovery', () => { - // Parser reports error but still captures the over-indented property. - assertValidParse( - [ - 'key: 1', - ' stray: value' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 16), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'number', start: pos(0, 5), end: pos(0, 6), value: 1 } - }, - { - key: { type: 'string', start: pos(1, 4), end: pos(1, 9), value: 'stray' }, - value: { type: 'string', start: pos(1, 11), end: pos(1, 16), value: 'value' } - } - ] - }, - [ - { - message: 'Unexpected indentation', - code: 'indentation', - start: pos(1, 0), - end: pos(1, 16) - } - ] - ); - }); - - test('empty values and inline empty array', () => { - assertValidParse( - [ - 'empty:', - 'array: []' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 9), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 5), value: 'empty' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 6), value: '' } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 5), value: 'array' }, - value: { type: 'array', start: pos(1, 7), end: pos(1, 9), items: [] } - } - ] - }, - [] - ); - }); - - - - test('nested empty objects', () => { - // Parser should create nodes for both parent and child, with child having empty string value. - assertValidParse( - [ - 'parent:', - ' child:' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 8), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'parent' }, - value: { - type: 'object', start: pos(1, 2), end: pos(1, 8), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 7), value: 'child' }, - value: { type: 'string', start: pos(1, 8), end: pos(1, 8), value: '' } - } - ] - } - } - ] - }, - [] - ); - }); - - test('empty object with only colons', () => { - // Test object with empty values - assertValidParse( - ['key1:', 'key2:', 'key3:'], - { - type: 'object', start: pos(0, 0), end: pos(2, 5), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'key1' }, - value: { type: 'string', start: pos(0, 5), end: pos(0, 5), value: '' } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 4), value: 'key2' }, - value: { type: 'string', start: pos(1, 5), end: pos(1, 5), value: '' } - }, - { - key: { type: 'string', start: pos(2, 0), end: pos(2, 4), value: 'key3' }, - value: { type: 'string', start: pos(2, 5), end: pos(2, 5), value: '' } - } - ] - }, - [] - ); + const errors: YamlParseError[] = []; + const input = [ + 'name: John', + 'name: Jane', + ].join('\n'); + const node = parse(input, errors, { allowDuplicateKeys: true }); + assertMap(node, 2); + assert.strictEqual(errors.length, 0); }); - test('large input performance', () => { - // Test that large inputs are handled efficiently - const input = Array.from({ length: 1000 }, (_, i) => `key${i}: value${i}`); - const expectedProperties = Array.from({ length: 1000 }, (_, i) => ({ - key: { type: 'string' as const, start: pos(i, 0), end: pos(i, `key${i}`.length), value: `key${i}` }, - value: { type: 'string' as const, start: pos(i, `key${i}: `.length), end: pos(i, `key${i}: value${i}`.length), value: `value${i}` } - })); + test('wrong indentation emits error but still parses', () => { + const errors: YamlParseError[] = []; + const input = [ + 'parent:', + ' child1: a', + ' child2: b', + ].join('\n'); + const node = parse(input, errors); + assert.ok(node); + // Should have produced an indentation error + assert.ok(errors.some(e => e.code === 'unexpected-indentation')); + }); + }); + + suite('Offset tracking', () => { + test('scalar offsets in mapping', () => { + const input = 'key: value'; + const map = parseOk(input) as YamlMapNode; + assert.strictEqual(map.properties[0].key.startOffset, 0); + assert.strictEqual(map.properties[0].key.endOffset, 3); + const val = map.properties[0].value as YamlScalarNode; + assert.strictEqual(val.startOffset, 5); + assert.strictEqual(val.endOffset, 10); + }); + + test('offsets are zero-based and endOffset is exclusive', () => { + const input = '"hi"'; + const node = parseOk(input) as YamlScalarNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 4); + assert.strictEqual(node.value, 'hi'); + assert.strictEqual(node.rawValue, '"hi"'); + }); + + test('sequence item offsets', () => { + const input = [ + '- a', + '- b', + ].join('\n'); + const seq = parseOk(input) as YamlSequenceNode; + const first = seq.items[0] as YamlScalarNode; + assert.strictEqual(first.startOffset, 2); + assert.strictEqual(first.endOffset, 3); + }); + }); + + suite('Nested sequences', () => { + test('block sequence in block sequence (dash-dash)', () => { + const input = [ + '- - s1_i1', + ' - s1_i2', + '- s2', + ].join('\n'); + const outer = assertSequence(parseOk(input), 2); + const inner = assertSequence(outer.items[0], 2); + assertScalar(input, inner.items[0], { value: 's1_i1' }); + assertScalar(input, inner.items[1], { value: 's1_i2' }); + assertScalar(input, outer.items[1], { value: 's2' }); + }); + + test('sequence at same indent as parent mapping key', () => { + const input = [ + 'one:', + '- 2', + '- 3', + 'four: 5', + ].join('\n'); + const map = assertMap(parseOk(input), 2); + assertScalar(input, map.properties[0].key, { value: 'one' }); + const seq = assertSequence(map.properties[0].value, 2); + assertScalar(input, seq.items[0], { value: '2' }); + assertScalar(input, seq.items[1], { value: '3' }); + assertScalar(input, map.properties[1].key, { value: 'four' }); + assertScalar(input, map.properties[1].value, { value: '5' }); + }); + + test('sequence indented under mapping key', () => { + const input = [ + 'foo:', + ' - 42', + 'bar:', + ' - 44', + ].join('\n'); + const map = assertMap(parseOk(input), 2); + const seq1 = assertSequence(map.properties[0].value, 1); + assertScalar(input, seq1.items[0], { value: '42' }); + const seq2 = assertSequence(map.properties[1].value, 1); + assertScalar(input, seq2.items[0], { value: '44' }); + }); + }); + + suite('Multiline plain scalars', () => { + test('multiline scalar in mapping value', () => { + const input = [ + 'a: b', + ' c', + ].join('\n'); + const map = assertMap(parseOk(input), 1); + assertScalar(input, map.properties[0].value, { value: 'b c' }); + }); + + test('multiline scalar with multiple continuation lines', () => { + const input = [ + 'plain:', + ' This unquoted scalar', + ' spans many lines.', + ].join('\n'); + const map = assertMap(parseOk(input), 1); + assertScalar(input, map.properties[0].value, { value: 'This unquoted scalar spans many lines.' }); + }); + + test('multiline scalar at top level', () => { + const input = [ + 'a', + 'b', + ' c', + 'd', + ].join('\n'); + const result = parseOk(input); + assertScalar(input, result, { value: 'a b c d' }); + }); + test('multiline scalar with empty line preserves newline', () => { + const input = [ + 'a: val1', + ' val2', + '', + ' val3', + ].join('\n'); + const map = assertMap(parseOk(input), 1); + // Empty line between val2 and val3 becomes \n + assertScalar(input, map.properties[0].value, { value: 'val1 val2\nval3' }); + }); + + test('multiline scalar stops at same indent as mapping', () => { + const input = [ + 'a: b', + ' c', + 'd: e', + ].join('\n'); + const map = assertMap(parseOk(input), 2); + assertScalar(input, map.properties[0].value, { value: 'b c' }); + assertScalar(input, map.properties[1].value, { value: 'e' }); + }); + + test('multiline scalar value on next line', () => { + const input = [ + 'a:', + ' b', + ' c', + ].join('\n'); + const map = assertMap(parseOk(input), 1); + assertScalar(input, map.properties[0].value, { value: 'b c' }); + }); + + test('multiline scalar stops at comment', () => { + const input = [ + 'value1', + '# a comment', + 'value2', + ].join('\n'); + // Comment terminates the scalar continuation, so value2 is not part of value1 + const result = parseOk(input); + assertScalar(input, result, { value: 'value1' }); + }); + + test('multiline scalar with multiple mappings', () => { + const input = [ + 'a: b', + ' c', + 'd:', + ' e', + ' f', + ].join('\n'); + const map = assertMap(parseOk(input), 2); + assertScalar(input, map.properties[0].value, { value: 'b c' }); + assertScalar(input, map.properties[1].value, { value: 'e f' }); + }); + }); + + suite('Edge cases', () => { + test('colon in unquoted value', () => { + const input = 'url: http://example.com'; + const map = parseOk(input) as YamlMapNode; + assertScalar(input, map.properties[0].value, { value: 'http://example.com' }); + }); + + test('trailing whitespace is trimmed from unquoted scalars', () => { + const input = 'name: John '; + const map = parseOk(input) as YamlMapNode; + assertScalar(input, map.properties[0].value, { value: 'John' }); + }); + + test('empty flow map', () => { + const node = parseOk('{}'); + const map = assertMap(node, 0); + assert.strictEqual(map.startOffset, 0); + assert.strictEqual(map.endOffset, 2); + }); + + test('empty flow sequence', () => { + const node = parseOk('[]'); + const seq = assertSequence(node, 0); + assert.strictEqual(seq.startOffset, 0); + assert.strictEqual(seq.endOffset, 2); + }); + + test('CRLF line endings', () => { + const input = 'name: John\r\nage: 30'; + const map = parseOk(input) as YamlMapNode; + assertMap(map, 2); + assertScalar(input, map.properties[0].value, { value: 'John' }); + assertScalar(input, map.properties[1].value, { value: '30' }); + }); + }); + + suite('Old test suite', () => { + + test('mapping value on next line', () => { + const input = [ + 'name:', + ' John Doe', + 'colors:', + ' [ Red, Green, Blue ]', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].value, { value: 'John Doe' }); + const colors = assertSequence(map.properties[1].value, 3); + assertScalar(input, colors.items[0], { value: 'Red' }); + assertScalar(input, colors.items[1], { value: 'Green' }); + assertScalar(input, colors.items[2], { value: 'Blue' }); + }); + + test('flow map with different data types', () => { + const input = '{active: true, score: 85.5, role: null}'; + const node = parseOk(input); + const map = assertMap(node, 3); + assertScalar(input, map.properties[0].key, { value: 'active' }); + assertScalar(input, map.properties[0].value, { value: 'true' }); + assertScalar(input, map.properties[1].key, { value: 'score' }); + assertScalar(input, map.properties[1].value, { value: '85.5' }); + assertScalar(input, map.properties[2].key, { value: 'role' }); + assertScalar(input, map.properties[2].value, { value: 'null' }); + }); + + test('flow map with quoted keys and values', () => { + const input = '{"name": "John Doe", "age": 30}'; + const node = parseOk(input); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].key, { value: 'name', format: 'double' }); + assertScalar(input, map.properties[0].value, { value: 'John Doe', format: 'double' }); + assertScalar(input, map.properties[1].key, { value: 'age', format: 'double' }); + assertScalar(input, map.properties[1].value, { value: '30' }); + }); + + test('special characters in values', () => { + const input = `key: value with \t special chars`; + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: `value with \t special chars` }); + }); + + test('various whitespace after colon', () => { + const input = `key:\t \t \t value`; + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'value' }); + }); + + test('inline array with comment continuation', () => { + const input = [ + '[one # comment about two', + ',two, three]', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 3); + assertScalar(input, seq.items[0], { value: 'one' }); + assertScalar(input, seq.items[1], { value: 'two' }); + assertScalar(input, seq.items[2], { value: 'three' }); + }); + + test('multi-line flow sequence', () => { + const input = [ + '[', + ' geen, ', + ' yello, red]', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 3); + assertScalar(input, seq.items[0], { value: 'geen' }); + assertScalar(input, seq.items[1], { value: 'yello' }); + assertScalar(input, seq.items[2], { value: 'red' }); + }); + + test('nested block sequences (dash on next line)', () => { + const input = [ + '-', + ' - Apple', + ' - Banana', + ' - Cherry', + ].join('\n'); + const node = parseOk(input); + const outer = assertSequence(node, 1); + const inner = assertSequence(outer.items[0], 3); + assertScalar(input, inner.items[0], { value: 'Apple' }); + assertScalar(input, inner.items[1], { value: 'Banana' }); + assertScalar(input, inner.items[2], { value: 'Cherry' }); + }); + + test('nested flow sequences', () => { + const input = [ + '[', + ' [ee], [ff, gg]', + ']', + ].join('\n'); + const node = parseOk(input); + const outer = assertSequence(node, 2); + const first = assertSequence(outer.items[0], 1); + assertScalar(input, first.items[0], { value: 'ee' }); + const second = assertSequence(outer.items[1], 2); + assertScalar(input, second.items[0], { value: 'ff' }); + assertScalar(input, second.items[1], { value: 'gg' }); + }); + + test('mapping with sequence containing a mapping', () => { + const input = [ + 'items:', + '- name: John', + ' age: 30', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].key, { value: 'items' }); + const seq = assertSequence(map.properties[0].value, 1); + const item = assertMap(seq.items[0], 2); + assertScalar(input, item.properties[0].value, { value: 'John' }); + assertScalar(input, item.properties[1].value, { value: '30' }); + }); + + test('sequence of mappings with varying styles', () => { + const input = [ + '-', + ' name: one', + '- name: two', + '-', + ' name: three', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 3); + const first = assertMap(seq.items[0], 1); + assertScalar(input, first.properties[0].value, { value: 'one' }); + const second = assertMap(seq.items[1], 1); + assertScalar(input, second.properties[0].value, { value: 'two' }); + const third = assertMap(seq.items[2], 1); + assertScalar(input, third.properties[0].value, { value: 'three' }); + }); + + test('sequence of multi-property mappings', () => { + const input = [ + 'products:', + ' - name: Laptop', + ' price: 999.99', + ' in_stock: true', + ' - name: Mouse', + ' price: 25.50', + ' in_stock: false', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + const products = assertSequence(map.properties[0].value, 2); + const laptop = assertMap(products.items[0], 3); + assertScalar(input, laptop.properties[0].value, { value: 'Laptop' }); + assertScalar(input, laptop.properties[1].value, { value: '999.99' }); + assertScalar(input, laptop.properties[2].value, { value: 'true' }); + const mouse = assertMap(products.items[1], 3); + assertScalar(input, mouse.properties[0].value, { value: 'Mouse' }); + assertScalar(input, mouse.properties[1].value, { value: '25.50' }); + assertScalar(input, mouse.properties[2].value, { value: 'false' }); + }); + + test('flow sequence with mixed types', () => { + // Note: current parser treats all values as scalars (strings), not typed + const input = 'vals: [1, true, null, "str"]'; + const node = parseOk(input); + const map = assertMap(node, 1); + const vals = assertSequence(map.properties[0].value, 4); + assertScalar(input, vals.items[0], { value: '1' }); + assertScalar(input, vals.items[1], { value: 'true' }); + assertScalar(input, vals.items[2], { value: 'null' }); + assertScalar(input, vals.items[3], { value: 'str', format: 'double' }); + }); + + test('flow map with nested flow sequence', () => { + const input = 'config: {env: "prod", settings: [true, 42], debug: false}'; + const node = parseOk(input); + const map = assertMap(node, 1); + const config = assertMap(map.properties[0].value, 3); + assertScalar(input, config.properties[0].key, { value: 'env' }); + assertScalar(input, config.properties[0].value, { value: 'prod', format: 'double' }); + const settings = assertSequence(config.properties[1].value, 2); + assertScalar(input, settings.items[0], { value: 'true' }); + assertScalar(input, settings.items[1], { value: '42' }); + assertScalar(input, config.properties[2].key, { value: 'debug' }); + assertScalar(input, config.properties[2].value, { value: 'false' }); + }); + + test('full-line and inline comments', () => { + const input = [ + '# This is a comment', + 'name: John Doe # inline comment', + 'age: 30', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].key, { value: 'name' }); + assertScalar(input, map.properties[0].value, { value: 'John Doe' }); + assertScalar(input, map.properties[1].key, { value: 'age' }); + assertScalar(input, map.properties[1].value, { value: '30' }); + }); + + test('unexpected indentation with recovery', () => { + const errors: YamlParseError[] = []; + const input = [ + 'key: 1', + ' stray: value', + ].join('\n'); + const node = parse(input, errors); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].key, { value: 'key' }); + assertScalar(input, map.properties[0].value, { value: '1' }); + assertScalar(input, map.properties[1].key, { value: 'stray' }); + assertScalar(input, map.properties[1].value, { value: 'value' }); + // Should report an indentation error + assert.ok(errors.some(e => e.code === 'unexpected-indentation')); + }); + + test('empty value followed by non-empty', () => { + const input = [ + 'empty:', + 'array: []', + ].join('\n'); + const errors: YamlParseError[] = []; + const node = parse(input, errors); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].key, { value: 'empty' }); + assertScalar(input, map.properties[0].value, { value: '' }); + assertScalar(input, map.properties[1].key, { value: 'array' }); + const arr = assertSequence(map.properties[1].value, 0); + assert.ok(arr); + }); + + test('nested mapping with empty value', () => { + const input = [ + 'parent:', + ' child:', + ].join('\n'); + const errors: YamlParseError[] = []; + const node = parse(input, errors); + const map = assertMap(node, 1); + const parent = assertMap(map.properties[0].value, 1); + assertScalar(input, parent.properties[0].key, { value: 'child' }); + assertScalar(input, parent.properties[0].value, { value: '' }); + }); + + test('multiple keys with empty values', () => { + const errors: YamlParseError[] = []; + const input = [ + 'key1:', + 'key2:', + 'key3:', + ].join('\n'); + const node = parse(input, errors); + const map = assertMap(node, 3); + assertScalar(input, map.properties[0].key, { value: 'key1' }); + assertScalar(input, map.properties[0].value, { value: '' }); + assertScalar(input, map.properties[1].key, { value: 'key2' }); + assertScalar(input, map.properties[1].value, { value: '' }); + assertScalar(input, map.properties[2].key, { value: 'key3' }); + assertScalar(input, map.properties[2].value, { value: '' }); + }); + + test('large input performance', () => { + const lines = Array.from({ length: 1000 }, (_, i) => `key${i}: value${i}`); + const input = lines.join('\n'); const start = Date.now(); - assertValidParse( - input, - { - type: 'object', - start: pos(0, 0), - end: pos(999, 'key999: value999'.length), - properties: expectedProperties - }, - [] - ); + const node = parseOk(input); const duration = Date.now() - start; - - ok(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); + const map = assertMap(node, 1000); + assertScalar(input, map.properties[0].key, { value: 'key0' }); + assertScalar(input, map.properties[999].key, { value: 'key999' }); + assert.ok(duration < 500, `Parsing took ${duration}ms, expected < 500ms`); }); test('deeply nested structure performance', () => { - // Test that deeply nested structures are handled efficiently const lines = []; for (let i = 0; i < 50; i++) { - const indent = ' '.repeat(i); - lines.push(`${indent}level${i}:`); + lines.push(' '.repeat(i) + `level${i}:`); } lines.push(' '.repeat(50) + 'deepValue: reached'); - + const input = lines.join('\n'); const start = Date.now(); const errors: YamlParseError[] = []; - const result = parse(lines.join('\n'), errors); + const result = parse(input, errors); const duration = Date.now() - start; + assert.ok(result); + assert.strictEqual(result.type, 'map'); + assert.ok(duration < 500, `Parsing took ${duration}ms, expected < 500ms`); + }); + + test('unclosed flow sequence with empty lines', () => { + const errors: YamlParseError[] = []; + const input = [ + 'key: [', + '', + '', + '', + '', + ].join('\n'); + const node = parse(input, errors); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].key, { value: 'key' }); + const seq = map.properties[0].value as YamlSequenceNode; + assert.strictEqual(seq.type, 'sequence'); + assert.strictEqual(seq.items.length, 0); + }); - ok(result); - strictEqual(result.type, 'object'); - strictEqual(errors.length, 0); - ok(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); - }); - - test('malformed array with position issues', () => { - // Test malformed arrays that might cause position advancement issues - assertValidParse( - [ - 'key: [', - '', - '', - '', - '' - ], - { - type: 'object', start: pos(0, 0), end: pos(5, 0), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'array', start: pos(0, 5), end: pos(5, 0), items: [] } - } - ] - }, - [] - ); - }); - - test('self-referential like structure', () => { - // Test structures that might appear self-referential - assertValidParse( - [ - 'a:', - ' b:', - ' a:', - ' b:', - ' value: test' - ], - { - type: 'object', start: pos(0, 0), end: pos(4, 19), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 1), value: 'a' }, - value: { - type: 'object', start: pos(1, 2), end: pos(4, 19), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 3), value: 'b' }, - value: { - type: 'object', start: pos(2, 4), end: pos(4, 19), properties: [ - { - key: { type: 'string', start: pos(2, 4), end: pos(2, 5), value: 'a' }, - value: { - type: 'object', start: pos(3, 6), end: pos(4, 19), properties: [ - { - key: { type: 'string', start: pos(3, 6), end: pos(3, 7), value: 'b' }, - value: { - type: 'object', start: pos(4, 8), end: pos(4, 19), properties: [ - { - key: { type: 'string', start: pos(4, 8), end: pos(4, 13), value: 'value' }, - value: { type: 'string', start: pos(4, 15), end: pos(4, 19), value: 'test' } - } - ] - } - } - ] - } - } - ] - } - } - ] - } - } - ] - }, - [] - ); - }); - - test('array with empty lines', () => { - // Test arrays spanning multiple lines with empty lines - assertValidParse( - ['arr: [', '', 'item1,', '', 'item2', '', ']'], - { - type: 'object', start: pos(0, 0), end: pos(6, 1), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'arr' }, - value: { - type: 'array', start: pos(0, 5), end: pos(6, 1), items: [ - { type: 'string', start: pos(2, 0), end: pos(2, 5), value: 'item1' }, - { type: 'string', start: pos(4, 0), end: pos(4, 5), value: 'item2' } - ] - } - } - ] - }, - [] - ); - }); - - test('whitespace advancement robustness', () => { - // Test that whitespace advancement works correctly - assertValidParse( - [`key: value`], - { - type: 'object', start: pos(0, 0), end: pos(0, 15), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'string', start: pos(0, 10), end: pos(0, 15), value: 'value' } - } - ] - }, - [] - ); - }); - - - test('missing end quote in string values', () => { - // Test unclosed double quote - parser treats it as bare string with quote included - assertValidParse( - ['name: "John'], - { - type: 'object', start: pos(0, 0), end: pos(0, 11), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 11), value: 'John' } - } - ] - }, - [] - ); - - // Test unclosed single quote - parser treats it as bare string with quote included - assertValidParse( - ['description: \'Hello world'], - { - type: 'object', start: pos(0, 0), end: pos(0, 25), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 11), value: 'description' }, - value: { type: 'string', start: pos(0, 13), end: pos(0, 25), value: 'Hello world' } - } - ] - }, - [] - ); - - // Test unclosed quote in multi-line context - assertValidParse( - [ - 'data: "incomplete', - 'next: value' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 11), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'data' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 17), value: 'incomplete' } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 4), value: 'next' }, - value: { type: 'string', start: pos(1, 6), end: pos(1, 11), value: 'value' } - } - ] - }, - [] - ); - - // Test properly quoted strings for comparison - assertValidParse( - ['name: "John"'], - { - type: 'object', start: pos(0, 0), end: pos(0, 12), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 12), value: 'John' } - } - ] - }, - [] - ); - }); - - test('comment in inline array #269078', () => { - // Test malformed array with comment-like content - should not cause endless loop - assertValidParse( - [ - 'mode: agent', - 'tools: [#r' - ], - { - type: 'object', start: pos(0, 0), end: pos(2, 0), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'mode' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 11), value: 'agent' } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 5), value: 'tools' }, - value: { type: 'array', start: pos(1, 7), end: pos(2, 0), items: [] } - } - ] - }, - [] - ); + test('deeply nested same-named keys', () => { + const input = [ + 'a:', + ' b:', + ' a:', + ' b:', + ' value: test', + ].join('\n'); + const node = parseOk(input); + const outerA = assertMap(node, 1); + assertScalar(input, outerA.properties[0].key, { value: 'a' }); + const outerB = assertMap(outerA.properties[0].value, 1); + assertScalar(input, outerB.properties[0].key, { value: 'b' }); + const innerA = assertMap(outerB.properties[0].value, 1); + assertScalar(input, innerA.properties[0].key, { value: 'a' }); + const innerB = assertMap(innerA.properties[0].value, 1); + assertScalar(input, innerB.properties[0].key, { value: 'b' }); + const leaf = assertMap(innerB.properties[0].value, 1); + assertScalar(input, leaf.properties[0].key, { value: 'value' }); + assertScalar(input, leaf.properties[0].value, { value: 'test' }); }); + test('flow sequence with empty lines between items', () => { + const input = ['arr: [', '', 'item1,', '', 'item2', '', ']'].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + const seq = assertSequence(map.properties[0].value, 2); + assertScalar(input, seq.items[0], { value: 'item1' }); + assertScalar(input, seq.items[1], { value: 'item2' }); + }); - }); + test('excessive whitespace after colon', () => { + const input = 'key: value'; + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'value' }); + }); + + test('unclosed double quote', () => { + const input = 'name: "John'; + const errors: YamlParseError[] = []; + const node = parse(input, errors); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].key, { value: 'name' }); + // Parser should recover: value should be 'John' (sans quote) + assertScalar(input, map.properties[0].value, { value: 'John' }); + }); + + test('unclosed single quote', () => { + const input = `description: 'Hello world`; + const errors: YamlParseError[] = []; + const node = parse(input, errors); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].key, { value: 'description' }); + assertScalar(input, map.properties[0].value, { value: 'Hello world' }); + }); + + test('comment in unclosed flow sequence', () => { + const input = [ + 'mode: agent', + 'tools: [#r', + ].join('\n'); + const errors: YamlParseError[] = []; + const node = parse(input, errors); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].key, { value: 'mode' }); + assertScalar(input, map.properties[0].value, { value: 'agent' }); + assertScalar(input, map.properties[1].key, { value: 'tools' }); + const seq = map.properties[1].value as YamlSequenceNode; + assert.strictEqual(seq.type, 'sequence'); + assert.strictEqual(seq.items.length, 0); + }); + test('duplicate keys emit error', () => { + const errors: YamlParseError[] = []; + const input = [ + 'key: 1', + 'key: 2', + ].join('\n'); + const node = parse(input, errors); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].value, { value: '1' }); + assertScalar(input, map.properties[1].value, { value: '2' }); + assert.ok(errors.some(e => e.code === 'duplicate-key')); + }); + + test('duplicate keys allowed via option', () => { + const errors: YamlParseError[] = []; + const input = [ + 'key: 1', + 'key: 2', + ].join('\n'); + const node = parse(input, errors, { allowDuplicateKeys: true }); + assertMap(node, 2); + assert.strictEqual(errors.length, 0); + }); + }); }); diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 0c6cc59730901..5b64a6cbb1d83 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -15,7 +15,7 @@ import { getPathLabel } from '../../base/common/labels.js'; import { Disposable, DisposableStore } from '../../base/common/lifecycle.js'; import { Schemas, VSCODE_AUTHORITY } from '../../base/common/network.js'; import { join, posix } from '../../base/common/path.js'; -import { IProcessEnvironment, isLinux, isLinuxSnap, isMacintosh, isWindows, OS } from '../../base/common/platform.js'; +import { INodeProcess, IProcessEnvironment, isLinux, isLinuxSnap, isMacintosh, isWindows, OS } from '../../base/common/platform.js'; import { assertType } from '../../base/common/types.js'; import { URI } from '../../base/common/uri.js'; import { generateUuid } from '../../base/common/uuid.js'; @@ -38,6 +38,7 @@ import { EncryptionMainService } from '../../platform/encryption/electron-main/e import { NativeBrowserElementsMainService, INativeBrowserElementsMainService } from '../../platform/browserElements/electron-main/nativeBrowserElementsMainService.js'; import { ipcBrowserViewChannelName } from '../../platform/browserView/common/browserView.js'; import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js'; +import { BrowserViewCDPProxyServer, IBrowserViewCDPProxyServer } from '../../platform/browserView/electron-main/browserViewCDPProxyServer.js'; import { NativeParsedArgs } from '../../platform/environment/common/argv.js'; import { IEnvironmentMainService } from '../../platform/environment/electron-main/environmentMainService.js'; import { isLaunchedFromCli } from '../../platform/environment/node/argvHelper.js'; @@ -1040,6 +1041,7 @@ export class CodeApplication extends Disposable { services.set(INativeBrowserElementsMainService, new SyncDescriptor(NativeBrowserElementsMainService, undefined, false /* proxied to other processes */)); // Browser View + services.set(IBrowserViewCDPProxyServer, new SyncDescriptor(BrowserViewCDPProxyServer, undefined, true)); services.set(IBrowserViewMainService, new SyncDescriptor(BrowserViewMainService, undefined, false /* proxied to other processes */)); // Keyboard Layout @@ -1202,6 +1204,7 @@ export class CodeApplication extends Disposable { // Browser View const browserViewChannel = ProxyChannel.fromService(accessor.get(IBrowserViewMainService), disposables); mainProcessElectronServer.registerChannel(ipcBrowserViewChannelName, browserViewChannel); + sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewChannelName, browserViewChannel)); // Signing const signChannel = ProxyChannel.fromService(accessor.get(ISignService), disposables); @@ -1285,7 +1288,12 @@ export class CodeApplication extends Disposable { const context = isLaunchedFromCli(process.env) ? OpenContext.CLI : OpenContext.DESKTOP; const args = this.environmentMainService.args; - // First check for windows from protocol links to open + // Embedded app launches directly into the sessions window + if ((process as INodeProcess).isEmbeddedApp) { + return windowsMainService.openSessionsWindow({ context, contextWindowId: undefined }); + } + + // Then check for windows from protocol links to open if (initialProtocolUrls) { // Openables can open as windows directly diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index ae5f6def0951d..6569ef1fbc808 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -134,6 +134,8 @@ import { IMcpGalleryManifestService } from '../../../platform/mcp/common/mcpGall import { McpGalleryManifestIPCService } from '../../../platform/mcp/common/mcpGalleryManifestServiceIpc.js'; import { IMeteredConnectionService } from '../../../platform/meteredConnection/common/meteredConnection.js'; import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js'; +import { IPlaywrightService } from '../../../platform/browserView/common/playwrightService.js'; +import { PlaywrightService } from '../../../platform/browserView/node/playwrightService.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -401,6 +403,9 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Web Content Extractor services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService)); + // Playwright + services.set(IPlaywrightService, new SyncDescriptor(PlaywrightService)); + return new InstantiationService(services); } @@ -467,6 +472,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Web Content Extractor const webContentExtractorChannel = ProxyChannel.fromService(accessor.get(ISharedWebContentExtractorService), this._store); this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel); + + // Playwright + const playwrightChannel = ProxyChannel.fromService(accessor.get(IPlaywrightService), this._store); + this.server.registerChannel('playwright', playwrightChannel); } private registerErrorHandler(logService: ILogService): void { diff --git a/src/vs/editor/browser/view/viewController.ts b/src/vs/editor/browser/view/viewController.ts index 9f94a1b4f824e..5acc299c8ef3a 100644 --- a/src/vs/editor/browser/view/viewController.ts +++ b/src/vs/editor/browser/view/viewController.ts @@ -175,16 +175,22 @@ export class ViewController { } } - // Check if current token is a string. + // Expand to the contiguous run of string tokens (StandardTokenType.String) around the click position. const lineTokens = tokens.getLineTokens(lineNumber); - const index = lineTokens.findTokenIndexAtOffset(column - 1); - if (lineTokens.getStandardTokenType(index) !== StandardTokenType.String) { - return undefined; + let startIndex = lineTokens.findTokenIndexAtOffset(column - 1); + let endIndex = startIndex; + while (startIndex > 0 && + lineTokens.getStandardTokenType(startIndex - 1) === StandardTokenType.String) { + startIndex--; + } + while (endIndex + 1 < lineTokens.getCount() && + lineTokens.getStandardTokenType(endIndex + 1) === StandardTokenType.String) { + endIndex++; } // Verify the click is after starting or before closing quote. - const tokenStart = lineTokens.getStartOffset(index); - const tokenEnd = lineTokens.getEndOffset(index); + const tokenStart = lineTokens.getStartOffset(startIndex); + const tokenEnd = lineTokens.getEndOffset(endIndex); if (column !== tokenStart + 2 && column !== tokenEnd) { return undefined; } @@ -258,9 +264,12 @@ export class ViewController { if (data.inSelectionMode) { this._wordSelectDrag(data.position, data.revealType); } else { - const model = this.viewModel.model; - const modelPos = this._convertViewToModelPosition(data.position); - const selection = ViewController._trySelectBracketContent(model, modelPos) || ViewController._trySelectStringContent(model, modelPos); + let selection: Selection | undefined; + if (options.get(EditorOption.doubleClickSelectsBlock)) { + const model = this.viewModel.model; + const modelPos = this._convertViewToModelPosition(data.position); + selection = ViewController._trySelectBracketContent(model, modelPos) || ViewController._trySelectStringContent(model, modelPos); + } if (selection) { this._select(selection); } else { diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index acc23078daea8..34cdd09c350c4 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -566,6 +566,11 @@ export interface IEditorOptions { * Defaults to false. */ formatOnPaste?: boolean; + /** + * Controls whether double-clicking next to a bracket or quote selects the content inside. + * Defaults to true. + */ + doubleClickSelectsBlock?: boolean; /** * Controls if the editor should allow to move selections via drag and drop. * Defaults to false. @@ -5915,7 +5920,8 @@ export const enum EditorOption { inlineCompletionsAccessibilityVerbose, effectiveEditContext, scrollOnMiddleClick, - effectiveAllowVariableFonts + effectiveAllowVariableFonts, + doubleClickSelectsBlock } export const EditorOptions = { @@ -6208,6 +6214,10 @@ export const EditorOptions = { domReadOnly: register(new EditorBooleanOption( EditorOption.domReadOnly, 'domReadOnly', false, )), + doubleClickSelectsBlock: register(new EditorBooleanOption( + EditorOption.doubleClickSelectsBlock, 'doubleClickSelectsBlock', true, + { description: nls.localize('doubleClickSelectsBlock', "Controls whether double-clicking next to a bracket or quote selects the content inside.") } + )), dragAndDrop: register(new EditorBooleanOption( EditorOption.dragAndDrop, 'dragAndDrop', true, { description: nls.localize('dragAndDrop', "Controls whether the editor should allow moving selections via drag and drop.") } diff --git a/src/vs/editor/common/languageFeatureRegistry.ts b/src/vs/editor/common/languageFeatureRegistry.ts index d76b0906419f4..c0a0c07d2f6a9 100644 --- a/src/vs/editor/common/languageFeatureRegistry.ts +++ b/src/vs/editor/common/languageFeatureRegistry.ts @@ -6,7 +6,7 @@ import { Emitter } from '../../base/common/event.js'; import { IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { ITextModel, shouldSynchronizeModel } from './model.js'; -import { LanguageFilter, LanguageSelector, score } from './languageSelector.js'; +import { LanguageFilter, LanguageSelector, score, selectLanguageIds } from './languageSelector.js'; import { URI } from '../../base/common/uri.js'; interface Entry { @@ -115,6 +115,14 @@ export class LanguageFeatureRegistry { return this._entries.map(entry => entry.provider); } + get registeredLanguageIds(): ReadonlySet { + const result = new Set(); + for (const entry of this._entries) { + selectLanguageIds(entry.selector, result); + } + return result; + } + ordered(model: ITextModel, recursive = false): T[] { const result: T[] = []; this._orderedForEach(model, recursive, entry => result.push(entry.provider)); @@ -226,4 +234,3 @@ function isBuiltinSelector(selector: LanguageSelector): boolean { return Boolean((selector as LanguageFilter).isBuiltin); } - diff --git a/src/vs/editor/common/languageSelector.ts b/src/vs/editor/common/languageSelector.ts index 6374d380f48e5..80ffb5450d19d 100644 --- a/src/vs/editor/common/languageSelector.ts +++ b/src/vs/editor/common/languageSelector.ts @@ -142,3 +142,18 @@ export function targetsNotebooks(selector: LanguageSelector): boolean { return !!(selector).notebookType; } } + +export function selectLanguageIds(selector: LanguageSelector, into: Set): void { + if (typeof selector === 'string') { + into.add(selector); + } else if (Array.isArray(selector)) { + for (const item of selector) { + selectLanguageIds(item, into); + } + } else { + const language = (selector).language; + if (language) { + into.add(language); + } + } +} diff --git a/src/vs/editor/common/standalone/standaloneEnums.ts b/src/vs/editor/common/standalone/standaloneEnums.ts index 1caf3ced18fa7..cb0db8b268d0e 100644 --- a/src/vs/editor/common/standalone/standaloneEnums.ts +++ b/src/vs/editor/common/standalone/standaloneEnums.ts @@ -346,7 +346,8 @@ export enum EditorOption { inlineCompletionsAccessibilityVerbose = 169, effectiveEditContext = 170, scrollOnMiddleClick = 171, - effectiveAllowVariableFonts = 172 + effectiveAllowVariableFonts = 172, + doubleClickSelectsBlock = 173 } /** diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts b/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts index cce59aaa131ce..3019704a6287d 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts @@ -82,7 +82,7 @@ export class QuickFixAction extends EditorAction2 { }, menu: { id: MenuId.InlineChatEditorAffordance, - group: '0_quickfix', + group: '1_quickfix', order: 0, when: ContextKeyExpr.and(EditorContextKeys.writable, EditorContextKeys.hasCodeActionsProvider) } diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionController.ts b/src/vs/editor/contrib/codeAction/browser/codeActionController.ts index 5124acc51a1fe..6463d89e4c92b 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionController.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionController.ts @@ -13,7 +13,8 @@ import { onUnexpectedError } from '../../../../base/common/errors.js'; import { HierarchicalKind } from '../../../../base/common/hierarchicalKind.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { derived, IObservable } from '../../../../base/common/observable.js'; +import { derivedOpts, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { Event } from '../../../../base/common/event.js'; import { localize } from '../../../../nls.js'; import { IActionListDelegate } from '../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; @@ -35,11 +36,12 @@ import { ModelDecorationOptions } from '../../../common/model/textModel.js'; import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js'; import { MessageController } from '../../message/browser/messageController.js'; import { CodeActionAutoApply, CodeActionFilter, CodeActionItem, CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSource } from '../common/types.js'; -import { ApplyCodeActionReason, applyCodeAction } from './codeAction.js'; +import { ApplyCodeActionReason, applyCodeAction, autoFixCommandId, quickFixCommandId } from './codeAction.js'; import { CodeActionKeybindingResolver } from './codeActionKeybindingResolver.js'; import { toMenuItems } from './codeActionMenu.js'; import { CodeActionModel, CodeActionsState } from './codeActionModel.js'; -import { LightBulbInfo, LightBulbWidget } from './lightBulbWidget.js'; +import { computeLightBulbInfo, LightBulbInfo, LightBulbWidget } from './lightBulbWidget.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; interface IActionShowOptions { readonly includeDisabledActions?: boolean; @@ -68,12 +70,34 @@ export class CodeActionController extends Disposable implements IEditorContribut private _disposed = false; - public readonly lightBulbState: IObservable = derived(this, reader => { + set onlyLightBulbWithEmptySelection(value: boolean) { const widget = this._lightBulbWidget.rawValue; - if (!widget) { - return undefined; + if (widget) { + widget.onlyWithEmptySelection = value; } - return widget.lightBulbInfo.read(reader); + this._onlyLightBulbWithEmptySelection = value; + } + + private _onlyLightBulbWithEmptySelection = false; + + private readonly _lightBulbInfoObs = observableValue(this, undefined); + private readonly _preferredKbLabel = observableValue(this, undefined); + private readonly _quickFixKbLabel = observableValue(this, undefined); + + private _hasLightBulbStateObservers = false; + + public readonly lightBulbState: IObservable = derivedOpts({ + owner: this, + onLastObserverRemoved: () => { + this._hasLightBulbStateObservers = false; + this._model.ignoreLightbulbOff = false; + }, + }, reader => { + if (!this._hasLightBulbStateObservers) { + this._hasLightBulbStateObservers = true; + this._model.ignoreLightbulbOff = true; + } + return this._lightBulbInfoObs.read(reader); }); constructor( @@ -88,6 +112,7 @@ export class CodeActionController extends Disposable implements IEditorContribut @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IEditorProgressService private readonly _progressService: IEditorProgressService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, ) { super(); @@ -95,10 +120,16 @@ export class CodeActionController extends Disposable implements IEditorContribut this._model = this._register(new CodeActionModel(this._editor, languageFeaturesService.codeActionProvider, markerService, contextKeyService, progressService, _configurationService)); this._register(this._model.onDidChangeState(newState => this.update(newState))); + this._register(Event.runAndSubscribe(this._keybindingService.onDidUpdateKeybindings, () => { + this._preferredKbLabel.set(this._keybindingService.lookupKeybinding(autoFixCommandId)?.getLabel() ?? undefined, undefined); + this._quickFixKbLabel.set(this._keybindingService.lookupKeybinding(quickFixCommandId)?.getLabel() ?? undefined, undefined); + })); + this._lightBulbWidget = new Lazy(() => { const widget = this._editor.getContribution(LightBulbWidget.ID); if (widget) { this._register(widget.onClick(e => this.showCodeActionsFromLightbulb(e.actions, e))); + widget.onlyWithEmptySelection = this._onlyLightBulbWithEmptySelection; } return widget; }); @@ -175,6 +206,7 @@ export class CodeActionController extends Disposable implements IEditorContribut private async update(newState: CodeActionsState.State): Promise { if (newState.type !== CodeActionsState.Type.Triggered) { this.hideLightBulbWidget(); + this._lightBulbInfoObs.set(undefined, undefined); return; } @@ -197,6 +229,7 @@ export class CodeActionController extends Disposable implements IEditorContribut } this._lightBulbWidget.value?.update(actions, newState.trigger, newState.position); + this._lightBulbInfoObs.set(computeLightBulbInfo(actions, newState.trigger, this._preferredKbLabel.get(), this._quickFixKbLabel.get()), undefined); if (newState.trigger.type === CodeActionTriggerType.Invoke) { if (newState.trigger.filter?.include) { // Triggered for specific scope diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts b/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts index c05cb6744f465..454e9892722ae 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts @@ -36,6 +36,8 @@ class CodeActionOracle extends Disposable { private readonly _autoTriggerTimer = this._register(new TimeoutTimer()); + ignoreLightbulbOff = false; + constructor( private readonly _editor: ICodeEditor, private readonly _markerService: IMarkerService, @@ -74,9 +76,9 @@ class CodeActionOracle extends Disposable { return selection; } const enabled = this._editor.getOption(EditorOption.lightbulb).enabled; - if (enabled === ShowLightbulbIconMode.Off) { + if (enabled === ShowLightbulbIconMode.Off && !this.ignoreLightbulbOff) { return undefined; - } else if (enabled === ShowLightbulbIconMode.On) { + } else if (enabled === ShowLightbulbIconMode.Off || enabled === ShowLightbulbIconMode.On) { return selection; } else if (enabled === ShowLightbulbIconMode.OnCode) { const isSelectionEmpty = selection.isEmpty(); @@ -167,6 +169,22 @@ export class CodeActionModel extends Disposable { private _disposed = false; + private _ignoreLightbulbOff = false; + + set ignoreLightbulbOff(value: boolean) { + if (this._ignoreLightbulbOff === value) { + return; + } + this._ignoreLightbulbOff = value; + const oracle = this._codeActionOracle.value; + if (oracle) { + oracle.ignoreLightbulbOff = value; + if (value) { + oracle.trigger({ type: CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default }); + } + } + } + constructor( private readonly _editor: ICodeEditor, private readonly _registry: LanguageFeatureRegistry, @@ -221,7 +239,7 @@ export class CodeActionModel extends Disposable { const supportedActions: string[] = this._registry.all(model).flatMap(provider => provider.providedCodeActionKinds ?? []); this._supportedCodeActions.set(supportedActions.join(' ')); - this._codeActionOracle.value = new CodeActionOracle(this._editor, this._markerService, trigger => { + const oracle = new CodeActionOracle(this._editor, this._markerService, trigger => { if (!trigger) { this.setState(CodeActionsState.Empty); return; @@ -363,6 +381,8 @@ export class CodeActionModel extends Disposable { }, 500); } }, undefined); + oracle.ignoreLightbulbOff = this._ignoreLightbulbOff; + this._codeActionOracle.value = oracle; this._codeActionOracle.value.trigger({ type: CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default }); } else { this._supportedCodeActions.reset(); diff --git a/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts b/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts index 3de0481ef730a..67fd18ea9cec6 100644 --- a/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts +++ b/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts @@ -12,7 +12,7 @@ import { autorun, derived, IObservable, observableValue } from '../../../../base import { ThemeIcon } from '../../../../base/common/themables.js'; import './lightBulbWidget.css'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition, IEditorMouseEvent } from '../../../browser/editorBrowser.js'; -import { EditorOption } from '../../../common/config/editorOptions.js'; +import { EditorOption, ShowLightbulbIconMode } from '../../../common/config/editorOptions.js'; import { IPosition } from '../../../common/core/position.js'; import { GlyphMarginLane, IModelDecorationsChangeAccessor, TrackedRangeStickiness } from '../../../common/model.js'; import { ModelDecorationOptions } from '../../../common/model/textModel.js'; @@ -62,9 +62,49 @@ namespace LightBulbState { export type State = typeof Hidden | Showing; } +export function computeLightBulbInfo(actions: CodeActionSet, trigger: CodeActionTrigger, preferredKbLabel: string | undefined, quickFixKbLabel: string | undefined, forGutter: boolean = false): LightBulbInfo | undefined { + if (actions.validActions.length <= 0) { + return undefined; + } + + let icon: ThemeIcon; + let autoRun = false; + if (actions.allAIFixes) { + icon = forGutter ? GUTTER_SPARKLE_FILLED_ICON : Codicon.sparkleFilled; + if (actions.validActions.length === 1) { + autoRun = true; + } + } else if (actions.hasAutoFix) { + if (actions.hasAIFix) { + icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON : Codicon.lightbulbSparkleAutofix; + } else { + icon = forGutter ? GUTTER_LIGHTBULB_AUTO_FIX_ICON : Codicon.lightbulbAutofix; + } + } else if (actions.hasAIFix) { + icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_ICON : Codicon.lightbulbSparkle; + } else { + icon = forGutter ? GUTTER_LIGHTBULB_ICON : Codicon.lightBulb; + } + + let title: string; + if (autoRun) { + title = nls.localize('codeActionAutoRun', "Run: {0}", actions.validActions[0].action.title); + } else if (actions.hasAutoFix && preferredKbLabel) { + title = nls.localize('preferredcodeActionWithKb', "Show Code Actions. Preferred Quick Fix Available ({0})", preferredKbLabel); + } else if (!actions.hasAutoFix && quickFixKbLabel) { + title = nls.localize('codeActionWithKb', "Show Code Actions ({0})", quickFixKbLabel); + } else { + title = nls.localize('codeAction', "Show Code Actions"); + } + + return { actions, trigger, icon, autoRun, title, isGutter: forGutter }; +} + export class LightBulbWidget extends Disposable implements IContentWidget { private _gutterDecorationID: string | undefined; + onlyWithEmptySelection = false; + private static readonly GUTTER_DECORATION = ModelDecorationOptions.register({ description: 'codicon-gutter-lightbulb-decoration', glyphMarginClassName: ThemeIcon.asClassName(Codicon.lightBulb), @@ -117,39 +157,7 @@ export class LightBulbWidget extends Disposable implements IContentWidget { if (state.type !== LightBulbState.Type.Showing) { return undefined; } - - const { actions, trigger } = state; - let icon: ThemeIcon; - let autoRun = false; - if (actions.allAIFixes) { - icon = forGutter ? GUTTER_SPARKLE_FILLED_ICON : Codicon.sparkleFilled; - if (actions.validActions.length === 1) { - autoRun = true; - } - } else if (actions.hasAutoFix) { - if (actions.hasAIFix) { - icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON : Codicon.lightbulbSparkleAutofix; - } else { - icon = forGutter ? GUTTER_LIGHTBULB_AUTO_FIX_ICON : Codicon.lightbulbAutofix; - } - } else if (actions.hasAIFix) { - icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_ICON : Codicon.lightbulbSparkle; - } else { - icon = forGutter ? GUTTER_LIGHTBULB_ICON : Codicon.lightBulb; - } - - let title: string; - if (autoRun) { - title = nls.localize('codeActionAutoRun', "Run: {0}", actions.validActions[0].action.title); - } else if (actions.hasAutoFix && preferredKbLabel) { - title = nls.localize('preferredcodeActionWithKb', "Show Code Actions. Preferred Quick Fix Available ({0})", preferredKbLabel); - } else if (!actions.hasAutoFix && quickFixKbLabel) { - title = nls.localize('codeActionWithKb', "Show Code Actions ({0})", quickFixKbLabel); - } else { - title = nls.localize('codeAction', "Show Code Actions"); - } - - return { actions, trigger, icon, autoRun, title, isGutter: forGutter }; + return computeLightBulbInfo(state.actions, state.trigger, preferredKbLabel, quickFixKbLabel, forGutter); } constructor( @@ -288,6 +296,11 @@ export class LightBulbWidget extends Disposable implements IContentWidget { return this.hide(); } + if (this.onlyWithEmptySelection && !this._editor.getSelection()?.isEmpty()) { + this.gutterHide(); + return this.hide(); + } + const hasTextFocus = this._editor.hasTextFocus(); if (!hasTextFocus) { this.gutterHide(); @@ -295,7 +308,7 @@ export class LightBulbWidget extends Disposable implements IContentWidget { } const options = this._editor.getOptions(); - if (!options.get(EditorOption.lightbulb).enabled) { + if (options.get(EditorOption.lightbulb).enabled === ShowLightbulbIconMode.Off) { this.gutterHide(); return this.hide(); } diff --git a/src/vs/editor/contrib/colorPicker/browser/colorPickerModel.ts b/src/vs/editor/contrib/colorPicker/browser/colorPickerModel.ts index 06a0c278e273c..c3de9973b97dc 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorPickerModel.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorPickerModel.ts @@ -5,9 +5,10 @@ import { Color } from '../../../../base/common/color.js'; import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { IColorPresentation } from '../../../common/languages.js'; -export class ColorPickerModel { +export class ColorPickerModel extends Disposable { readonly originalColor: Color; private _color: Color; @@ -41,16 +42,17 @@ export class ColorPickerModel { this._onDidChangePresentation.fire(this.presentation); } - private readonly _onColorFlushed = new Emitter(); + private readonly _onColorFlushed = this._register(new Emitter()); readonly onColorFlushed: Event = this._onColorFlushed.event; - private readonly _onDidChangeColor = new Emitter(); + private readonly _onDidChangeColor = this._register(new Emitter()); readonly onDidChangeColor: Event = this._onDidChangeColor.event; - private readonly _onDidChangePresentation = new Emitter(); + private readonly _onDidChangePresentation = this._register(new Emitter()); readonly onDidChangePresentation: Event = this._onDidChangePresentation.event; constructor(color: Color, availableColorPresentations: IColorPresentation[], private presentationIndex: number) { + super(); this.originalColor = color; this._color = color; this._colorPresentations = availableColorPresentations; diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 6892e304e968d..f4b3115a52ad7 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -279,6 +279,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( getDefaultAccountAuthenticationProvider: () => { return { id: 'mockProvider', name: 'Mock Provider', enterprise: false }; }, refresh: async () => { return null; }, signIn: async () => { return null; }, + signOut: async () => { }, }); options.serviceCollection.set(IRenameSymbolTrackerService, new NullRenameSymbolTrackerService()); diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 90c8c11a1f8c3..0824dfbb53337 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -1139,6 +1139,10 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { async signIn(): Promise { return null; } + + async signOut(): Promise { + // no-op + } } export interface IEditorOverrideServices { diff --git a/src/vs/editor/test/browser/view/viewController.test.ts b/src/vs/editor/test/browser/view/viewController.test.ts index 95d44801cb795..1a7f4faf2e03b 100644 --- a/src/vs/editor/test/browser/view/viewController.test.ts +++ b/src/vs/editor/test/browser/view/viewController.test.ts @@ -309,6 +309,23 @@ suite('ViewController - String content selection', () => { assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); }); + test('Select string content containing escape characters', () => { + // 0123456789... + const text = 'var x = "hello\\"world";'; + // Token layout: [0..8) Other [8..22) String("hello\"world") [22..23) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 9, type: StandardTokenType.String }, + { startIndex: 14, type: StandardTokenType.String }, + { startIndex: 16, type: StandardTokenType.String }, + { startIndex: 21, type: StandardTokenType.String }, + { startIndex: 22, type: StandardTokenType.Other }, + ]); + // Column right after opening quote: offset 9 → column 10 + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello\\"world'); + }); + // -- Click in middle of string should NOT select the whole string -- test('Click in middle of string does not select whole string', () => { diff --git a/src/vs/editor/test/common/modes/languageSelector.test.ts b/src/vs/editor/test/common/modes/languageSelector.test.ts index 31f6c051af466..be9c597f98f9d 100644 --- a/src/vs/editor/test/common/modes/languageSelector.test.ts +++ b/src/vs/editor/test/common/modes/languageSelector.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { LanguageSelector, score } from '../../../common/languageSelector.js'; +import { LanguageSelector, score, selectLanguageIds } from '../../../common/languageSelector.js'; suite('LanguageSelector', function () { @@ -173,4 +173,27 @@ suite('LanguageSelector', function () { }, obj.uri, obj.langId, true, undefined, undefined); assert.strictEqual(value, 0); }); + + test('selectLanguageIds', function () { + const result = new Set(); + + selectLanguageIds('typescript', result); + assert.deepStrictEqual([...result], ['typescript']); + + result.clear(); + selectLanguageIds({ language: 'python', scheme: 'file' }, result); + assert.deepStrictEqual([...result], ['python']); + + result.clear(); + selectLanguageIds({ scheme: 'file' }, result); + assert.deepStrictEqual([...result], []); + + result.clear(); + selectLanguageIds(['javascript', { language: 'css' }, { scheme: 'untitled' }], result); + assert.deepStrictEqual([...result].sort(), ['css', 'javascript']); + + result.clear(); + selectLanguageIds('*', result); + assert.deepStrictEqual([...result], ['*']); + }); }); diff --git a/src/vs/editor/test/common/viewLayout/lineHeights.test.ts b/src/vs/editor/test/common/viewLayout/lineHeights.test.ts index c94726913baa3..113083e038a4f 100644 --- a/src/vs/editor/test/common/viewLayout/lineHeights.test.ts +++ b/src/vs/editor/test/common/viewLayout/lineHeights.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { LineHeightsManager } from '../../../common/viewLayout/lineHeights.js'; +import { CustomLineHeightData, LineHeightsManager } from '../../../common/viewLayout/lineHeights.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; suite('Editor ViewLayout - LineHeightsManager', () => { @@ -276,4 +276,26 @@ suite('Editor ViewLayout - LineHeightsManager', () => { assert.strictEqual(manager.heightForLineNumber(5), 40); assert.strictEqual(manager.heightForLineNumber(6), 30); }); + + test('onLinesInserted with same decoration ID extending to inserted line', () => { + const manager = new LineHeightsManager(10, []); + // Set up a special line at line 1 with decoration 'decA' + manager.insertOrChangeCustomLineHeight('decA', 1, 1, 30); + manager.commit(); + + assert.strictEqual(manager.heightForLineNumber(1), 30); + assert.strictEqual(manager.heightForLineNumber(2), 10); + + // Insert line 2 to line 2, with the same decoration ID 'decA' covering line 2 + manager.onLinesInserted(2, 2, [ + new CustomLineHeightData('decA', 2, 2, 30) + ]); + + // After insertion, the decoration 'decA' now covers line 2 + // Since insertOrChangeCustomLineHeight removes the old decoration first, + // line 1 no longer has the custom height, and line 2 gets it + assert.strictEqual(manager.heightForLineNumber(1), 10); + assert.strictEqual(manager.heightForLineNumber(2), 30); + assert.strictEqual(manager.heightForLineNumber(3), 10); + }); }); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 80497d1a193b3..ea558fdbd7c01 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -3721,6 +3721,11 @@ declare namespace monaco.editor { * Defaults to false. */ formatOnPaste?: boolean; + /** + * Controls whether double-clicking next to a bracket or quote selects the content inside. + * Defaults to true. + */ + doubleClickSelectsBlock?: boolean; /** * Controls if the editor should allow to move selections via drag and drop. * Defaults to false. @@ -5242,7 +5247,8 @@ declare namespace monaco.editor { inlineCompletionsAccessibilityVerbose = 169, effectiveEditContext = 170, scrollOnMiddleClick = 171, - effectiveAllowVariableFonts = 172 + effectiveAllowVariableFonts = 172, + doubleClickSelectsBlock = 173 } export const EditorOptions: { @@ -5291,6 +5297,7 @@ declare namespace monaco.editor { disableLayerHinting: IEditorOption; disableMonospaceOptimizations: IEditorOption; domReadOnly: IEditorOption; + doubleClickSelectsBlock: IEditorOption; dragAndDrop: IEditorOption; emptySelectionClipboard: IEditorOption; dropIntoEditor: IEditorOption>>; diff --git a/src/vs/platform/accessibility/browser/accessibilityService.ts b/src/vs/platform/accessibility/browser/accessibilityService.ts index d6db2a65b33da..35f6639e2697f 100644 --- a/src/vs/platform/accessibility/browser/accessibilityService.ts +++ b/src/vs/platform/accessibility/browser/accessibilityService.ts @@ -24,6 +24,10 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe protected _systemMotionReduced: boolean; protected readonly _onDidChangeReducedMotion = this._register(new Emitter()); + protected _configTransparencyReduced: 'auto' | 'on' | 'off'; + protected _systemTransparencyReduced: boolean; + protected readonly _onDidChangeReducedTransparency = this._register(new Emitter()); + private _linkUnderlinesEnabled: boolean; protected readonly _onDidChangeLinkUnderline = this._register(new Emitter()); @@ -45,6 +49,10 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe this._configMotionReduced = this._configurationService.getValue('workbench.reduceMotion'); this._onDidChangeReducedMotion.fire(); } + if (e.affectsConfiguration('workbench.reduceTransparency')) { + this._configTransparencyReduced = this._configurationService.getValue('workbench.reduceTransparency'); + this._onDidChangeReducedTransparency.fire(); + } })); updateContextKey(); this._register(this.onDidChangeScreenReaderOptimized(() => updateContextKey())); @@ -53,9 +61,14 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe this._systemMotionReduced = reduceMotionMatcher.matches; this._configMotionReduced = this._configurationService.getValue<'auto' | 'on' | 'off'>('workbench.reduceMotion'); + const reduceTransparencyMatcher = mainWindow.matchMedia(`(prefers-reduced-transparency: reduce)`); + this._systemTransparencyReduced = reduceTransparencyMatcher.matches; + this._configTransparencyReduced = this._configurationService.getValue<'auto' | 'on' | 'off'>('workbench.reduceTransparency'); + this._linkUnderlinesEnabled = this._configurationService.getValue('accessibility.underlineLinks'); this.initReducedMotionListeners(reduceMotionMatcher); + this.initReducedTransparencyListeners(reduceTransparencyMatcher); this.initLinkUnderlineListeners(); } @@ -78,6 +91,24 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe this._register(this.onDidChangeReducedMotion(() => updateRootClasses())); } + private initReducedTransparencyListeners(reduceTransparencyMatcher: MediaQueryList) { + + this._register(addDisposableListener(reduceTransparencyMatcher, 'change', () => { + this._systemTransparencyReduced = reduceTransparencyMatcher.matches; + if (this._configTransparencyReduced === 'auto') { + this._onDidChangeReducedTransparency.fire(); + } + })); + + const updateRootClasses = () => { + const reduce = this.isTransparencyReduced(); + this._layoutService.mainContainer.classList.toggle('monaco-reduce-transparency', reduce); + }; + + updateRootClasses(); + this._register(this.onDidChangeReducedTransparency(() => updateRootClasses())); + } + private initLinkUnderlineListeners() { this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('accessibility.underlineLinks')) { @@ -119,6 +150,15 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe return config === 'on' || (config === 'auto' && this._systemMotionReduced); } + get onDidChangeReducedTransparency(): Event { + return this._onDidChangeReducedTransparency.event; + } + + isTransparencyReduced(): boolean { + const config = this._configTransparencyReduced; + return config === 'on' || (config === 'auto' && this._systemTransparencyReduced); + } + alwaysUnderlineAccessKeys(): Promise { return Promise.resolve(false); } diff --git a/src/vs/platform/accessibility/common/accessibility.ts b/src/vs/platform/accessibility/common/accessibility.ts index 1757eb84e024b..741d5fffc5b34 100644 --- a/src/vs/platform/accessibility/common/accessibility.ts +++ b/src/vs/platform/accessibility/common/accessibility.ts @@ -14,10 +14,12 @@ export interface IAccessibilityService { readonly onDidChangeScreenReaderOptimized: Event; readonly onDidChangeReducedMotion: Event; + readonly onDidChangeReducedTransparency: Event; alwaysUnderlineAccessKeys(): Promise; isScreenReaderOptimized(): boolean; isMotionReduced(): boolean; + isTransparencyReduced(): boolean; getAccessibilitySupport(): AccessibilitySupport; setAccessibilitySupport(accessibilitySupport: AccessibilitySupport): void; alert(message: string): void; diff --git a/src/vs/platform/accessibility/test/browser/accessibilityService.test.ts b/src/vs/platform/accessibility/test/browser/accessibilityService.test.ts new file mode 100644 index 0000000000000..869869d10508f --- /dev/null +++ b/src/vs/platform/accessibility/test/browser/accessibilityService.test.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { IConfigurationService, IConfigurationChangeEvent } from '../../../configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { IContextKeyService } from '../../../contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../keybinding/test/common/mockKeybindingService.js'; +import { ILayoutService } from '../../../layout/browser/layoutService.js'; +import { AccessibilityService } from '../../browser/accessibilityService.js'; + +suite('AccessibilityService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let configurationService: TestConfigurationService; + let container: HTMLElement; + + function createService(config: Record = {}): AccessibilityService { + const instantiationService = store.add(new TestInstantiationService()); + + configurationService = new TestConfigurationService({ + 'editor.accessibilitySupport': 'off', + 'workbench.reduceMotion': 'off', + 'workbench.reduceTransparency': 'off', + 'accessibility.underlineLinks': false, + ...config, + }); + instantiationService.stub(IConfigurationService, configurationService); + + instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); + + container = document.createElement('div'); + instantiationService.stub(ILayoutService, { + mainContainer: container, + activeContainer: container, + getContainer() { return container; }, + onDidLayoutContainer: Event.None, + }); + + return store.add(instantiationService.createInstance(AccessibilityService)); + } + + suite('isTransparencyReduced', () => { + + test('returns false when config is off', () => { + const service = createService({ 'workbench.reduceTransparency': 'off' }); + assert.strictEqual(service.isTransparencyReduced(), false); + }); + + test('returns true when config is on', () => { + const service = createService({ 'workbench.reduceTransparency': 'on' }); + assert.strictEqual(service.isTransparencyReduced(), true); + }); + + test('adds CSS class when config is on', () => { + createService({ 'workbench.reduceTransparency': 'on' }); + assert.strictEqual(container.classList.contains('monaco-reduce-transparency'), true); + }); + + test('does not add CSS class when config is off', () => { + createService({ 'workbench.reduceTransparency': 'off' }); + assert.strictEqual(container.classList.contains('monaco-reduce-transparency'), false); + }); + + test('fires event and updates class on config change', () => { + const service = createService({ 'workbench.reduceTransparency': 'off' }); + assert.strictEqual(service.isTransparencyReduced(), false); + + let fired = false; + store.add(service.onDidChangeReducedTransparency(() => { fired = true; })); + + // Simulate config change + configurationService.setUserConfiguration('workbench.reduceTransparency', 'on'); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration(id: string) { return id === 'workbench.reduceTransparency'; }, + } satisfies Partial as unknown as IConfigurationChangeEvent); + + assert.strictEqual(fired, true); + assert.strictEqual(service.isTransparencyReduced(), true); + assert.strictEqual(container.classList.contains('monaco-reduce-transparency'), true); + }); + }); + + suite('isMotionReduced', () => { + + test('returns false when config is off', () => { + const service = createService({ 'workbench.reduceMotion': 'off' }); + assert.strictEqual(service.isMotionReduced(), false); + }); + + test('returns true when config is on', () => { + const service = createService({ 'workbench.reduceMotion': 'on' }); + assert.strictEqual(service.isMotionReduced(), true); + }); + + test('adds CSS classes when config is on', () => { + createService({ 'workbench.reduceMotion': 'on' }); + assert.strictEqual(container.classList.contains('monaco-reduce-motion'), true); + assert.strictEqual(container.classList.contains('monaco-enable-motion'), false); + }); + + test('adds CSS classes when config is off', () => { + createService({ 'workbench.reduceMotion': 'off' }); + assert.strictEqual(container.classList.contains('monaco-reduce-motion'), false); + assert.strictEqual(container.classList.contains('monaco-enable-motion'), true); + }); + }); +}); diff --git a/src/vs/platform/accessibility/test/common/testAccessibilityService.ts b/src/vs/platform/accessibility/test/common/testAccessibilityService.ts index 4f21111492ebe..6ef551ba9f21b 100644 --- a/src/vs/platform/accessibility/test/common/testAccessibilityService.ts +++ b/src/vs/platform/accessibility/test/common/testAccessibilityService.ts @@ -12,9 +12,11 @@ export class TestAccessibilityService implements IAccessibilityService { onDidChangeScreenReaderOptimized = Event.None; onDidChangeReducedMotion = Event.None; + onDidChangeReducedTransparency = Event.None; isScreenReaderOptimized(): boolean { return false; } isMotionReduced(): boolean { return true; } + isTransparencyReduced(): boolean { return false; } alwaysUnderlineAccessKeys(): Promise { return Promise.resolve(false); } setAccessibilitySupport(accessibilitySupport: AccessibilitySupport): void { } getAccessibilitySupport(): AccessibilitySupport { return AccessibilitySupport.Unknown; } diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 63e7b67818a8f..554a7cde357ea 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { Button } from '../../../base/browser/ui/button/button.js'; import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js'; @@ -18,7 +19,7 @@ import './actionWidget.css'; import { localize } from '../../../nls.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; import { IKeybindingService } from '../../keybinding/common/keybinding.js'; -import { defaultListStyles } from '../../theme/browser/defaultStyles.js'; +import { defaultButtonStyles, defaultListStyles } from '../../theme/browser/defaultStyles.js'; import { asCssVariable } from '../../theme/common/colorRegistry.js'; import { ILayoutService } from '../../layout/browser/layoutService.js'; import { IHoverService } from '../../hover/browser/hover.js'; @@ -67,16 +68,42 @@ export interface IActionListItem { * Optional toolbar actions shown when the item is focused or hovered. */ readonly toolbarActions?: IAction[]; + /** + * Optional section identifier. Items with the same section belong to the same + * collapsible group. Only meaningful when the ActionList is created with + * collapsible sections. + */ + readonly section?: string; + /** + * When true, clicking this item toggles the section's collapsed state + * instead of selecting it. + */ + readonly isSectionToggle?: boolean; + /** + * Optional CSS class name to add to the row container. + */ + readonly className?: string; + /** + * Optional badge text to display after the label (e.g., "New"). + */ + readonly badge?: string; + /** + * When set, the description is rendered as a primary button. + * The callback is invoked when the button is clicked. + */ + readonly descriptionButton?: { readonly label: string; readonly onDidClick: () => void }; } interface IActionMenuTemplateData { readonly container: HTMLElement; readonly icon: HTMLElement; readonly text: HTMLElement; + readonly badge: HTMLElement; readonly description?: HTMLElement; readonly keybinding: KeybindingLabel; readonly toolbar: HTMLElement; readonly elementDisposables: DisposableStore; + previousClassName?: string; } export const enum ActionListItemKind { @@ -159,6 +186,10 @@ class ActionItemRenderer implements IListRenderer, IAction text.className = 'title'; container.append(text); + const badge = document.createElement('span'); + badge.className = 'action-item-badge'; + container.append(badge); + const description = document.createElement('span'); description.className = 'description'; container.append(description); @@ -171,7 +202,7 @@ class ActionItemRenderer implements IListRenderer, IAction const elementDisposables = new DisposableStore(); - return { container, icon, text, description, keybinding, toolbar, elementDisposables }; + return { container, icon, text, badge, description, keybinding, toolbar, elementDisposables }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { @@ -194,10 +225,40 @@ class ActionItemRenderer implements IListRenderer, IAction dom.setVisibility(!element.hideIcon, data.icon); + // Apply optional className - clean up previous to avoid stale classes + // from virtualized row reuse + if (data.previousClassName) { + data.container.classList.remove(data.previousClassName); + } + data.container.classList.toggle('action-list-custom', !!element.className); + if (element.className) { + data.container.classList.add(element.className); + } + data.previousClassName = element.className; + data.text.textContent = stripNewlines(element.label); + // Render optional badge + if (element.badge) { + data.badge.textContent = element.badge; + data.badge.style.display = ''; + } else { + data.badge.textContent = ''; + data.badge.style.display = 'none'; + } + // if there is a keybinding, prioritize over description for now - if (element.keybinding) { + if (element.descriptionButton) { + data.description!.textContent = ''; + data.description!.style.display = 'inline'; + const button = new Button(data.description!, { ...defaultButtonStyles, small: true }); + button.label = element.descriptionButton.label; + data.elementDisposables.add(button.onDidClick(e => { + e?.stopPropagation(); + element.descriptionButton!.onDidClick(); + })); + data.elementDisposables.add(button); + } else if (element.keybinding) { data.description!.textContent = element.keybinding.getLabel(); data.description!.style.display = 'inline'; data.description!.style.letterSpacing = '0.5px'; @@ -261,14 +322,34 @@ function getKeyboardNavigationLabel(item: IActionListItem): string | undef return undefined; } +/** + * Options for configuring the action list. + */ +export interface IActionListOptions { + /** + * When true, shows a filter input at the bottom of the list. + */ + readonly showFilter?: boolean; + + /** + * Section IDs that should be collapsed by default. + */ + readonly collapsedByDefault?: ReadonlySet; + + /** + * Minimum width for the action list. + */ + readonly minWidth?: number; +} + export class ActionList extends Disposable { public readonly domNode: HTMLElement; private readonly _list: List>; - private readonly _actionLineHeight = 28; - private readonly _headerLineHeight = 28; + private readonly _actionLineHeight = 24; + private readonly _headerLineHeight = 24; private readonly _separatorLineHeight = 8; private readonly _allMenuItems: readonly IActionListItem[]; @@ -277,12 +358,20 @@ export class ActionList extends Disposable { private _hover = this._register(new MutableDisposable()); + private readonly _collapsedSections = new Set(); + private _filterText = ''; + private readonly _filterInput: HTMLInputElement | undefined; + private readonly _filterContainer: HTMLElement | undefined; + private _lastMinWidth = 0; + private _hasLaidOut = false; + constructor( user: string, preview: boolean, items: readonly IActionListItem[], private readonly _delegate: IActionListDelegate, accessibilityProvider: Partial>> | undefined, + private readonly _options: IActionListOptions | undefined, @IContextViewService private readonly _contextViewService: IContextViewService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @ILayoutService private readonly _layoutService: ILayoutService, @@ -291,6 +380,14 @@ export class ActionList extends Disposable { super(); this.domNode = document.createElement('div'); this.domNode.classList.add('actionList'); + + // Initialize collapsed sections + if (this._options?.collapsedByDefault) { + for (const section of this._options.collapsedByDefault) { + this._collapsedSections.add(section); + } + } + const virtualDelegate: IListVirtualDelegate> = { getHeight: element => { switch (element.kind) { @@ -312,7 +409,7 @@ export class ActionList extends Disposable { new SeparatorRenderer(), ], { keyboardSupport: false, - typeNavigationEnabled: true, + typeNavigationEnabled: !this._options?.showFilter, keyboardNavigationLabelProvider: { getKeyboardNavigationLabel }, accessibilityProvider: { getAriaLabel: element => { @@ -352,13 +449,151 @@ export class ActionList extends Disposable { this._register(this._list.onDidChangeSelection(e => this.onListSelection(e))); this._allMenuItems = items; - this._list.splice(0, this._list.length, this._allMenuItems); + + // Create filter input + if (this._options?.showFilter) { + this._filterContainer = document.createElement('div'); + this._filterContainer.className = 'action-list-filter'; + + this._filterInput = document.createElement('input'); + this._filterInput.type = 'text'; + this._filterInput.className = 'action-list-filter-input'; + this._filterInput.placeholder = localize('actionList.filter.placeholder', "Search..."); + this._filterInput.setAttribute('aria-label', localize('actionList.filter.ariaLabel', "Filter items")); + this._filterContainer.appendChild(this._filterInput); + + this._register(dom.addDisposableListener(this._filterInput, 'input', () => { + this._filterText = this._filterInput!.value; + this._applyFilter(); + })); + + // Keyboard navigation from filter input + this._register(dom.addDisposableListener(this._filterInput, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + this._list.domFocus(); + const lastIndex = this._list.length - 1; + if (lastIndex >= 0) { + this._list.focusLast(undefined, this.focusCondition); + } + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + this._list.domFocus(); + this.focusNext(); + } else if (e.key === 'Enter') { + e.preventDefault(); + this.acceptSelected(); + } else if (e.key === 'Escape') { + if (this._filterText) { + e.preventDefault(); + e.stopPropagation(); + this._filterInput!.value = ''; + this._filterText = ''; + this._applyFilter(); + } + } + })); + } + + this._applyFilter(); if (this._list.length) { this.focusNext(); } } + private _toggleSection(section: string): void { + if (this._collapsedSections.has(section)) { + this._collapsedSections.delete(section); + } else { + this._collapsedSections.add(section); + } + this._applyFilter(); + } + + private _applyFilter(): void { + const filterLower = this._filterText.toLowerCase(); + const isFiltering = filterLower.length > 0; + const visible: IActionListItem[] = []; + + for (const item of this._allMenuItems) { + if (item.kind === ActionListItemKind.Header) { + if (isFiltering) { + // When filtering, skip all headers + continue; + } + visible.push(item); + continue; + } + + if (item.kind === ActionListItemKind.Separator) { + if (isFiltering) { + continue; + } + visible.push(item); + continue; + } + + // Action item + if (isFiltering) { + // When filtering, skip section toggle items and only match content + if (item.isSectionToggle) { + continue; + } + // Match against label and description + const label = (item.label ?? '').toLowerCase(); + const desc = (item.description ?? '').toLowerCase(); + if (label.includes(filterLower) || desc.includes(filterLower)) { + visible.push(item); + } + } else { + // Update icon for section toggle items based on collapsed state + if (item.isSectionToggle && item.section) { + const collapsed = this._collapsedSections.has(item.section); + visible.push({ + ...item, + group: { ...item.group!, icon: collapsed ? Codicon.chevronRight : Codicon.chevronDown }, + }); + continue; + } + // Not filtering - check collapsed sections + if (item.section && this._collapsedSections.has(item.section)) { + continue; + } + visible.push(item); + } + } + + // Capture whether the filter input currently has focus before splice + // which may cause DOM changes that shift focus. + const filterInputHasFocus = this._filterInput && dom.isActiveElement(this._filterInput); + + this._list.splice(0, this._list.length, visible); + + // Re-layout to adjust height after items changed + if (this._hasLaidOut) { + this.layout(this._lastMinWidth); + // Restore focus after splice destroyed DOM elements, + // otherwise the blur handler in ActionWidgetService closes the widget. + // Keep focus on the filter input if the user is typing a filter. + if (filterInputHasFocus) { + this._filterInput!.focus(); + } else { + this._list.domFocus(); + } + // Reposition the context view so the widget grows in the correct direction + this._contextViewService.layout(); + } + } + + /** + * Returns the filter container element, if filter is enabled. + * The caller is responsible for appending it to the widget DOM. + */ + get filterContainer(): HTMLElement | undefined { + return this._filterContainer; + } + private focusCondition(element: IActionListItem): boolean { return !element.disabled && element.kind === ActionListItemKind.Action; } @@ -371,39 +606,57 @@ export class ActionList extends Disposable { } layout(minWidth: number): number { - // Updating list height, depending on how many separators and headers there are. - const numHeaders = this._allMenuItems.filter(item => item.kind === 'header').length; - const numSeparators = this._allMenuItems.filter(item => item.kind === 'separator').length; - const itemsHeight = this._allMenuItems.length * this._actionLineHeight; - const heightWithHeaders = itemsHeight + numHeaders * this._headerLineHeight - numHeaders * this._actionLineHeight; - const heightWithSeparators = heightWithHeaders + numSeparators * this._separatorLineHeight - numSeparators * this._actionLineHeight; - this._list.layout(heightWithSeparators); - let maxWidth = minWidth; - - if (this._allMenuItems.length >= 50) { - maxWidth = 380; + this._hasLaidOut = true; + this._lastMinWidth = minWidth; + // Compute height based on currently visible items in the list + const visibleCount = this._list.length; + let listHeight = 0; + for (let i = 0; i < visibleCount; i++) { + const element = this._list.element(i); + switch (element.kind) { + case ActionListItemKind.Header: + listHeight += this._headerLineHeight; + break; + case ActionListItemKind.Separator: + listHeight += this._separatorLineHeight; + break; + default: + listHeight += this._actionLineHeight; + break; + } + } + + this._list.layout(listHeight); + const effectiveMinWidth = Math.max(minWidth, this._options?.minWidth ?? 0); + let maxWidth = effectiveMinWidth; + + if (visibleCount >= 50) { + maxWidth = Math.max(380, effectiveMinWidth); } else { // For finding width dynamically (not using resize observer) - const itemWidths: number[] = this._allMenuItems.map((_, index): number => { - const element = this._getRowElement(index); + const itemWidths: number[] = []; + for (let i = 0; i < visibleCount; i++) { + const element = this._getRowElement(i); if (element) { element.style.width = 'auto'; const width = element.getBoundingClientRect().width; element.style.width = ''; - return width; + itemWidths.push(width); } - return 0; - }); + } // resize observer - can be used in the future since list widget supports dynamic height but not width - maxWidth = Math.max(...itemWidths, minWidth); + maxWidth = Math.max(...itemWidths, effectiveMinWidth); } + const filterHeight = this._filterContainer ? 36 : 0; const maxVhPrecentage = 0.7; - const height = Math.min(heightWithSeparators, this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage); - this._list.layout(height, maxWidth); + const maxHeight = this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage; + const height = Math.min(listHeight + filterHeight, maxHeight); + const listFinalHeight = height - filterHeight; + this._list.layout(listFinalHeight, maxWidth); - this.domNode.style.height = `${height}px`; + this.domNode.style.height = `${listFinalHeight}px`; this._list.domFocus(); return maxWidth; @@ -415,7 +668,6 @@ export class ActionList extends Disposable { if (focused.length > 0) { this._list.reveal(focused[0]); } - this._list.domFocus(); } focusNext() { @@ -424,7 +676,6 @@ export class ActionList extends Disposable { if (focused.length > 0) { this._list.reveal(focused[0]); } - this._list.domFocus(); } acceptSelected(preview?: boolean) { @@ -449,6 +700,10 @@ export class ActionList extends Disposable { } const element = e.elements[0]; + if (element.isSectionToggle) { + this._list.setSelection([]); + return; + } if (element.item && this.focusCondition(element)) { this._delegate.onSelect(element.item, e.browserEvent instanceof PreviewSelectedEvent); } else { @@ -457,7 +712,6 @@ export class ActionList extends Disposable { } private onFocus() { - this._list.domFocus(); const focused = this._list.getFocus(); if (focused.length === 0) { return; @@ -529,6 +783,11 @@ export class ActionList extends Disposable { } private onListClick(e: IListMouseEvent>): void { + if (e.element && e.element.isSectionToggle && e.element.section) { + const section = e.element.section; + queueMicrotask(() => this._toggleSection(section)); + return; + } if (e.element && this.focusCondition(e.element)) { this._list.setFocus([]); } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 0c63d24728647..e3969db45e1eb 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -56,12 +56,12 @@ /** Styles for each row in the list element **/ .action-widget .monaco-list .monaco-list-row { - padding: 0 4px 0 4px; + padding: 0 4px 0 8px; white-space: nowrap; cursor: pointer; touch-action: none; width: 100%; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-small); } .action-widget .monaco-list .monaco-list-row.action.focused:not(.option-disabled) { @@ -73,8 +73,8 @@ .action-widget .monaco-list-row.group-header { color: var(--vscode-descriptionForeground) !important; - font-weight: 600; - font-size: 13px; + font-weight: 500; + font-size: 11px; } .action-widget .monaco-list-row.group-header:not(:first-of-type) { @@ -120,8 +120,17 @@ .action-widget .monaco-list-row.action { display: flex; - gap: 4px; + gap: 6px; align-items: center; + color: var(--vscode-foreground) !important; +} + +.action-widget .monaco-list-row.action .codicon { + font-size: 12px; +} + +.action-widget .monaco-list-row.action .action-list-item-toolbar .codicon { + font-size: 16px; } .action-widget .monaco-list-row.action.option-disabled, @@ -142,6 +151,16 @@ text-overflow: ellipsis; } +.action-widget .monaco-list-row.action .action-item-badge { + padding: 0px 6px; + border-radius: 10px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 11px; + line-height: 18px; + flex-shrink: 0; +} + .action-widget .monaco-list-row.action .monaco-keybinding > .monaco-keybinding-key { background-color: var(--vscode-keybindingLabel-background); color: var(--vscode-keybindingLabel-foreground); @@ -168,13 +187,13 @@ } .action-widget .action-widget-action-bar .actions-container { - padding: 4px 8px 2px 24px; + padding: 2px 8px 0px 26px; width: auto; } .action-widget-action-bar .action-label { color: var(--vscode-textLink-activeForeground); - font-size: 13px; + font-size: 12px; line-height: 22px; padding: 0; pointer-events: all; @@ -197,8 +216,10 @@ .action-widget .monaco-list .monaco-list-row .description { opacity: 0.7; margin-left: 0.5em; + flex-shrink: 0; } + /* Item toolbar - shows on hover/focus */ .action-widget .monaco-list-row.action .action-list-item-toolbar { display: none; @@ -219,3 +240,29 @@ gap: 4px; font-size: 12px; } + +/* Filter input */ +.action-widget .action-list-filter { + border-top: 1px solid var(--vscode-editorHoverWidget-border); + padding: 4px; +} + +.action-widget .action-list-filter-input { + width: 100%; + box-sizing: border-box; + padding: 4px 8px; + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 3px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 12px; + outline: none; +} + +.action-widget .action-list-filter-input:focus { + border-color: var(--vscode-focusBorder); +} + +.action-widget .action-list-filter-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 21b49245bebcc..53483956586f0 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -10,7 +10,7 @@ import { KeyCode, KeyMod } from '../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import './actionWidget.css'; import { localize, localize2 } from '../../../nls.js'; -import { acceptSelectedActionCommand, ActionList, IActionListDelegate, IActionListItem, previewSelectedActionCommand } from './actionList.js'; +import { acceptSelectedActionCommand, ActionList, IActionListDelegate, IActionListItem, IActionListOptions, previewSelectedActionCommand } from './actionList.js'; import { Action2, registerAction2 } from '../../actions/common/actions.js'; import { IContextKeyService, RawContextKey } from '../../contextkey/common/contextkey.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; @@ -36,7 +36,7 @@ export const IActionWidgetService = createDecorator('actio export interface IActionWidgetService { readonly _serviceBrand: undefined; - show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>): void; + show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>, listOptions?: IActionListOptions): void; hide(didCancel?: boolean): void; @@ -60,10 +60,10 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { super(); } - show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>): void { + show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>, listOptions?: IActionListOptions): void { const visibleContext = ActionWidgetContextKeys.Visible.bindTo(this._contextKeyService); - const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider); + const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider, listOptions); this._contextViewService.showContextView({ getAnchor: () => anchor, render: (container: HTMLElement) => { @@ -137,6 +137,11 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { } } + // Filter input (appended after the list, before action bar visually) + if (this._list.value?.filterContainer) { + widget.appendChild(this._list.value.filterContainer); + } + const width = this._list.value?.layout(actionBarWidth); widget.style.width = `${width}px`; diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 0fb0d916c6ad9..b7b61da059f8e 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -6,7 +6,7 @@ import { IActionWidgetService } from './actionWidget.js'; import { IAction } from '../../../base/common/actions.js'; import { BaseDropdown, IActionProvider, IBaseDropdownOptions } from '../../../base/browser/ui/dropdown/dropdown.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover } from './actionList.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListOptions } from './actionList.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { Codicon } from '../../../base/common/codicons.js'; import { getActiveElement, isHTMLElement } from '../../../base/browser/dom.js'; @@ -52,6 +52,11 @@ export interface IActionWidgetDropdownOptions extends IBaseDropdownOptions { * provided, no telemetry will be sent. */ readonly reporter?: { id: string; name?: string; includeOptions?: boolean }; + + /** + * Options for the underlying ActionList (filter, collapsible sections). + */ + readonly listOptions?: IActionListOptions; } /** @@ -201,7 +206,8 @@ export class ActionWidgetDropdown extends BaseDropdown { this._options.getAnchor?.() ?? this.element, undefined, actionBarActions, - accessibilityProvider + accessibilityProvider, + this._options.listOptions ); } diff --git a/src/vs/platform/actions/browser/buttonbar.ts b/src/vs/platform/actions/browser/buttonbar.ts index 52fbbe18092ea..b94a519c4f52f 100644 --- a/src/vs/platform/actions/browser/buttonbar.ts +++ b/src/vs/platform/actions/browser/buttonbar.ts @@ -8,6 +8,7 @@ import { createInstantHoverDelegate } from '../../../base/browser/ui/hover/hover import { ActionRunner, IAction, IActionRunner, SubmenuAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../base/common/actions.js'; import { Codicon } from '../../../base/common/codicons.js'; import { Emitter, Event } from '../../../base/common/event.js'; +import { IMarkdownString, isMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { localize } from '../../../nls.js'; @@ -24,6 +25,8 @@ export type IButtonConfigProvider = (action: IAction, index: number) => { showIcon?: boolean; showLabel?: boolean; isSecondary?: boolean; + customLabel?: string | IMarkdownString; + customClass?: string; } | undefined; export interface IWorkbenchButtonBarOptions { @@ -117,8 +120,15 @@ export class WorkbenchButtonBar extends ButtonBar { btn.checked = action.checked ?? false; btn.element.classList.add('default-colors'); const showLabel = conifgProvider(action, i)?.showLabel ?? true; + const customClass = conifgProvider(action, i)?.customClass; + const customLabel = conifgProvider(action, i)?.customLabel; + + if (customClass) { + btn.element.classList.add(customClass); + } + if (showLabel) { - btn.label = action.label; + btn.label = customLabel ?? action.label; } else { btn.element.classList.add('monaco-text-button'); } @@ -129,7 +139,12 @@ export class WorkbenchButtonBar extends ButtonBar { } else { // this is REALLY hacky but combining a codicon and normal text is ugly because // the former define a font which doesn't work for text - btn.label = `$(${action.item.icon.id}) ${action.label}`; + const labelValue = customLabel ?? action.label; + btn.label = isMarkdownString(labelValue) + ? new MarkdownString(`$(${action.item.icon.id}) ${labelValue.value}`, { + isTrusted: labelValue.isTrusted, supportThemeIcons: true, supportHtml: labelValue.supportHtml + }) + : `$(${action.item.icon.id}) ${labelValue}`; } } else if (action.class) { btn.element.classList.add(...action.class.split(' ')); diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index 96e950596204e..feb53c1efcb5e 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -520,7 +520,12 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { super.actionRunner = actionRunner; this._defaultAction.actionRunner = actionRunner; - this._dropdown.actionRunner = actionRunner; + // When togglePrimaryAction is enabled, keep the dropdown's private + // action runner so that the onDidRun listener only fires for actions + // originating from the dropdown, not from unrelated toolbar buttons. + if (!this._options?.togglePrimaryAction) { + this._dropdown.actionRunner = actionRunner; + } if (this._primaryActionListener.value) { this.registerTogglePrimaryActionListener(); } diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index a81fe449d6253..c3d2db6d98236 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -245,6 +245,7 @@ export class MenuId { static readonly MergeInputResultToolbar = new MenuId('MergeToolbarResultToolbar'); static readonly InlineSuggestionToolbar = new MenuId('InlineSuggestionToolbar'); static readonly InlineEditToolbar = new MenuId('InlineEditToolbar'); + static readonly AgentFeedbackEditorContent = new MenuId('AgentFeedbackEditorContent'); static readonly ChatContext = new MenuId('ChatContext'); static readonly ChatCodeBlock = new MenuId('ChatCodeblock'); static readonly ChatCompareBlock = new MenuId('ChatCompareBlock'); @@ -274,6 +275,7 @@ export class MenuId { static readonly ChatTitleBarMenu = new MenuId('ChatTitleBarMenu'); static readonly ChatAttachmentsContext = new MenuId('ChatAttachmentsContext'); static readonly ChatTipContext = new MenuId('ChatTipContext'); + static readonly ChatTipToolbar = new MenuId('ChatTipToolbar'); static readonly ChatToolOutputResourceToolbar = new MenuId('ChatToolOutputResourceToolbar'); static readonly ChatTextEditorMenu = new MenuId('ChatTextEditorMenu'); static readonly ChatToolOutputResourceContext = new MenuId('ChatToolOutputResourceContext'); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index f22fd39e70b0c..5c8c9517dfb4a 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -275,4 +275,9 @@ export interface IBrowserViewService { * @param id The browser view identifier */ clearStorage(id: string): Promise; + + /** + * Get a CDP WebSocket endpoint URL. + */ + getDebugWebSocketEndpoint(): Promise; } diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts new file mode 100644 index 0000000000000..75240497f8832 --- /dev/null +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const IPlaywrightService = createDecorator('playwrightService'); + +/** + * A service for using Playwright to connect to and automate the integrated browser. + */ +export interface IPlaywrightService { + readonly _serviceBrand: undefined; + + // TODO@kycutler: define a more specific API. + initialize(): Promise; +} diff --git a/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts b/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts new file mode 100644 index 0000000000000..9142b497166dd --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import type * as http from 'http'; +import { AddressInfo, Socket } from 'net'; +import { upgradeToISocket } from '../../../base/parts/ipc/node/ipc.net.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; +import { CDPBrowserProxy } from '../common/cdp/proxy.js'; +import { CDPEvent, CDPRequest, CDPError, CDPErrorCode, ICDPBrowserTarget, ICDPConnection } from '../common/cdp/types.js'; +import { disposableTimeout } from '../../../base/common/async.js'; +import { ISocket } from '../../../base/parts/ipc/common/ipc.net.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const IBrowserViewCDPProxyServer = createDecorator('browserViewCDPProxyServer'); + +export interface IBrowserViewCDPProxyServer { + readonly _serviceBrand: undefined; + + /** + * Returns a debug endpoint with a short-lived, single-use token. + */ + getWebSocketEndpoint(): Promise; +} + +/** + * WebSocket server that provides CDP debugging for browser views. + */ +export class BrowserViewCDPProxyServer extends Disposable implements IBrowserViewCDPProxyServer { + declare readonly _serviceBrand: undefined; + + private server: http.Server | undefined; + private port: number | undefined; + private readonly tokens: TokenManager; + + constructor( + private readonly browserTarget: ICDPBrowserTarget, + @ILogService private readonly logService: ILogService + ) { + super(); + + this.tokens = this._register(new TokenManager()); + } + + /** + * Returns a debug endpoint with a short-lived, single-use token in the + * WebSocket URL. The token is revoked once a WebSocket connection is made + * or after 30 seconds, whichever comes first. + */ + async getWebSocketEndpoint(): Promise { + await this.ensureServerStarted(); + + const token = await this.tokens.issueToken(); + return this.getWebSocketUrl(token); + } + + private getWebSocketUrl(token: string): string { + return `ws://localhost:${this.port}/devtools/browser?token=${token}`; + } + + private async ensureServerStarted(): Promise { + if (this.server) { + return; + } + + const http = await import('http'); + this.server = http.createServer(); + + await new Promise((resolve, reject) => { + // Only listen on localhost to prevent external access + this.server!.listen(0, '127.0.0.1', () => resolve()); + this.server!.once('error', reject); + }); + + const address = this.server.address() as AddressInfo; + this.port = address.port; + + this.server.on('request', (req, res) => this.handleHttpRequest(req, res)); + this.server.on('upgrade', (req: http.IncomingMessage, socket: Socket) => this.handleWebSocketUpgrade(req, socket)); + } + + private async handleHttpRequest(_req: http.IncomingMessage, res: http.ServerResponse): Promise { + this.logService.debug(`[BrowserViewDebugProxy] HTTP request at ${_req.url}`); + // No support for HTTP endpoints for now. + res.writeHead(404); + res.end(); + } + + private handleWebSocketUpgrade(req: http.IncomingMessage, socket: Socket): void { + const [pathname, params] = (req.url || '').split('?'); + + const token = new URLSearchParams(params).get('token'); + if (!token || !this.tokens.consumeToken(token)) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.end(); + return; + } + + const browserMatch = pathname.match(/^\/devtools\/browser(\/.*)?$/); + + this.logService.debug(`[BrowserViewDebugProxy] WebSocket upgrade requested: ${pathname}`); + + if (!browserMatch) { + this.logService.warn(`[BrowserViewDebugProxy] Rejecting WebSocket on unknown path: ${pathname}`); + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + socket.end(); + return; + } + + this.logService.debug(`[BrowserViewDebugProxy] WebSocket connected: ${pathname}`); + + const upgraded = upgradeToISocket(req, socket, { + debugLabel: 'browser-view-cdp-' + generateUuid(), + enableMessageSplitting: false, + }); + + if (!upgraded) { + return; + } + + const proxy = new CDPBrowserProxy(this.browserTarget); + const disposables = this.wireWebSocket(upgraded, proxy); + this._register(disposables); + this._register(upgraded); + } + + /** + * Wire a WebSocket (ISocket) to an ICDPConnection bidirectionally. + * Returns a DisposableStore that cleans up all subscriptions. + */ + private wireWebSocket(upgraded: ISocket, connection: ICDPConnection): DisposableStore { + const disposables = new DisposableStore(); + + // Socket -> Connection: parse JSON, call sendMessage, write response/error + disposables.add(upgraded.onData((rawData: VSBuffer) => { + try { + const message = rawData.toString(); + const { id, method, params, sessionId } = JSON.parse(message) as CDPRequest; + this.logService.debug(`[BrowserViewDebugProxy] <- ${message}`); + connection.sendMessage(method, params, sessionId) + .then((result: unknown) => { + const response = { id, result, sessionId }; + const responseStr = JSON.stringify(response); + this.logService.debug(`[BrowserViewDebugProxy] -> ${responseStr}`); + upgraded.write(VSBuffer.fromString(responseStr)); + }) + .catch((error: Error) => { + const response = { + id, + error: { + code: error instanceof CDPError ? error.code : CDPErrorCode.ServerError, + message: error.message || 'Unknown error' + }, + sessionId + }; + const responseStr = JSON.stringify(response); + this.logService.debug(`[BrowserViewDebugProxy] -> ${responseStr}`); + upgraded.write(VSBuffer.fromString(responseStr)); + }); + } catch (error) { + this.logService.error('[BrowserViewDebugProxy] Error parsing message:', error); + upgraded.end(); + } + })); + + // Connection -> Socket: serialize events and write + disposables.add(connection.onEvent((event: CDPEvent) => { + const eventStr = JSON.stringify(event); + this.logService.debug(`[BrowserViewDebugProxy] -> ${eventStr}`); + upgraded.write(VSBuffer.fromString(eventStr)); + })); + + // Connection close -> close socket + disposables.add(connection.onClose(() => { + this.logService.debug(`[BrowserViewDebugProxy] WebSocket closing`); + upgraded.end(); + })); + + // Socket closed -> cleanup + disposables.add(upgraded.onClose(() => { + this.logService.debug(`[BrowserViewDebugProxy] WebSocket closed`); + connection.dispose(); + disposables.dispose(); + })); + + return disposables; + } + + override dispose(): void { + if (this.server) { + this.server.close(); + this.server = undefined; + } + + super.dispose(); + } +} + +class TokenManager extends Disposable { + /** Map of currently valid single-use tokens. Each expires after 30 seconds. */ + private readonly tokens = new Map(); + + /** + * Creates a short-lived, single-use token. + * The token is revoked once consumed or after 30 seconds. + */ + async issueToken(): Promise { + const token = this.makeToken(); + this.tokens.set(token, { expiresAt: Date.now() + 30_000 }); + this._register(disposableTimeout(() => this.tokens.delete(token), 30_000)); + return token; + } + + consumeToken(token: string): boolean { + if (!token) { + return false; + } + const info = this.tokens.get(token); + if (!info) { + return false; + } + this.tokens.delete(token); + return Date.now() <= info.expiresAt; + } + + private makeToken(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join(''); + const base64 = btoa(binary); + const urlSafeToken = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + + return urlSafeToken; + } +} diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 34956252adeb7..f29b4c18a438c 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -15,12 +15,15 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { BrowserViewUri } from '../common/browserViewUri.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; +import { IBrowserViewCDPProxyServer } from './browserViewCDPProxyServer.js'; import { IProductService } from '../../product/common/productService.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); export interface IBrowserViewMainService extends IBrowserViewService, ICDPBrowserTarget { + readonly _serviceBrand: undefined; + tryGetBrowserView(id: string): BrowserView | undefined; } @@ -48,7 +51,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, - @IProductService private readonly productService: IProductService + @IProductService private readonly productService: IProductService, + @IBrowserViewCDPProxyServer private readonly cdpProxyServer: IBrowserViewCDPProxyServer, ) { super(); } @@ -359,4 +363,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa ); await browserSession.electronSession.clearData(); } + + async getDebugWebSocketEndpoint(): Promise { + return this.cdpProxyServer.getWebSocketEndpoint(); + } } diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts new file mode 100644 index 0000000000000..2dcd8fbd04e9c --- /dev/null +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { ILogService } from '../../log/common/log.js'; +import { IBrowserViewService, ipcBrowserViewChannelName } from '../common/browserView.js'; +import { IPlaywrightService } from '../common/playwrightService.js'; +import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; + +// eslint-disable-next-line local/code-import-patterns +import type { Browser } from 'playwright-core'; + +/** + * Shared-process implementation of {@link IPlaywrightService}. + */ +export class PlaywrightService extends Disposable implements IPlaywrightService { + declare readonly _serviceBrand: undefined; + + private readonly browserViewService: IBrowserViewService; + private _browser: Browser | undefined; + private _initPromise: Promise | undefined; + + constructor( + @IMainProcessService mainProcessService: IMainProcessService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + const channel = mainProcessService.getChannel(ipcBrowserViewChannelName); + this.browserViewService = ProxyChannel.toService(channel); + } + + async initialize(): Promise { + if (this._browser?.isConnected()) { + return; + } + + if (this._initPromise) { + return this._initPromise; + } + + this._initPromise = (async () => { + try { + this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); + + const playwright = await import('playwright-core'); + const endpoint = await this.browserViewService.getDebugWebSocketEndpoint(); + const browser = await playwright.chromium.connectOverCDP(endpoint); + + this.logService.debug('[PlaywrightService] Connected to browser'); + + browser.on('disconnected', () => { + this.logService.debug('[PlaywrightService] Browser disconnected'); + if (this._browser === browser) { + this._browser = undefined; + } + }); + + // This can happen if the service was disposed while we were waiting for the connection. In that case, clean up immediately. + if (this._initPromise === undefined) { + browser.close().catch(() => { /* ignore */ }); + throw new Error('PlaywrightService was disposed during initialization'); + } + + this._browser = browser; + } finally { + this._initPromise = undefined; + } + })(); + + return this._initPromise; + } + + override dispose(): void { + if (this._browser) { + this._browser.close().catch(() => { /* ignore */ }); + this._browser = undefined; + } + this._initPromise = undefined; + super.dispose(); + } +} diff --git a/src/vs/platform/debug/common/extensionHostDebugIpc.ts b/src/vs/platform/debug/common/extensionHostDebugIpc.ts index fb8b2a379220a..74e3b97956d2d 100644 --- a/src/vs/platform/debug/common/extensionHostDebugIpc.ts +++ b/src/vs/platform/debug/common/extensionHostDebugIpc.ts @@ -8,14 +8,14 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; import { IAttachSessionEvent, ICloseSessionEvent, IExtensionHostDebugService, IOpenExtensionWindowResult, IReloadSessionEvent, ITerminateSessionEvent } from './extensionHostDebug.js'; -export class ExtensionHostDebugBroadcastChannel implements IServerChannel { +export class ExtensionHostDebugBroadcastChannel extends Disposable implements IServerChannel { static readonly ChannelName = 'extensionhostdebugservice'; - private readonly _onCloseEmitter = new Emitter(); - private readonly _onReloadEmitter = new Emitter(); - private readonly _onTerminateEmitter = new Emitter(); - private readonly _onAttachEmitter = new Emitter(); + private readonly _onCloseEmitter = this._register(new Emitter()); + private readonly _onReloadEmitter = this._register(new Emitter()); + private readonly _onTerminateEmitter = this._register(new Emitter()); + private readonly _onAttachEmitter = this._register(new Emitter()); call(ctx: TContext, command: string, arg?: any): Promise { switch (command) { diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index 63a9b956608ac..cd67c68841230 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -15,6 +15,7 @@ export interface IDefaultAccountProvider { getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; refresh(): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; + signOut(): Promise; } export const IDefaultAccountService = createDecorator('defaultAccountService'); @@ -29,4 +30,5 @@ export interface IDefaultAccountService { setDefaultAccountProvider(provider: IDefaultAccountProvider): void; refresh(): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; + signOut(): Promise; } diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index 4fb1a9eb9aa4c..281ee03246a20 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -320,6 +320,41 @@ export interface IEditorOptions { */ alwaysOnTop?: boolean; }; + + /** + * Options that only apply when `MODAL_GROUP` is used for opening. + */ + modal?: IModalEditorPartOptions; +} + +export interface IModalEditorPartOptions { + + /** + * The navigation context for navigating between items + * within this modal editor. Pass `undefined` to clear. + */ + readonly navigation?: IModalEditorNavigation; +} + +/** + * Context for navigating between items within a modal editor. + */ +export interface IModalEditorNavigation { + + /** + * Total number of items in the navigation list. + */ + readonly total: number; + + /** + * Current 0-based index in the navigation list. + */ + readonly current: number; + + /** + * Navigate to the item at the given 0-based index. + */ + readonly navigate: (index: number) => void; } export interface ITextEditorSelection { diff --git a/src/vs/platform/environment/node/userDataPath.ts b/src/vs/platform/environment/node/userDataPath.ts index 3d0037b28c218..9c6361e341acf 100644 --- a/src/vs/platform/environment/node/userDataPath.ts +++ b/src/vs/platform/environment/node/userDataPath.ts @@ -5,7 +5,7 @@ import { homedir } from 'os'; import { NativeParsedArgs } from '../common/argv.js'; - +import { INodeProcess } from '../../../base/common/platform.js'; // This file used to be a pure JS file and was always // importing `path` from node.js even though we ship // our own version of the library and prefer to use @@ -46,7 +46,11 @@ function doGetUserDataPath(cliArgs: NativeParsedArgs, productName: string): stri // 0. Running out of sources has a fixed productName if (process.env['VSCODE_DEV']) { - productName = 'code-oss-dev'; + if ((process as INodeProcess).isEmbeddedApp) { + productName = 'sessions-oss-dev'; + } else { + productName = 'code-oss-dev'; + } } // 1. Support portable mode diff --git a/src/vs/platform/meteredConnection/common/meteredConnection.config.contribution.ts b/src/vs/platform/meteredConnection/common/meteredConnection.config.contribution.ts index eeff32a25eac5..1259f403a12ec 100644 --- a/src/vs/platform/meteredConnection/common/meteredConnection.config.contribution.ts +++ b/src/vs/platform/meteredConnection/common/meteredConnection.config.contribution.ts @@ -14,11 +14,17 @@ configurationRegistry.registerConfiguration({ title: localize('networkConfigurationTitle', "Network"), type: 'object', properties: { - 'network.respectMeteredConnections': { - type: 'boolean', - default: true, + 'network.meteredConnection': { + type: 'string', + enum: ['auto', 'on', 'off'], + enumDescriptions: [ + localize('meteredConnection.auto', "Automatically detect metered connections using the operating system's network status."), + localize('meteredConnection.on', "Always treat the network connection as metered. Automatic updates and downloads will be postponed."), + localize('meteredConnection.off', "Never treat the network connection as metered.") + ], + default: 'auto', scope: ConfigurationScope.APPLICATION, - description: localize('respectMeteredConnections', "When enabled, automatic updates and downloads will be postponed when on a metered network connection (such as mobile data or tethering)."), + description: localize('meteredConnection', "Controls whether the current network connection should be treated as metered. When metered, automatic updates, extension downloads, and other background network activity will be postponed to reduce data usage."), tags: ['usesOnlineServices'] } } diff --git a/src/vs/platform/meteredConnection/common/meteredConnection.ts b/src/vs/platform/meteredConnection/common/meteredConnection.ts index 321aec6c5403c..2eb6796aae722 100644 --- a/src/vs/platform/meteredConnection/common/meteredConnection.ts +++ b/src/vs/platform/meteredConnection/common/meteredConnection.ts @@ -18,7 +18,8 @@ export interface IMeteredConnectionService { /** * Whether the current network connection is metered. - * Always returns `false` if the `network.respectMeteredConnections` setting is disabled. + * Always returns `false` if the `network.meteredConnection` setting is `off`. + * Always returns `true` if the `network.meteredConnection` setting is `on`. */ readonly isConnectionMetered: boolean; @@ -28,7 +29,9 @@ export interface IMeteredConnectionService { readonly onDidChangeIsConnectionMetered: Event; } -export const METERED_CONNECTION_SETTING_KEY = 'network.respectMeteredConnections'; +export const METERED_CONNECTION_SETTING_KEY = 'network.meteredConnection'; + +export type MeteredConnectionSettingValue = 'on' | 'off' | 'auto'; /** * Network Information API @@ -77,20 +80,20 @@ export abstract class AbstractMeteredConnectionService extends Disposable implem private _isConnectionMetered: boolean; private _isBrowserConnectionMetered: boolean; - private _respectMeteredConnections: boolean; + private _meteredConnectionSetting: MeteredConnectionSettingValue; constructor(configurationService: IConfigurationService, isBrowserConnectionMetered: boolean) { super(); this._isBrowserConnectionMetered = isBrowserConnectionMetered; - this._respectMeteredConnections = configurationService.getValue(METERED_CONNECTION_SETTING_KEY); - this._isConnectionMetered = this._respectMeteredConnections && this._isBrowserConnectionMetered; + this._meteredConnectionSetting = configurationService.getValue(METERED_CONNECTION_SETTING_KEY); + this._isConnectionMetered = this._meteredConnectionSetting === 'on' || (this._meteredConnectionSetting !== 'off' && this._isBrowserConnectionMetered); this._register(configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(METERED_CONNECTION_SETTING_KEY)) { - const value = configurationService.getValue(METERED_CONNECTION_SETTING_KEY); - if (value !== this._respectMeteredConnections) { - this._respectMeteredConnections = value; + const value = configurationService.getValue(METERED_CONNECTION_SETTING_KEY); + if (value !== this._meteredConnectionSetting) { + this._meteredConnectionSetting = value; this.onUpdated(); } } @@ -117,7 +120,7 @@ export abstract class AbstractMeteredConnectionService extends Disposable implem } protected onUpdated() { - const value = this._respectMeteredConnections && this._isBrowserConnectionMetered; + const value = this._meteredConnectionSetting === 'on' || (this._meteredConnectionSetting !== 'off' && this._isBrowserConnectionMetered); if (value !== this._isConnectionMetered) { this._isConnectionMetered = value; this.onChangeIsConnectionMetered(); diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 585585003d353..aa73a7c63c437 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -129,6 +129,8 @@ export interface ICommonNativeHostService { openWindow(options?: IOpenEmptyWindowOptions): Promise; openWindow(toOpen: IWindowOpenable[], options?: IOpenWindowOptions): Promise; + openSessionsWindow(): Promise; + isFullScreen(options?: INativeHostOptions): Promise; toggleFullScreen(options?: INativeHostOptions): Promise; @@ -145,12 +147,7 @@ export interface ICommonNativeHostService { toggleWindowAlwaysOnTop(options?: INativeHostOptions): Promise; setWindowAlwaysOnTop(alwaysOnTop: boolean, options?: INativeHostOptions): Promise; - /** - * Only supported on Windows and macOS. Updates the window controls to match the title bar size. - * - * @param options `backgroundColor` and `foregroundColor` are only supported on Windows - */ - updateWindowControls(options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string }): Promise; + updateWindowControls(options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): Promise; updateWindowAccentColor(color: 'default' | 'off' | string, inactiveColor: string | undefined): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 22ad671ed56d0..3edc2ef195d9a 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -304,6 +304,13 @@ export class NativeHostMainService extends Disposable implements INativeHostMain }, options); } + async openSessionsWindow(windowId: number | undefined): Promise { + await this.windowsMainService.openSessionsWindow({ + context: OpenContext.API, + contextWindowId: windowId, + }); + } + async isFullScreen(windowId: number | undefined, options?: INativeHostOptions): Promise { const window = this.windowById(options?.targetWindowId, windowId); return window?.isFullScreen ?? false; @@ -374,7 +381,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain } } - async updateWindowControls(windowId: number | undefined, options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string }): Promise { + async updateWindowControls(windowId: number | undefined, options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): Promise { const window = this.windowById(options?.targetWindowId, windowId); window?.updateWindowControls(options); } diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 9afd0964022b0..bd509719a3cce 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -525,35 +525,3 @@ outline: 1px solid var(--vscode-list-focusOutline) !important; outline-offset: -1px; } - -/* Entrance animation */ -@keyframes quick-input-entrance { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.quick-input-widget.animating-entrance { - animation: quick-input-entrance 150ms cubic-bezier(0.1, 0.9, 0.2, 1) forwards; -} - -/* Exit animation */ -@keyframes quick-input-exit { - from { - opacity: 1; - transform: translateY(0); - } - to { - opacity: 0; - transform: translateY(-8px); - } -} - -.quick-input-widget.animating-exit { - animation: quick-input-exit 50ms cubic-bezier(0.9, 0.1, 1, 0.2) forwards; -} diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 11964ea5a30bf..8e5283ef9ad92 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -37,7 +37,6 @@ import { TriStateCheckbox, createToggleActionViewItemProvider } from '../../../b import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; import { QuickInputTreeController } from './tree/quickInputTreeController.js'; import { QuickTree } from './tree/quickTree.js'; -import { isMotionReduced } from '../../../base/browser/ui/motion/motion.js'; import { AnchorAlignment, AnchorPosition, layout2d } from '../../../base/common/layout.js'; import { getAnchorRect } from '../../../base/browser/ui/contextview/contextview.js'; @@ -81,7 +80,6 @@ export class QuickInputController extends Disposable { private viewState: QuickInputViewState | undefined; private dndController: QuickInputDragAndDropController | undefined; - private _cancelExitAnimation: (() => void) | undefined; private readonly inQuickInputContext: IContextKey; private readonly quickInputTypeContext: IContextKey; @@ -713,26 +711,12 @@ export class QuickInputController extends Disposable { const backKeybindingLabel = this.options.backKeybindingLabel(); backButton.tooltip = backKeybindingLabel ? localize('quickInput.backWithKeybinding', "Back ({0})", backKeybindingLabel) : localize('quickInput.back', "Back"); - const wasVisible = ui.container.style.display !== 'none'; ui.container.style.display = ''; - // Cancel any in-flight exit animation that would set display:none - this._cancelExitAnimation?.(); - this._cancelExitAnimation = undefined; this.updateLayout(); this.dndController?.setEnabled(!controller.anchor); this.dndController?.layoutContainer(); ui.inputBox.setFocus(); this.quickInputTypeContext.set(controller.type); - - // Animate entrance: fade in + slide down (only when first appearing) - if (!wasVisible && !isMotionReduced(ui.container)) { - ui.container.classList.add('animating-entrance'); - const onAnimationEnd = () => { - ui.container.classList.remove('animating-entrance'); - ui.container.removeEventListener('animationend', onAnimationEnd); - }; - ui.container.addEventListener('animationend', onAnimationEnd); - } } isVisible(): boolean { @@ -799,24 +783,7 @@ export class QuickInputController extends Disposable { this.controller = null; this.onHideEmitter.fire(); if (container) { - // Animate exit: fade out + slide up (faster than open) - if (!isMotionReduced(container)) { - container.classList.add('animating-exit'); - const cleanupAnimation = () => { - container.classList.remove('animating-exit'); - container.removeEventListener('animationend', onAnimationEnd); - this._cancelExitAnimation = undefined; - }; - const onAnimationEnd = () => { - // Set display after animation completes to actually hide the element - container.style.display = 'none'; - cleanupAnimation(); - }; - this._cancelExitAnimation = cleanupAnimation; - container.addEventListener('animationend', onAnimationEnd); - } else { - container.style.display = 'none'; - } + container.style.display = 'none'; } if (!focusChanged) { let currentElement = this.previousFocusElement; diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 4431f19b65917..18c7915e8f24a 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -339,7 +339,7 @@ export interface IPtyService { shutdown(id: number, immediate: boolean): Promise; input(id: number, data: string): Promise; sendSignal(id: number, signal: string): Promise; - resize(id: number, cols: number, rows: number): Promise; + resize(id: number, cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): Promise; clearBuffer(id: number): Promise; getInitialCwd(id: number): Promise; getCwd(id: number): Promise; @@ -391,7 +391,7 @@ export interface IPtyServiceContribution { handleProcessReady(persistentProcessId: number, process: ITerminalChildProcess): void; handleProcessDispose(persistentProcessId: number): void; handleProcessInput(persistentProcessId: number, data: string): void; - handleProcessResize(persistentProcessId: number, cols: number, rows: number): void; + handleProcessResize(persistentProcessId: number, cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void; } export interface IPtyHostController { @@ -810,7 +810,7 @@ export interface ITerminalChildProcess { input(data: string): void; sendSignal(signal: string): void; processBinary(data: string): Promise; - resize(cols: number, rows: number): void; + resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void; clearBuffer(): void | Promise; /** diff --git a/src/vs/platform/terminal/node/ptyHostService.ts b/src/vs/platform/terminal/node/ptyHostService.ts index d85ba59ef1242..0e66435f1ef7f 100644 --- a/src/vs/platform/terminal/node/ptyHostService.ts +++ b/src/vs/platform/terminal/node/ptyHostService.ts @@ -254,8 +254,8 @@ export class PtyHostService extends Disposable implements IPtyHostService { processBinary(id: number, data: string): Promise { return this._proxy.processBinary(id, data); } - resize(id: number, cols: number, rows: number): Promise { - return this._proxy.resize(id, cols, rows); + resize(id: number, cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): Promise { + return this._proxy.resize(id, cols, rows, pixelWidth, pixelHeight); } clearBuffer(id: number): Promise { return this._proxy.clearBuffer(id); diff --git a/src/vs/platform/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index 826b09f47c81d..e73086df28c9e 100644 --- a/src/vs/platform/terminal/node/ptyService.ts +++ b/src/vs/platform/terminal/node/ptyService.ts @@ -455,13 +455,13 @@ export class PtyService extends Disposable implements IPtyService { return this._throwIfNoPty(id).writeBinary(data); } @traceRpc - async resize(id: number, cols: number, rows: number): Promise { + async resize(id: number, cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): Promise { const pty = this._throwIfNoPty(id); if (pty) { for (const contrib of this._contributions) { - contrib.handleProcessResize(id, cols, rows); + contrib.handleProcessResize(id, cols, rows, pixelWidth, pixelHeight); } - pty.resize(cols, rows); + pty.resize(cols, rows, pixelWidth, pixelHeight); } } @traceRpc @@ -902,7 +902,7 @@ class PersistentTerminalProcess extends Disposable { writeBinary(data: string): Promise { return this._terminalProcess.processBinary(data); } - resize(cols: number, rows: number): void { + resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void { if (this._inReplay) { return; } @@ -911,7 +911,7 @@ class PersistentTerminalProcess extends Disposable { // Buffered events should flush when a resize occurs this._bufferer.flushBuffer(this._persistentProcessId); - return this._terminalProcess.resize(cols, rows); + return this._terminalProcess.resize(cols, rows, pixelWidth, pixelHeight); } async clearBuffer(): Promise { this._serializer.clearBuffer(); diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index 707bfa7fc0ff7..1f49fa67daa7f 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -515,7 +515,7 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess } } - resize(cols: number, rows: number): void { + resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void { if (this._store.isDisposed) { return; } @@ -537,7 +537,10 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess this._logService.trace('node-pty.IPty#resize', cols, rows); try { - this._ptyProcess.resize(cols, rows); + const pixelSize = pixelWidth !== undefined && pixelHeight !== undefined + ? { width: pixelWidth, height: pixelHeight } + : undefined; + this._ptyProcess.resize(cols, rows, pixelSize); } catch (e) { // Swallow error if the pty has already exited this._logService.trace('node-pty.IPty#resize exception ' + e.message); diff --git a/src/vs/platform/tunnel/common/tunnel.ts b/src/vs/platform/tunnel/common/tunnel.ts index 2c04a5ca4f8f5..49eb6191d2f52 100644 --- a/src/vs/platform/tunnel/common/tunnel.ts +++ b/src/vs/platform/tunnel/common/tunnel.ts @@ -216,6 +216,7 @@ export class DisposableTunnel { dispose(): Promise { this._onDispose.fire(); + this._onDispose.dispose(); return this._dispose(); } } diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 8e3db7c247bb2..7f30494da4a37 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -62,6 +62,7 @@ export const enum DisablementReason { MissingConfiguration, InvalidConfiguration, RunningAsAdmin, + EmbeddedApp, } export type Uninitialized = { type: StateType.Uninitialized }; diff --git a/src/vs/platform/update/common/updateIpc.ts b/src/vs/platform/update/common/updateIpc.ts index e6675d73f4009..9eaf8210757e2 100644 --- a/src/vs/platform/update/common/updateIpc.ts +++ b/src/vs/platform/update/common/updateIpc.ts @@ -41,7 +41,7 @@ export class UpdateChannelClient implements IUpdateService { declare readonly _serviceBrand: undefined; private readonly disposables = new DisposableStore(); - private readonly _onStateChange = new Emitter(); + private readonly _onStateChange = this.disposables.add(new Emitter()); readonly onStateChange: Event = this._onStateChange.event; private _state: State = State.Uninitialized; diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index e65a9823839c7..a0c89233f3d4b 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -16,9 +16,10 @@ import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; +import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; +import { INodeProcess } from '../../../base/common/platform.js'; export class DarwinUpdateService extends AbstractUpdateService implements IRelaunchHandler { @@ -67,6 +68,12 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau } protected override async initialize(): Promise { + if ((process as INodeProcess).isEmbeddedApp) { + this.setState(State.Disabled(DisablementReason.EmbeddedApp)); + this.logService.info('update#ctor - updates are disabled for embedded app'); + return; + } + await super.initialize(); this.onRawError(this.onError, this, this.disposables); this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this.disposables); diff --git a/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts b/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts index c176090f9d84a..14157f0fd1a28 100644 --- a/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts +++ b/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts @@ -84,8 +84,8 @@ export class WebPageLoader extends Disposable { .once('did-start-loading', this.onStartLoading.bind(this)) .once('did-finish-load', this.onFinishLoad.bind(this)) .once('did-fail-load', this.onFailLoad.bind(this)) - .once('will-navigate', this.onRedirect.bind(this)) - .once('will-redirect', this.onRedirect.bind(this)) + .on('will-navigate', this.onRedirect.bind(this)) + .on('will-redirect', this.onRedirect.bind(this)) .on('select-client-certificate', (event) => event.preventDefault()); this._window.webContents.session.webRequest.onBeforeSendHeaders( @@ -262,6 +262,9 @@ export class WebPageLoader extends Disposable { if (statusCode === -3) { this.trace(`Ignoring ERR_ABORTED (-3) as it may be caused by CSP or other measures`); void this._queue.queue(() => this.extractContent()); + } else if (statusCode === -27) { + this.trace(`Ignoring ERR_BLOCKED_BY_CLIENT (-27) as it may be caused by ad-blockers or similar extensions`); + void this._queue.queue(() => this.extractContent()); } else { void this._queue.queue(() => this.extractContent({ status: 'error', statusCode, error })); } @@ -289,6 +292,13 @@ export class WebPageLoader extends Disposable { return; } + // Ignore script-initiated navigation (ads/trackers etc) + if (this._didFinishLoad) { + this.trace(`Blocking post-load navigation to ${url} (likely ad/tracker script)`); + event.preventDefault(); + return; + } + // Otherwise, prevent redirect and report it event.preventDefault(); this._onResult({ status: 'redirect', toURI }); diff --git a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts index 6c86c592226e8..5de4f73f0d310 100644 --- a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts +++ b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts @@ -19,6 +19,7 @@ interface MockElectronEvent { class MockWebContents { private readonly _listeners = new Map void)[]>(); + private readonly _onceListeners = new Set<(...args: unknown[]) => void>(); public readonly debugger: MockDebugger; public loadURL = sinon.stub().resolves(); public getTitle = sinon.stub().returns('Test Page Title'); @@ -41,6 +42,7 @@ class MockWebContents { this._listeners.set(event, []); } this._listeners.get(event)!.push(listener); + this._onceListeners.add(listener); return this; } @@ -57,7 +59,16 @@ class MockWebContents { for (const listener of listeners) { listener(...args); } - this._listeners.delete(event); + // Remove once listeners, keep on listeners + const remaining = listeners.filter(l => !this._onceListeners.has(l)); + for (const listener of listeners) { + this._onceListeners.delete(listener); + } + if (remaining.length > 0) { + this._listeners.set(event, remaining); + } else { + this._listeners.delete(event); + } } beginFrameSubscription(_onlyDirty: boolean, callback: () => void): void { @@ -232,6 +243,27 @@ suite('WebPageLoader', () => { } })); + test('ERR_BLOCKED_BY_CLIENT is ignored and content extraction continues', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = URI.parse('https://example.com/page'); + + const loader = createWebPageLoader(uri); + setupDebuggerMock(); + + const loadPromise = loader.load(); + + // Simulate ERR_BLOCKED_BY_CLIENT (-27) which should be ignored + const mockEvent: MockElectronEvent = {}; + window.webContents.emit('did-fail-load', mockEvent, -27, 'ERR_BLOCKED_BY_CLIENT'); + + const result = await loadPromise; + + // ERR_BLOCKED_BY_CLIENT should not cause an error status, content should be extracted + assert.strictEqual(result.status, 'ok'); + if (result.status === 'ok') { + assert.ok(result.result.includes('Test content from page')); + } + })); + //#endregion //#region Redirect Tests @@ -394,6 +426,68 @@ suite('WebPageLoader', () => { assert.strictEqual(result.status, 'ok'); })); + test('post-load navigation to different domain is blocked silently and content is extracted', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = URI.parse('https://example.com/page'); + const adRedirectUrl = 'https://eus.rubiconproject.com/usync.html?p=12776'; + + const loader = createWebPageLoader(uri, { followRedirects: false }); + setupDebuggerMock(); + + const loadPromise = loader.load(); + + // Simulate successful page load + window.webContents.emit('did-start-loading'); + window.webContents.emit('did-finish-load'); + + // Simulate ad/tracker script redirecting after page load + const mockEvent: MockElectronEvent = { + preventDefault: sinon.stub() + }; + window.webContents.emit('will-navigate', mockEvent, adRedirectUrl); + + const result = await loadPromise; + + // Navigation should be prevented + assert.ok((mockEvent.preventDefault!).called); + // But result should be ok (content extracted), NOT redirect + assert.strictEqual(result.status, 'ok'); + assert.ok(result.result.includes('Test content from page')); + })); + + test('initial same-domain navigation is allowed but later cross-domain navigation is blocked', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = URI.parse('https://example.com/page'); + const sameDomainUrl = 'https://example.com/otherpage'; + const crossDomainUrl = 'https://eus.rubiconproject.com/usync.html?p=12776'; + + const loader = createWebPageLoader(uri, { followRedirects: false }); + setupDebuggerMock(); + + const loadPromise = loader.load(); + + // First navigation: same-authority, should be allowed + const initialEvent: MockElectronEvent = { + preventDefault: sinon.stub() + }; + window.webContents.emit('will-navigate', initialEvent, sameDomainUrl); + assert.ok(!(initialEvent.preventDefault!).called); + + // Simulate successful page load + window.webContents.emit('did-start-loading'); + window.webContents.emit('did-finish-load'); + + // Second navigation: cross-domain after load, should be blocked + const crossDomainEvent: MockElectronEvent = { + preventDefault: sinon.stub() + }; + window.webContents.emit('will-navigate', crossDomainEvent, crossDomainUrl); + + const result = await loadPromise; + + assert.ok((crossDomainEvent.preventDefault!).called); + assert.strictEqual(result.status, 'ok'); + assert.ok(result.result.includes('Test content from page')); + })); + test('redirect to non-trusted domain is blocked', async () => { const uri = URI.parse('https://example.com/page'); const redirectUrl = 'https://untrusted-domain.com/redirected'; diff --git a/src/vs/platform/window/common/window.ts b/src/vs/platform/window/common/window.ts index 256c55eba5089..291648bca96e3 100644 --- a/src/vs/platform/window/common/window.ts +++ b/src/vs/platform/window/common/window.ts @@ -465,6 +465,8 @@ export interface INativeWindowConfiguration extends IWindowConfiguration, Native os: IOSConfiguration; policiesData?: IStringDictionary<{ definition: PolicyDefinition; value: PolicyValue }>; + + isSessionsWindow?: boolean; } /** diff --git a/src/vs/platform/window/electron-main/window.ts b/src/vs/platform/window/electron-main/window.ts index 13268e716eff3..748e90019de9c 100644 --- a/src/vs/platform/window/electron-main/window.ts +++ b/src/vs/platform/window/electron-main/window.ts @@ -38,7 +38,7 @@ export interface IBaseWindow extends IDisposable { readonly isFullScreen: boolean; toggleFullScreen(): void; - updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string }): void; + updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): void; matches(webContents: electron.WebContents): boolean; } diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 3841cfa34a759..7455376e3f45c 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -46,6 +46,7 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { VSBuffer } from '../../../base/common/buffer.js'; import { errorHandler } from '../../../base/common/errors.js'; import { FocusMode } from '../../native/common/native.js'; +import { Color } from '../../../base/common/color.js'; export interface IWindowCreationOptions { readonly state: IWindowState; @@ -404,7 +405,10 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { private static readonly windowControlHeightStateStorageKey = 'windowControlHeight'; - updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string }): void { + private windowControlsDimmed = false; + private lastWindowControlColors: { backgroundColor?: string; foregroundColor?: string } | undefined; + + updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): void { const win = this.win; if (!win) { return; @@ -417,9 +421,25 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { // Windows/Linux: update window controls via setTitleBarOverlay() if (!isMacintosh && useWindowControlsOverlay(this.configurationService)) { + + // Update dimmed state if explicitly provided + if (options.dimmed !== undefined) { + this.windowControlsDimmed = options.dimmed; + } + + const backgroundColor = options.backgroundColor ?? this.lastWindowControlColors?.backgroundColor; + const foregroundColor = options.foregroundColor ?? this.lastWindowControlColors?.foregroundColor; + + if (options.backgroundColor !== undefined || options.foregroundColor !== undefined) { + this.lastWindowControlColors = { backgroundColor, foregroundColor }; + } + + const effectiveBackgroundColor = this.windowControlsDimmed && backgroundColor ? this.dimColor(backgroundColor) : backgroundColor; + const effectiveForegroundColor = this.windowControlsDimmed && foregroundColor ? this.dimColor(foregroundColor) : foregroundColor; + win.setTitleBarOverlay({ - color: options.backgroundColor?.trim() === '' ? undefined : options.backgroundColor, - symbolColor: options.foregroundColor?.trim() === '' ? undefined : options.foregroundColor, + color: effectiveBackgroundColor?.trim() === '' ? undefined : effectiveBackgroundColor, + symbolColor: effectiveForegroundColor?.trim() === '' ? undefined : effectiveForegroundColor, height: options.height ? options.height - 1 : undefined // account for window border }); } @@ -439,6 +459,24 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { } } + private dimColor(color: string): string { + + // Blend a CSS color with black at 30% opacity to match the + // dimming overlay of `rgba(0, 0, 0, 0.3)` used by modals. + + const parsed = Color.Format.CSS.parse(color); + if (!parsed) { + return color; + } + + const dimFactor = 0.7; // 1 - 0.3 opacity of black overlay + const r = Math.round(parsed.rgba.r * dimFactor); + const g = Math.round(parsed.rgba.g * dimFactor); + const b = Math.round(parsed.rgba.b * dimFactor); + + return `rgb(${r}, ${g}, ${b})`; + } + //#endregion //#region Fullscreen @@ -1166,6 +1204,8 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { let windowUrl: string; if (process.env.VSCODE_DEV && process.env.VSCODE_DEV_SERVER_URL) { windowUrl = process.env.VSCODE_DEV_SERVER_URL; // support URL override for development + } else if (configuration.isSessionsWindow) { + windowUrl = FileAccess.asBrowserUri(`vs/sessions/electron-browser/sessions${this.environmentMainService.isBuilt ? '' : '-dev'}.html`).toString(true); } else { windowUrl = FileAccess.asBrowserUri(`vs/code/electron-browser/workbench/workbench${this.environmentMainService.isBuilt ? '' : '-dev'}.html`).toString(true); } diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index 3cba9d126ff78..50b1321e83e30 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -39,9 +39,10 @@ export interface IWindowsMainService { open(openConfig: IOpenConfiguration): Promise; openEmptyWindow(openConfig: IOpenEmptyConfiguration, options?: IOpenEmptyWindowOptions): Promise; openExtensionDevelopmentHostWindow(extensionDevelopmentPath: string[], openConfig: IOpenConfiguration): Promise; - openExistingWindow(window: ICodeWindow, openConfig: IOpenConfiguration): void; + openSessionsWindow(openConfig: IBaseOpenConfiguration): Promise; + sendToFocused(channel: string, ...args: unknown[]): void; sendToOpeningWindow(channel: string, ...args: unknown[]): void; sendToAll(channel: string, payload?: unknown, windowIdsToIgnore?: number[]): void; diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index e4f9be6fdb984..9771a607efe92 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -19,7 +19,7 @@ import { basename, join, normalize, posix } from '../../../base/common/path.js'; import { getMarks, mark } from '../../../base/common/performance.js'; import { IProcessEnvironment, isMacintosh, isWindows, OS } from '../../../base/common/platform.js'; import { cwd } from '../../../base/common/process.js'; -import { extUriBiasedIgnorePathCase, isEqualAuthority, normalizePath, originalFSPath, removeTrailingPathSeparator } from '../../../base/common/resources.js'; +import { extUriBiasedIgnorePathCase, isEqual, isEqualAuthority, normalizePath, originalFSPath, removeTrailingPathSeparator } from '../../../base/common/resources.js'; import { assertReturnsDefined } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { getNLSLanguage, getNLSMessages, localize } from '../../../nls.js'; @@ -39,7 +39,7 @@ import { getRemoteAuthority } from '../../remote/common/remoteHosts.js'; import { IStateService } from '../../state/node/state.js'; import { IAddRemoveFoldersRequest, INativeOpenFileRequest, INativeWindowConfiguration, IOpenEmptyWindowOptions, IPath, IPathsToWaitFor, isFileToOpen, isFolderToOpen, isWorkspaceToOpen, IWindowOpenable, IWindowSettings } from '../../window/common/window.js'; import { CodeWindow } from './windowImpl.js'; -import { IOpenConfiguration, IOpenEmptyConfiguration, IWindowsCountChangedEvent, IWindowsMainService, OpenContext, getLastFocused } from './windows.js'; +import { IBaseOpenConfiguration, IOpenConfiguration, IOpenEmptyConfiguration, IWindowsCountChangedEvent, IWindowsMainService, OpenContext, getLastFocused } from './windows.js'; import { findWindowOnExtensionDevelopmentPath, findWindowOnFile, findWindowOnWorkspaceOrFolder } from './windowsFinder.js'; import { IWindowState, WindowsStateHandler } from './windowsStateHandler.js'; import { IRecent } from '../../workspaces/common/workspaces.js'; @@ -58,6 +58,7 @@ import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-mai import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { ICSSDevelopmentService } from '../../cssDev/node/cssDevService.js'; import { ResourceSet } from '../../../base/common/map.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; //#region Helper Interfaces @@ -291,6 +292,31 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic this.handleChatRequest(openConfig, [window]); } + async openSessionsWindow(openConfig: IBaseOpenConfiguration): Promise { + this.logService.trace('windowsManager#openSessionsWindow'); + + const agentSessionsWorkspaceUri = this.environmentMainService.agentSessionsWorkspace; + if (!agentSessionsWorkspaceUri) { + throw new Error('Sessions workspace is not configured'); + } + + // Ensure the workspace file exists + const workspaceExists = await this.fileService.exists(agentSessionsWorkspaceUri); + if (!workspaceExists) { + const emptyWorkspaceContent = JSON.stringify({ folders: [] }, null, '\t'); + await this.fileService.writeFile(agentSessionsWorkspaceUri, VSBuffer.fromString(emptyWorkspaceContent)); + } + + // Open in a new browser window with the agent sessions workspace + return this.open({ + ...openConfig, + urisToOpen: [{ workspaceUri: agentSessionsWorkspaceUri }], + cli: this.environmentMainService.args, + forceNewWindow: true, + noRecentEntry: true, + }); + } + async open(openConfig: IOpenConfiguration): Promise { this.logService.trace('windowsManager#open'); @@ -1541,7 +1567,9 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic policiesData: this.policyService.serialize(), continueOn: this.environmentMainService.continueOn, - cssModules: this.cssDevelopmentService.isEnabled ? await this.cssDevelopmentService.getCssModules() : undefined + cssModules: this.cssDevelopmentService.isEnabled ? await this.cssDevelopmentService.getCssModules() : undefined, + + isSessionsWindow: isWorkspaceIdentifier(options.workspace) && isEqual(options.workspace.configPath, this.environmentMainService.agentSessionsWorkspace), }; // New window diff --git a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts index 2a95195c06bd9..eb3bad9ad55ae 100644 --- a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts +++ b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts @@ -73,7 +73,7 @@ suite('WindowsFinder', () => { isDocumentEdited(): boolean { throw new Error('Method not implemented.'); } updateTouchBar(items: UriDto[][]): void { throw new Error('Method not implemented.'); } serializeWindowState(): IWindowState { throw new Error('Method not implemented'); } - updateWindowControls(options: { height?: number | undefined; backgroundColor?: string | undefined; foregroundColor?: string | undefined }): void { throw new Error('Method not implemented.'); } + updateWindowControls(options: { height?: number | undefined; backgroundColor?: string | undefined; foregroundColor?: string | undefined; dimmed?: boolean | undefined }): void { throw new Error('Method not implemented.'); } notifyZoomLevel(level: number): void { throw new Error('Method not implemented.'); } matches(webContents: Electron.WebContents): boolean { throw new Error('Method not implemented.'); } dispose(): void { } diff --git a/src/vs/platform/workspace/common/workspace.ts b/src/vs/platform/workspace/common/workspace.ts index c45fcaa00c0fa..a0459d077e6ea 100644 --- a/src/vs/platform/workspace/common/workspace.ts +++ b/src/vs/platform/workspace/common/workspace.ts @@ -282,10 +282,6 @@ export interface IWorkspace { */ readonly configuration?: URI | null; - /** - * Whether this workspace is an agent sessions workspace. - */ - readonly isAgentSessionsWorkspace?: boolean; } export function isWorkspace(thing: unknown): thing is IWorkspace { @@ -349,7 +345,6 @@ export class Workspace implements IWorkspace { private _transient: boolean, private _configuration: URI | null, private ignorePathCasing: (key: URI) => boolean, - private _isAgentSessionsWorkspace?: boolean, ) { this.foldersMap = TernarySearchTree.forUris(this.ignorePathCasing, () => true); this.folders = folders; @@ -360,7 +355,6 @@ export class Workspace implements IWorkspace { this._configuration = workspace.configuration; this._transient = workspace.transient; this.ignorePathCasing = workspace.ignorePathCasing; - this._isAgentSessionsWorkspace = workspace.isAgentSessionsWorkspace; this.folders = workspace.folders; } @@ -380,10 +374,6 @@ export class Workspace implements IWorkspace { this._configuration = configuration; } - get isAgentSessionsWorkspace(): boolean | undefined { - return this._isAgentSessionsWorkspace; - } - getFolder(resource: URI): IWorkspaceFolder | null { if (!resource) { return null; @@ -400,7 +390,7 @@ export class Workspace implements IWorkspace { } toJSON(): IWorkspace { - return { id: this.id, folders: this.folders, transient: this.transient, configuration: this.configuration, isAgentSessionsWorkspace: this.isAgentSessionsWorkspace }; + return { id: this.id, folders: this.folders, transient: this.transient, configuration: this.configuration }; } } diff --git a/src/vs/server/node/extensionHostConnection.ts b/src/vs/server/node/extensionHostConnection.ts index 0daf9ee703182..e093c3f9b04eb 100644 --- a/src/vs/server/node/extensionHostConnection.ts +++ b/src/vs/server/node/extensionHostConnection.ts @@ -109,7 +109,7 @@ class ConnectionData { export class ExtensionHostConnection extends Disposable { - private _onClose = new Emitter(); + private _onClose = this._register(new Emitter()); readonly onClose: Event = this._onClose.event; private readonly _canSendSocket: boolean; diff --git a/src/vs/server/node/remoteExtensionManagement.ts b/src/vs/server/node/remoteExtensionManagement.ts index e2ac965cb16dd..df587cf746828 100644 --- a/src/vs/server/node/remoteExtensionManagement.ts +++ b/src/vs/server/node/remoteExtensionManagement.ts @@ -111,6 +111,7 @@ export class ManagementConnection { this.protocol.dispose(); socket.end(); this._onClose.fire(undefined); + this._onClose.dispose(); } public acceptReconnection(remoteAddress: string, socket: ISocket, initialDataChunk: VSBuffer): void { diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md new file mode 100644 index 0000000000000..9dceeac3a2b24 --- /dev/null +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -0,0 +1,94 @@ +# 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. + +## Current Architecture + +### File Structure (Agentic) + +``` +src/vs/sessions/contrib/aiCustomizationManagement/browser/ +├── 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) +├── customizationCreatorService.ts # AI-guided creation flow +├── mcpListWidget.ts # MCP servers section +├── SPEC.md # Feature specification +└── media/ + └── aiCustomizationManagement.css + +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 +└── 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) + +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) + +- 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. + +--- + +## AI Feature Gating + +All commands and UI must respect `ChatContextKeys.enabled`: + +```typescript +All entry points (view contributions, commands) respect `ChatContextKeys.enabled`. +``` + +--- + +## References + +- [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) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md new file mode 100644 index 0000000000000..1448a8a3dca49 --- /dev/null +++ b/src/vs/sessions/LAYOUT.md @@ -0,0 +1,741 @@ +# Agent Sessions Workbench Layout Specification + +This document is the **authoritative specification** for the Agent Sessions workbench layout. All implementation changes must be reflected here, and all development work should reference this document. + +--- + +## 1. Overview + +The Agent Sessions Workbench (`Workbench` in `sessions/browser/workbench.ts`) provides a simplified, fixed layout optimized for agent session workflows. Unlike the default VS Code workbench, this layout: + +- Does **not** support settings-based customization +- Has **fixed** part positions +- Excludes several standard workbench parts + +--- + +## 2. Layout Structure + +### 2.1 Visual Representation + +``` +┌─────────┬───────────────────────────────────────────────────────┐ +│ │ Titlebar │ +│ ├────────────────────────────────────┬──────────────────┤ +│ Sidebar │ Chat Bar │ Auxiliary Bar │ +│ ├────────────────────────────────────┴──────────────────┤ +│ │ Panel │ +└─────────┴───────────────────────────────────────────────────────┘ + + ┌───────────────────────────────────────┐ + │ ╔═══════════════════════════╗ │ + │ ║ Editor Modal Overlay ║ │ + │ ║ ┌─────────────────────┐ ║ │ + │ ║ │ [header] [X] │ ║ │ + │ ║ ├─────────────────────┤ ║ │ + │ ║ │ │ ║ │ + │ ║ │ Editor Part │ ║ │ + │ ║ │ │ ║ │ + │ ║ │ │ ║ │ + │ ║ └─────────────────────┘ ║ │ + │ ╚═══════════════════════════╝ │ + └───────────────────────────────────────┘ + (shown when editors are open) +``` + +### 2.2 Parts + +#### Included Parts + +| Part | ID Constant | Position | Default Visibility | ViewContainerLocation | +|------|-------------|----------|------------|----------------------| +| Titlebar | `Parts.TITLEBAR_PART` | Top of right section | Always visible | — | +| Sidebar | `Parts.SIDEBAR_PART` | Left, spans full height from top to bottom | Visible | `ViewContainerLocation.Sidebar` | +| Chat Bar | `Parts.CHATBAR_PART` | Top-right section, takes remaining width | Visible | `ViewContainerLocation.ChatBar` | +| Editor | `Parts.EDITOR_PART` | **Modal overlay** (not in grid) | Hidden | — | +| Auxiliary Bar | `Parts.AUXILIARYBAR_PART` | Top-right section, right side | Visible | `ViewContainerLocation.AuxiliaryBar` | +| Panel | `Parts.PANEL_PART` | Below Chat Bar and Auxiliary Bar (right section only) | Hidden | `ViewContainerLocation.Panel` | + +#### Excluded Parts + +The following parts from the default workbench are **not included**: + +| Part | ID Constant | Reason | +|------|-------------|--------| +| Activity Bar | `Parts.ACTIVITYBAR_PART` | Simplified navigation; global activities (Accounts, Manage) are in titlebar instead | +| Status Bar | `Parts.STATUSBAR_PART` | Reduced chrome | +| Banner | `Parts.BANNER_PART` | Not needed | + +--- + +## 3. Titlebar Configuration + +The Agent Sessions workbench uses a fully independent titlebar part (`TitlebarPart`) with its own title service (`TitleService`), implemented in `sessions/browser/parts/titlebarPart.ts`. This is a standalone implementation (not extending `BrowserTitlebarPart`) with a simple three-section layout driven entirely by menus. + +### 3.1 Titlebar Part Architecture + +The titlebar is divided into three sections, each rendered by a `MenuWorkbenchToolBar`: + +| Section | Menu ID | Purpose | +|---------|---------|--------| +| Left | `Menus.TitleBarLeft` | Toggle sidebar and other left-aligned actions | +| Center | `Menus.CommandCenter` | Session picker widget (rendered via `IActionViewItemService`) | +| Right | `Menus.TitleBarRight` | Run script split button, open submenu, toggle secondary sidebar | + +No menubar, no editor actions, no layout controls, no `WindowTitle` dependency. + +### 3.2 Command Center + +The Agent Sessions titlebar includes a command center with a custom title bar widget (`SessionsTitleBarWidget`). It uses custom menu IDs separate from the default workbench command center to avoid conflicts: + +- **`Menus.CommandCenter`** — The center toolbar menu (replaces `MenuId.CommandCenter`) +- **`Menus.TitleBarControlMenu`** — A submenu registered in the command center whose rendering is intercepted by `IActionViewItemService` to display the custom widget + +The widget: +- Extends `BaseActionViewItem` and renders a clickable label showing the active session title +- Shows kind icon (provider type icon), session title, repository folder name, and changes summary (+insertions -deletions) +- On click, opens the `AgentSessionsPicker` quick pick to switch between sessions +- Gets the active session label from `IActiveSessionService.getActiveSession()` and the live model title from `IChatService`, falling back to "New Session" if no active session is found +- Re-renders automatically when the active session changes via `autorun` on `IActiveSessionService.activeSession`, and when session data changes via `IAgentSessionsService.model.onDidChangeSessions` +- Is registered via `SessionsTitleBarContribution` (an `IWorkbenchContribution` in `contrib/sessions/browser/sessionsTitleBarWidget.ts`) that calls `IActionViewItemService.register()` to intercept the submenu rendering + +### 3.3 Left Toolbar + +The Agent Sessions titlebar includes a custom left toolbar that appears after the app icon. This toolbar: + +- Uses `Menus.TitleBarLeft` for its actions +- Uses `HiddenItemStrategy.NoHide` so actions cannot be hidden by users +- Displays actions registered to `Menus.TitleBarLeft` + +### 3.4 Titlebar Actions + +| Action | ID | Location | Behavior | +|--------|-----|----------|----------| +| Toggle Sidebar | `workbench.action.agentToggleSidebarVisibility` | Left toolbar (`TitleBarLeft`) | Toggles primary sidebar visibility | +| Run Script | `workbench.action.agentSessions.runScript` | Right toolbar (`TitleBarRight`) | Split button: runs configured script or shows configure dialog | +| Open... | (submenu) | Right toolbar (`TitleBarRight`) | Split button submenu: Open Terminal, Open in VS Code | +| Toggle Secondary Sidebar | `workbench.action.agentToggleSecondarySidebarVisibility` | Right toolbar (`TitleBarRight`) | Toggles auxiliary bar visibility | + +The toggle sidebar action: +- Shows `layoutSidebarLeft` icon when sidebar is visible +- Shows `layoutSidebarLeftOff` icon when sidebar is hidden +- Bound to `Ctrl+B` / `Cmd+B` keybinding +- Announces visibility changes to screen readers + +The Run Script action: +- Displayed as a split button via `RunScriptDropdownMenuId` submenu on `Menus.TitleBarRight` +- Primary action runs the configured script command in a terminal +- Dropdown includes "Configure Run Action..." to set/change the script +- Registered in `contrib/chat/browser/runScriptAction.ts` + +The Open... action: +- Displayed as a split button via `Menus.OpenSubMenu` on `Menus.TitleBarRight` +- Contains "Open Terminal" (opens terminal at session worktree) and "Open in VS Code" (opens worktree in new VS Code window) +- Registered in `contrib/chat/browser/chat.contribution.ts` + +### 3.5 Panel Title Actions + +The panel title bar includes actions for controlling the panel: + +| Action | ID | Icon | Order | Behavior | +|--------|-----|------|-------|----------| +| Hide Panel | `workbench.action.agentTogglePanelVisibility` | `close` | 2 | Hides the panel | + +### 3.6 Account Widget + +The account widget has been moved from the titlebar to the **sidebar footer**. It is rendered as a custom `AccountWidget` action view item: + +- Registered in `contrib/accountMenu/browser/account.contribution.ts` +- Uses the `Menus.SidebarFooter` menu +- Shows account button with sign-in/sign-out and an update button when an update is available +- Account menu shows signed-in user label from `IDefaultAccountService` (or Sign In), Sign Out, Settings, and Check for Updates + +--- + +## 4. Grid Structure + +The layout uses `SerializableGrid` from `vs/base/browser/ui/grid/grid.js`. + +### 4.1 Grid Tree + +The Editor part is **not** in the grid — it is rendered as a modal overlay (see Section 4.3). + +``` +Orientation: HORIZONTAL (root) +├── Sidebar (leaf, size: 300px default) +└── Right Section (branch, VERTICAL, size: remaining width) + ├── Titlebar (leaf, size: titleBarHeight) + ├── Top Right (branch, HORIZONTAL, size: remaining height - panel) + │ ├── Chat Bar (leaf, size: remaining width) + │ └── Auxiliary Bar (leaf, size: 300px default) + └── Panel (leaf, size: 300px default, hidden by default) +``` + +This structure places the sidebar at the root level spanning the full window height. The titlebar, chat bar, auxiliary bar, and panel are all within the right section. + +### 4.2 Default Sizes + +| Part | Default Size | +|------|--------------| +| Sidebar | 300px width | +| Auxiliary Bar | 300px width | +| Chat Bar | Remaining space | +| Editor Modal | 80% of workbench (min 400x300, max 1200x900), calculated in TypeScript | +| Panel | 300px height | +| Titlebar | Determined by `minimumHeight` (~30px) | + +### 4.3 Editor Modal + +The Editor part is rendered as a **modal overlay** rather than being part of the grid. This provides a focused editing experience that hovers above the main workbench layout. + +#### Modal Structure + +``` +EditorModal +├── Overlay (semi-transparent backdrop) +├── Container (centered dialog) +│ ├── Header (32px, contains close button) +│ └── Content (editor part fills remaining space) +``` + +#### Behavior + +| Trigger | Action | +|---------|--------| +| Editor opens (`onWillOpenEditor`) | Modal shows automatically | +| All editors close | Modal hides automatically | +| Click backdrop | Close all editors, hide modal | +| Click close button (X) | Close all editors, hide modal | +| Press Escape key | Close all editors, hide modal | + +#### Modal Sizing + +Modal dimensions are calculated in TypeScript rather than CSS. The `EditorModal.layout()` method receives workbench dimensions and computes the modal size with constraints: + +| Property | Value | Constant | +|----------|-------|----------| +| Size Percentage | 80% of workbench | `MODAL_SIZE_PERCENTAGE = 0.8` | +| Max Width | 1200px | `MODAL_MAX_WIDTH = 1200` | +| Max Height | 900px | `MODAL_MAX_HEIGHT = 900` | +| Min Width | 400px | `MODAL_MIN_WIDTH = 400` | +| Min Height | 300px | `MODAL_MIN_HEIGHT = 300` | +| Header Height | 32px | `MODAL_HEADER_HEIGHT = 32` | + +The calculation: +```typescript +modalWidth = min(MODAL_MAX_WIDTH, max(MODAL_MIN_WIDTH, workbenchWidth * MODAL_SIZE_PERCENTAGE)) +modalHeight = min(MODAL_MAX_HEIGHT, max(MODAL_MIN_HEIGHT, workbenchHeight * MODAL_SIZE_PERCENTAGE)) +contentHeight = modalHeight - MODAL_HEADER_HEIGHT +``` + +#### CSS Classes + +| Class | Applied To | Notes | +|-------|------------|-------| +| `editor-modal-overlay` | Overlay container | Positioned absolute, full size | +| `editor-modal-overlay.visible` | When modal is shown | Enables pointer events | +| `editor-modal-backdrop` | Semi-transparent backdrop | Clicking closes modal | +| `editor-modal-container` | Centered modal dialog | Width/height set in TypeScript | +| `editor-modal-header` | Header with close button | Fixed 32px height | +| `editor-modal-content` | Editor content area | Width/height set in TypeScript | +| `editor-modal-visible` | Added to `mainContainer` when modal is visible | — | + +#### Implementation + +The modal is implemented in `EditorModal` class (`parts/editorModal.ts`): + +```typescript +class EditorModal extends Disposable { + // Events + readonly onDidChangeVisibility: Event; + + // State + get visible(): boolean; + + // Methods + show(): void; // Show modal using stored dimensions + hide(): void; // Hide modal + close(): void; // Close all editors, then hide + layout(workbenchWidth: number, workbenchHeight: number): void; // Store dimensions, re-layout if visible +} +``` + +The `Workbench.layout()` passes the workbench dimensions to `EditorModal.layout()`, which calculates and applies the modal size with min/max constraints. Dimensions are stored so that `show()` can use them when the modal becomes visible. + +--- + +## 5. Feature Support Matrix + +| Feature | Default Workbench | Agent Sessions | Notes | +|---------|-------------------|----------------|-------| +| Activity Bar | ✅ Configurable | ❌ Not included | — | +| Status Bar | ✅ Configurable | ❌ Not included | — | +| Sidebar Position | ✅ Left/Right | 🔒 Fixed: Left | `getSideBarPosition()` returns `Position.LEFT` | +| Panel Position | ✅ Top/Bottom/Left/Right | 🔒 Fixed: Bottom | `getPanelPosition()` returns `Position.BOTTOM` | +| Panel Alignment | ✅ Left/Center/Right/Justify | 🔒 Fixed: Justify | `getPanelAlignment()` returns `'justify'` | +| Maximize Panel | ✅ Supported | ✅ Supported | Excludes titlebar when maximizing | +| Maximize Auxiliary Bar | ✅ Supported | ❌ No-op | `toggleMaximizedAuxiliaryBar()` does nothing | +| Zen Mode | ✅ Supported | ❌ No-op | `toggleZenMode()` does nothing | +| Centered Editor Layout | ✅ Supported | ❌ No-op | `centerMainEditorLayout()` does nothing | +| Menu Bar Toggle | ✅ Supported | ❌ No-op | `toggleMenuBar()` does nothing | +| Resize Parts | ✅ Supported | ✅ Supported | Via grid or programmatic API | +| Hide/Show Parts | ✅ Supported | ✅ Supported | Via `setPartHidden()` | +| Window Maximized State | ✅ Supported | ✅ Supported | Tracked per window ID | +| Fullscreen | ✅ Supported | ✅ Supported | CSS class applied | + +--- + +## 6. API Reference + +### 6.1 Part Visibility + +```typescript +// Check if a part is visible +isVisible(part: Parts): boolean + +// Show or hide a part +setPartHidden(hidden: boolean, part: Parts): void +``` + +**Behavior:** +- Hiding a part also hides its active pane composite +- Showing a part restores the last active pane composite +- **Panel Part:** + - If the panel is maximized when hiding, it exits maximized state first +- **Editor Part Auto-Visibility:** + - Automatically shows when an editor is about to open (`onWillOpenEditor`) + - Automatically hides when the last editor closes (`onDidCloseEditor` + all groups empty) + +### 6.2 Part Sizing + +```typescript +// Get current size of a part +getSize(part: Parts): IViewSize + +// Set absolute size of a part +setSize(part: Parts, size: IViewSize): void + +// Resize by delta values +resizePart(part: Parts, sizeChangeWidth: number, sizeChangeHeight: number): void +``` + +### 6.3 Focus Management + +```typescript +// Focus a specific part +focusPart(part: Parts): void + +// Check if a part has focus +hasFocus(part: Parts): boolean + +// Focus the Chat Bar (default focus target) +focus(): void +``` + +### 6.4 Container Access + +```typescript +// Get the main container or active container +get mainContainer(): HTMLElement +get activeContainer(): HTMLElement + +// Get container for a specific part +getContainer(targetWindow: Window, part?: Parts): HTMLElement | undefined +``` + +### 6.5 Layout Offset + +```typescript +// Get offset info for positioning elements +get mainContainerOffset(): ILayoutOffsetInfo +get activeContainerOffset(): ILayoutOffsetInfo +``` + +Returns `{ top, quickPickTop }` where `top` is the titlebar height. + +--- + +## 7. Events + +| Event | Fired When | +|-------|------------| +| `onDidChangePartVisibility` | Any part visibility changes | +| `onDidLayoutMainContainer` | Main container is laid out | +| `onDidLayoutActiveContainer` | Active container is laid out | +| `onDidLayoutContainer` | Any container is laid out | +| `onDidChangeWindowMaximized` | Window maximized state changes | +| `onDidChangeNotificationsVisibility` | Notification visibility changes | +| `onWillShutdown` | Workbench is about to shut down | +| `onDidShutdown` | Workbench has shut down | + +**Events that never fire** (unsupported features): +- `onDidChangeZenMode` +- `onDidChangeMainEditorCenteredLayout` +- `onDidChangePanelAlignment` +- `onDidChangePanelPosition` +- `onDidChangeAuxiliaryBarMaximized` + +--- + +## 8. CSS Classes + +### 8.1 Visibility Classes + +Applied to `mainContainer` based on part visibility: + +| Class | Applied When | +|-------|--------------| +| `nosidebar` | Sidebar is hidden | +| `nomaineditorarea` | Editor modal is hidden | +| `noauxiliarybar` | Auxiliary bar is hidden | +| `nochatbar` | Chat bar is hidden | +| `nopanel` | Panel is hidden | +| `editor-modal-visible` | Editor modal is visible | + +### 8.2 Window State Classes + +| Class | Applied When | +|-------|--------------| +| `fullscreen` | Window is in fullscreen mode | +| `maximized` | Window is maximized | + +### 8.3 Platform Classes + +Applied during workbench render: +- `monaco-workbench` +- `agent-sessions-workbench` +- `windows` / `linux` / `mac` +- `web` (if running in browser) +- `chromium` / `firefox` / `safari` + +--- + +## 9. Agent Session Parts + +The Agent Sessions workbench uses specialized part implementations that extend the base pane composite infrastructure but are simplified for agent session contexts. + +### 9.1 Part Classes + +| Part | Class | Extends | Location | +|------|-------|---------|----------| +| Sidebar | `SidebarPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/sidebarPart.ts` | +| Auxiliary Bar | `AuxiliaryBarPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/auxiliaryBarPart.ts` | +| Panel | `PanelPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/panelPart.ts` | +| Chat Bar | `ChatBarPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/chatBarPart.ts` | +| Titlebar | `TitlebarPart` / `MainTitlebarPart` | `Part` | `sessions/browser/parts/titlebarPart.ts` | +| Project Bar | `ProjectBarPart` | `Part` | `sessions/browser/parts/projectBarPart.ts` | +| Editor Modal | `EditorModal` | `Disposable` | `sessions/browser/parts/editorModal.ts` | + +### 9.2 Key Differences from Standard Parts + +| Feature | Standard Parts | Agent Session Parts | +|---------|----------------|---------------------| +| Activity Bar integration | Full support | No activity bar; account widget in sidebar footer | +| Composite bar position | Configurable (top/bottom/title/hidden) | Fixed: Title | +| Composite bar visibility | Configurable | Sidebar: hidden (`shouldShowCompositeBar()` returns `false`); ChatBar: hidden; Auxiliary Bar & Panel: visible | +| Auto-hide support | Configurable | Disabled | +| Configuration listening | Many settings | Minimal | +| Context menu actions | Full set | Simplified | +| Title bar | Full support | Sidebar: `hasTitle: true` (with footer); ChatBar: `hasTitle: false`; Auxiliary Bar & Panel: `hasTitle: true` | +| Visual margins | None | Auxiliary Bar: 8px top/bottom/right (card appearance); Panel: 8px bottom/left/right (card appearance); Sidebar: 0 (flush) | + +### 9.3 Part Creation + +The agent sessions pane composite parts are created and registered via the `AgenticPaneCompositePartService` in `sessions/browser/paneCompositePartService.ts`. This service is registered as a singleton for `IPaneCompositePartService` and directly instantiates each part: + +```typescript +// In AgenticPaneCompositePartService constructor +this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(PanelPart)); +this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(SidebarPart)); +this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(AuxiliaryBarPart)); +this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(ChatBarPart)); +``` + +This architecture ensures that: +1. The agent sessions workbench uses its own part implementations rather than the standard workbench parts +2. Each part is instantiated eagerly in the constructor, as the service delegates all operations to the appropriate part by `ViewContainerLocation` + +### 9.4 Storage Keys + +Each agent session part uses separate storage keys to avoid conflicts with regular workbench state: + +| Part | Setting | Storage Key | +|------|---------|-------------| +| Sidebar | Active viewlet | `workbench.agentsession.sidebar.activeviewletid` | +| Sidebar | Pinned viewlets | `workbench.agentsession.pinnedViewlets2` | +| Sidebar | Placeholders | `workbench.agentsession.placeholderViewlets` | +| Sidebar | Workspace state | `workbench.agentsession.viewletsWorkspaceState` | +| Auxiliary Bar | Active panel | `workbench.agentsession.auxiliarybar.activepanelid` | +| Auxiliary Bar | Pinned views | `workbench.agentsession.auxiliarybar.pinnedPanels` | +| Auxiliary Bar | Placeholders | `workbench.agentsession.auxiliarybar.placeholderPanels` | +| Auxiliary Bar | Workspace state | `workbench.agentsession.auxiliarybar.viewContainersWorkspaceState` | +| Panel | Active panel | `workbench.agentsession.panelpart.activepanelid` | +| Panel | Pinned panels | `workbench.agentsession.panel.pinnedPanels` | +| Panel | Placeholders | `workbench.agentsession.panel.placeholderPanels` | +| Panel | Workspace state | `workbench.agentsession.panel.viewContainersWorkspaceState` | +| Chat Bar | Active panel | `workbench.chatbar.activepanelid` | +| Chat Bar | Pinned panels | `workbench.chatbar.pinnedPanels` | +| Chat Bar | Placeholders | `workbench.chatbar.placeholderPanels` | +| Chat Bar | Workspace state | `workbench.chatbar.viewContainersWorkspaceState` | + +### 9.5 Part Borders and Card Appearance + +Parts manage their own border and background styling via the `updateStyles()` method. The auxiliary bar and panel use a **card appearance** with CSS variables for background and border: + +| Part | Styling | Notes | +|------|---------|-------| +| Sidebar | Right border via `SIDE_BAR_BORDER` / `contrastBorder` | Flush appearance, no card styling | +| Chat Bar | Background only, no borders | `borderWidth` returns `0` | +| Auxiliary Bar | Card appearance via CSS variables `--part-background` / `--part-border-color` | Uses `SIDE_BAR_BACKGROUND` / `SIDE_BAR_BORDER`; transparent background on container; margins create card offset | +| Panel | Card appearance via CSS variables `--part-background` / `--part-border-color` | Uses `PANEL_BACKGROUND` / `PANEL_BORDER`; transparent background on container; margins create card offset | + +--- + +### 9.6 Auxiliary Bar Run Script Dropdown + +The `AuxiliaryBarPart` provides a custom `DropdownWithPrimaryActionViewItem` for the run script action (`workbench.action.agentSessions.runScript`). This is rendered as a split button with: + +- **Primary action**: Runs the main script action +- **Dropdown**: Shows additional actions from the `AgentSessionsRunScriptDropdown` menu +- The dropdown menu is created from `MenuId.for('AgentSessionsRunScriptDropdown')` and updates dynamically when menu items change + +### 9.7 Sidebar Footer + +The `SidebarPart` includes a footer section (35px height) positioned below the pane composite content. The sidebar uses a custom `layout()` override that reduces the content height by `FOOTER_HEIGHT` and renders a `MenuWorkbenchToolBar` driven by `Menus.SidebarFooter`. The footer hosts the account widget (see Section 3.6). + +On macOS native, the sidebar title area includes a traffic light spacer (70px) to push content past the system window controls, which is hidden in fullscreen mode. + +--- + +## 10. Workbench Contributions + +The Agent Sessions workbench registers contributions via module imports in `sessions.desktop.main.ts` (and `sessions.common.main.ts`). Key contributions: + +| Contribution | Class | Phase | Location | +|-------------|-------|-------|----------| +| Run Script | `RunScriptContribution` | `AfterRestored` | `contrib/chat/browser/runScriptAction.ts` | +| Title Bar Widget | `SessionsTitleBarContribution` | `AfterRestored` | `contrib/sessions/browser/sessionsTitleBarWidget.ts` | +| Account Widget | `AccountWidgetContribution` | `AfterRestored` | `contrib/accountMenu/browser/account.contribution.ts` | +| Active Session Service | `ActiveSessionService` | Singleton | `contrib/sessions/browser/activeSessionService.ts` | +| Prompts Service | `AgenticPromptsService` | Singleton | `contrib/chat/browser/promptsService.ts` | + +Additionally, `BranchChatSessionAction` is registered in `contrib/chat/browser/chat.contribution.ts`. + +### 10.1 Changes View + +The Changes view is registered in `contrib/changesView/browser/changesView.contribution.ts`: + +- **Container**: `CHANGES_VIEW_CONTAINER_ID` in `ViewContainerLocation.AuxiliaryBar` (default, hidden if empty) +- **View**: `CHANGES_VIEW_ID` with `ChangesViewPane` +- **Window visibility**: `WindowVisibility.Sessions` (only visible in agent sessions workbench) + +### 10.2 Sessions View + +The Sessions view is registered in `contrib/sessions/browser/sessions.contribution.ts`: + +- **Container**: Sessions container in `ViewContainerLocation.Sidebar` (default) +- **View**: `SessionsViewId` with `AgenticSessionsViewPane` +- **Window visibility**: `WindowVisibility.Sessions` + +--- + +## 11. File Structure + +``` +src/vs/sessions/ +├── README.md # Layer specification +├── LAYOUT.md # This specification +├── AI_CUSTOMIZATIONS.md # AI customization design document +├── sessions.common.main.ts # Common entry point (browser + desktop) +├── sessions.desktop.main.ts # Desktop entry point (imports all contributions) +├── common/ +│ └── contextkeys.ts # ChatBar context keys +├── browser/ # Core workbench implementation +│ ├── workbench.ts # Main layout implementation (Workbench class) +│ ├── menus.ts # Agent sessions menu IDs (Menus export) +│ ├── layoutActions.ts # Layout actions (toggle sidebar, secondary sidebar, panel) +│ ├── paneCompositePartService.ts # AgenticPaneCompositePartService +│ ├── style.css # Layout-specific styles (including editor modal) +│ ├── widget/ # Agent sessions chat widget +│ │ ├── AGENTS_CHAT_WIDGET.md # Chat widget architecture documentation +│ │ ├── agentSessionsChatWidget.ts # Main chat widget wrapper +│ │ ├── agentSessionsChatTargetConfig.ts # Target configuration (observable) +│ │ ├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar +│ │ └── media/ +│ │ └── agentSessionsChatWidget.css +│ └── parts/ +│ ├── titlebarPart.ts # Simplified titlebar part, MainTitlebarPart, AuxiliaryTitlebarPart, and TitleService +│ ├── sidebarPart.ts # Agent session sidebar (with footer and macOS traffic light spacer) +│ ├── auxiliaryBarPart.ts # Agent session auxiliary bar (with run script dropdown) +│ ├── panelPart.ts # Agent session panel +│ ├── chatBarPart.ts # Chat Bar part implementation +│ ├── projectBarPart.ts # Project bar part (folder entries, icon customization) +│ ├── editorModal.ts # Editor modal overlay implementation +│ ├── parts.ts # AgenticParts enum +│ ├── agentSessionsChatInputPart.ts # Chat input part adapter +│ ├── agentSessionsChatWelcomePart.ts # Chat welcome part +│ └── media/ +│ ├── titlebarpart.css +│ ├── sidebarPart.css +│ ├── chatBarPart.css +│ ├── projectBarPart.css +│ └── agentSessionsChatWelcomePart.css +├── electron-browser/ # Desktop-specific entry points +│ ├── sessions.main.ts +│ ├── sessions.ts +│ ├── sessions.html +│ └── sessions-dev.html +├── contrib/ # Feature contributions +│ ├── accountMenu/browser/ # Account menu widget for sidebar footer +│ │ ├── account.contribution.ts +│ │ └── media/ +│ ├── aiCustomizationManagement/browser/ # AI customization management editor +│ ├── aiCustomizationTreeView/browser/ # AI customization tree view sidebar +│ ├── changesView/browser/ # File changes view +│ │ ├── changesView.contribution.ts +│ │ ├── changesView.ts +│ │ └── media/ +│ ├── chat/browser/ # Chat actions and services +│ │ ├── chat.contribution.ts # Open in VS Code, Open Terminal, branch chat, run script, prompts service +│ │ ├── branchChatSessionAction.ts # Branch chat session action +│ │ ├── runScriptAction.ts # Run script contribution and split button +│ │ └── promptsService.ts # Agentic prompts service override +│ ├── configuration/browser/ # Configuration contribution +│ │ └── configuration.contribution.ts +│ └── sessions/browser/ # Sessions view and title bar widget +│ ├── sessions.contribution.ts # Sessions view container, view, and title bar widget registration +│ ├── sessionsViewPane.ts # Sessions list view pane +│ ├── sessionsTitleBarWidget.ts # Title bar widget (SessionsTitleBarWidget, SessionsTitleBarContribution) +│ ├── activeSessionService.ts # IActiveSessionService implementation +│ └── media/ +│ └── sessionsTitleBarWidget.css +``` + +--- + +## 12. Implementation Requirements + +When modifying the Agent Sessions layout: + +1. **Maintain fixed positions** — Do not add settings-based position customization +2. **Panel must span the right section width** — The grid structure places the panel below Chat Bar and Auxiliary Bar only +3. **Sidebar spans full window height** — Sidebar is at the root grid level, spanning from top to bottom independently of the titlebar +4. **New parts go in right section** — Any new parts should be added to the right section alongside Titlebar, Chat Bar, and Auxiliary Bar +5. **Update this spec** — All changes must be documented here +5. **Preserve no-op methods** — Unsupported features should remain as no-ops, not throw errors +6. **Handle pane composite lifecycle** — When hiding/showing parts, manage the associated pane composites +7. **Use agent session parts** — New functionality for parts should be added to the agent session part classes, not the standard parts + +--- + +## 13. Lifecycle + +### 13.1 Startup Sequence + +1. `constructor()` — Register error handlers +2. `startup()` — Initialize services and layout +3. `initServices()` — Set up service collection (including `TitleService`), register singleton services, set lifecycle to `Ready` +4. `initLayout()` — Get services, register layout listeners, register editor open/close listeners +5. `renderWorkbench()` — Create DOM, create parts, create editor modal, set up notifications +6. `createWorkbenchLayout()` — Build the grid structure +7. `createWorkbenchManagement()` — (No-op in agent sessions layout) +8. `layout()` — Perform initial layout +9. `restore()` — Restore parts (open default view containers), set lifecycle to `Restored`, then `Eventually` + +Note: Contributions are registered via module imports in `sessions.desktop.main.ts` (through `registerWorkbenchContribution2`, `registerAction2`, `registerSingleton` calls), not via a central registration function. + +### 13.2 Part Restoration + +During the `restore()` phase, `restoreParts()` is called to open the default view container for each visible part: + +```typescript +private restoreParts(): void { + const partsToRestore = [ + { location: ViewContainerLocation.Sidebar, visible: this.partVisibility.sidebar }, + { location: ViewContainerLocation.Panel, visible: this.partVisibility.panel }, + { location: ViewContainerLocation.AuxiliaryBar, visible: this.partVisibility.auxiliaryBar }, + { location: ViewContainerLocation.ChatBar, visible: this.partVisibility.chatBar }, + ]; + + for (const { location, visible } of partsToRestore) { + if (visible) { + const defaultViewContainer = this.viewDescriptorService.getDefaultViewContainer(location); + if (defaultViewContainer) { + this.paneCompositeService.openPaneComposite(defaultViewContainer.id, location); + } + } + } +} +``` + +This ensures that when a part is visible, its default view container is automatically opened and displayed. + +### 13.3 State Tracking + +```typescript +interface IPartVisibilityState { + sidebar: boolean; + auxiliaryBar: boolean; + editor: boolean; + panel: boolean; + chatBar: boolean; +} +``` + +**Initial state:** + +| Part | Initial Visibility | +|------|--------------------| +| Sidebar | `true` (visible) | +| Auxiliary Bar | `true` (visible) | +| Chat Bar | `true` (visible) | +| Editor | `false` (hidden) | +| Panel | `false` (hidden) | + +--- + +## 14. Sidebar Reveal Buttons + +> **Note:** Sidebar reveal buttons (`SidebarRevealButton`) have been removed from the implementation. The corresponding file `parts/sidebarRevealButton.ts` no longer exists. Sidebar visibility is controlled via the toggle actions in the titlebar (see Section 3.4). + +--- + +## Revision History + +| Date | Change | +|------|--------| +| 2026-02-17 | Added `-webkit-app-region: drag` to sidebar title area so it can be used to drag the window; interactive children (actions, composite bar, labels) marked `no-drag`; CSS rules scoped to `.agent-sessions-workbench` in `parts/media/sidebarPart.css` | +| 2026-02-13 | Documentation sync: Updated all file names, class names, and references to match current implementation. `AgenticWorkbench` → `Workbench`, `AgenticSidebarPart` → `SidebarPart`, `AgenticAuxiliaryBarPart` → `AuxiliaryBarPart`, `AgenticPanelPart` → `PanelPart`, `agenticWorkbench.ts` → `workbench.ts`, `agenticWorkbenchMenus.ts` → `menus.ts`, `agenticLayoutActions.ts` → `layoutActions.ts`, `AgenticTitleBarWidget` → `SessionsTitleBarWidget`, `AgenticTitleBarContribution` → `SessionsTitleBarContribution`. Removed references to deleted files (`sidebarRevealButton.ts`, `floatingToolbar.ts`, `agentic.contributions.ts`, `agenticTitleBarWidget.ts`). Updated pane composite architecture from `SyncDescriptor`-based to `AgenticPaneCompositePartService`. Moved account widget docs from titlebar to sidebar footer. Added documentation for sidebar footer, project bar, traffic light spacer, card appearance styling, widget directory, and new contrib structure (`accountMenu/`, `chat/`, `configuration/`, `sessions/`). Updated titlebar actions to reflect Run Script split button and Open submenu. Removed Toggle Maximize panel action (no longer registered). Updated contributions section with all current contributions and their locations. | +| 2026-02-13 | Changed grid structure: sidebar now spans full window height at root level (HORIZONTAL root orientation); Titlebar moved inside right section; Grid is now `Sidebar \| [Titlebar / TopRight / Panel]` instead of `Titlebar / [Sidebar \| RightSection]`; Panel maximize now excludes both titlebar and sidebar; Floating toolbar positioning no longer depends on titlebar height | +| 2026-02-11 | Simplified titlebar: replaced `BrowserTitlebarPart`-derived implementation with standalone `TitlebarPart` using three `MenuWorkbenchToolBar` sections (left/center/right); Removed `CommandCenterControl`, `WindowTitle`, layout toolbar, and manual toolbar management; Center section uses `Menus.CommandCenter` which renders session picker via `IActionViewItemService`; Right section uses `Menus.TitleBarRight` which includes account submenu; Removed `commandCenterControl.ts` file | +| 2026-02-11 | Removed activity actions (Accounts, Manage) from titlebar; Added `AgenticAccount` submenu to `TitleBarRight` with account icon; Menu shows signed-in user label from `IDefaultAccountService` (or Sign In action if no account), Settings, and Check for Updates; Added `AgenticAccountContribution` workbench contribution for dynamic account state; Added `AgenticAccount` menu ID to `Menus` | +| 2026-02-10 | Titlebar customization now uses class inheritance with protected getter overrides on `BrowserTitlebarPart`; Base class retains original API — no `ITitlebarPartOptions`/`ITitlebarPartConfiguration` removed; `AgenticTitlebarPart` and `AgenticTitleService` in `parts/agenticTitlebarPart.ts` override `isCommandCenterVisible`, `editorActionsEnabled`, `installMenubar()`, and menu ID getters | +| 2026-02-07 | Comprehensive spec update: fixed widget class names (`AgenticTitleBarWidget`/`AgenticTitleBarContribution`), corrected click behavior (uses `AgentSessionsPicker` not `FocusAgentSessionsAction`), corrected session label source (`IActiveSessionService`), fixed toggle terminal details (uses standard `toggleTerminal` command via `MenuRegistry.appendMenuItem` on right toolbar), added sidebar/chatbar storage keys, added chatbar to part classes table, documented contributions section with `RunScriptContribution`/`AgenticTitleBarContribution`/Changes view, added `agent-sessions-workbench` platform class, documented auxiliary bar run script dropdown, updated file structure with `actions/`, `views/`, `media/` directories, fixed lifecycle section numbering, corrected `focus()` target to ChatBar | +| 2026-02-07 | Moved `ToggleTerminalAction` to `contrib/terminal/browser/terminalAgentSessionActions.ts`; Menu item registered via `MenuRegistry.appendMenuItem` from `agenticLayoutActions.ts` to avoid layering violation |\n| 2026-02-07 | Added `TitleBarLeft`, `TitleBarCenter`, `TitleBarRight` menu IDs to `AgenticWorkbenchMenus`; Added `titleBarMenuId` option to `ITitlebarPartOptions` for overriding the global toolbar menu; Actions now use agent-session-specific menu IDs instead of shared `MenuId.TitleBarLeft` / `MenuId.TitleBar` | +| 2026-02-07 | Moved agent sessions workbench menu IDs to `agenticWorkbenchMenus.ts`; Renamed `AgentSessionMenus` to `AgenticWorkbenchMenus` | +| 2026-02-07 | Added `MenuId.AgentSessionsTitleBarContext` as a separate titlebar context menu ID; `contextMenuId` option now set in both main and auxiliary titlebar configurations | +| 2026-02-07 | Added `ToggleTerminalAction` to left toolbar; toggles panel with terminal view; bound to `` Ctrl+` `` | +| 2026-02-06 | `AgentSessionsTitleBarStatusWidget` now shows active chat session title instead of workspace label; Clicking opens sessions view via `FocusAgentSessionsAction`; Removed folder picker and recent folders | +| 2026-02-06 | Replaced command center folder picker with `AgentSessionsTitleBarStatusWidget` (custom `BaseActionViewItem`); Uses `IActionViewItemService` to intercept `AgentSessionsTitleBarControlMenu` submenu; Shows workspace label pill with quick pick for recent folders | +| 2026-02-06 | Added Command Center with custom `AgenticCommandCenter` menu IDs; Dropdown shows recent folders and Open Folder action; Added `AgenticCommandCenterContribution` | +| 2026-02-06 | Added sidebar reveal buttons (`SidebarRevealButton`) — round edge-hover buttons that appear when sidebars are hidden; implemented in `parts/sidebarRevealButton.ts` | +| 2026-02-06 | Auxiliary Bar now visible by default; Removed `AuxiliaryBarVisibilityContribution` (no longer auto-shows/hides based on chat state) | +| 2026-02-06 | Removed Command Center and Project Bar completely; Layout is now: Sidebar \| Chat Bar \| Auxiliary Bar; Global activities (Accounts, Settings) in titlebar via `supportsActivityActions` | +| 2026-02-06 | ~~Removed Project Bar; Added Command Center to titlebar~~ (superseded) | +| 2026-02-06 | ~~Project Bar now stores folder entries in workspace storage~~ (superseded) | +| 2026-02-05 | Auxiliary Bar now hidden by default; Added `AuxiliaryBarVisibilityContribution` to auto-show when chat session has requests, auto-hide when empty | +| 2026-02-05 | Hiding panel now exits maximized state first if panel was maximized | +| 2026-02-05 | Added panel maximize/minimize support via `toggleMaximizedPanel()`; Uses `Grid.maximizeView()` with exclusions for titlebar; Added `TogglePanelMaximizedAction` and `TogglePanelVisibilityAction` to panel title bar | +| 2026-02-05 | Changed layout structure: Panel is now below Chat Bar and Auxiliary Bar only (not full width); Sidebar spans full height | +| 2026-02-05 | Added configurable titlebar via `ITitlebarPartOptions` and `ITitlebarPartConfiguration`; Titlebar now disables command center, menubar, and editor actions; Added left toolbar with `MenuId.TitleBarLeft`; Added `ToggleSidebarVisibilityAction` in `agenticLayoutActions.ts` | +| 2026-02-04 | Modal sizing (80%, min/max constraints) moved from CSS to TypeScript; `EditorModal.layout()` now accepts workbench dimensions | +| 2026-02-04 | Editor now renders as modal overlay instead of in grid; Added `EditorModal` class in `parts/editorModal.ts`; Closing modal closes all editors; Grid layout is now Sidebar \| Chat Bar \| Auxiliary Bar | +| 2026-02-04 | Changed part creation to use `SyncDescriptor0` for lazy instantiation—parts are created when first accessed, not at service construction time | +| 2026-02-04 | Refactored part creation: each layout class now creates and passes parts to `PaneCompositePartService` via `IPaneCompositePartsConfiguration`, removing `isAgentSessionsWorkspace` dependency from the service | +| 2026-02-04 | Added `restoreParts()` to automatically open default view containers for visible parts during startup | +| 2026-02-04 | Restored Editor part; Layout order is now Sidebar \| Chat Bar \| Editor \| Auxiliary Bar | +| 2026-02-04 | Removed Editor part; Chat Bar now takes max width; Layout order changed to Sidebar \| Auxiliary Bar \| Chat Bar | +| 2026-02-04 | Added agent session specific parts (AgenticSidebarPart, AgenticAuxiliaryBarPart, AgenticPanelPart) in `sessions/browser/parts/`; PaneCompositePartService now selects parts based on isAgentSessionsWorkspace | +| 2026-02-04 | Editor and Panel hidden by default; Editor auto-shows on editor open, auto-hides when last editor closes | +| 2026-02-04 | Added Chat Bar part with `ViewContainerLocation.ChatBar` | +| Initial | Document created with base layout specification | diff --git a/src/vs/sessions/README.md b/src/vs/sessions/README.md new file mode 100644 index 0000000000000..002781a49ce4d --- /dev/null +++ b/src/vs/sessions/README.md @@ -0,0 +1,125 @@ +# vs/sessions — Agentic Sessions Window Layer + +## Overview + +The `vs/sessions` layer hosts the implementation of the **Agentic Window**, a dedicated workbench experience optimized for agent session workflows. This is a distinct top-level layer within the VS Code architecture, sitting alongside `vs/workbench`. + +## Architecture + +### Layering Rules + +``` +vs/base ← Foundation utilities +vs/platform ← Platform services +vs/editor ← Text editor core +vs/workbench ← Standard workbench +vs/sessions ← Agentic window (this layer) +``` + +**Key constraint:** `vs/sessions` may import from `vs/workbench` (and all layers below it), but `vs/workbench` must **never** import from `vs/sessions`. This ensures the standard workbench remains independent of the agentic window implementation. + +### Allowed Dependencies + +| From `vs/sessions` | Can Import | +|--------------------|------------| +| `vs/base/**` | ✅ | +| `vs/platform/**` | ✅ | +| `vs/editor/**` | ✅ | +| `vs/workbench/**` | ✅ | +| `vs/sessions/**` | ✅ (internal) | + +| From `vs/workbench` | Can Import | +|----------------------|------------| +| `vs/sessions/**` | ❌ **Forbidden** | + +### Folder Structure + +The `vs/sessions` layer follows the same layering conventions as `vs/workbench`: + +``` +src/vs/sessions/ +├── README.md ← This specification +├── LAYOUT.md ← Layout specification for the agentic workbench +├── AI_CUSTOMIZATIONS.md ← AI customization design document +├── sessions.common.main.ts ← Common (browser + desktop) entry point +├── sessions.desktop.main.ts ← Desktop entry point +├── common/ ← Shared types and context keys +│ └── contextkeys.ts ← ChatBar context keys +├── browser/ ← Core workbench implementation +│ ├── workbench.ts ← Main workbench layout (Workbench class) +│ ├── layoutActions.ts ← Layout toggle actions +│ ├── menus.ts ← Menu IDs for agent sessions menus (Menus export) +│ ├── paneCompositePartService.ts ← AgenticPaneCompositePartService +│ ├── style.css ← Layout styles +│ ├── widget/ ← Agent sessions chat widget +│ │ ├── AGENTS_CHAT_WIDGET.md ← Chat widget architecture documentation +│ │ ├── agentSessionsChatWidget.ts ← Main chat widget wrapper +│ │ ├── agentSessionsChatTargetConfig.ts ← Target configuration (observable) +│ │ ├── agentSessionsTargetPickerActionItem.ts ← Target picker for input toolbar +│ │ └── media/ +│ │ └── agentSessionsChatWidget.css +│ └── parts/ ← Workbench part implementations +│ ├── titlebarPart.ts ← Simplified titlebar part & title service +│ ├── sidebarPart.ts ← Sidebar part (with footer) +│ ├── auxiliaryBarPart.ts ← Auxiliary bar part (with run script dropdown) +│ ├── panelPart.ts ← Panel part +│ ├── chatBarPart.ts ← Chat bar part +│ ├── projectBarPart.ts ← Project bar part (folder entries) +│ ├── editorModal.ts ← Editor modal overlay +│ ├── parts.ts ← AgenticParts enum +│ ├── agentSessionsChatInputPart.ts ← Chat input part adapter +│ ├── agentSessionsChatWelcomePart.ts ← Chat welcome part +│ └── media/ ← Part CSS +├── electron-browser/ ← Desktop-specific entry points +│ ├── sessions.main.ts +│ ├── sessions.ts +│ ├── sessions.html +│ └── sessions-dev.html +├── contrib/ ← Feature contributions +│ ├── accountMenu/browser/ ← Account menu widget and sidebar footer +│ │ └── account.contribution.ts +│ ├── aiCustomizationManagement/ ← AI customization management editor +│ │ └── browser/ +│ ├── aiCustomizationTreeView/ ← AI customization tree view sidebar +│ │ └── browser/ +│ ├── changesView/browser/ ← File changes view +│ │ ├── changesView.contribution.ts +│ │ └── changesView.ts +│ ├── chat/browser/ ← Chat-related actions and services +│ │ ├── chat.contribution.ts +│ │ ├── branchChatSessionAction.ts +│ │ ├── runScriptAction.ts +│ │ └── promptsService.ts +│ ├── configuration/browser/ ← Configuration contribution +│ │ └── configuration.contribution.ts +│ └── sessions/browser/ ← Sessions view and title bar widget +│ ├── sessions.contribution.ts +│ ├── sessionsViewPane.ts +│ ├── sessionsTitleBarWidget.ts +│ ├── activeSessionService.ts +│ └── media/ +``` + +## What is the Agentic Window? + +The Agentic Window (`Workbench`) provides a simplified, fixed-layout workbench tailored for agent session workflows. Unlike the standard VS Code workbench: + +- **Fixed layout** — Part positions are not configurable via settings +- **Simplified chrome** — No activity bar, no status bar, no banner +- **Chat-first UX** — Chat bar is a primary part alongside sidebar and auxiliary bar +- **Modal editor** — Editors appear as modal overlays rather than in the main grid +- **Session-aware titlebar** — Titlebar shows active session with a session picker +- **Sidebar footer** — Account widget and sign-in live in the sidebar footer + +See [LAYOUT.md](LAYOUT.md) for the detailed layout specification. + +## Adding New Functionality + +When adding features to the agentic window: + +1. **Core workbench code** (layout, parts, services) goes under `browser/` +2. **Feature contributions** (views, actions, editors) go under `contrib//browser/` +3. Register contributions by importing them in `sessions.desktop.main.ts` (or `sessions.common.main.ts` for browser-compatible code) +4. Do **not** add imports from `vs/workbench` back to `vs/sessions` +5. Contributions can import from `vs/sessions/browser/` (core) and other `vs/sessions/contrib/*/` modules +6. Update the layout spec (`LAYOUT.md`) for any layout changes diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts new file mode 100644 index 0000000000000..d16de091c627c --- /dev/null +++ b/src/vs/sessions/browser/layoutActions.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { alert } from '../../base/browser/ui/aria/aria.js'; +import { Codicon } from '../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../base/common/keyCodes.js'; +import { localize, localize2 } from '../../nls.js'; +import { Categories } from '../../platform/action/common/actionCommonCategories.js'; +import { Action2, registerAction2 } from '../../platform/actions/common/actions.js'; +import { Menus } from './menus.js'; +import { ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../platform/keybinding/common/keybindingsRegistry.js'; +import { registerIcon } from '../../platform/theme/common/iconRegistry.js'; +import { AuxiliaryBarVisibleContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; +import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; + +// Register Icons +const panelLeftIcon = registerIcon('agent-panel-left', Codicon.layoutSidebarLeft, localize('panelLeft', "Represents a side bar in the left position")); +const panelLeftOffIcon = registerIcon('agent-panel-left-off', Codicon.layoutSidebarLeftOff, localize('panelLeftOff', "Represents a side bar in the left position that is hidden")); +const panelRightIcon = registerIcon('agent-panel-right', Codicon.layoutSidebarRight, localize('panelRight', "Represents a secondary side bar in the right position")); +const panelRightOffIcon = registerIcon('agent-panel-right-off', Codicon.layoutSidebarRightOff, localize('panelRightOff', "Represents a secondary side bar in the right position that is hidden")); +const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); + +class ToggleSidebarVisibilityAction extends Action2 { + + static readonly ID = 'workbench.action.agentToggleSidebarVisibility'; + static readonly LABEL = localize('compositePart.hideSideBarLabel', "Hide Primary Side Bar"); + + constructor() { + super({ + id: ToggleSidebarVisibilityAction.ID, + title: localize2('toggleSidebar', 'Toggle Primary Side Bar Visibility'), + icon: panelLeftOffIcon, + toggled: { + condition: SideBarVisibleContext, + icon: panelLeftIcon, + title: localize('primary sidebar', "Primary Side Bar"), + mnemonicTitle: localize({ key: 'primary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Primary Side Bar"), + }, + metadata: { + description: localize('openAndCloseSidebar', 'Open/Show and Close/Hide Sidebar'), + }, + category: Categories.View, + f1: true, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyB + }, + menu: [ + { + id: Menus.TitleBarLeft, + group: 'navigation', + order: 0 + } + ] + }); + } + + run(accessor: ServicesAccessor): void { + const layoutService = accessor.get(IWorkbenchLayoutService); + const isCurrentlyVisible = layoutService.isVisible(Parts.SIDEBAR_PART); + + layoutService.setPartHidden(isCurrentlyVisible, Parts.SIDEBAR_PART); + + // Announce visibility change to screen readers + const alertMessage = isCurrentlyVisible + ? localize('sidebarHidden', "Primary Side Bar hidden") + : localize('sidebarVisible', "Primary Side Bar shown"); + alert(alertMessage); + } +} + +class ToggleSecondarySidebarVisibilityAction extends Action2 { + + static readonly ID = 'workbench.action.agentToggleSecondarySidebarVisibility'; + static readonly LABEL = localize('compositePart.hideSecondarySideBarLabel', "Hide Secondary Side Bar"); + + constructor() { + super({ + id: ToggleSecondarySidebarVisibilityAction.ID, + title: localize2('toggleSecondarySidebar', 'Toggle Secondary Side Bar Visibility'), + icon: panelRightOffIcon, + toggled: { + condition: AuxiliaryBarVisibleContext, + icon: panelRightIcon, + title: localize('secondary sidebar', "Secondary Side Bar"), + mnemonicTitle: localize({ key: 'secondary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Secondary Side Bar"), + }, + metadata: { + description: localize('openAndCloseSecondarySidebar', 'Open/Show and Close/Hide Secondary Side Bar'), + }, + category: Categories.View, + f1: true, + menu: [ + { + id: Menus.TitleBarRight, + group: 'navigation', + order: 10 + } + ] + }); + } + + run(accessor: ServicesAccessor): void { + const layoutService = accessor.get(IWorkbenchLayoutService); + const isCurrentlyVisible = layoutService.isVisible(Parts.AUXILIARYBAR_PART); + + layoutService.setPartHidden(isCurrentlyVisible, Parts.AUXILIARYBAR_PART); + + // Announce visibility change to screen readers + const alertMessage = isCurrentlyVisible + ? localize('secondarySidebarHidden', "Secondary Side Bar hidden") + : localize('secondarySidebarVisible', "Secondary Side Bar shown"); + alert(alertMessage); + } +} + +class TogglePanelVisibilityAction extends Action2 { + + static readonly ID = 'workbench.action.agentTogglePanelVisibility'; + + constructor() { + super({ + id: TogglePanelVisibilityAction.ID, + title: localize2('togglePanel', 'Toggle Panel Visibility'), + category: Categories.View, + f1: true, + icon: panelCloseIcon, + menu: [ + { + id: Menus.PanelTitle, + group: 'navigation', + order: 2 + } + ] + }); + } + + run(accessor: ServicesAccessor): void { + const layoutService = accessor.get(IWorkbenchLayoutService); + layoutService.setPartHidden(layoutService.isVisible(Parts.PANEL_PART), Parts.PANEL_PART); + } +} + +registerAction2(ToggleSidebarVisibilityAction); +registerAction2(ToggleSecondarySidebarVisibilityAction); +registerAction2(TogglePanelVisibilityAction); diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts new file mode 100644 index 0000000000000..8d0fdbe0d1d9f --- /dev/null +++ b/src/vs/sessions/browser/menus.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MenuId } from '../../platform/actions/common/actions.js'; + +/** + * Menu IDs for the Agent Sessions workbench layout. + */ +export const Menus = { + ChatBarTitle: new MenuId('ChatBarTitle'), + CommandCenter: new MenuId('SessionsCommandCenter'), + CommandCenterCenter: new MenuId('SessionsCommandCenterCenter'), + TitleBarContext: new MenuId('SessionsTitleBarContext'), + TitleBarControlMenu: new MenuId('SessionsTitleBarControlMenu'), + TitleBarLeft: new MenuId('SessionsTitleBarLeft'), + TitleBarCenter: new MenuId('SessionsTitleBarCenter'), + TitleBarRight: new MenuId('SessionsTitleBarRight'), + OpenSubMenu: new MenuId('SessionsOpenSubMenu'), + PanelTitle: new MenuId('SessionsPanelTitle'), + SidebarTitle: new MenuId('SessionsSidebarTitle'), + AuxiliaryBarTitle: new MenuId('SessionsAuxiliaryBarTitle'), + AuxiliaryBarTitleLeft: new MenuId('SessionsAuxiliaryBarTitleLeft'), + SidebarFooter: new MenuId('SessionsSidebarFooter'), +} as const; diff --git a/src/vs/sessions/browser/paneCompositePartService.ts b/src/vs/sessions/browser/paneCompositePartService.ts new file mode 100644 index 0000000000000..060cdfdedade6 --- /dev/null +++ b/src/vs/sessions/browser/paneCompositePartService.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../base/common/event.js'; +import { assertReturnsDefined } from '../../base/common/types.js'; +import { IInstantiationService } from '../../platform/instantiation/common/instantiation.js'; +import { IProgressIndicator } from '../../platform/progress/common/progress.js'; +import { IPaneComposite } from '../../workbench/common/panecomposite.js'; +import { ViewContainerLocation } from '../../workbench/common/views.js'; +import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; +import { Disposable } from '../../base/common/lifecycle.js'; +import { PaneCompositeDescriptor } from '../../workbench/browser/panecomposite.js'; +import { IPaneCompositePart } from '../../workbench/browser/parts/paneCompositePart.js'; +import { SINGLE_WINDOW_PARTS } from '../../workbench/services/layout/browser/layoutService.js'; +import { PanelPart } from './parts/panelPart.js'; +import { SidebarPart } from './parts/sidebarPart.js'; +import { AuxiliaryBarPart } from './parts/auxiliaryBarPart.js'; +import { ChatBarPart } from './parts/chatBarPart.js'; +import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; + +export class AgenticPaneCompositePartService extends Disposable implements IPaneCompositePartService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidPaneCompositeOpen = this._register(new Emitter<{ composite: IPaneComposite; viewContainerLocation: ViewContainerLocation }>()); + readonly onDidPaneCompositeOpen = this._onDidPaneCompositeOpen.event; + + private readonly _onDidPaneCompositeClose = this._register(new Emitter<{ composite: IPaneComposite; viewContainerLocation: ViewContainerLocation }>()); + readonly onDidPaneCompositeClose = this._onDidPaneCompositeClose.event; + + private readonly paneCompositeParts = new Map(); + + constructor( + @IInstantiationService instantiationService: IInstantiationService + ) { + super(); + + this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(PanelPart)); + this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(SidebarPart)); + this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(AuxiliaryBarPart)); + this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(ChatBarPart)); + } + + private registerPart(location: ViewContainerLocation, part: IPaneCompositePart): void { + this.paneCompositeParts.set(location, part); + this._register(part.onDidPaneCompositeOpen(composite => this._onDidPaneCompositeOpen.fire({ composite, viewContainerLocation: location }))); + this._register(part.onDidPaneCompositeClose(composite => this._onDidPaneCompositeClose.fire({ composite, viewContainerLocation: location }))); + } + + getRegistryId(viewContainerLocation: ViewContainerLocation): string { + return this.getPartByLocation(viewContainerLocation).registryId; + } + + getPartId(viewContainerLocation: ViewContainerLocation): SINGLE_WINDOW_PARTS { + return this.getPartByLocation(viewContainerLocation).partId; + } + + openPaneComposite(id: string | undefined, viewContainerLocation: ViewContainerLocation, focus?: boolean): Promise { + return this.getPartByLocation(viewContainerLocation).openPaneComposite(id, focus); + } + + getActivePaneComposite(viewContainerLocation: ViewContainerLocation): IPaneComposite | undefined { + return this.getPartByLocation(viewContainerLocation).getActivePaneComposite(); + } + + getPaneComposite(id: string, viewContainerLocation: ViewContainerLocation): PaneCompositeDescriptor | undefined { + return this.getPartByLocation(viewContainerLocation).getPaneComposite(id); + } + + getPaneComposites(viewContainerLocation: ViewContainerLocation): PaneCompositeDescriptor[] { + return this.getPartByLocation(viewContainerLocation).getPaneComposites(); + } + + getPinnedPaneCompositeIds(viewContainerLocation: ViewContainerLocation): string[] { + return this.getPartByLocation(viewContainerLocation).getPinnedPaneCompositeIds(); + } + + getVisiblePaneCompositeIds(viewContainerLocation: ViewContainerLocation): string[] { + return this.getPartByLocation(viewContainerLocation).getVisiblePaneCompositeIds(); + } + + getPaneCompositeIds(viewContainerLocation: ViewContainerLocation): string[] { + return this.getPartByLocation(viewContainerLocation).getPaneCompositeIds(); + } + + getProgressIndicator(id: string, viewContainerLocation: ViewContainerLocation): IProgressIndicator | undefined { + return this.getPartByLocation(viewContainerLocation).getProgressIndicator(id); + } + + hideActivePaneComposite(viewContainerLocation: ViewContainerLocation): void { + this.getPartByLocation(viewContainerLocation).hideActivePaneComposite(); + } + + getLastActivePaneCompositeId(viewContainerLocation: ViewContainerLocation): string { + return this.getPartByLocation(viewContainerLocation).getLastActivePaneCompositeId(); + } + + private getPartByLocation(viewContainerLocation: ViewContainerLocation): IPaneCompositePart { + return assertReturnsDefined(this.paneCompositeParts.get(viewContainerLocation)); + } + +} + +registerSingleton(IPaneCompositePartService, AgenticPaneCompositePartService, InstantiationType.Delayed); diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts new file mode 100644 index 0000000000000..abb205600d572 --- /dev/null +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; +import { INotificationService } from '../../../platform/notification/common/notification.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { ActiveAuxiliaryContext, AuxiliaryBarFocusContext } from '../../../workbench/common/contextkeys.js'; +import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_BORDER, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; +import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; +import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; +import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; +import { IAction } from '../../../base/common/actions.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js'; +import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { ActionsOrientation, IActionViewItem } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; +import { IMenuService, IMenu, MenuId, MenuItemAction } from '../../../platform/actions/common/actions.js'; +import { Menus } from '../menus.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { DropdownWithPrimaryActionViewItem } from '../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; +import { IBaseActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; +import { getFlatContextMenuActions } from '../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { Extensions } from '../../../workbench/browser/panecomposite.js'; + +/** + * Auxiliary bar part specifically for agent sessions workbench. + * This is a simplified version of the AuxiliaryBarPart for agent session contexts. + */ +export class AuxiliaryBarPart extends AbstractPaneCompositePart { + + static readonly activeViewSettingsKey = 'workbench.agentsession.auxiliarybar.activepanelid'; + static readonly pinnedViewsKey = 'workbench.agentsession.auxiliarybar.pinnedPanels'; + static readonly placeholdeViewContainersKey = 'workbench.agentsession.auxiliarybar.placeholderPanels'; + static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.auxiliarybar.viewContainersWorkspaceState'; + + /** Visual margin values for the card-like appearance */ + static readonly MARGIN_TOP = 8; + static readonly MARGIN_BOTTOM = 8; + static readonly MARGIN_RIGHT = 8; + + // Action ID for run script - defined here to avoid layering issues + private static readonly RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; + private static readonly RUN_SCRIPT_DROPDOWN_MENU_ID = MenuId.for('AgentSessionsRunScriptDropdown'); + + // Run script dropdown management + private readonly _runScriptDropdown = this._register(new MutableDisposable()); + private readonly _runScriptMenu = this._register(new MutableDisposable()); + private readonly _runScriptMenuListener = this._register(new MutableDisposable()); + + // Use the side bar dimensions + override readonly minimumWidth: number = 170; + override readonly maximumWidth: number = Number.POSITIVE_INFINITY; + override readonly minimumHeight: number = 0; + override readonly maximumHeight: number = Number.POSITIVE_INFINITY; + + get preferredHeight(): number | undefined { + return this.layoutService.mainContainerDimension.height * 0.4; + } + + get preferredWidth(): number | undefined { + const activeComposite = this.getActivePaneComposite(); + + if (!activeComposite) { + return undefined; + } + + const width = activeComposite.getOptimalWidth(); + if (typeof width !== 'number') { + return undefined; + } + + return Math.max(width, 300); + } + + readonly priority = LayoutPriority.Low; + + constructor( + @INotificationService notificationService: INotificationService, + @IStorageService storageService: IStorageService, + @IContextMenuService contextMenuService: IContextMenuService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IContextKeyService contextKeyService: IContextKeyService, + @IExtensionService extensionService: IExtensionService, + @IMenuService menuService: IMenuService, + ) { + super( + Parts.AUXILIARYBAR_PART, + { + hasTitle: true, + trailingSeparator: false, + borderWidth: () => 0, + }, + AuxiliaryBarPart.activeViewSettingsKey, + ActiveAuxiliaryContext.bindTo(contextKeyService), + AuxiliaryBarFocusContext.bindTo(contextKeyService), + 'auxiliarybar', + 'auxiliarybar', + undefined, + SIDE_BAR_TITLE_BORDER, + ViewContainerLocation.AuxiliaryBar, + Extensions.Auxiliary, + Menus.AuxiliaryBarTitle, + Menus.AuxiliaryBarTitleLeft, + notificationService, + storageService, + contextMenuService, + layoutService, + keybindingService, + hoverService, + instantiationService, + themeService, + viewDescriptorService, + contextKeyService, + extensionService, + menuService, + ); + + } + + override updateStyles(): void { + super.updateStyles(); + + const container = assertReturnsDefined(this.getContainer()); + + // Store background and border as CSS variables for the card styling on .part + container.style.setProperty('--part-background', this.getColor(SIDE_BAR_BACKGROUND) || ''); + container.style.setProperty('--part-border-color', this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder) || 'transparent'); + container.style.backgroundColor = 'transparent'; + container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; + + // Clear borders - the card appearance uses border-radius instead + container.style.borderLeftColor = ''; + container.style.borderRightColor = ''; + container.style.borderLeftStyle = ''; + container.style.borderRightStyle = ''; + container.style.borderLeftWidth = ''; + container.style.borderRightWidth = ''; + } + + protected getCompositeBarOptions(): IPaneCompositeBarOptions { + const $this = this; + return { + partContainerClass: 'auxiliarybar', + pinnedViewContainersKey: AuxiliaryBarPart.pinnedViewsKey, + placeholderViewContainersKey: AuxiliaryBarPart.placeholdeViewContainersKey, + viewContainersWorkspaceStateKey: AuxiliaryBarPart.viewContainersWorkspaceStateKey, + icon: false, + orientation: ActionsOrientation.HORIZONTAL, + recomputeSizes: true, + activityHoverOptions: { + position: () => this.getCompositeBarPosition() === CompositeBarPosition.BOTTOM ? HoverPosition.ABOVE : HoverPosition.BELOW, + }, + fillExtraContextMenuActions: actions => this.fillExtraContextMenuActions(actions), + compositeSize: 0, + iconSize: 16, + get overflowActionSize() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? 40 : 30; }, + colors: theme => ({ + activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + get activeBorderBottomColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_ACTIVE_TITLE_BORDER) : theme.getColor(ACTIVITY_BAR_TOP_ACTIVE_BORDER); }, + get activeForegroundColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND) : theme.getColor(ACTIVITY_BAR_TOP_FOREGROUND); }, + get inactiveForegroundColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND) : theme.getColor(ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND); }, + badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), + badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), + get dragAndDropBorder() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_DRAG_AND_DROP_BORDER) : theme.getColor(ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER); } + }), + compact: true + }; + } + + protected override actionViewItemProvider(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { + // Create a DropdownWithPrimaryActionViewItem for the run script action + if (action.id === AuxiliaryBarPart.RUN_SCRIPT_ACTION_ID && action instanceof MenuItemAction) { + // Create and store the menu so we can listen for changes + if (!this._runScriptMenu.value) { + this._runScriptMenu.value = this.menuService.createMenu(AuxiliaryBarPart.RUN_SCRIPT_DROPDOWN_MENU_ID, this.contextKeyService); + this._runScriptMenuListener.value = this._runScriptMenu.value.onDidChange(() => this._updateRunScriptDropdown()); + } + + const dropdownActions = this._getRunScriptDropdownActions(); + + const dropdownAction: IAction = { + id: 'runScriptDropdown', + label: '', + tooltip: '', + class: undefined, + enabled: true, + run: () => { } + }; + + this._runScriptDropdown.value = this.instantiationService.createInstance( + DropdownWithPrimaryActionViewItem, + action, + dropdownAction, + dropdownActions, + '', + { + hoverDelegate: options.hoverDelegate, + getKeyBinding: (action: IAction) => this.keybindingService.lookupKeybinding(action.id, this.contextKeyService) + } + ); + + return this._runScriptDropdown.value; + } + + return super.actionViewItemProvider(action, options); + } + + private _getRunScriptDropdownActions(): IAction[] { + if (!this._runScriptMenu.value) { + return []; + } + return getFlatContextMenuActions(this._runScriptMenu.value.getActions({ shouldForwardArgs: true })); + } + + private _updateRunScriptDropdown(): void { + if (this._runScriptDropdown.value) { + const dropdownActions = this._getRunScriptDropdownActions(); + const dropdownAction: IAction = { + id: 'runScriptDropdown', + label: '', + tooltip: '', + class: undefined, + enabled: true, + run: () => { } + }; + this._runScriptDropdown.value.update(dropdownAction, dropdownActions); + } + } + + private fillExtraContextMenuActions(_actions: IAction[]): void { } + + protected shouldShowCompositeBar(): boolean { + return true; + } + + protected getCompositeBarPosition(): CompositeBarPosition { + return CompositeBarPosition.TITLE; + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + return; + } + + // Layout content with reduced dimensions to account for visual margins + super.layout( + width - AuxiliaryBarPart.MARGIN_RIGHT, + height - AuxiliaryBarPart.MARGIN_TOP - AuxiliaryBarPart.MARGIN_BOTTOM, + top, left + ); + + // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. + // Part.layout() only stores _dimension and _contentPosition - no other side effects. + Part.prototype.layout.call(this, width, height, top, left); + } + + override toJSON(): object { + return { + type: Parts.AUXILIARYBAR_PART + }; + } +} diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts new file mode 100644 index 0000000000000..3a1b3be4ce6a5 --- /dev/null +++ b/src/vs/sessions/browser/parts/chatBarPart.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatBarPart.css'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; +import { INotificationService } from '../../../platform/notification/common/notification.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; +import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; +import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js'; +import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { ActionsOrientation } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; +import { IMenuService } from '../../../platform/actions/common/actions.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { Extensions } from '../../../workbench/browser/panecomposite.js'; +import { Menus } from '../menus.js'; +import { ActiveChatBarContext, ChatBarFocusContext } from '../../common/contextkeys.js'; + +export class ChatBarPart extends AbstractPaneCompositePart { + + static readonly activeViewSettingsKey = 'workbench.chatbar.activepanelid'; + static readonly pinnedViewsKey = 'workbench.chatbar.pinnedPanels'; + static readonly placeholdeViewContainersKey = 'workbench.chatbar.placeholderPanels'; + static readonly viewContainersWorkspaceStateKey = 'workbench.chatbar.viewContainersWorkspaceState'; + + // Use the side bar dimensions + override readonly minimumWidth: number = 170; + override readonly maximumWidth: number = Number.POSITIVE_INFINITY; + override readonly minimumHeight: number = 0; + override readonly maximumHeight: number = Number.POSITIVE_INFINITY; + + get preferredHeight(): number | undefined { + return this.layoutService.mainContainerDimension.height * 0.4; + } + + get preferredWidth(): number | undefined { + const activeComposite = this.getActivePaneComposite(); + + if (!activeComposite) { + return undefined; + } + + const width = activeComposite.getOptimalWidth(); + if (typeof width !== 'number') { + return undefined; + } + + return Math.max(width, 300); + } + + readonly priority = LayoutPriority.High; + + constructor( + @INotificationService notificationService: INotificationService, + @IStorageService storageService: IStorageService, + @IContextMenuService contextMenuService: IContextMenuService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IContextKeyService contextKeyService: IContextKeyService, + @IExtensionService extensionService: IExtensionService, + @IMenuService menuService: IMenuService + ) { + super( + Parts.CHATBAR_PART, + { + hasTitle: false, + trailingSeparator: true, + borderWidth: () => 0, + }, + ChatBarPart.activeViewSettingsKey, + ActiveChatBarContext.bindTo(contextKeyService), + ChatBarFocusContext.bindTo(contextKeyService), + 'chatbar', + 'chatbar', + undefined, + SIDE_BAR_TITLE_BORDER, + ViewContainerLocation.ChatBar, + Extensions.ChatBar, + Menus.ChatBarTitle, + undefined, + notificationService, + storageService, + contextMenuService, + layoutService, + keybindingService, + hoverService, + instantiationService, + themeService, + viewDescriptorService, + contextKeyService, + extensionService, + menuService, + ); + } + + override updateStyles(): void { + super.updateStyles(); + + const container = assertReturnsDefined(this.getContainer()); + container.style.backgroundColor = this.getColor(SIDE_BAR_BACKGROUND) || ''; + container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; + } + + protected getCompositeBarOptions(): IPaneCompositeBarOptions { + return { + partContainerClass: 'chatbar', + pinnedViewContainersKey: ChatBarPart.pinnedViewsKey, + placeholderViewContainersKey: ChatBarPart.placeholdeViewContainersKey, + viewContainersWorkspaceStateKey: ChatBarPart.viewContainersWorkspaceStateKey, + icon: false, + orientation: ActionsOrientation.HORIZONTAL, + recomputeSizes: true, + activityHoverOptions: { + position: () => HoverPosition.BELOW, + }, + fillExtraContextMenuActions: () => { }, + compositeSize: 0, + iconSize: 16, + overflowActionSize: 30, + colors: theme => ({ + activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + activeBorderBottomColor: theme.getColor(PANEL_ACTIVE_TITLE_BORDER), + activeForegroundColor: theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND), + inactiveForegroundColor: theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND), + badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), + badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), + dragAndDropBorder: theme.getColor(PANEL_DRAG_AND_DROP_BORDER) + }), + compact: true + }; + } + + protected shouldShowCompositeBar(): boolean { + return false; + } + + protected getCompositeBarPosition(): CompositeBarPosition { + return CompositeBarPosition.TITLE; + } + + override toJSON(): object { + return { + type: Parts.CHATBAR_PART + }; + } +} diff --git a/src/vs/sessions/browser/parts/editorModal.ts b/src/vs/sessions/browser/parts/editorModal.ts new file mode 100644 index 0000000000000..6b30966f5623c --- /dev/null +++ b/src/vs/sessions/browser/parts/editorModal.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../base/browser/dom.js'; +import { mainWindow } from '../../../base/browser/window.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js'; +import { mark } from '../../../base/common/performance.js'; + +const MODAL_HEADER_HEIGHT = 32; +const MODAL_SIZE_PERCENTAGE = 0.8; +const MODAL_MIN_WIDTH = 400; +const MODAL_MAX_WIDTH = 1200; +const MODAL_MIN_HEIGHT = 300; +const MODAL_MAX_HEIGHT = 900; + +export class EditorModal extends Disposable { + + private readonly _onDidChangeVisibility = this._register(new Emitter()); + readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; + + private readonly overlay: HTMLElement; + private readonly container: HTMLElement; + private readonly content: HTMLElement; + + private _visible = false; + get visible(): boolean { return this._visible; } + + private _workbenchWidth = 0; + private _workbenchHeight = 0; + + constructor( + private readonly parentContainer: HTMLElement, + private readonly editorPart: Part, + private readonly editorGroupService: IEditorGroupsService + ) { + super(); + + // Create modal structure + this.overlay = this.createOverlay(); + this.container = this.createContainer(); + this.content = this.createContent(); + + // Assemble the modal + this.container.appendChild(this.content); + this.overlay.appendChild(this.container); + + // Create and add editor part to modal content + this.createEditorPart(); + + // Register keyboard handler + this.registerKeyboardHandler(); + + // Add to parent + this.parentContainer.appendChild(this.overlay); + } + + private createOverlay(): HTMLElement { + const overlay = $('div.editor-modal-overlay'); + + // Create backdrop (clicking closes the modal) + const backdrop = $('div.editor-modal-backdrop'); + backdrop.addEventListener('click', () => this.close()); + overlay.appendChild(backdrop); + + return overlay; + } + + private createContainer(): HTMLElement { + const container = $('div.editor-modal-container'); + container.setAttribute('role', 'dialog'); + container.setAttribute('aria-modal', 'true'); + + // Create header with close button + const header = $('div.editor-modal-header'); + const closeButton = $('button.editor-modal-close-button'); + closeButton.setAttribute('aria-label', 'Close'); + closeButton.title = 'Close (Escape)'; + const closeIcon = $('span'); + closeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.close)); + closeButton.appendChild(closeIcon); + closeButton.addEventListener('click', () => this.close()); + header.appendChild(closeButton); + container.appendChild(header); + + return container; + } + + private createContent(): HTMLElement { + return $('div.editor-modal-content'); + } + + private createEditorPart(): void { + const editorPartContainer = document.createElement('div'); + editorPartContainer.classList.add('part', 'editor'); + editorPartContainer.id = Parts.EDITOR_PART; + editorPartContainer.setAttribute('role', 'main'); + + mark('code/willCreatePart/workbench.parts.editor'); + this.editorPart.create(editorPartContainer, { restorePreviousState: false }); + mark('code/didCreatePart/workbench.parts.editor'); + + this.content.appendChild(editorPartContainer); + } + + private registerKeyboardHandler(): void { + mainWindow.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this._visible) { + this.close(); + } + }); + } + + show(): void { + if (this._visible) { + return; + } + + this._visible = true; + this.overlay.classList.add('visible'); + + this.doLayout(); + + this._onDidChangeVisibility.fire(true); + } + + hide(): void { + if (!this._visible) { + return; + } + + this._visible = false; + this.overlay.classList.remove('visible'); + + this._onDidChangeVisibility.fire(false); + } + + close(): void { + if (!this._visible) { + return; + } + + // Close all editors in all groups + for (const group of this.editorGroupService.groups) { + group.closeAllEditors(); + } + + // Hide the modal + this.hide(); + } + + layout(workbenchWidth: number, workbenchHeight: number): void { + this._workbenchWidth = workbenchWidth; + this._workbenchHeight = workbenchHeight; + + if (this._visible) { + this.doLayout(); + } + } + + private doLayout(): void { + // Calculate modal dimensions based on workbench size with constraints + const modalWidth = Math.floor( + Math.min(MODAL_MAX_WIDTH, Math.max(MODAL_MIN_WIDTH, this._workbenchWidth * MODAL_SIZE_PERCENTAGE)) + ); + const modalHeight = Math.floor( + Math.min(MODAL_MAX_HEIGHT, Math.max(MODAL_MIN_HEIGHT, this._workbenchHeight * MODAL_SIZE_PERCENTAGE)) + ); + + // Set the modal container dimensions + this.container.style.width = `${modalWidth}px`; + this.container.style.height = `${modalHeight}px`; + + // Calculate content dimensions (subtract header height) + const contentWidth = modalWidth; + const contentHeight = modalHeight - MODAL_HEADER_HEIGHT; + + if (contentWidth > 0 && contentHeight > 0) { + // Explicitly size the content area + this.content.style.width = `${contentWidth}px`; + this.content.style.height = `${contentHeight}px`; + + // Layout the editor part + this.editorPart.layout(contentWidth, contentHeight, 0, 0); + } + } +} diff --git a/src/vs/sessions/browser/parts/media/chatBarPart.css b/src/vs/sessions/browser/parts/media/chatBarPart.css new file mode 100644 index 0000000000000..4db26e2e5b032 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/chatBarPart.css @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench.nochatbar .part.chatbar { + display: none !important; + visibility: hidden !important; +} + +.monaco-workbench .part.chatbar > .content .monaco-editor, +.monaco-workbench .part.chatbar > .content .monaco-editor .margin, +.monaco-workbench .part.chatbar > .content .monaco-editor .monaco-editor-background { + background-color: var(--vscode-sideBar-background); +} + +.monaco-workbench .part.chatbar .title-actions .actions-container { + justify-content: flex-end; +} + +.monaco-workbench .part.chatbar .title-actions .action-item { + margin-right: 4px; +} + +.monaco-workbench .part.chatbar > .title { + background-color: var(--vscode-sideBarTitle-background); +} + +.monaco-workbench .part.chatbar > .title > .title-label { + flex: 1; +} + +.monaco-workbench .part.chatbar > .title > .title-label h2 { + text-transform: uppercase; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container { + flex: 1; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus { + outline: 0 !important; /* activity bar indicates focus custom */ +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { + outline-offset: 2px; +} + +.hc-black .monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.hc-light .monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { + border-radius: 0px; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before { + position: absolute; + left: 5px; /* place icon in center */ +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before { + border-top-color: var(--vscode-panelTitle-activeBorder) !important; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label, +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .action-label { + color: var(--vscode-sideBarTitle-foreground) !important; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label, +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label { + outline: var(--vscode-contrastActiveBorder, unset) solid 1px !important; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.checked):hover .action-label { + outline: var(--vscode-contrastActiveBorder, unset) dashed 1px !important; +} + +.monaco-workbench .chatbar.part.pane-composite-part > .composite.title > .title-actions { + flex: inherit; +} + +.monaco-workbench .chatbar.pane-composite-part > .title.has-composite-bar > .title-actions .monaco-action-bar .action-item { + max-width: 150px; +} diff --git a/src/vs/sessions/browser/parts/media/projectBarPart.css b/src/vs/sessions/browser/parts/media/projectBarPart.css new file mode 100644 index 0000000000000..70576594a7a3f --- /dev/null +++ b/src/vs/sessions/browser/parts/media/projectBarPart.css @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench .part.projectbar { + width: 48px; + height: 100%; +} + +.monaco-workbench .projectbar.bordered::before { + content: ''; + float: left; + position: absolute; + box-sizing: border-box; + height: 100%; + width: 0px; + border-color: inherit; +} + +.monaco-workbench .projectbar.left.bordered::before { + right: 0; + border-right-style: solid; + border-right-width: 1px; +} + +.monaco-workbench .projectbar > .content { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.monaco-workbench .projectbar > .content > .actions-container { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-bottom: auto; +} + +/* Action items (both add button and workspace entries) */ +.monaco-workbench .projectbar .action-item { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + cursor: pointer; +} + +.monaco-workbench .projectbar .action-item:focus { + outline: 0 !important; /* project bar indicates focus custom */ +} + +.monaco-workbench .projectbar .action-item:focus-visible::before { + content: ''; + position: absolute; + inset: 6px; + border: 1px solid var(--vscode-focusBorder); + border-radius: 4px; + pointer-events: none; +} + +.monaco-workbench .projectbar .action-item .action-label { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + font-size: 16px; + border-radius: 4px; + color: var(--vscode-activityBar-inactiveForeground); +} + +.monaco-workbench .projectbar .action-item:hover .action-label { + color: var(--vscode-activityBar-foreground); +} + +/* Add folder button */ +.monaco-workbench .projectbar .action-item.add-folder { + margin-bottom: 4px; +} + +.monaco-workbench .projectbar .action-item.add-folder .action-label { + font-size: 20px; +} + +/* Workspace entry icon - shows first letter */ +.monaco-workbench .projectbar .action-item.workspace-entry .action-label.workspace-icon { + font-weight: 600; + font-size: 18px; + text-transform: uppercase; + background-color: var(--vscode-activityBar-inactiveForeground); + color: var(--vscode-activityBar-background); + border-radius: 6px; +} + +.monaco-workbench .projectbar .action-item.workspace-entry:hover .action-label.workspace-icon { + background-color: var(--vscode-activityBar-foreground); +} + +/* Workspace entry with codicon icon */ +.monaco-workbench .projectbar .action-item.workspace-entry .action-label.workspace-icon.codicon-icon { + font-weight: normal; + font-size: 24px; + text-transform: none; + background-color: transparent; + color: var(--vscode-activityBar-inactiveForeground); + border-radius: 0; +} + +.monaco-workbench .projectbar .action-item.workspace-entry:hover .action-label.workspace-icon.codicon-icon { + background-color: transparent; + color: var(--vscode-activityBar-foreground); +} + +.monaco-workbench .projectbar .action-item.workspace-entry.checked .action-label.workspace-icon.codicon-icon { + background-color: transparent; + color: var(--vscode-activityBar-foreground); +} + +/* Selected/checked state */ +.monaco-workbench .projectbar .action-item.workspace-entry.checked .action-label.workspace-icon { + background-color: var(--vscode-activityBar-foreground); + color: var(--vscode-activityBar-background); +} + +/* Active item indicator (vertical bar on the left) */ +.monaco-workbench .projectbar .action-item .active-item-indicator { + position: absolute; + left: 0; + width: 2px; + height: 24px; + background-color: transparent; + border-radius: 0 2px 2px 0; +} + +.monaco-workbench .projectbar .action-item.workspace-entry.checked .active-item-indicator { + background-color: var(--vscode-activityBar-activeBorder, var(--vscode-activityBar-foreground)); +} + +/* Active background for checked items */ +.monaco-workbench .projectbar .action-item.workspace-entry.checked { + background-color: var(--vscode-activityBar-activeBackground); +} + +/* High contrast styling */ +.monaco-workbench.hc-black .projectbar .action-item .action-label, +.monaco-workbench.hc-light .projectbar .action-item .action-label { + padding: 6px; +} + +.monaco-workbench.hc-black .projectbar .action-item.checked .action-label::before, +.monaco-workbench.hc-light .projectbar .action-item.checked .action-label::before { + outline: 1px solid var(--vscode-contrastActiveBorder); +} + +.monaco-workbench.hc-black .projectbar .action-item:hover .action-label::before, +.monaco-workbench.hc-light .projectbar .action-item:hover .action-label::before { + outline: 1px dashed var(--vscode-contrastActiveBorder); +} + +/* ===== Global Composite Bar (Accounts, Settings) at bottom ===== */ + +.monaco-workbench .projectbar > .content > .monaco-action-bar { + text-align: center; + background-color: inherit; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item { + display: block; + position: relative; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-label { + position: relative; + z-index: 1; + display: flex; + overflow: hidden; + width: 48px; + height: 48px; + margin-right: 0; + box-sizing: border-box; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-label.codicon { + font-size: 24px; + align-items: center; + justify-content: center; + color: var(--vscode-activityBar-inactiveForeground); +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item.active .action-label.codicon, +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:focus .action-label.codicon, +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:hover .action-label.codicon { + color: var(--vscode-activityBar-foreground) !important; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:focus { + outline: 0 !important; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .profile-badge, +.monaco-workbench .projectbar > .content > .monaco-action-bar .active-item-indicator, +.monaco-workbench .projectbar > .content > .monaco-action-bar .badge { + position: absolute; + top: 0; + bottom: 0; + margin: auto; + left: 0; + overflow: hidden; + width: 100%; + height: 100%; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .active-item-indicator, +.monaco-workbench .projectbar > .content > .monaco-action-bar .badge { + z-index: 2; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .profile-badge { + z-index: 1; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .active-item-indicator { + pointer-events: none; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .badge .badge-content { + position: absolute; + top: 24px; + right: 8px; + font-size: 9px; + font-weight: 600; + min-width: 8px; + height: 16px; + line-height: 16px; + padding: 0 4px; + border-radius: 20px; + text-align: center; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .profile-badge .profile-text-overlay { + position: absolute; + font-weight: 600; + font-size: 9px; + line-height: 10px; + top: 24px; + right: 6px; + padding: 2px 3px; + border-radius: 7px; + background-color: var(--vscode-profileBadge-background); + color: var(--vscode-profileBadge-foreground); + border: 2px solid var(--vscode-activityBar-background); +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:active .profile-text-overlay, +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:focus .profile-text-overlay, +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:hover .profile-text-overlay { + color: var(--vscode-activityBar-foreground); +} diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css new file mode 100644 index 0000000000000..d8f4c72894978 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Hide traffic light spacer in fullscreen (traffic lights are not shown) */ +.monaco-workbench.fullscreen .part.sidebar .window-controls-container { + display: none; +} + +/* Make the sidebar title area draggable to move the window */ +.agent-sessions-workbench .part.sidebar > .composite.title { + position: relative; +} + +.agent-sessions-workbench .part.sidebar > .composite.title > .titlebar-drag-region { + top: 0; + left: 0; + display: block; + position: absolute; + width: 100%; + height: 100%; + -webkit-app-region: drag; +} + +/* Interactive elements in the title area must not be draggable */ +.agent-sessions-workbench .part.sidebar > .composite.title .action-item { + -webkit-app-region: no-drag; +} + +/* Sidebar Footer Container */ +.monaco-workbench .part.sidebar > .sidebar-footer { + display: flex; + align-items: center; + padding: 6px; + border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); + flex-shrink: 0; +} + +/* Make the toolbar and its action-item fill the full footer width */ +.monaco-workbench .part.sidebar > .sidebar-footer .monaco-toolbar, +.monaco-workbench .part.sidebar > .sidebar-footer .monaco-action-bar, +.monaco-workbench .part.sidebar > .sidebar-footer .actions-container, +.monaco-workbench .part.sidebar > .sidebar-footer .action-item { + flex: 1; + width: 100%; + max-width: 100%; + cursor: default; +} diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css new file mode 100644 index 0000000000000..b005dbe50de41 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Left Tool Bar Container */ +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container { + display: none; + padding-left: 8px; + flex-grow: 0; + flex-shrink: 0; + text-align: center; + position: relative; + z-index: 2500; + -webkit-app-region: no-drag; + height: 100%; + order: 2; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container:not(.has-no-actions) { + display: flex; + justify-content: center; + align-items: center; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container .codicon { + color: inherit; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item { + display: flex; +} + +/* TODO: Hack to avoid flicker when sidebar becomes visible. + * The contribution swaps the menu item synchronously, but the toolbar + * re-render is async, causing a brief flash. Hide the container via + * CSS when sidebar is visible (nosidebar class is removed synchronously). */ +.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container { + display: none !important; +} diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts new file mode 100644 index 0000000000000..c8f0ce71f8bfd --- /dev/null +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../workbench/browser/parts/panel/media/panelpart.css'; +import { IAction } from '../../../base/common/actions.js'; +import { ActionsOrientation } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { ActivePanelContext, PanelFocusContext } from '../../../workbench/common/contextkeys.js'; +import { IWorkbenchLayoutService, Parts, Position } from '../../../workbench/services/layout/browser/layoutService.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { PANEL_BACKGROUND, PANEL_BORDER, PANEL_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_DRAG_AND_DROP_BORDER, PANEL_TITLE_BADGE_BACKGROUND, PANEL_TITLE_BADGE_FOREGROUND } from '../../../workbench/common/theme.js'; +import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { INotificationService } from '../../../platform/notification/common/notification.js'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; +import { IMenuService } from '../../../platform/actions/common/actions.js'; +import { Menus } from '../menus.js'; +import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { Extensions } from '../../../workbench/browser/panecomposite.js'; + +/** + * Panel part specifically for agent sessions workbench. + * This is a simplified version of the PanelPart for agent session contexts. + */ +export class PanelPart extends AbstractPaneCompositePart { + + //#region IView + + readonly minimumWidth: number = 300; + readonly maximumWidth: number = Number.POSITIVE_INFINITY; + readonly minimumHeight: number = 77; + readonly maximumHeight: number = Number.POSITIVE_INFINITY; + + get preferredHeight(): number | undefined { + return this.layoutService.mainContainerDimension.height * 0.4; + } + + get preferredWidth(): number | undefined { + const activeComposite = this.getActivePaneComposite(); + + if (!activeComposite) { + return undefined; + } + + const width = activeComposite.getOptimalWidth(); + if (typeof width !== 'number') { + return undefined; + } + + return Math.max(width, 300); + } + + //#endregion + + static readonly activePanelSettingsKey = 'workbench.agentsession.panelpart.activepanelid'; + + /** Visual margin values for the card-like appearance */ + static readonly MARGIN_BOTTOM = 8; + static readonly MARGIN_LEFT = 8; + static readonly MARGIN_RIGHT = 8; + + constructor( + @INotificationService notificationService: INotificationService, + @IStorageService storageService: IStorageService, + @IContextMenuService contextMenuService: IContextMenuService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IContextKeyService contextKeyService: IContextKeyService, + @IExtensionService extensionService: IExtensionService, + @IMenuService menuService: IMenuService, + @IConfigurationService private readonly configurationService: IConfigurationService + ) { + super( + Parts.PANEL_PART, + { hasTitle: true, trailingSeparator: true }, + PanelPart.activePanelSettingsKey, + ActivePanelContext.bindTo(contextKeyService), + PanelFocusContext.bindTo(contextKeyService), + 'panel', + 'panel', + undefined, + PANEL_TITLE_BORDER, + ViewContainerLocation.Panel, + Extensions.Panels, + Menus.PanelTitle, + undefined, + notificationService, + storageService, + contextMenuService, + layoutService, + keybindingService, + hoverService, + instantiationService, + themeService, + viewDescriptorService, + contextKeyService, + extensionService, + menuService, + ); + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('workbench.panel.showLabels')) { + this.updateCompositeBar(true); + } + })); + } + + override updateStyles(): void { + super.updateStyles(); + + const container = assertReturnsDefined(this.getContainer()); + + // Store background and border as CSS variables for the card styling on .part + container.style.setProperty('--part-background', this.getColor(PANEL_BACKGROUND) || ''); + container.style.setProperty('--part-border-color', this.getColor(PANEL_BORDER) || this.getColor(contrastBorder) || 'transparent'); + container.style.backgroundColor = 'transparent'; + + // Clear inline borders - the card appearance uses CSS border-radius instead + container.style.borderTopColor = ''; + container.style.borderTopStyle = ''; + container.style.borderTopWidth = ''; + } + + protected getCompositeBarOptions(): IPaneCompositeBarOptions { + return { + partContainerClass: 'panel', + pinnedViewContainersKey: 'workbench.agentsession.panel.pinnedPanels', + placeholderViewContainersKey: 'workbench.agentsession.panel.placeholderPanels', + viewContainersWorkspaceStateKey: 'workbench.agentsession.panel.viewContainersWorkspaceState', + icon: this.configurationService.getValue('workbench.panel.showLabels') === false, + orientation: ActionsOrientation.HORIZONTAL, + recomputeSizes: true, + activityHoverOptions: { + position: () => this.layoutService.getPanelPosition() === Position.BOTTOM && !this.layoutService.isPanelMaximized() ? HoverPosition.ABOVE : HoverPosition.BELOW, + }, + fillExtraContextMenuActions: actions => this.fillExtraContextMenuActions(actions), + compositeSize: 0, + iconSize: 16, + compact: true, + overflowActionSize: 44, + colors: theme => ({ + activeBackgroundColor: theme.getColor(PANEL_BACKGROUND), + inactiveBackgroundColor: theme.getColor(PANEL_BACKGROUND), + activeBorderBottomColor: theme.getColor(PANEL_ACTIVE_TITLE_BORDER), + activeForegroundColor: theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND), + inactiveForegroundColor: theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND), + badgeBackground: theme.getColor(PANEL_TITLE_BADGE_BACKGROUND), + badgeForeground: theme.getColor(PANEL_TITLE_BADGE_FOREGROUND), + dragAndDropBorder: theme.getColor(PANEL_DRAG_AND_DROP_BORDER) + }) + }; + } + + private fillExtraContextMenuActions(_actions: IAction[]): void { } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.PANEL_PART)) { + return; + } + + // Layout content with reduced dimensions to account for visual margins + super.layout( + width - PanelPart.MARGIN_LEFT - PanelPart.MARGIN_RIGHT, + height - PanelPart.MARGIN_BOTTOM, + top, left + ); + + // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. + Part.prototype.layout.call(this, width, height, top, left); + } + + protected override shouldShowCompositeBar(): boolean { + return true; + } + + protected getCompositeBarPosition(): CompositeBarPosition { + return CompositeBarPosition.TITLE; + } + + toJSON(): object { + return { + type: Parts.PANEL_PART + }; + } +} diff --git a/src/vs/base/browser/ui/motion/motion.css b/src/vs/sessions/browser/parts/parts.ts similarity index 66% rename from src/vs/base/browser/ui/motion/motion.css rename to src/vs/sessions/browser/parts/parts.ts index 69e257be2d380..3a1ed5afc86a9 100644 --- a/src/vs/base/browser/ui/motion/motion.css +++ b/src/vs/sessions/browser/parts/parts.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Utility class applied during panel animations to prevent content overflow */ -.monaco-split-view2 > .split-view-container > .split-view-view.motion-animating { - overflow: hidden; +export const enum AgenticParts { + PROJECTBAR_PART = 'workbench.parts.projectbar', } diff --git a/src/vs/sessions/browser/parts/projectBarPart.ts b/src/vs/sessions/browser/parts/projectBarPart.ts new file mode 100644 index 0000000000000..e902c52e66edd --- /dev/null +++ b/src/vs/sessions/browser/parts/projectBarPart.ts @@ -0,0 +1,584 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/projectBarPart.css'; +import { Part } from '../../../workbench/browser/part.js'; +import { IWorkbenchLayoutService, Position } from '../../../workbench/services/layout/browser/layoutService.js'; +import { IColorTheme, IThemeService } from '../../../platform/theme/common/themeService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../platform/workspace/common/workspace.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { $, addDisposableListener, append, clearNode, Dimension, EventType, getActiveDocument, getWindow } from '../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { ACTIVITY_BAR_BACKGROUND, ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_BORDER, ACTIVITY_BAR_FOREGROUND, ACTIVITY_BAR_INACTIVE_FOREGROUND } from '../../../workbench/common/theme.js'; +import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { codiconsLibrary } from '../../../base/common/codiconsLibrary.js'; +import { Lazy } from '../../../base/common/lazy.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; +import { GlobalCompositeBar } from '../../../workbench/browser/parts/globalCompositeBar.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IAction, Action, Separator } from '../../../base/common/actions.js'; +import { URI } from '../../../base/common/uri.js'; +import { IFileDialogService } from '../../../platform/dialogs/common/dialogs.js'; +import { IPathService } from '../../../workbench/services/path/common/pathService.js'; +import { IWorkspaceEditingService } from '../../../workbench/services/workspaces/common/workspaceEditing.js'; +import { ILabelService } from '../../../platform/label/common/label.js'; +import { basename } from '../../../base/common/resources.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; +import { IQuickInputService, IQuickPickItem } from '../../../platform/quickinput/common/quickInput.js'; +import { getIconRegistry, IconContribution } from '../../../platform/theme/common/iconRegistry.js'; +import { defaultInputBoxStyles } from '../../../platform/theme/browser/defaultStyles.js'; +import { WorkbenchIconSelectBox } from '../../../workbench/services/userDataProfile/browser/iconSelectBox.js'; +import { localize } from '../../../nls.js'; +import { AgenticParts } from './parts.js'; + +const HOVER_GROUP_ID = 'projectbar'; +const PROJECT_BAR_FOLDERS_KEY = 'workbench.agentsession.projectbar.folders'; + +type ProjectBarEntryDisplayType = 'letter' | 'icon'; + +interface IProjectBarEntryData { + readonly uri: string; + readonly displayType?: ProjectBarEntryDisplayType; + readonly iconId?: string; +} + +interface IProjectBarEntry { + readonly uri: URI; + readonly name: string; + displayType: ProjectBarEntryDisplayType; + iconId?: string; +} + +const icons = new Lazy(() => { + const iconDefinitions = getIconRegistry().getIcons(); + const includedChars = new Set(); + const dedupedIcons = iconDefinitions.filter(e => { + if (e.id === codiconsLibrary.blank.id) { + return false; + } + if (ThemeIcon.isThemeIcon(e.defaults)) { + return false; + } + if (includedChars.has(e.defaults.fontCharacter)) { + return false; + } + includedChars.add(e.defaults.fontCharacter); + return true; + }); + return dedupedIcons; +}); + +/** + * ProjectBarPart displays project folder entries stored in workspace storage and allows selection between them. + * When a folder is selected, the workspace editing service is used to replace the current workspace folder + * with the selected one. It is positioned to the left of the sidebar and has the same visual style as the activity bar. + * Also includes global activities (accounts, settings) at the bottom. + */ +export class ProjectBarPart extends Part { + + static readonly ACTION_HEIGHT = 48; + + //#region IView + + readonly minimumWidth: number = 48; + readonly maximumWidth: number = 48; + readonly minimumHeight: number = 0; + readonly maximumHeight: number = Number.POSITIVE_INFINITY; + + //#endregion + + private content: HTMLElement | undefined; + private actionsContainer: HTMLElement | undefined; + private addFolderButton: HTMLElement | undefined; + private entries: IProjectBarEntry[] = []; + private _selectedFolderUri: URI | undefined; + private readonly globalCompositeBar: GlobalCompositeBar; + + private readonly workspaceEntryDisposables = this._register(new MutableDisposable()); + + private readonly _onDidSelectWorkspace = this._register(new Emitter()); + readonly onDidSelectWorkspace: Event = this._onDidSelectWorkspace.event; + + constructor( + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IThemeService themeService: IThemeService, + @IStorageService private readonly storageService: IStorageService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IPathService private readonly pathService: IPathService, + @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, + @ILabelService private readonly labelService: ILabelService, + @IHoverService private readonly hoverService: IHoverService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(AgenticParts.PROJECTBAR_PART, { hasTitle: false }, themeService, storageService, layoutService); + + // Create the global composite bar for accounts and settings at the bottom + this.globalCompositeBar = this._register(instantiationService.createInstance( + GlobalCompositeBar, + () => this.getContextMenuActions(), + (theme: IColorTheme) => ({ + activeForegroundColor: theme.getColor(ACTIVITY_BAR_FOREGROUND), + inactiveForegroundColor: theme.getColor(ACTIVITY_BAR_INACTIVE_FOREGROUND), + badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), + badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), + activeBackgroundColor: undefined, + inactiveBackgroundColor: undefined, + activeBorderBottomColor: undefined, + }), + { + position: () => this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT, + } + )); + + // Load entries from storage + this.loadEntriesFromStorage(); + } + + private getContextMenuActions(): IAction[] { + return this.globalCompositeBar.getContextMenuActions(); + } + + private loadEntriesFromStorage(): void { + const raw = this.storageService.get(PROJECT_BAR_FOLDERS_KEY, StorageScope.WORKSPACE); + if (raw) { + try { + const data: (string | IProjectBarEntryData)[] = JSON.parse(raw); + this.entries = data.map(item => { + // Support legacy format (just URIs as strings) and new format (objects with display settings) + if (typeof item === 'string') { + const uri = URI.parse(item); + return { uri, name: basename(uri), displayType: 'letter' as ProjectBarEntryDisplayType }; + } else { + const uri = URI.parse(item.uri); + return { + uri, + name: basename(uri), + displayType: item.displayType ?? 'letter', + iconId: item.iconId + }; + } + }); + } catch { + this.entries = []; + } + } else { + this.entries = []; + } + + // The selected folder is always the first workspace folder + const currentFolders = this.workspaceContextService.getWorkspace().folders; + this._selectedFolderUri = currentFolders.length > 0 ? currentFolders[0].uri : undefined; + } + + private saveEntriesToStorage(): void { + const data: IProjectBarEntryData[] = this.entries.map(e => ({ + uri: e.uri.toString(), + displayType: e.displayType, + iconId: e.iconId + })); + this.storageService.store(PROJECT_BAR_FOLDERS_KEY, JSON.stringify(data), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + private addFolderEntry(uri: URI): void { + // Don't add duplicates + if (this.entries.some(e => e.uri.toString() === uri.toString())) { + return; + } + + this.entries.push({ uri, name: basename(uri), displayType: 'letter' }); + this.saveEntriesToStorage(); + + // Select the newly added folder + this._selectedFolderUri = uri; + this.saveEntriesToStorage(); + this.applySelectedFolder(); + this._onDidSelectWorkspace.fire(this._selectedFolderUri); + + this.renderContent(); + } + + private async applySelectedFolder(): Promise { + if (!this._selectedFolderUri) { + return; + } + + const currentFolders = this.workspaceContextService.getWorkspace().folders; + const foldersToRemove = currentFolders.map(f => f.uri); + + // Remove existing workspace folders and add the selected one + await this.workspaceEditingService.updateFolders( + 0, + foldersToRemove.length, + [{ uri: this._selectedFolderUri }] + ); + } + + protected override createContentArea(parent: HTMLElement): HTMLElement { + this.element = parent; + this.content = append(this.element, $('.content')); + + // Create actions container for workspace folders and add button + this.actionsContainer = append(this.content, $('.actions-container')); + + // Create the UI for workspace folders + this.renderContent(); + + // Create global composite bar at the bottom (accounts, settings) + this.globalCompositeBar.create(this.content); + + return this.content; + } + + private renderContent(): void { + if (!this.actionsContainer) { + return; + } + + // Clear existing content + clearNode(this.actionsContainer); + this.workspaceEntryDisposables.value = new DisposableStore(); + + // Create add folder button + this.createAddFolderButton(this.actionsContainer); + + // Create workspace folder entries + this.createWorkspaceEntries(this.actionsContainer); + } + + private createAddFolderButton(container: HTMLElement): void { + this.addFolderButton = append(container, $('.action-item.add-folder')); + const actionLabel = append(this.addFolderButton, $('span.action-label')); + + // Add the plus icon using codicon + actionLabel.classList.add(...ThemeIcon.asClassNameArray(Codicon.add)); + + // Add hover tooltip + this.workspaceEntryDisposables.value?.add( + this.hoverService.setupDelayedHover( + this.addFolderButton, + { + appearance: { showPointer: true }, + position: { hoverPosition: HoverPosition.RIGHT }, + content: 'Add Folder to Project' + }, + { groupId: HOVER_GROUP_ID } + ) + ); + + // Click handler to add folder + this.workspaceEntryDisposables.value?.add( + addDisposableListener(this.addFolderButton, EventType.CLICK, () => { + this.pickAndAddFolder(); + }) + ); + + // Keyboard support + this.addFolderButton.setAttribute('tabindex', '0'); + this.addFolderButton.setAttribute('role', 'button'); + this.addFolderButton.setAttribute('aria-label', 'Add Folder to Project'); + this.workspaceEntryDisposables.value?.add( + addDisposableListener(this.addFolderButton, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.pickAndAddFolder(); + } + }) + ); + } + + private async pickAndAddFolder(): Promise { + const folders = await this.fileDialogService.showOpenDialog({ + openLabel: 'Add', + title: 'Add Folder to Project', + canSelectFolders: true, + canSelectMany: false, + defaultUri: await this.fileDialogService.defaultFolderPath(), + availableFileSystems: [this.pathService.defaultUriScheme] + }); + + if (folders?.length) { + this.addFolderEntry(folders[0]); + } + } + + private createWorkspaceEntries(container: HTMLElement): void { + for (let i = 0; i < this.entries.length; i++) { + this.createWorkspaceEntry(container, this.entries[i], i); + } + + // Auto-select first entry if available and none selected + if (this.entries.length > 0 && this._selectedFolderUri) { + this._onDidSelectWorkspace.fire(this._selectedFolderUri); + } + } + + private createWorkspaceEntry(container: HTMLElement, entry: IProjectBarEntry, index: number): void { + const entryDisposables = this.workspaceEntryDisposables.value!; + + const entryElement = append(container, $('.action-item.workspace-entry')); + const actionLabel = append(entryElement, $('span.action-label.workspace-icon')); + append(entryElement, $('span.active-item-indicator')); + + // Render based on display type + const folderName = entry.name; + if (entry.displayType === 'icon' && entry.iconId) { + // Render codicon + const icon = ThemeIcon.fromId(entry.iconId); + actionLabel.classList.add(...ThemeIcon.asClassNameArray(icon)); + actionLabel.classList.add('codicon-icon'); + actionLabel.textContent = ''; + } else { + // Default: render first letter of folder name + const firstLetter = folderName.charAt(0).toUpperCase(); + actionLabel.textContent = firstLetter; + } + + // Set selected state + const isSelected = this._selectedFolderUri?.toString() === entry.uri.toString(); + if (isSelected) { + entryElement.classList.add('checked'); + } + + // Build hover content with full path + const folderPath = this.labelService.getUriLabel(entry.uri, { relative: false }); + + // Add hover tooltip with folder name + entryDisposables.add( + this.hoverService.setupDelayedHover( + entryElement, + { + appearance: { showPointer: true }, + position: { hoverPosition: HoverPosition.RIGHT }, + content: folderPath + }, + { groupId: HOVER_GROUP_ID } + ) + ); + + // Click handler to select workspace + entryDisposables.add( + addDisposableListener(entryElement, EventType.CLICK, () => { + this.selectWorkspace(index); + }) + ); + + // Keyboard support + entryElement.setAttribute('tabindex', '0'); + entryElement.setAttribute('role', 'button'); + entryElement.setAttribute('aria-label', folderName); + entryElement.setAttribute('aria-pressed', isSelected ? 'true' : 'false'); + entryDisposables.add( + addDisposableListener(entryElement, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.selectWorkspace(index); + } + }) + ); + + // Context menu with customize and remove actions + entryDisposables.add( + addDisposableListener(entryElement, EventType.CONTEXT_MENU, (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const event = new StandardMouseEvent(getWindow(entryElement), e); + this.contextMenuService.showContextMenu({ + getAnchor: () => event, + getActions: () => [ + new Action('projectbar.customize', localize('projectbar.customize', "Customize"), undefined, true, () => this.showCustomizeQuickPick(index)), + new Separator(), + new Action('projectbar.removeFolder', localize('projectbar.removeFolder', "Remove Folder"), undefined, true, () => this.removeFolderEntry(index)) + ] + }); + }) + ); + } + + private selectWorkspace(index: number): void { + if (index < 0 || index >= this.entries.length) { + return; + } + + const entry = this.entries[index]; + if (this._selectedFolderUri?.toString() === entry.uri.toString()) { + return; // Already selected + } + + this._selectedFolderUri = entry.uri; + this.saveEntriesToStorage(); + + // Re-render to update visual state + this.renderContent(); + + // Apply the selected folder as the workspace folder + this.applySelectedFolder(); + + // Fire selection event + this._onDidSelectWorkspace.fire(this._selectedFolderUri); + } + + private removeFolderEntry(index: number): void { + if (index < 0 || index >= this.entries.length) { + return; + } + + const removedUri = this.entries[index].uri; + this.entries.splice(index, 1); + this.saveEntriesToStorage(); + + // If the removed entry was the selected one, select the first remaining entry + if (this._selectedFolderUri?.toString() === removedUri.toString()) { + if (this.entries.length > 0) { + this._selectedFolderUri = this.entries[0].uri; + this.applySelectedFolder(); + this._onDidSelectWorkspace.fire(this._selectedFolderUri); + } else { + this._selectedFolderUri = undefined; + this._onDidSelectWorkspace.fire(undefined); + } + } + + this.renderContent(); + } + + private async showCustomizeQuickPick(index: number): Promise { + if (index < 0 || index >= this.entries.length) { + return; + } + + const entry = this.entries[index]; + + interface ICustomizeQuickPickItem extends IQuickPickItem { + customType: 'letter' | 'icon'; + } + + const items: ICustomizeQuickPickItem[] = [ + { + customType: 'letter', + label: localize('projectbar.customize.letter', "Letter"), + description: localize('projectbar.customize.letter.description', "Show the first letter of the workspace name") + }, + { + customType: 'icon', + label: localize('projectbar.customize.icon', "Icon"), + description: localize('projectbar.customize.icon.description', "Choose a codicon to represent the workspace") + } + ]; + + const picked = await this.quickInputService.pick(items, { + placeHolder: localize('projectbar.customize.placeholder', "Choose how to display the workspace in the project bar"), + title: localize('projectbar.customize.title', "Customize Workspace Appearance") + }); + + if (!picked) { + return; + } + + if (picked.customType === 'letter') { + entry.displayType = 'letter'; + entry.iconId = undefined; + this.saveEntriesToStorage(); + this.renderContent(); + } else if (picked.customType === 'icon') { + const icon = await this.pickIcon(); + if (icon) { + entry.displayType = 'icon'; + entry.iconId = icon.id; + this.saveEntriesToStorage(); + this.renderContent(); + } + } + } + + private async pickIcon(): Promise { + const iconSelectBox = this.instantiationService.createInstance(WorkbenchIconSelectBox, { + icons: icons.value, + inputBoxStyles: defaultInputBoxStyles + }); + + const dimension = new Dimension(486, 260); + return new Promise(resolve => { + const disposables = new DisposableStore(); + + disposables.add(iconSelectBox.onDidSelect(e => { + resolve(e); + disposables.dispose(); + iconSelectBox.dispose(); + })); + + iconSelectBox.clearInput(); + const body = getActiveDocument().body; + const bodyRect = body.getBoundingClientRect(); + const hoverWidget = this.hoverService.showInstantHover({ + content: iconSelectBox.domNode, + target: { + targetElements: [body], + x: bodyRect.left + (bodyRect.width - dimension.width) / 2, + y: bodyRect.top + this.layoutService.activeContainerOffset.top + }, + position: { + hoverPosition: HoverPosition.BELOW, + }, + persistence: { + sticky: true, + }, + }, true); + + if (hoverWidget) { + disposables.add(hoverWidget); + } + + iconSelectBox.layout(dimension); + iconSelectBox.focus(); + }); + } + + get selectedWorkspaceFolder(): URI | undefined { + return this._selectedFolderUri; + } + + override updateStyles(): void { + super.updateStyles(); + + const container = assertReturnsDefined(this.getContainer()); + const background = this.getColor(ACTIVITY_BAR_BACKGROUND) || ''; + container.style.backgroundColor = background; + + const borderColor = this.getColor(ACTIVITY_BAR_BORDER) || this.getColor(contrastBorder) || ''; + container.classList.toggle('bordered', !!borderColor); + container.style.borderColor = borderColor ? borderColor : ''; + } + + focus(): void { + // Focus the add folder button (first focusable element) + this.addFolderButton?.focus(); + } + + focusGlobalCompositeBar(): void { + this.globalCompositeBar.focus(); + } + + override layout(width: number, height: number): void { + super.layout(width, height, 0, 0); + + // The global composite bar takes some height at the bottom + // The actions container will take the remaining space due to CSS flex layout + } + + toJSON(): object { + return { + type: AgenticParts.PROJECTBAR_PART + }; + } +} diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts new file mode 100644 index 0000000000000..b689ed528f40f --- /dev/null +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../workbench/browser/parts/sidebar/media/sidebarpart.css'; +import './media/sidebarPart.css'; +import { IWorkbenchLayoutService, Parts, Position as SideBarPosition } from '../../../workbench/services/layout/browser/layoutService.js'; +import { SidebarFocusContext, ActiveViewletContext } from '../../../workbench/common/contextkeys.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { SIDE_BAR_TITLE_FOREGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_BACKGROUND, SIDE_BAR_FOREGROUND, SIDE_BAR_BORDER, SIDE_BAR_DRAG_AND_DROP_BACKGROUND, ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER } from '../../../workbench/common/theme.js'; +import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { INotificationService } from '../../../platform/notification/common/notification.js'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { AnchorAlignment } from '../../../base/browser/ui/contextview/contextview.js'; +import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; +import { LayoutPriority } from '../../../base/browser/ui/grid/grid.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; +import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { ICompositeTitleLabel } from '../../../workbench/browser/parts/compositePart.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { ActionsOrientation } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; +import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; +import { IMenuService } from '../../../platform/actions/common/actions.js'; +import { Separator } from '../../../base/common/actions.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { Extensions } from '../../../workbench/browser/panecomposite.js'; +import { Menus } from '../menus.js'; +import { $, append, getWindowId, prepend } from '../../../base/browser/dom.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; +import { isMacintosh, isNative } from '../../../base/common/platform.js'; +import { isFullscreen, onDidChangeFullscreen } from '../../../base/browser/browser.js'; +import { mainWindow } from '../../../base/browser/window.js'; + +/** + * Sidebar part specifically for agent sessions workbench. + * This is a simplified version of the SidebarPart for agent session contexts. + */ +export class SidebarPart extends AbstractPaneCompositePart { + + static readonly activeViewletSettingsKey = 'workbench.agentsession.sidebar.activeviewletid'; + static readonly pinnedViewContainersKey = 'workbench.agentsession.pinnedViewlets2'; + static readonly placeholderViewContainersKey = 'workbench.agentsession.placeholderViewlets'; + static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.viewletsWorkspaceState'; + + /** Visual margin values - sidebar is flush (no card appearance) */ + static readonly MARGIN_TOP = 0; + static readonly MARGIN_BOTTOM = 0; + static readonly MARGIN_LEFT = 0; + static readonly FOOTER_HEIGHT = 39; + + + //#region IView + + readonly minimumWidth: number = 170; + readonly maximumWidth: number = Number.POSITIVE_INFINITY; + readonly minimumHeight: number = 0; + readonly maximumHeight: number = Number.POSITIVE_INFINITY; + override get snap(): boolean { return true; } + + readonly priority: LayoutPriority = LayoutPriority.Low; + + get preferredWidth(): number | undefined { + const viewlet = this.getActivePaneComposite(); + + if (!viewlet) { + return undefined; + } + + const width = viewlet.getOptimalWidth(); + if (typeof width !== 'number') { + return undefined; + } + + return Math.max(width, 300); + } + + //#endregion + + constructor( + @INotificationService notificationService: INotificationService, + @IStorageService storageService: IStorageService, + @IContextMenuService contextMenuService: IContextMenuService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IContextKeyService contextKeyService: IContextKeyService, + @IExtensionService extensionService: IExtensionService, + @IMenuService menuService: IMenuService, + ) { + super( + Parts.SIDEBAR_PART, + { hasTitle: true, trailingSeparator: false, borderWidth: () => 0 }, + SidebarPart.activeViewletSettingsKey, + ActiveViewletContext.bindTo(contextKeyService), + SidebarFocusContext.bindTo(contextKeyService), + 'sideBar', + 'viewlet', + SIDE_BAR_TITLE_FOREGROUND, + SIDE_BAR_TITLE_BORDER, + ViewContainerLocation.Sidebar, + Extensions.Viewlets, + Menus.SidebarTitle, + Menus.TitleBarLeft, + notificationService, + storageService, + contextMenuService, + layoutService, + keybindingService, + hoverService, + instantiationService, + themeService, + viewDescriptorService, + contextKeyService, + extensionService, + menuService, + ); + } + + override create(parent: HTMLElement): void { + super.create(parent); + this.createFooter(parent); + } + + protected override createTitleArea(parent: HTMLElement): HTMLElement | undefined { + const titleArea = super.createTitleArea(parent); + + if (titleArea) { + // Add a drag region so the sidebar title area can be used to move the window, + // matching the titlebar's drag behavior. + prepend(titleArea, $('div.titlebar-drag-region')); + } + + // macOS native: the sidebar spans full height and the traffic lights + // overlay the top-left corner. Add a fixed-width spacer inside the + // title area to push content horizontally past the traffic lights. + if (titleArea && isMacintosh && isNative) { + const spacer = $('div.window-controls-container'); + spacer.style.width = '70px'; + spacer.style.height = '100%'; + spacer.style.flexShrink = '0'; + spacer.style.order = '-1'; // match global-actions-left order so DOM order is respected + prepend(titleArea, spacer); + + // Hide spacer in fullscreen (traffic lights are not shown) + const updateSpacerVisibility = () => { + spacer.style.display = isFullscreen(mainWindow) ? 'none' : ''; + }; + updateSpacerVisibility(); + this._register(onDidChangeFullscreen(windowId => { + if (windowId === getWindowId(mainWindow)) { + updateSpacerVisibility(); + } + })); + } + + return titleArea; + } + + private createFooter(parent: HTMLElement): void { + const footer = append(parent, $('.sidebar-footer')); + + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, footer, Menus.SidebarFooter, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'sidebarFooter', + })); + } + + override updateStyles(): void { + super.updateStyles(); + + const container = assertReturnsDefined(this.getContainer()); + + container.style.backgroundColor = this.getColor(SIDE_BAR_BACKGROUND) || ''; + container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; + container.style.outlineColor = this.getColor(SIDE_BAR_DRAG_AND_DROP_BACKGROUND) ?? ''; + + // Right border to separate from the right section + const borderColor = this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder) || ''; + container.style.borderRightWidth = borderColor ? '1px' : ''; + container.style.borderRightStyle = borderColor ? 'solid' : ''; + container.style.borderRightColor = borderColor; + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.SIDEBAR_PART)) { + return; + } + + // Layout content with reduced height to account for footer + super.layout( + width, + height - SidebarPart.FOOTER_HEIGHT, + top, left + ); + + // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. + Part.prototype.layout.call(this, width, height, top, left); + } + + protected override getTitleAreaDropDownAnchorAlignment(): AnchorAlignment { + return this.layoutService.getSideBarPosition() === SideBarPosition.LEFT ? AnchorAlignment.LEFT : AnchorAlignment.RIGHT; + } + + protected override createTitleLabel(_parent: HTMLElement): ICompositeTitleLabel { + // No title label in agent sessions sidebar + return { + updateTitle: () => { }, + updateStyles: () => { } + }; + } + + protected getCompositeBarOptions(): IPaneCompositeBarOptions { + return { + partContainerClass: 'sidebar', + pinnedViewContainersKey: SidebarPart.pinnedViewContainersKey, + placeholderViewContainersKey: SidebarPart.placeholderViewContainersKey, + viewContainersWorkspaceStateKey: SidebarPart.viewContainersWorkspaceStateKey, + icon: false, + orientation: ActionsOrientation.HORIZONTAL, + recomputeSizes: true, + activityHoverOptions: { + position: () => this.getCompositeBarPosition() === CompositeBarPosition.BOTTOM ? HoverPosition.ABOVE : HoverPosition.BELOW, + }, + fillExtraContextMenuActions: actions => { + if (this.getCompositeBarPosition() === CompositeBarPosition.TITLE) { + const viewsSubmenuAction = this.getViewsSubmenuAction(); + if (viewsSubmenuAction) { + actions.push(new Separator()); + actions.push(viewsSubmenuAction); + } + } + }, + compositeSize: 0, + iconSize: 16, + overflowActionSize: 30, + colors: theme => ({ + activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + activeBorderBottomColor: theme.getColor(ACTIVITY_BAR_TOP_ACTIVE_BORDER), + activeForegroundColor: theme.getColor(ACTIVITY_BAR_TOP_FOREGROUND), + inactiveForegroundColor: theme.getColor(ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND), + badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), + badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), + dragAndDropBorder: theme.getColor(ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER) + }), + compact: true + }; + } + + protected shouldShowCompositeBar(): boolean { + return false; + } + + protected getCompositeBarPosition(): CompositeBarPosition { + return CompositeBarPosition.TITLE; + } + + async focusActivityBar(): Promise { + if (this.shouldShowCompositeBar()) { + this.focusCompositeBar(); + } + } + + toJSON(): object { + return { + type: Parts.SIDEBAR_PART + }; + } +} diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts new file mode 100644 index 0000000000000..b2231dd47b821 --- /dev/null +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -0,0 +1,419 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../workbench/browser/parts/titlebar/media/titlebarpart.css'; +import './media/titlebarpart.css'; +import { MultiWindowParts, Part } from '../../../workbench/browser/part.js'; +import { ITitleService } from '../../../workbench/services/title/browser/titleService.js'; +import { getZoomFactor, isWCOEnabled, getWCOTitlebarAreaRect, isFullscreen, onDidChangeFullscreen } from '../../../base/browser/browser.js'; +import { hasCustomTitlebar, hasNativeTitlebar, DEFAULT_CUSTOM_TITLEBAR_HEIGHT, TitlebarStyle, getTitleBarStyle, getWindowControlsStyle, WindowControlsStyle } from '../../../platform/window/common/window.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { TITLE_BAR_ACTIVE_BACKGROUND, TITLE_BAR_ACTIVE_FOREGROUND, TITLE_BAR_INACTIVE_FOREGROUND, TITLE_BAR_INACTIVE_BACKGROUND, TITLE_BAR_BORDER, WORKBENCH_BACKGROUND } from '../../../workbench/common/theme.js'; +import { isMacintosh, isWeb, isNative, platformLocale } from '../../../base/common/platform.js'; +import { Color } from '../../../base/common/color.js'; +import { EventType, EventHelper, Dimension, append, $, addDisposableListener, prepend, getWindow, getWindowId } from '../../../base/browser/dom.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { Parts, IWorkbenchLayoutService } from '../../../workbench/services/layout/browser/layoutService.js'; + +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IHostService } from '../../../workbench/services/host/browser/host.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; +import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js'; +import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; +import { safeIntl } from '../../../base/common/date.js'; +import { ITitlebarPart, ITitleProperties, ITitleVariable, IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titlebar/titlebarPart.js'; +import { Menus } from '../menus.js'; + +/** + * Simplified agent sessions titlebar part. + * + * Three sections driven entirely by menus: + * - **Left**: `Menus.TitleBarLeft` toolbar + * - **Center**: `Menus.CommandCenter` toolbar (renders session picker via IActionViewItemService) + * - **Right**: `Menus.TitleBarRight` toolbar (includes account submenu) + * + * No menubar, no editor actions, no layout controls, no WindowTitle dependency. + */ +export class TitlebarPart extends Part implements ITitlebarPart { + + //#region IView + + readonly minimumWidth: number = 0; + readonly maximumWidth: number = Number.POSITIVE_INFINITY; + + get minimumHeight(): number { + const wcoEnabled = isWeb && isWCOEnabled(); + let value = DEFAULT_CUSTOM_TITLEBAR_HEIGHT; + if (wcoEnabled) { + value = Math.max(value, getWCOTitlebarAreaRect(getWindow(this.element))?.height ?? 0); + } + + return value / (this.preventZoom ? getZoomFactor(getWindow(this.element)) : 1); + } + + get maximumHeight(): number { return this.minimumHeight; } + + //#endregion + + //#region Events + + private readonly _onMenubarVisibilityChange = this._register(new Emitter()); + readonly onMenubarVisibilityChange = this._onMenubarVisibilityChange.event; + + private readonly _onWillDispose = this._register(new Emitter()); + readonly onWillDispose = this._onWillDispose.event; + + //#endregion + + private rootContainer!: HTMLElement; + private windowControlsContainer: HTMLElement | undefined; + + private leftContent!: HTMLElement; + private centerContent!: HTMLElement; + private rightContent!: HTMLElement; + + private readonly titleBarStyle: TitlebarStyle; + private isInactive: boolean = false; + + constructor( + id: string, + targetWindow: CodeWindow, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IConfigurationService protected readonly configurationService: IConfigurationService, + @IInstantiationService protected readonly instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IHostService private readonly hostService: IHostService, + ) { + super(id, { hasTitle: false }, themeService, storageService, layoutService); + + this.titleBarStyle = getTitleBarStyle(this.configurationService); + + this.registerListeners(getWindowId(targetWindow)); + } + + private registerListeners(targetWindowId: number): void { + this._register(this.hostService.onDidChangeFocus(focused => focused ? this.onFocus() : this.onBlur())); + this._register(this.hostService.onDidChangeActiveWindow(windowId => windowId === targetWindowId ? this.onFocus() : this.onBlur())); + } + + private onBlur(): void { + this.isInactive = true; + this.updateStyles(); + } + + private onFocus(): void { + this.isInactive = false; + this.updateStyles(); + } + + updateProperties(_properties: ITitleProperties): void { + // No window title to update in simplified titlebar + } + + registerVariables(_variables: ITitleVariable[]): void { + // No window title variables in simplified titlebar + } + + updateOptions(_options: { compact: boolean }): void { + // No compact mode support in agent sessions titlebar + } + + protected override createContentArea(parent: HTMLElement): HTMLElement { + this.element = parent; + this.rootContainer = append(parent, $('.titlebar-container.has-center')); + + // Draggable region + prepend(this.rootContainer, $('div.titlebar-drag-region')); + + this.leftContent = append(this.rootContainer, $('.titlebar-left')); + this.centerContent = append(this.rootContainer, $('.titlebar-center')); + this.rightContent = append(this.rootContainer, $('.titlebar-right')); + + // Window Controls Container (must be before left toolbar for correct ordering) + if (!hasNativeTitlebar(this.configurationService, this.titleBarStyle)) { + let primaryWindowControlsLocation = isMacintosh ? 'left' : 'right'; + if (isMacintosh && isNative) { + const localeInfo = safeIntl.Locale(platformLocale).value; + const textInfo = (localeInfo as { textInfo?: { direction?: string } }).textInfo; + if (textInfo?.direction === 'rtl') { + primaryWindowControlsLocation = 'right'; + } + } + + if (isMacintosh && isNative && primaryWindowControlsLocation === 'left') { + // macOS native: traffic lights are rendered by the OS at the top-left corner. + // Add a fixed-width spacer to push content past the traffic lights. + const spacer = append(this.leftContent, $('div.window-controls-container')); + spacer.style.width = '70px'; + spacer.style.flexShrink = '0'; + + // Hide spacer in fullscreen (traffic lights are not shown) + const updateSpacerVisibility = () => { + spacer.style.display = isFullscreen(mainWindow) ? 'none' : ''; + }; + updateSpacerVisibility(); + this._register(onDidChangeFullscreen(windowId => { + if (windowId === getWindowId(mainWindow)) { + updateSpacerVisibility(); + } + })); + } else if (getWindowControlsStyle(this.configurationService) === WindowControlsStyle.HIDDEN) { + // controls explicitly disabled + } else { + this.windowControlsContainer = append(primaryWindowControlsLocation === 'left' ? this.leftContent : this.rightContent, $('div.window-controls-container')); + if (isWeb) { + append(primaryWindowControlsLocation === 'left' ? this.rightContent : this.leftContent, $('div.window-controls-container')); + } + + if (isWCOEnabled()) { + this.windowControlsContainer.classList.add('wco-enabled'); + } + } + } + + // Left toolbar (driven by Menus.TitleBarLeft, rendered after window controls via CSS order) + const leftToolbarContainer = append(this.leftContent, $('div.left-toolbar-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, leftToolbarContainer, Menus.TitleBarLeft, { + contextMenu: Menus.TitleBarContext, + telemetrySource: 'titlePart.left', + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + })); + + // Center toolbar - command center (renders session picker via IActionViewItemService) + // Uses .window-title > .command-center nesting to match default workbench CSS selectors + const windowTitle = append(this.centerContent, $('div.window-title')); + const centerToolbarContainer = append(windowTitle, $('div.command-center')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, centerToolbarContainer, Menus.CommandCenter, { + contextMenu: Menus.TitleBarContext, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + telemetrySource: 'commandCenter', + toolbarOptions: { primaryGroup: () => true }, + })); + + // Right toolbar (driven by Menus.TitleBarRight - includes account submenu) + const rightToolbarContainer = append(this.rightContent, $('div.action-toolbar-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRight, { + contextMenu: Menus.TitleBarContext, + telemetrySource: 'titlePart.right', + toolbarOptions: { primaryGroup: () => true }, + })); + + // Context menu on the titlebar + this._register(addDisposableListener(this.rootContainer, EventType.CONTEXT_MENU, e => { + EventHelper.stop(e); + this.onContextMenu(e); + })); + + this.updateStyles(); + + return this.element; + } + + override updateStyles(): void { + super.updateStyles(); + + if (this.element) { + this.element.classList.toggle('inactive', this.isInactive); + + const titleBackground = this.getColor(this.isInactive ? TITLE_BAR_INACTIVE_BACKGROUND : TITLE_BAR_ACTIVE_BACKGROUND, (color, theme) => { + return color.isOpaque() ? color : color.makeOpaque(WORKBENCH_BACKGROUND(theme)); + }) || ''; + this.element.style.backgroundColor = titleBackground; + + if (titleBackground && Color.fromHex(titleBackground).isLighter()) { + this.element.classList.add('light'); + } else { + this.element.classList.remove('light'); + } + + const titleForeground = this.getColor(this.isInactive ? TITLE_BAR_INACTIVE_FOREGROUND : TITLE_BAR_ACTIVE_FOREGROUND); + this.element.style.color = titleForeground || ''; + + const titleBorder = this.getColor(TITLE_BAR_BORDER); + this.element.style.borderBottom = titleBorder ? `1px solid ${titleBorder}` : ''; + } + } + + private onContextMenu(e: MouseEvent): void { + const event = new StandardMouseEvent(getWindow(this.element), e); + this.contextMenuService.showContextMenu({ + getAnchor: () => event, + menuId: Menus.TitleBarContext, + contextKeyService: this.contextKeyService, + domForShadowRoot: isMacintosh && isNative ? event.target : undefined + }); + } + + private lastLayoutDimension: Dimension | undefined; + + get preventZoom(): boolean { + return getZoomFactor(getWindow(this.element)) < 1; + } + + override layout(width: number, height: number): void { + this.lastLayoutDimension = new Dimension(width, height); + this.updateLayout(); + super.layoutContents(width, height); + } + + private updateLayout(): void { + if (!hasCustomTitlebar(this.configurationService, this.titleBarStyle)) { + return; + } + + const zoomFactor = getZoomFactor(getWindow(this.element)); + this.element.style.setProperty('--zoom-factor', zoomFactor.toString()); + this.rootContainer.classList.toggle('counter-zoom', this.preventZoom); + + this.updateCenterOffset(); + } + + private updateCenterOffset(): void { + if (!this.centerContent || !this.lastLayoutDimension) { + return; + } + + // Center the command center relative to the viewport. + // The titlebar only covers the right section (sidebar is to the left), + // so we shift the center content left by half the sidebar width + // using a negative margin. + const windowWidth = this.layoutService.mainContainerDimension.width; + const titlebarWidth = this.lastLayoutDimension.width; + const leftOffset = windowWidth - titlebarWidth; + this.centerContent.style.marginLeft = leftOffset > 0 ? `${-leftOffset / 2}px` : ''; + this.centerContent.style.marginRight = leftOffset > 0 ? `${leftOffset / 2}px` : ''; + } + + focus(): void { + // eslint-disable-next-line no-restricted-syntax + (this.element.querySelector('[tabindex]:not([tabindex="-1"])') as HTMLElement | null)?.focus(); + } + + toJSON(): object { + return { type: Parts.TITLEBAR_PART }; + } + + override dispose(): void { + this._onWillDispose.fire(); + super.dispose(); + } +} + +/** + * Main agent sessions titlebar part (for the main window). + */ +export class MainTitlebarPart extends TitlebarPart { + + constructor( + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHostService hostService: IHostService, + ) { + super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); + } +} + +/** + * Auxiliary agent sessions titlebar part (for auxiliary windows). + */ +export class AuxiliaryTitlebarPart extends TitlebarPart implements IAuxiliaryTitlebarPart { + + private static COUNTER = 1; + + get height() { return this.minimumHeight; } + + constructor( + readonly container: HTMLElement, + editorGroupsContainer: IEditorGroupsContainer, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHostService hostService: IHostService, + ) { + const id = AuxiliaryTitlebarPart.COUNTER++; + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); + } +} + +/** + * Agent Sessions title service - manages the titlebar parts. + */ +export class TitleService extends MultiWindowParts implements ITitleService { + + declare _serviceBrand: undefined; + + readonly mainPart: TitlebarPart; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStorageService storageService: IStorageService, + @IThemeService themeService: IThemeService + ) { + super('workbench.agentSessionsTitleService', themeService, storageService); + + this.mainPart = this._register(this.instantiationService.createInstance(MainTitlebarPart)); + this.onMenubarVisibilityChange = this.mainPart.onMenubarVisibilityChange; + this._register(this.registerPart(this.mainPart)); + } + + //#region Auxiliary Titlebar Parts + + createAuxiliaryTitlebarPart(container: HTMLElement, editorGroupsContainer: IEditorGroupsContainer, instantiationService: IInstantiationService): IAuxiliaryTitlebarPart { + const titlebarPartContainer = $('.part.titlebar', { role: 'none' }); + titlebarPartContainer.style.position = 'relative'; + container.insertBefore(titlebarPartContainer, container.firstChild); + + const disposables = new DisposableStore(); + + const titlebarPart = instantiationService.createInstance(AuxiliaryTitlebarPart, titlebarPartContainer, editorGroupsContainer); + disposables.add(this.registerPart(titlebarPart)); + + disposables.add(Event.runAndSubscribe(titlebarPart.onDidChange, () => titlebarPartContainer.style.height = `${titlebarPart.height}px`)); + titlebarPart.create(titlebarPartContainer); + + Event.once(titlebarPart.onWillDispose)(() => disposables.dispose()); + + return titlebarPart; + } + + //#endregion + + //#region Service Implementation + + readonly onMenubarVisibilityChange: Event; + + updateProperties(properties: ITitleProperties): void { + for (const part of this.parts) { + part.updateProperties(properties); + } + } + + registerVariables(variables: ITitleVariable[]): void { + for (const part of this.parts) { + part.registerVariables(variables); + } + } + + //#endregion +} diff --git a/src/vs/sessions/browser/style.css b/src/vs/sessions/browser/style.css new file mode 100644 index 0000000000000..aa371216552a2 --- /dev/null +++ b/src/vs/sessions/browser/style.css @@ -0,0 +1,167 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Ensure workbench container is positioned for overlay */ +.agent-sessions-workbench { + position: relative; +} + +/* ---- Sidebar & Auxiliary Bar Card Appearance ---- */ + +/** + * The auxiliary bar and panel use a card-like appearance with margins and border-radius + * applied directly on the .part element. The grid allocates full space; CSS margin shrinks + * the part within its split-view-view wrapper. Layout dimensions are reduced in code + * (AgenticAuxiliaryBarPart) to keep internal layout correct. + * + * No z-index or stacking-context changes - sashes render naturally on top. + * + * Margin values (must match the constants in the Part classes): + * Sidebar: no card (flush, spans full height) + * Auxiliary bar: top=8, bottom=8, right=8 + * Panel: bottom=8, left=8, right=8 + */ + +.agent-sessions-workbench .part.sidebar { + background: var(--vscode-sideBar-background); + border-right: 1px solid var(--vscode-sideBar-border, transparent); +} + +.agent-sessions-workbench .part.auxiliarybar { + margin: 8px 8px 8px 0; + background: var(--part-background); + border: 1px solid var(--part-border-color, transparent); + border-radius: 8px; +} + +.agent-sessions-workbench .part.panel { + margin: 0 8px 8px 8px; + background: var(--part-background); + border: 1px solid var(--part-border-color, transparent); + border-radius: 8px; +} + +/* Grid background matches the chat bar / sidebar background */ +.agent-sessions-workbench > .monaco-grid-view { + background-color: var(--vscode-sideBar-background); +} + +/* Editor Modal Overlay */ +.agent-sessions-workbench .editor-modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + visibility: hidden; +} + +.agent-sessions-workbench .editor-modal-overlay.visible { + pointer-events: auto; + visibility: visible; +} + +/* Modal Backdrop */ +.agent-sessions-workbench .editor-modal-backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + opacity: 0; + transition: opacity 0.15s ease-out; +} + +.agent-sessions-workbench .editor-modal-overlay.visible .editor-modal-backdrop { + opacity: 1; +} + +/* Modal Container */ +.agent-sessions-workbench .editor-modal-container { + position: relative; + display: flex; + flex-direction: column; + /* Width and height are set dynamically in TypeScript */ + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + overflow: hidden; + transform: scale(0.95); + opacity: 0; + transition: transform 0.15s ease-out, opacity 0.15s ease-out; +} + +.agent-sessions-workbench .editor-modal-overlay.visible .editor-modal-container { + transform: scale(1); + opacity: 1; +} + +/* Modal Header with close button */ +.agent-sessions-workbench .editor-modal-header { + display: flex; + align-items: center; + justify-content: flex-end; + height: 32px; + min-height: 32px; + padding: 0 8px; + background-color: var(--vscode-editorGroupHeader-tabsBackground); + border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, transparent); +} + +.agent-sessions-workbench .editor-modal-close-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + color: var(--vscode-icon-foreground); + cursor: pointer; + border-radius: 4px; +} + +.agent-sessions-workbench .editor-modal-close-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .editor-modal-close-button:active { + background-color: var(--vscode-toolbar-activeBackground); +} + +/* Editor Content Area */ +.agent-sessions-workbench .editor-modal-content { + flex: 1; + overflow: hidden; + position: relative; + min-height: 0; /* Allow flexbox shrinking */ +} + +.agent-sessions-workbench .editor-modal-content > .part.editor { + position: absolute !important; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +/* ---- Chat Input ---- */ + +.agent-sessions-workbench .interactive-session .chat-input-container { + border-radius: 8px !important; +} + +.agent-sessions-workbench .interactive-session .interactive-input-part { + margin: 0 8px !important; + display: inherit !important; + padding: 4px 0 8px 0px !important; +} diff --git a/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md b/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md new file mode 100644 index 0000000000000..6b600f9441cf6 --- /dev/null +++ b/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md @@ -0,0 +1,503 @@ +# Agent Sessions Chat Widget Architecture + +This document describes the architecture of the **Agent Sessions Chat Widget** (`AgentSessionsChatWidget`), a new extensible chat widget designed for the agent sessions window. It replaces the tightly-coupled agent session logic inside `ChatWidget` and `ChatInputPart` with a clean, composable system built around the wrapper pattern. + +--- + +## 1. Motivation: Why a New Architecture? + +### The Problem with Patching Core Widgets + +The original approach to supporting agent sessions involved adding agent-specific logic directly into the core `ChatWidget` and `ChatInputPart`. Over time, this led to significant coupling and code complexity: + +**Inside `ChatWidget` (~100+ lines of agent-specific code):** +- `ChatFullWelcomePart` is directly instantiated inside `ChatWidget.render()`, with the widget reaching into the welcome part's DOM to reparent the input element between `fullWelcomePart.inputSlot` and `mainInputContainer` +- `showFullWelcome` creates a forked rendering path — 5+ conditional branches in `render()`, `updateChatViewVisibility()`, and `renderWelcomeViewContentIfNeeded()` +- `lockToCodingAgent()` / `unlockFromCodingAgent()` add ~55 lines of method code plus ~20 lines of scattered `_lockedAgent`-gated logic throughout `clear()`, `forcedAgent` computation, welcome content generation, and scroll lock behavior +- The `_lockedToCodingAgentContextKey` context key is set/read in many places, creating implicit coupling between agent session state and widget rendering + +**Inside `ChatInputPart` (~50+ lines of agent-specific code):** +- Imports `AgentSessionProviders`, `getAgentSessionProvider`, `getAgentSessionProviderName` directly +- Manages `_pendingDelegationTarget`, `agentSessionTypeKey` context key, and `sessionTargetWidget` +- Has ~15 call sites checking `sessionTypePickerDelegate?.getActiveSessionProvider()` to determine option groups, picker rendering, and session type handling +- `getEffectiveSessionType()` resolves session type through a delegate → session context → fallback chain + +**Consequences:** +- **Fragile changes** — modifying agent session behavior requires touching core `ChatWidget` internals, risking regressions in the standard chat experience +- **Testing difficulty** — agent session logic is interleaved with general chat logic, making it hard to test either in isolation +- **Feature creep** — every new agent session feature (target restriction, deferred creation, cached option groups) adds more conditional branches to shared code +- **Unclear ownership** — it's hard to tell where "chat widget" ends and "agent sessions" begins + +### The Solution: Composition Over Modification + +The `AgentSessionsChatWidget` wraps `ChatWidget` instead of patching it. Agent-specific behavior lives in self-contained components that compose with the core widget through well-defined interfaces (`submitHandler`, `hiddenPickerIds`, `excludeOptionGroup`, `ISessionTypePickerDelegate` bridge). The core `ChatWidget` requires no agent-specific modifications. + +--- + +## 2. Overview + +The Agent Sessions Chat Widget provides: + +- **Deferred session creation** — the UI is fully interactive before any session resource exists +- **Target configuration** — users select which agent provider (Local, Cloud, etc.) to use +- **Welcome view** — a branded empty-state experience with mascot, target buttons, and option pickers +- **Initial session options** — option selections travel atomically with the first request to the extension +- **Configurable picker placement** — pickers can be rendered in the welcome view, input toolbar, or both + +```mermaid +graph TD + A[AgentSessionsChatWidget] --> B[ChatWidget] + A --> C[AgentSessionsChatWelcomePart] + A --> D[AgentSessionsChatTargetConfig] + + B --> F[ChatInputPart] + C --> G[Target Buttons] + C --> H[Option Pickers] + C --> I[Input Slot] + D --> J[Observable Target State] + E[AgentSessionsChatInputPart] -.->|standalone adapter| F + E -.-> D +``` + +> **Note:** `AgentSessionsChatInputPart` is a standalone adapter that bridges `IAgentChatTargetConfig` to `ChatInputPart`. It is available for consumers that need a `ChatInputPart` outside of a full `ChatWidget`, but `AgentSessionsChatWidget` itself creates the bridge delegate inline and passes it through `wrappedViewOptions` to the `ChatWidget`'s own `ChatInputPart`. + +--- + +## 3. Key Components + +### 3.1 `AgentSessionsChatWidget` + +**Location:** `src/vs/sessions/browser/widget/agentSessionsChatWidget.ts` + +The main wrapper around `ChatWidget`. It: + +1. **Owns the target config** — creates `AgentSessionsChatTargetConfig` from provided options +2. **Intercepts submission via two mechanisms** — uses `submitHandler` to create the session on first send, and monkey-patches `acceptInput` to attach `initialSessionOptions` to the session context +3. **Manages the welcome view** — shows `AgentSessionsChatWelcomePart` when the chat is empty +4. **Gathers initial options** — collects all option selections and attaches them to the session context +5. **Hides duplicate pickers** — uses `hiddenPickerIds` and `excludeOptionGroup` to avoid showing pickers in both the welcome view and input toolbar +6. **Caches option groups** — persists extension-contributed option groups to `StorageService` so pickers render immediately on next load before extensions activate + +#### Submission Interception: Two Mechanisms + +The widget uses two complementary interception points: + +- **`submitHandler`** (in `wrappedViewOptions`): Called by `ChatWidget._acceptInput()` before the normal send flow. If the session hasn't been created yet, it calls `_createSessionForCurrentTarget()`, restores the input text (which gets cleared by `setModel()`), and returns `false` to let the normal flow continue. +- **Monkey-patched `acceptInput`**: Called when `ChatSubmitAction` directly invokes `chatWidget.acceptInput()`. This captures the input text, creates the session if needed, then calls `_gatherAllOptionSelections()` to merge all option picks and attaches them to `contributedChatSession.initialSessionOptions` before delegating to the original `acceptInput`. + +Both paths converge on the same session creation and option gathering logic. The `submitHandler` handles the ChatWidget-internal send path, while the monkey-patch handles external callers (like `ChatSubmitAction`). + +```mermaid +sequenceDiagram + participant User + participant Widget as AgentSessionsChatWidget + participant Welcome as AgentSessionsChatWelcomePart + participant ChatWidget + participant Extension + + User->>Widget: Opens agent sessions window + Widget->>ChatWidget: render() with wrappedViewOptions + Widget->>Welcome: Create welcome view + Welcome-->>User: Shows mascot + target buttons + input + + User->>Welcome: Selects "Cloud" target + Welcome->>Widget: targetConfig.setSelectedTarget(Cloud) + Widget->>ChatWidget: Bridge to sessionTypePickerDelegate + + User->>Welcome: Picks repository + Welcome-->>Welcome: Stores in _selectedOptions + + User->>ChatWidget: Types message + clicks Send + Note over ChatWidget: ChatSubmitAction calls acceptInput() + ChatWidget->>Widget: Monkey-patched acceptInput() + Widget->>Widget: Capture input text + Widget->>Widget: _createSessionForCurrentTarget() + Widget->>Widget: _gatherAllOptionSelections() + Widget->>ChatWidget: setContributedChatSession({...initialSessionOptions}) + Widget->>ChatWidget: originalAcceptInput(capturedInput) + Note over ChatWidget: _acceptInput() → submitHandler check (already created, returns false) + ChatWidget->>Extension: $invokeAgent(request, {chatSessionContext: {initialSessionOptions}}) +``` + +### 3.2 `AgentSessionsChatTargetConfig` + +**Location:** `src/vs/sessions/browser/widget/agentSessionsChatTargetConfig.ts` + +A reactive configuration object that tracks: + +- **Allowed targets** — which agent providers are available (e.g., `[Background, Cloud]`) +- **Selected target** — which provider the user has chosen +- **Events** — fires when the target or allowed set changes + +```typescript +interface IAgentChatTargetConfig { + readonly allowedTargets: IObservable>; + readonly selectedTarget: IObservable; + readonly onDidChangeSelectedTarget: Event; + readonly onDidChangeAllowedTargets: Event>; + setSelectedTarget(target: AgentSessionProviders): void; + addAllowedTarget(target: AgentSessionProviders): void; + removeAllowedTarget(target: AgentSessionProviders): void; + setAllowedTargets(targets: AgentSessionProviders[]): void; +} +``` + +The target config is **purely UI state** — changing targets does NOT create sessions or resources. + +### 3.3 `AgentSessionsChatWelcomePart` + +**Location:** `src/vs/sessions/browser/parts/agentSessionsChatWelcomePart.ts` + +Renders the welcome view when the chat is empty: + +- **Mascot** — product branding image +- **Target buttons** — Local / Cloud toggle with sliding indicator +- **Option pickers** — extension-contributed option groups (repository, folder, etc.) +- **Input slot** — where the chat input is placed when in welcome mode + +The welcome part reads from `IAgentChatTargetConfig` and the `IChatSessionsService` for option groups. + +### 3.4 `AgentSessionsChatInputPart` + +**Location:** `src/vs/sessions/browser/parts/agentSessionsChatInputPart.ts` + +A standalone adapter around `ChatInputPart` that bridges `IAgentChatTargetConfig` to the existing `ISessionTypePickerDelegate` interface. It creates a `createTargetConfigDelegate()` bridge so the standard `ChatInputPart` can work with the new target config system without modifications. + +**Important:** `AgentSessionsChatWidget` does *not* use this adapter directly. Instead, it creates its own bridge delegate inline and passes it to `ChatWidget` via `wrappedViewOptions.sessionTypePickerDelegate`. The `AgentSessionsChatInputPart` is available for consumers that need a `ChatInputPart` with target config integration outside the context of a full `ChatWidget` (e.g., a detached input field). + +### 3.5 `AgentSessionsTargetPickerActionItem` + +**Location:** `src/vs/sessions/browser/widget/agentSessionsTargetPickerActionItem.ts` + +A dropdown picker action item for the input toolbar that reads available targets from `IAgentChatTargetConfig` (rather than `chatSessionsService`). Selection calls `targetConfig.setSelectedTarget()` with no session creation side effects. It renders the current target's icon and name, with a chevron to open the dropdown of allowed targets. The picker automatically re-renders when the selected target or allowed targets change. + +--- + +## 4. Chat Input Lifecycle: First Load vs New Session + +The chat input behaves differently depending on whether it's the very first load (before the extension activates) or a subsequent "New Session" after the extension is already active. + +### 4.1 First Load (Extension Not Yet Activated) + +When the agent sessions window opens for the first time: + +1. The `ChatWidget` renders with **no model** — `viewModel` is `undefined` +2. `ChatInputPart` has no `sessionResource`, so pickers query the `sessionTypePickerDelegate` for the effective session type +3. The extension hasn't activated yet, so: + - `chatSessionHasModels` context key is `false` (no option groups registered) + - `lockedToCodingAgent` is `false` (contribution not available yet) + - The `ChatSessionPrimaryPickerAction` menu item is **hidden** (its `when` clause requires both) +4. **Cached option groups** (from a previous run) are loaded from storage and seeded into the service, allowing pickers to render immediately with stale-but-useful data +5. **Pending session resource** — `_generatePendingSessionResource()` generates a lightweight URI (e.g., `copilotcli:/untitled-`) synchronously. No async work or extension activation needed. This resource allows picker commands and `notifySessionOptionsChange` events to flow through the existing pipeline. +6. When the extension activates: + - `onDidChangeAvailability` fires → `updateWidgetLockStateFromSessionType` sets `lockedToCodingAgent = true` + - `onDidChangeOptionGroups` fires with fresh data → `chatSessionHasModels = true` + - The `when` clause is now satisfied → toolbar re-renders with the picker action + - The welcome part re-renders pickers with live data from the extension +7. **Extension can now fire `notifySessionOptionsChange`** with the pending resource — the service stores values in `_pendingSessionOptions`, fires `onDidChangeSessionOptions`, and the welcome part and `ChatInputPart` match the resource and sync picker state. + +``` +State: No viewModel, _pendingSessionResource is set immediately (sync) +ChatInputPart: Uses delegate.getActiveSessionProvider() for session type +Pickers: Initially hidden, appear when extension activates +Option groups: Cached from storage → overwritten by extension +Session options: Stored in lightweight _pendingSessionOptions map (no ContributedChatSessionData) +``` + +### 4.2 New Session (Extension Already Active) + +When the user clicks "New Session" after completing a request: + +1. `resetSession()` is called +2. The old model is cleared via `setModel(undefined)` and the model ref is disposed +3. `_sessionCreated` is reset to `false` +4. `_pendingSessionResource` is cleared +5. Pending option selections from `ChatInputPart` are cleared via `takePendingOptionSelections()` +6. The welcome view becomes visible and pickers are re-rendered via `resetSelectedOptions()` +7. `_generatePendingSessionResource()` generates a fresh pending resource (synchronous) +8. The `ChatWidget` again has **no model** — same as first load from the input's perspective +9. BUT the extension is already active, so: + - `lockedToCodingAgent` is already `true` (contribution is available) + - `chatSessionHasModels` is already `true` (option groups are registered) + - Pickers render **immediately** with live data — no waiting for extension activation + - Option groups are fresh (not stale cached data) + - `getOrCreateChatSession` resolves quickly since the content provider is already registered + +``` +State: No viewModel, _pendingSessionResource set after init +ChatInputPart: Uses delegate.getActiveSessionProvider() for session type +Pickers: Render immediately (extension already active, context keys already set) +Option groups: Live data from extension (already registered) +``` + +### 4.3 Key Differences + +| Aspect | First Load | New Session | +|--------|-----------|-------------| +| Extension state | Not activated | Already active | +| `lockedToCodingAgent` | `false` → `true` (async) | Already `true` | +| `chatSessionHasModels` | `false` → `true` (async) | Already `true` | +| Input toolbar pickers | Hidden → appear on activation | Visible immediately | +| Welcome part pickers | Cached → replaced with live data | Live data from start | +| Session resource | Generated as pending, session data created eagerly | Old cleared, new pending generated | +| `_pendingSessionResource` | Set after `getOrCreateChatSession` completes | Cleared and re-initialized | +| `_pendingOptionSelections` | Empty | Cleared via `takePendingOptionSelections()` | +| Extension option changes | Received after pending init completes | Received immediately | + +### 4.4 The `locked` Flag and Session Reset + +Extensions can mark option items as `locked` (e.g., locking the folder picker after a request starts). This is a **session-specific** concept: + +- During an active session, the extension sends `notifySessionOptionsChange` with `{ ...option, locked: true }` +- The welcome part syncs these via `syncOptionsFromSession`, but **strips the `locked` flag** before storing in `_selectedOptions` +- This ensures that when the welcome view re-renders (e.g., after reset), pickers are always interactive +- Locking only affects the `ChatSessionPickerActionItem` widget's `currentOption.locked` check, which disables the dropdown + +--- + +## 5. Resourceless Chat Input + +### The Problem + +Traditional chat sessions require a session resource (URI) to exist before the user can interact. This means: +- Extensions must register and load before the UI is usable +- Creating a session involves an async round-trip to the extension +- The user sees a loading state instead of being productive + +### The Solution + +The Agent Sessions Chat Widget defers **chat model creation** to the **moment of first submit**, but eagerly initializes **session data** so extensions can interact with options before the user sends a message: + +```mermaid +stateDiagram-v2 + [*] --> PendingSession: Widget renders + PendingSession --> PendingSession: getOrCreateChatSession (session data only) + PendingSession --> PendingSession: Extension fires notifySessionOptionsChange + PendingSession --> PendingSession: User selects target + PendingSession --> PendingSession: User picks options + PendingSession --> PendingSession: User types message + PendingSession --> SessionCreated: User clicks Send + SessionCreated --> Active: Chat model created + model set + Active --> Active: Subsequent sends go through normally + Active --> PendingSession: User clears/resets +``` + +**Before chat model creation (pending session state):** +- A **pending session resource** is generated via `getResourceForNewChatSession()` and `chatSessionsService.getOrCreateChatSession()` is called eagerly. This creates session data (options store) and invokes `provideChatSessionContent` so the extension knows the resource. +- The extension can fire `notifySessionOptionsChange(pendingResource, updates)` at any time — the welcome part matches the pending resource and syncs option values. +- Target selection is tracked in `AgentSessionsChatTargetConfig` +- User option selections are cached in `_pendingOptionSelections` (ChatInputPart) and `_selectedOptions` (welcome part), AND forwarded to the extension via `notifySessionOptionsChange` using the pending resource. +- The chat input works normally — user can type, attach context, change mode + +**At chat model creation (triggered by either `submitHandler` or the patched `acceptInput`):** +1. `_createSessionForCurrentTarget()` reads the current target from the config +2. **Reuses the pending session resource** (the same URI used for session data) — no new resource is generated +3. For non-local targets, calls `loadSessionForResource(resource, location, CancellationToken.None)` which reuses the existing session data from `getOrCreateChatSession()`; for local targets, calls `startSession(location)` directly +4. Sets the model on the `ChatWidget` via `setModel()` (this clears the input editor, so the input text is captured and restored) +5. `_gatherAllOptionSelections()` collects options from welcome part + input toolbar +6. Options are attached to `contributedChatSession.initialSessionOptions` via `model.setContributedChatSession()` +7. The request proceeds through the normal `ChatWidget._acceptInput` flow + +--- + +## 6. Initial Session Options (`initialSessionOptions`) + +### The Problem + +When a session is created on first submit, the extension needs to know what options the user selected (model, repository, agent, etc.). But the traditional `provideHandleOptionsChange` mechanism is async and fire-and-forget — there's no guarantee the extension processes it before the request arrives. + +### The Solution + +Options travel **atomically with the first request** via `initialSessionOptions` on the `ChatSessionContext`: + +```mermaid +flowchart LR + A[Welcome Part
_selectedOptions] -->|merge| C[_gatherAllOptionSelections] + B[Input Toolbar
_pendingOptionSelections] -->|merge| C + C --> D[model.setContributedChatSession
initialSessionOptions] + D --> E[mainThreadChatAgents2
serialize to DTO] + E --> F[extHostChatAgents2
pass to extension] + F --> G[Extension handler
reads from context] +``` + +### Data Flow + +| Layer | Type | Field | +|-------|------|-------| +| Internal model | `IChatSessionContext` | `initialSessionOptions?: ReadonlyArray<{optionId: string, value: string \| { id: string; name: string }}>` | +| Protocol DTO | `IChatSessionContextDto` | `initialSessionOptions?: ReadonlyArray<{optionId: string, value: string}>` | +| Extension API | `ChatSessionContext` | `initialSessionOptions?: ReadonlyArray<{optionId: string, value: string}>` | + +> **Note:** The internal model allows `value` to be either a `string` or `{ id, name }` (matching `IChatSessionProviderOptionItem`'s structural type). During serialization to the protocol DTO in `mainThreadChatAgents2`, the value is converted to `string`. The extension always receives `string` values. + +### Extension Usage + +```typescript +// In the extension's request handler: +async handleRequest(request, context, stream, token) { + const { chatSessionContext } = context; + + // ⚠️ IMPORTANT: Apply initial options BEFORE any code that reads + // folder/model/agent state (e.g., lockRepoOption, hasUncommittedChanges). + // The initialSessionOptions override defaults set by provideChatSessionContent. + const initialOptions = chatSessionContext?.initialSessionOptions; + if (initialOptions) { + for (const { optionId, value } of initialOptions) { + // Apply options to internal state + if (optionId === 'model') { setModel(value); } + if (optionId === 'repository') { setRepository(value); } + } + } + + // Now downstream reads (trust checks, uncommitted changes, etc.) + // see the correct options. + // ... +} +``` + +### Priority Order + +When `_gatherAllOptionSelections()` merges options: + +1. **Welcome part selections** (lowest priority) — includes defaults for repository/folder pickers +2. **Input toolbar selections** (highest priority) — explicit user picks override welcome defaults + +--- + +## 7. Picker Placement + +Pickers can appear in two locations: +- **Welcome view** — above the input, managed by `AgentSessionsChatWelcomePart` +- **Input toolbar** — inside the chat input, managed by `ChatInputPart` + +To avoid duplication, the widget uses two mechanisms: + +### `hiddenPickerIds` + +Hides entire picker actions from the input toolbar: + +```typescript +hiddenPickerIds: new Set([ + OpenSessionTargetPickerAction.ID, // Target picker in welcome + OpenModePickerAction.ID, // Mode picker hidden + OpenModelPickerAction.ID, // Model picker hidden + ConfigureToolsAction.ID, // Tools config hidden +]) +``` + +### `excludeOptionGroup` + +Selectively excludes specific option groups from `ChatSessionPrimaryPickerAction` in the input toolbar while keeping others: + +```typescript +excludeOptionGroup: (group) => { + const idLower = group.id.toLowerCase(); + const nameLower = group.name.toLowerCase(); + // Repository/folder pickers are in the welcome view + return idLower === 'repositories' || idLower === 'folders' || + nameLower === 'repository' || nameLower === 'repositories' || + nameLower === 'folder' || nameLower === 'folders'; +} +``` + +This allows the input toolbar to still show model pickers from `ChatSessionPrimaryPickerAction` while the welcome view handles the repository picker. + +```mermaid +graph TB + subgraph "Welcome View (above input)" + T[Target Buttons
Local | Cloud] + R[Repository Picker
via excludeOptionGroup] + end + + subgraph "Input Toolbar (inside input)" + M[Model Picker
from ChatSessionPrimaryPickerAction] + S[Send Button] + end + + subgraph "Hidden from toolbar" + H1[Target Picker
hiddenPickerIds] + H2[Mode Picker
hiddenPickerIds] + end +``` + +--- + +## 8. File Structure + +``` +src/vs/sessions/browser/widget/ +├── AGENTS_CHAT_WIDGET.md # This document +├── agentSessionsChatWidget.ts # Main widget wrapper +├── agentSessionsChatTargetConfig.ts # Target configuration (observable) +├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar +└── media/ + └── agentSessionsChatWidget.css # Widget-specific styles + +src/vs/sessions/browser/parts/ +├── agentSessionsChatInputPart.ts # Input part adapter +└── agentSessionsChatWelcomePart.ts # Welcome view (mascot + pickers) +``` + +--- + +## 9. Adding a New Agent Provider + +To add a new agent provider (e.g., "Codex"): + +1. **Add to `AgentSessionProviders` enum** in `agentSessions.ts` +2. **Update target config** in `chatViewPane.ts`: + ```typescript + allowedTargets: [Background, Cloud, Codex] + ``` +3. **Register session content provider** in the extension +4. **Handle `initialSessionOptions`** in the extension's request handler +5. **Register option groups** via `provideChatSessionProviderOptions` + +The welcome part and input toolbar automatically pick up new targets and option groups. + +--- + +## 10. Comparison with Old Architecture + +### Side-by-Side + +| Aspect | Old (`ChatFullWelcomePart` inside `ChatWidget`) | New (`AgentSessionsChatWidget` wrapper) | +|--------|---------------------------|--------------------------------| +| Session creation | Eager (on load) | Deferred (on first send) | +| Target selection | `ISessionTypePickerDelegate` callback | `IAgentChatTargetConfig` observable | +| Option delivery | `provideHandleOptionsChange` (async, fire-and-forget) | `initialSessionOptions` (atomic with request) | +| Welcome view | Inside `ChatWidget` via `showFullWelcome` flag | Separate `AgentSessionsChatWelcomePart` | +| Picker placement | Hardcoded in `ChatInputPart` | Configurable via `hiddenPickerIds` + `excludeOptionGroup` | +| Input reparenting | `ChatWidget` reaches into welcome part's DOM | `AgentSessionsChatWidget` manages its own DOM layout | +| Agent lock state | `lockToCodingAgent()` / `unlockFromCodingAgent()` on `ChatWidget` | Not needed — target config is external state | +| Extensibility | Requires modifying `ChatWidget` internals | Self-contained, composable components | + +### Benefits of the New Architecture + +**1. Clean Separation of Concerns** + +The old approach embeds agent session logic (target selection, welcome view, lock state, option caching) directly inside `ChatWidget`. This means every agent feature touches the same file that powers the standard chat experience. The new architecture keeps `ChatWidget` focused on its core responsibility — rendering a chat conversation — and pushes agent-specific behavior into dedicated components. + +**2. Reduced Risk of Regressions** + +In the old architecture, `ChatWidget.render()` has forked control flow gated on `showFullWelcome`, and `ChatInputPart` has ~15 call sites checking session type delegates. A change to how pickers render could break the standard chat. In the new architecture, `AgentSessionsChatWidget` composes with `ChatWidget` through stable, narrow interfaces (`submitHandler`, `hiddenPickerIds`, `excludeOptionGroup`), so changes to agent session behavior cannot break the core widget. + +**3. Testable in Isolation** + +`AgentSessionsChatTargetConfig` can be unit-tested independently — it's a pure observable state container with no DOM or service dependencies beyond `Disposable`. The old `ISessionTypePickerDelegate` was an ad-hoc callback interface defined inline, making it harder to mock and test. + +**4. Deferred Session Creation** + +The old architecture creates sessions eagerly, requiring an async round-trip to the extension before the UI is usable. The new architecture lets the user interact immediately (type, select targets, pick options) and only creates the session on first send. This eliminates the loading state and makes the initial experience feel instant. + +**5. Atomic Option Delivery** + +The old `provideHandleOptionsChange` mechanism sends option changes asynchronously — if the user changes a repository picker and immediately sends a message, there's a race condition where the extension might not have processed the option change yet. The new `initialSessionOptions` mechanism bundles all option selections with the first request, guaranteeing the extension sees the correct state. + +**6. Easier to Add New Agent Providers** + +Adding a new provider in the old architecture requires modifying `ChatWidget`, `ChatInputPart`, and `ChatFullWelcomePart`. In the new architecture, it's a matter of adding to the `AgentSessionProviders` enum and updating the `allowedTargets` config — the welcome part and input toolbar automatically discover new targets and option groups. + +**7. No Core Widget Modifications Required** + +The entire agent sessions feature works by wrapping `ChatWidget` with composition hooks that `ChatWidget` already exposes (`submitHandler`, `viewOptions`). This means the agent sessions team can iterate independently without coordinating changes to shared core widget code. diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts new file mode 100644 index 0000000000000..a7c2ce6026f9a --- /dev/null +++ b/src/vs/sessions/browser/workbench.ts @@ -0,0 +1,1438 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../workbench/browser/style.js'; +import './style.css'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; +import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; +import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; +import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; +import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; +import { mark } from '../../base/common/performance.js'; +import { onUnexpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; +import { isWindows, isLinux, isWeb, isNative, isMacintosh } from '../../base/common/platform.js'; +import { Parts, Position, PanelAlignment, IWorkbenchLayoutService, SINGLE_WINDOW_PARTS, MULTI_WINDOW_PARTS, IPartVisibilityChangeEvent, positionToString } from '../../workbench/services/layout/browser/layoutService.js'; +import { ILayoutOffsetInfo } from '../../platform/layout/browser/layoutService.js'; +import { Part } from '../../workbench/browser/part.js'; +import { Direction, ISerializableView, ISerializedGrid, ISerializedLeafNode, ISerializedNode, IViewSize, Orientation, SerializableGrid } from '../../base/browser/ui/grid/grid.js'; +import { IEditorGroupsService } from '../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../workbench/services/editor/common/editorService.js'; +import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../workbench/common/views.js'; +import { ILogService } from '../../platform/log/common/log.js'; +import { IInstantiationService, ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; +import { ITitleService } from '../../workbench/services/title/browser/titleService.js'; +import { mainWindow, CodeWindow } from '../../base/browser/window.js'; +import { coalesce } from '../../base/common/arrays.js'; +import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; +import { InstantiationService } from '../../platform/instantiation/common/instantiationService.js'; +import { getSingletonServiceDescriptors } from '../../platform/instantiation/common/extensions.js'; +import { ILifecycleService, LifecyclePhase, WillShutdownEvent } from '../../workbench/services/lifecycle/common/lifecycle.js'; +import { IStorageService, WillSaveStateReason, StorageScope, StorageTarget } from '../../platform/storage/common/storage.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../platform/configuration/common/configuration.js'; +import { IHostService } from '../../workbench/services/host/browser/host.js'; +import { IDialogService } from '../../platform/dialogs/common/dialogs.js'; +import { INotificationService } from '../../platform/notification/common/notification.js'; +import { NotificationService } from '../../workbench/services/notification/common/notificationService.js'; +import { IHoverService, WorkbenchHoverDelegate } from '../../platform/hover/browser/hover.js'; +import { setHoverDelegateFactory } from '../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { setBaseLayerHoverDelegate } from '../../base/browser/ui/hover/hoverDelegate2.js'; +import { Registry } from '../../platform/registry/common/platform.js'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../workbench/common/contributions.js'; +import { IEditorFactoryRegistry, EditorExtensions } from '../../workbench/common/editor.js'; +import { setARIAContainer } from '../../base/browser/ui/aria/aria.js'; +import { FontMeasurements } from '../../editor/browser/config/fontMeasurements.js'; +import { createBareFontInfoFromRawSettings } from '../../editor/common/config/fontInfoFromSettings.js'; +import { toErrorMessage } from '../../base/common/errorMessage.js'; +import { WorkbenchContextKeysHandler } from '../../workbench/browser/contextkeys.js'; +import { PixelRatio } from '../../base/browser/pixelRatio.js'; +import { AccessibilityProgressSignalScheduler } from '../../platform/accessibilitySignal/browser/progressAccessibilitySignalScheduler.js'; +import { setProgressAccessibilitySignalScheduler } from '../../base/browser/ui/progressbar/progressAccessibilitySignal.js'; +import { AccessibleViewRegistry } from '../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { NotificationAccessibleView } from '../../workbench/browser/parts/notifications/notificationAccessibleView.js'; +import { NotificationsCenter } from '../../workbench/browser/parts/notifications/notificationsCenter.js'; +import { NotificationsAlerts } from '../../workbench/browser/parts/notifications/notificationsAlerts.js'; +import { NotificationsStatus } from '../../workbench/browser/parts/notifications/notificationsStatus.js'; +import { registerNotificationCommands } from '../../workbench/browser/parts/notifications/notificationsCommands.js'; +import { NotificationsToasts } from '../../workbench/browser/parts/notifications/notificationsToasts.js'; +import { IMarkdownRendererService } from '../../platform/markdown/browser/markdownRenderer.js'; +import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; +import { EditorModal } from './parts/editorModal.js'; +import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; +import { TitleService } from './parts/titlebarPart.js'; + +//#region Workbench Options + +export interface IWorkbenchOptions { + /** + * Extra classes to be added to the workbench container. + */ + extraClasses?: string[]; +} + +//#endregion + +//#region Layout Classes + +enum LayoutClasses { + SIDEBAR_HIDDEN = 'nosidebar', + MAIN_EDITOR_AREA_HIDDEN = 'nomaineditorarea', + PANEL_HIDDEN = 'nopanel', + AUXILIARYBAR_HIDDEN = 'noauxiliarybar', + CHATBAR_HIDDEN = 'nochatbar', + FULLSCREEN = 'fullscreen', + MAXIMIZED = 'maximized', + EDITOR_MODAL_VISIBLE = 'editor-modal-visible' +} + +//#endregion + +//#region Part Visibility State + +interface IPartVisibilityState { + sidebar: boolean; + auxiliaryBar: boolean; + editor: boolean; + panel: boolean; + chatBar: boolean; +} + +//#endregion + +export class Workbench extends Disposable implements IWorkbenchLayoutService { + + declare readonly _serviceBrand: undefined; + + //#region Lifecycle Events + + private readonly _onWillShutdown = this._register(new Emitter()); + readonly onWillShutdown = this._onWillShutdown.event; + + private readonly _onDidShutdown = this._register(new Emitter()); + readonly onDidShutdown = this._onDidShutdown.event; + + //#endregion + + //#region Events + + private readonly _onDidChangeZenMode = this._register(new Emitter()); + readonly onDidChangeZenMode = this._onDidChangeZenMode.event; + + private readonly _onDidChangeMainEditorCenteredLayout = this._register(new Emitter()); + readonly onDidChangeMainEditorCenteredLayout = this._onDidChangeMainEditorCenteredLayout.event; + + private readonly _onDidChangePanelAlignment = this._register(new Emitter()); + readonly onDidChangePanelAlignment = this._onDidChangePanelAlignment.event; + + private readonly _onDidChangeWindowMaximized = this._register(new Emitter<{ windowId: number; maximized: boolean }>()); + readonly onDidChangeWindowMaximized = this._onDidChangeWindowMaximized.event; + + private readonly _onDidChangePanelPosition = this._register(new Emitter()); + readonly onDidChangePanelPosition = this._onDidChangePanelPosition.event; + + private readonly _onDidChangePartVisibility = this._register(new Emitter()); + readonly onDidChangePartVisibility = this._onDidChangePartVisibility.event; + + private readonly _onDidChangeNotificationsVisibility = this._register(new Emitter()); + readonly onDidChangeNotificationsVisibility = this._onDidChangeNotificationsVisibility.event; + + private readonly _onDidChangeAuxiliaryBarMaximized = this._register(new Emitter()); + readonly onDidChangeAuxiliaryBarMaximized = this._onDidChangeAuxiliaryBarMaximized.event; + + private readonly _onDidLayoutMainContainer = this._register(new Emitter()); + readonly onDidLayoutMainContainer = this._onDidLayoutMainContainer.event; + + private readonly _onDidLayoutActiveContainer = this._register(new Emitter()); + readonly onDidLayoutActiveContainer = this._onDidLayoutActiveContainer.event; + + private readonly _onDidLayoutContainer = this._register(new Emitter<{ container: HTMLElement; dimension: IDimension }>()); + readonly onDidLayoutContainer = this._onDidLayoutContainer.event; + + private readonly _onDidAddContainer = this._register(new Emitter<{ container: HTMLElement; disposables: DisposableStore }>()); + readonly onDidAddContainer = this._onDidAddContainer.event; + + private readonly _onDidChangeActiveContainer = this._register(new Emitter()); + readonly onDidChangeActiveContainer = this._onDidChangeActiveContainer.event; + + //#endregion + + //#region Properties + + readonly mainContainer = document.createElement('div'); + + get activeContainer(): HTMLElement { + return this.getContainerFromDocument(getActiveDocument()); + } + + get containers(): Iterable { + const containers: HTMLElement[] = []; + for (const { window } of getWindows()) { + containers.push(this.getContainerFromDocument(window.document)); + } + return containers; + } + + private getContainerFromDocument(targetDocument: Document): HTMLElement { + if (targetDocument === this.mainContainer.ownerDocument) { + return this.mainContainer; + } else { + // eslint-disable-next-line no-restricted-syntax + return targetDocument.body.getElementsByClassName('monaco-workbench')[0] as HTMLElement; + } + } + + private _mainContainerDimension!: IDimension; + get mainContainerDimension(): IDimension { return this._mainContainerDimension; } + + get activeContainerDimension(): IDimension { + return this.getContainerDimension(this.activeContainer); + } + + private getContainerDimension(container: HTMLElement): IDimension { + if (container === this.mainContainer) { + return this.mainContainerDimension; + } else { + return getClientArea(container); + } + } + + get mainContainerOffset(): ILayoutOffsetInfo { + return this.computeContainerOffset(); + } + + get activeContainerOffset(): ILayoutOffsetInfo { + return this.computeContainerOffset(); + } + + private computeContainerOffset(): ILayoutOffsetInfo { + let top = 0; + let quickPickTop = 0; + + if (this.isVisible(Parts.TITLEBAR_PART, mainWindow)) { + top = this.getPart(Parts.TITLEBAR_PART).maximumHeight; + quickPickTop = top; + } + + return { top, quickPickTop }; + } + + //#endregion + + //#region State + + private readonly parts = new Map(); + private workbenchGrid!: SerializableGrid; + + private titleBarPartView!: ISerializableView; + private sideBarPartView!: ISerializableView; + private panelPartView!: ISerializableView; + private auxiliaryBarPartView!: ISerializableView; + + // Editor modal + private editorModal!: EditorModal; + private chatBarPartView!: ISerializableView; + + private readonly partVisibility: IPartVisibilityState = { + sidebar: true, + auxiliaryBar: true, + editor: false, + panel: false, + chatBar: true + }; + + private mainWindowFullscreen = false; + private readonly maximized = new Set(); + + private readonly restoredPromise = new DeferredPromise(); + readonly whenRestored = this.restoredPromise.p; + private restored = false; + + readonly openedDefaultEditors = false; + + //#endregion + + //#region Services + + private editorGroupService!: IEditorGroupsService; + private editorService!: IEditorService; + private paneCompositeService!: IPaneCompositePartService; + private viewDescriptorService!: IViewDescriptorService; + + //#endregion + + constructor( + protected readonly parent: HTMLElement, + private readonly options: IWorkbenchOptions | undefined, + private readonly serviceCollection: ServiceCollection, + private readonly logService: ILogService + ) { + super(); + + // Perf: measure workbench startup time + mark('code/willStartWorkbench'); + + this.registerErrorHandler(logService); + } + + //#region Error Handling + + private registerErrorHandler(logService: ILogService): void { + // Increase stack trace limit for better errors stacks + if (!isFirefox) { + Error.stackTraceLimit = 100; + } + + // Listen on unhandled rejection events + // Note: intentionally not registered as disposable to handle + // errors that can occur during shutdown phase. + mainWindow.addEventListener('unhandledrejection', (event) => { + // See https://developer.mozilla.org/en-US/docs/Web/API/PromiseRejectionEvent + onUnexpectedError(event.reason); + + // Prevent the printing of this event to the console + event.preventDefault(); + }); + + // Install handler for unexpected errors + setUnexpectedErrorHandler(error => this.handleUnexpectedError(error, logService)); + } + + private previousUnexpectedError: { message: string | undefined; time: number } = { message: undefined, time: 0 }; + private handleUnexpectedError(error: unknown, logService: ILogService): void { + const message = toErrorMessage(error, true); + if (!message) { + return; + } + + const now = Date.now(); + if (message === this.previousUnexpectedError.message && now - this.previousUnexpectedError.time <= 1000) { + return; // Return if error message identical to previous and shorter than 1 second + } + + this.previousUnexpectedError.time = now; + this.previousUnexpectedError.message = message; + + // Log it + logService.error(message); + } + + //#endregion + + //#region Startup + + startup(): IInstantiationService { + try { + // Configure emitter leak warning threshold + this._register(setGlobalLeakWarningThreshold(175)); + + // Services + const instantiationService = this.initServices(this.serviceCollection); + + instantiationService.invokeFunction(accessor => { + const lifecycleService = accessor.get(ILifecycleService); + const storageService = accessor.get(IStorageService); + const configurationService = accessor.get(IConfigurationService); + const hostService = accessor.get(IHostService); + const hoverService = accessor.get(IHoverService); + const dialogService = accessor.get(IDialogService); + const notificationService = accessor.get(INotificationService) as NotificationService; + const markdownRendererService = accessor.get(IMarkdownRendererService); + + // Set code block renderer for markdown rendering + markdownRendererService.setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); + + // Default Hover Delegate must be registered before creating any workbench/layout components + setHoverDelegateFactory((placement, enableInstantHover) => instantiationService.createInstance(WorkbenchHoverDelegate, placement, { instantHover: enableInstantHover }, {})); + setBaseLayerHoverDelegate(hoverService); + + // Layout + this.initLayout(accessor); + + // Registries - this creates and registers all parts + Registry.as(WorkbenchExtensions.Workbench).start(accessor); + Registry.as(EditorExtensions.EditorFactory).start(accessor); + + // Context Keys + this._register(instantiationService.createInstance(WorkbenchContextKeysHandler)); + + // Register Listeners + this.registerListeners(lifecycleService, storageService, configurationService, hostService, dialogService); + + // Render Workbench + this.renderWorkbench(instantiationService, notificationService, storageService, configurationService); + + // Workbench Layout + this.createWorkbenchLayout(); + + // Workbench Management + this.createWorkbenchManagement(instantiationService); + + // Layout + this.layout(); + + // Restore + this.restore(lifecycleService); + }); + + return instantiationService; + } catch (error) { + onUnexpectedError(error); + + throw error; // rethrow because this is a critical issue we cannot handle properly here + } + } + + private initServices(serviceCollection: ServiceCollection): IInstantiationService { + // Layout Service + serviceCollection.set(IWorkbenchLayoutService, this); + + // Title Service - agent sessions titlebar with dedicated part overrides + serviceCollection.set(ITitleService, new SyncDescriptor(TitleService, [])); + + // All Contributed Services + const contributedServices = getSingletonServiceDescriptors(); + for (const [id, descriptor] of contributedServices) { + serviceCollection.set(id, descriptor); + } + + const instantiationService = new InstantiationService(serviceCollection, true); + + // Wrap up + instantiationService.invokeFunction(accessor => { + const lifecycleService = accessor.get(ILifecycleService); + + // TODO@Sandeep debt around cyclic dependencies + const configurationService = accessor.get(IConfigurationService); + // eslint-disable-next-line local/code-no-in-operator + if (configurationService && 'acquireInstantiationService' in configurationService) { + (configurationService as { acquireInstantiationService: (instantiationService: unknown) => void }).acquireInstantiationService(instantiationService); + } + + // Signal to lifecycle that services are set + lifecycleService.phase = LifecyclePhase.Ready; + }); + + return instantiationService; + } + + private registerListeners(lifecycleService: ILifecycleService, storageService: IStorageService, configurationService: IConfigurationService, hostService: IHostService, dialogService: IDialogService): void { + // Configuration changes + this._register(configurationService.onDidChangeConfiguration(e => this.updateFontAliasing(e, configurationService))); + + // Font Info + if (isNative) { + this._register(storageService.onWillSaveState(e => { + if (e.reason === WillSaveStateReason.SHUTDOWN) { + this.storeFontInfo(storageService); + } + })); + } else { + this._register(lifecycleService.onWillShutdown(() => this.storeFontInfo(storageService))); + } + + // Lifecycle + this._register(lifecycleService.onWillShutdown(event => this._onWillShutdown.fire(event))); + this._register(lifecycleService.onDidShutdown(() => { + this._onDidShutdown.fire(); + this.dispose(); + })); + + // Flush storage on window focus loss + this._register(hostService.onDidChangeFocus(focus => { + if (!focus) { + storageService.flush(); + } + })); + + // Dialogs showing/hiding + this._register(dialogService.onWillShowDialog(() => this.mainContainer.classList.add('modal-dialog-visible'))); + this._register(dialogService.onDidShowDialog(() => this.mainContainer.classList.remove('modal-dialog-visible'))); + } + + //#region Font Aliasing and Caching + + private fontAliasing: 'default' | 'antialiased' | 'none' | 'auto' | undefined; + private updateFontAliasing(e: IConfigurationChangeEvent | undefined, configurationService: IConfigurationService) { + if (!isMacintosh) { + return; // macOS only + } + + if (e && !e.affectsConfiguration('workbench.fontAliasing')) { + return; + } + + const aliasing = configurationService.getValue<'default' | 'antialiased' | 'none' | 'auto'>('workbench.fontAliasing'); + if (this.fontAliasing === aliasing) { + return; + } + + this.fontAliasing = aliasing; + + // Remove all + const fontAliasingValues: (typeof aliasing)[] = ['antialiased', 'none', 'auto']; + this.mainContainer.classList.remove(...fontAliasingValues.map(value => `monaco-font-aliasing-${value}`)); + + // Add specific + if (fontAliasingValues.some(option => option === aliasing)) { + this.mainContainer.classList.add(`monaco-font-aliasing-${aliasing}`); + } + } + + private restoreFontInfo(storageService: IStorageService, configurationService: IConfigurationService): void { + const storedFontInfoRaw = storageService.get('editorFontInfo', StorageScope.APPLICATION); + if (storedFontInfoRaw) { + try { + const storedFontInfo = JSON.parse(storedFontInfoRaw); + if (Array.isArray(storedFontInfo)) { + FontMeasurements.restoreFontInfo(mainWindow, storedFontInfo); + } + } catch (err) { + /* ignore */ + } + } + + FontMeasurements.readFontInfo(mainWindow, createBareFontInfoFromRawSettings(configurationService.getValue('editor'), PixelRatio.getInstance(mainWindow).value)); + } + + private storeFontInfo(storageService: IStorageService): void { + const serializedFontInfo = FontMeasurements.serializeFontInfo(mainWindow); + if (serializedFontInfo) { + storageService.store('editorFontInfo', JSON.stringify(serializedFontInfo), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + } + + //#endregion + + private renderWorkbench(instantiationService: IInstantiationService, notificationService: NotificationService, storageService: IStorageService, configurationService: IConfigurationService): void { + // ARIA & Signals + setARIAContainer(this.mainContainer); + setProgressAccessibilitySignalScheduler((msDelayTime: number, msLoopTime?: number) => instantiationService.createInstance(AccessibilityProgressSignalScheduler, msDelayTime, msLoopTime)); + + // State specific classes + const platformClass = isWindows ? 'windows' : isLinux ? 'linux' : 'mac'; + const workbenchClasses = coalesce([ + 'monaco-workbench', + 'agent-sessions-workbench', + platformClass, + isWeb ? 'web' : undefined, + isChrome ? 'chromium' : isFirefox ? 'firefox' : isSafari ? 'safari' : undefined, + ...this.getLayoutClasses(), + ...(this.options?.extraClasses ? this.options.extraClasses : []) + ]); + + this.mainContainer.classList.add(...workbenchClasses); + + // Apply font aliasing + this.updateFontAliasing(undefined, configurationService); + + // Warm up font cache information before building up too many dom elements + this.restoreFontInfo(storageService, configurationService); + + // Create Parts (excluding editor - it will be in a modal) + for (const { id, role, classes } of [ + { id: Parts.TITLEBAR_PART, role: 'none', classes: ['titlebar'] }, + { id: Parts.SIDEBAR_PART, role: 'none', classes: ['sidebar', 'left'] }, + { id: Parts.AUXILIARYBAR_PART, role: 'none', classes: ['auxiliarybar', 'basepanel', 'right'] }, + { id: Parts.CHATBAR_PART, role: 'main', classes: ['chatbar', 'basepanel', 'right'] }, + { id: Parts.PANEL_PART, role: 'none', classes: ['panel', 'basepanel', positionToString(this.getPanelPosition())] }, + ]) { + const partContainer = this.createPartContainer(id, role, classes); + + mark(`code/willCreatePart/${id}`); + this.getPart(id).create(partContainer); + mark(`code/didCreatePart/${id}`); + } + + // Create Editor Part in modal + this.createEditorModal(); + + // Notification Handlers + this.createNotificationsHandlers(instantiationService, notificationService); + + // Add Workbench to DOM + this.parent.appendChild(this.mainContainer); + } + + private createNotificationsHandlers(instantiationService: IInstantiationService, notificationService: NotificationService): void { + // Instantiate Notification components + const notificationsCenter = this._register(instantiationService.createInstance(NotificationsCenter, this.mainContainer, notificationService.model)); + const notificationsToasts = this._register(instantiationService.createInstance(NotificationsToasts, this.mainContainer, notificationService.model)); + this._register(instantiationService.createInstance(NotificationsAlerts, notificationService.model)); + const notificationsStatus = instantiationService.createInstance(NotificationsStatus, notificationService.model); + + // Visibility + this._register(notificationsCenter.onDidChangeVisibility(() => { + notificationsStatus.update(notificationsCenter.isVisible, notificationsToasts.isVisible); + notificationsToasts.update(notificationsCenter.isVisible); + })); + + this._register(notificationsToasts.onDidChangeVisibility(() => { + notificationsStatus.update(notificationsCenter.isVisible, notificationsToasts.isVisible); + })); + + // Register Commands + registerNotificationCommands(notificationsCenter, notificationsToasts, notificationService.model); + + // Register notification accessible view + AccessibleViewRegistry.register(new NotificationAccessibleView()); + + // Register with Layout + this.registerNotifications({ + onDidChangeNotificationsVisibility: Event.map(Event.any(notificationsToasts.onDidChangeVisibility, notificationsCenter.onDidChangeVisibility), () => notificationsToasts.isVisible || notificationsCenter.isVisible) + }); + } + + private createPartContainer(id: string, role: string, classes: string[]): HTMLElement { + const part = document.createElement('div'); + part.classList.add('part', ...classes); + part.id = id; + part.setAttribute('role', role); + return part; + } + + private createEditorModal(): void { + const editorPart = this.getPart(Parts.EDITOR_PART); + this.editorModal = this._register(new EditorModal( + this.mainContainer, + editorPart, + this.editorGroupService + )); + } + + private restore(lifecycleService: ILifecycleService): void { + // Update perf marks + mark('code/didStartWorkbench'); + performance.measure('perf: workbench create & restore', 'code/didLoadWorkbenchMain', 'code/didStartWorkbench'); + + // Restore parts (open default view containers) + this.restoreParts(); + + // Set lifecycle phase to `Restored` + lifecycleService.phase = LifecyclePhase.Restored; + + // Mark as restored + this.setRestored(); + + // Set lifecycle phase to `Eventually` after a short delay and when idle (min 2.5sec, max 5sec) + const eventuallyPhaseScheduler = this._register(new RunOnceScheduler(() => { + this._register(runWhenWindowIdle(mainWindow, () => lifecycleService.phase = LifecyclePhase.Eventually, 2500)); + }, 2500)); + eventuallyPhaseScheduler.schedule(); + } + + private restoreParts(): void { + // Open default view containers for each visible part + const partsToRestore: { location: ViewContainerLocation; visible: boolean }[] = [ + { location: ViewContainerLocation.Sidebar, visible: this.partVisibility.sidebar }, + { location: ViewContainerLocation.Panel, visible: this.partVisibility.panel }, + { location: ViewContainerLocation.AuxiliaryBar, visible: this.partVisibility.auxiliaryBar }, + { location: ViewContainerLocation.ChatBar, visible: this.partVisibility.chatBar }, + ]; + + for (const { location, visible } of partsToRestore) { + if (visible) { + const defaultViewContainer = this.viewDescriptorService.getDefaultViewContainer(location); + if (defaultViewContainer) { + this.paneCompositeService.openPaneComposite(defaultViewContainer.id, location); + } + } + } + } + + //#endregion + + //#region Initialization + + initLayout(accessor: ServicesAccessor): void { + // Services - accessing these triggers their instantiation + // which creates and registers the parts + this.editorGroupService = accessor.get(IEditorGroupsService); + this.editorService = accessor.get(IEditorService); + this.paneCompositeService = accessor.get(IPaneCompositePartService); + this.viewDescriptorService = accessor.get(IViewDescriptorService); + accessor.get(ITitleService); + + // Register layout listeners + this.registerLayoutListeners(); + + // Show editor part when an editor opens + this._register(this.editorService.onWillOpenEditor(() => { + if (!this.partVisibility.editor) { + this.setEditorHidden(false); + } + })); + + // Hide editor part when last editor closes + this._register(this.editorService.onDidCloseEditor(() => { + if (this.partVisibility.editor && this.areAllGroupsEmpty()) { + this.setEditorHidden(true); + } + })); + + // Initialize layout state (must be done before createWorkbenchLayout) + this._mainContainerDimension = getClientArea(this.parent, new Dimension(800, 600)); + } + + private areAllGroupsEmpty(): boolean { + for (const group of this.editorGroupService.groups) { + if (!group.isEmpty) { + return false; + } + } + return true; + } + + private registerLayoutListeners(): void { + // Fullscreen changes + this._register(onDidChangeFullscreen(windowId => { + if (windowId === getWindowId(mainWindow)) { + this.mainWindowFullscreen = isFullscreen(mainWindow); + this.updateFullscreenClass(); + this.layout(); + } + })); + } + + private updateFullscreenClass(): void { + if (this.mainWindowFullscreen) { + this.mainContainer.classList.add(LayoutClasses.FULLSCREEN); + } else { + this.mainContainer.classList.remove(LayoutClasses.FULLSCREEN); + } + } + + //#endregion + + //#region Workbench Layout Creation + + createWorkbenchLayout(): void { + const titleBar = this.getPart(Parts.TITLEBAR_PART); + const editorPart = this.getPart(Parts.EDITOR_PART); + const panelPart = this.getPart(Parts.PANEL_PART); + const auxiliaryBarPart = this.getPart(Parts.AUXILIARYBAR_PART); + const sideBar = this.getPart(Parts.SIDEBAR_PART); + const chatBarPart = this.getPart(Parts.CHATBAR_PART); + + // View references for parts in the grid (editor is NOT in grid) + this.titleBarPartView = titleBar; + this.sideBarPartView = sideBar; + this.panelPartView = panelPart; + this.auxiliaryBarPartView = auxiliaryBarPart; + this.chatBarPartView = chatBarPart; + + const viewMap: { [key: string]: ISerializableView } = { + [Parts.TITLEBAR_PART]: this.titleBarPartView, + [Parts.PANEL_PART]: this.panelPartView, + [Parts.SIDEBAR_PART]: this.sideBarPartView, + [Parts.AUXILIARYBAR_PART]: this.auxiliaryBarPartView, + [Parts.CHATBAR_PART]: this.chatBarPartView + }; + + const fromJSON = ({ type }: { type: string }) => viewMap[type]; + const workbenchGrid = SerializableGrid.deserialize( + this.createGridDescriptor(), + { fromJSON }, + { proportionalLayout: false } + ); + + this.mainContainer.prepend(workbenchGrid.element); + this.mainContainer.setAttribute('role', 'application'); + this.workbenchGrid = workbenchGrid; + this.workbenchGrid.edgeSnapping = this.mainWindowFullscreen; + + // Listen for part visibility changes (for parts in grid) + for (const part of [titleBar, panelPart, sideBar, auxiliaryBarPart, chatBarPart]) { + this._register(part.onDidVisibilityChange(visible => { + if (part === sideBar) { + this.setSideBarHidden(!visible); + } else if (part === panelPart) { + this.setPanelHidden(!visible); + } else if (part === auxiliaryBarPart) { + this.setAuxiliaryBarHidden(!visible); + } else if (part === chatBarPart) { + this.setChatBarHidden(!visible); + } + + this._onDidChangePartVisibility.fire({ partId: part.getId(), visible }); + this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + })); + } + + // Listen for editor part visibility changes (modal) + this._register(editorPart.onDidVisibilityChange(visible => { + this.setEditorHidden(!visible); + this._onDidChangePartVisibility.fire({ partId: editorPart.getId(), visible }); + this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + })); + } + + createWorkbenchManagement(_instantiationService: IInstantiationService): void { + // No floating toolbars in this layout + } + + /** + * Creates the grid descriptor for the Agent Sessions layout. + * Editor is NOT included - it's rendered as a modal overlay. + * + * Structure (horizontal orientation): + * - Sidebar (left, spans full height from top to bottom) + * - Right section (vertical): + * - Titlebar (top of right section) + * - Top right (horizontal): Chat Bar | Auxiliary Bar + * - Panel (below chat and auxiliary bar only) + */ + private createGridDescriptor(): ISerializedGrid { + const { width, height } = this._mainContainerDimension; + + // Default sizes + const sideBarSize = 300; + const auxiliaryBarSize = 300; + const panelSize = 300; + const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; + + // Calculate right section width and chat bar width + const rightSectionWidth = Math.max(0, width - sideBarSize); + const chatBarWidth = Math.max(0, rightSectionWidth - auxiliaryBarSize); + + const contentHeight = height - titleBarHeight; + const topRightHeight = contentHeight - panelSize; + + const titleBarNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.TITLEBAR_PART }, + size: titleBarHeight, + visible: true + }; + + const sideBarNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.SIDEBAR_PART }, + size: sideBarSize, + visible: this.partVisibility.sidebar + }; + + const auxiliaryBarNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.AUXILIARYBAR_PART }, + size: auxiliaryBarSize, + visible: this.partVisibility.auxiliaryBar + }; + + const chatBarNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.CHATBAR_PART }, + size: chatBarWidth, + visible: this.partVisibility.chatBar + }; + + const panelNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.PANEL_PART }, + size: panelSize, + visible: this.partVisibility.panel + }; + + // Top right section: Chat Bar | Auxiliary Bar (horizontal) + const topRightSection: ISerializedNode = { + type: 'branch', + data: [chatBarNode, auxiliaryBarNode], + size: topRightHeight + }; + + // Right section: Titlebar | Top Right | Panel (vertical) + const rightSection: ISerializedNode = { + type: 'branch', + data: [titleBarNode, topRightSection, panelNode], + size: rightSectionWidth + }; + + const result: ISerializedGrid = { + root: { + type: 'branch', + size: height, + data: [ + sideBarNode, + rightSection + ] + }, + orientation: Orientation.HORIZONTAL, + width, + height + }; + + return result; + } + + //#endregion + + //#region Layout Methods + + layout(): void { + this._mainContainerDimension = getClientArea( + this.mainWindowFullscreen ? mainWindow.document.body : this.parent + ); + this.logService.trace(`Workbench#layout, height: ${this._mainContainerDimension.height}, width: ${this._mainContainerDimension.width}`); + + size(this.mainContainer, this._mainContainerDimension.width, this._mainContainerDimension.height); + + // Layout the grid widget + this.workbenchGrid.layout(this._mainContainerDimension.width, this._mainContainerDimension.height); + + // Layout the editor modal with workbench dimensions + this.editorModal.layout(this._mainContainerDimension.width, this._mainContainerDimension.height); + + // Emit as event + this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + } + + private handleContainerDidLayout(container: HTMLElement, dimension: IDimension): void { + this._onDidLayoutContainer.fire({ container, dimension }); + if (container === this.mainContainer) { + this._onDidLayoutMainContainer.fire(dimension); + } + if (container === this.activeContainer) { + this._onDidLayoutActiveContainer.fire(dimension); + } + } + + getLayoutClasses(): string[] { + return coalesce([ + !this.partVisibility.sidebar ? LayoutClasses.SIDEBAR_HIDDEN : undefined, + !this.partVisibility.editor ? LayoutClasses.MAIN_EDITOR_AREA_HIDDEN : undefined, + !this.partVisibility.panel ? LayoutClasses.PANEL_HIDDEN : undefined, + !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, + !this.partVisibility.chatBar ? LayoutClasses.CHATBAR_HIDDEN : undefined, + this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined + ]); + } + + //#endregion + + //#region Part Management + + registerPart(part: Part): IDisposable { + const id = part.getId(); + this.parts.set(id, part); + return toDisposable(() => this.parts.delete(id)); + } + + getPart(key: Parts): Part { + const part = this.parts.get(key); + if (!part) { + throw new Error(`Unknown part ${key}`); + } + return part; + } + + hasFocus(part: Parts): boolean { + const container = this.getContainer(mainWindow, part); + if (!container) { + return false; + } + + const activeElement = getActiveElement(); + if (!activeElement) { + return false; + } + + return isAncestorUsingFlowTo(activeElement, container); + } + + focusPart(part: MULTI_WINDOW_PARTS, targetWindow: Window): void; + focusPart(part: SINGLE_WINDOW_PARTS): void; + focusPart(part: Parts, targetWindow: Window = mainWindow): void { + switch (part) { + case Parts.EDITOR_PART: + this.editorGroupService.activeGroup.focus(); + break; + case Parts.PANEL_PART: + this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)?.focus(); + break; + case Parts.SIDEBAR_PART: + this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)?.focus(); + break; + case Parts.AUXILIARYBAR_PART: + this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)?.focus(); + break; + case Parts.CHATBAR_PART: + this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.ChatBar)?.focus(); + break; + default: { + const container = this.getContainer(targetWindow, part); + container?.focus(); + } + } + } + + focus(): void { + this.focusPart(Parts.CHATBAR_PART); + } + + //#endregion + + //#region Container Methods + + getContainer(targetWindow: Window): HTMLElement; + getContainer(targetWindow: Window, part: Parts): HTMLElement | undefined; + getContainer(targetWindow: Window, part?: Parts): HTMLElement | undefined { + if (typeof part === 'undefined') { + return this.getContainerFromDocument(targetWindow.document); + } + + if (targetWindow === mainWindow) { + return this.parts.get(part)?.getContainer(); + } + + // For auxiliary windows, only editor part is supported + if (part === Parts.EDITOR_PART) { + const container = this.getContainerFromDocument(targetWindow.document); + const partCandidate = this.editorGroupService.getPart(container); + if (partCandidate instanceof Part) { + return partCandidate.getContainer(); + } + } + + return undefined; + } + + whenContainerStylesLoaded(_window: CodeWindow): Promise | undefined { + return undefined; + } + + //#endregion + + //#region Part Visibility + + isActivityBarHidden(): boolean { + return true; // No activity bar in this layout + } + + isVisible(part: SINGLE_WINDOW_PARTS): boolean; + isVisible(part: MULTI_WINDOW_PARTS, targetWindow: Window): boolean; + isVisible(part: Parts, targetWindow?: Window): boolean { + switch (part) { + case Parts.TITLEBAR_PART: + return true; // Always visible + case Parts.SIDEBAR_PART: + return this.partVisibility.sidebar; + case Parts.AUXILIARYBAR_PART: + return this.partVisibility.auxiliaryBar; + case Parts.EDITOR_PART: + return this.partVisibility.editor; + case Parts.PANEL_PART: + return this.partVisibility.panel; + case Parts.CHATBAR_PART: + return this.partVisibility.chatBar; + case Parts.ACTIVITYBAR_PART: + case Parts.STATUSBAR_PART: + case Parts.BANNER_PART: + default: + return false; + } + } + + setPartHidden(hidden: boolean, part: Parts): void { + switch (part) { + case Parts.SIDEBAR_PART: + this.setSideBarHidden(hidden); + break; + case Parts.AUXILIARYBAR_PART: + this.setAuxiliaryBarHidden(hidden); + break; + case Parts.EDITOR_PART: + this.setEditorHidden(hidden); + break; + case Parts.PANEL_PART: + this.setPanelHidden(hidden); + break; + case Parts.CHATBAR_PART: + this.setChatBarHidden(hidden); + break; + } + } + + private setSideBarHidden(hidden: boolean): void { + if (this.partVisibility.sidebar === !hidden) { + return; + } + + this.partVisibility.sidebar = !hidden; + + // Adjust CSS - for hiding, defer adding the class until animation + // completes so the part stays visible during the exit animation. + if (!hidden) { + this.mainContainer.classList.remove(LayoutClasses.SIDEBAR_HIDDEN); + } + + // Propagate to grid + this.workbenchGrid.setViewVisible( + this.sideBarPartView, + !hidden, + ); + + // If sidebar becomes visible, show last active Viewlet or default viewlet + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { + const viewletToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Sidebar); + if (viewletToOpen) { + this.paneCompositeService.openPaneComposite(viewletToOpen, ViewContainerLocation.Sidebar); + } + } + } + + private setAuxiliaryBarHidden(hidden: boolean): void { + if (this.partVisibility.auxiliaryBar === !hidden) { + return; + } + + this.partVisibility.auxiliaryBar = !hidden; + + // Adjust CSS - for hiding, defer adding the class until animation + // completes so the part stays visible during the exit animation. + if (!hidden) { + this.mainContainer.classList.remove(LayoutClasses.AUXILIARYBAR_HIDDEN); + } + + // Propagate to grid + this.workbenchGrid.setViewVisible( + this.auxiliaryBarPartView, + !hidden, + ); + + // If auxiliary bar becomes visible, show last active pane composite + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { + const paneCompositeToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.AuxiliaryBar); + if (paneCompositeToOpen) { + this.paneCompositeService.openPaneComposite(paneCompositeToOpen, ViewContainerLocation.AuxiliaryBar); + } + } + } + + private setEditorHidden(hidden: boolean): void { + if (this.partVisibility.editor === !hidden) { + return; + } + + this.partVisibility.editor = !hidden; + + // Adjust CSS for main container + if (hidden) { + this.mainContainer.classList.add(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN); + this.mainContainer.classList.remove(LayoutClasses.EDITOR_MODAL_VISIBLE); + } else { + this.mainContainer.classList.remove(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN); + this.mainContainer.classList.add(LayoutClasses.EDITOR_MODAL_VISIBLE); + } + + // Show/hide modal + if (hidden) { + this.editorModal.hide(); + } else { + this.editorModal.show(); + } + } + + private setPanelHidden(hidden: boolean): void { + if (this.partVisibility.panel === !hidden) { + return; + } + + // If hiding and the panel is maximized, exit maximized state first + if (hidden && this.workbenchGrid.hasMaximizedView()) { + this.workbenchGrid.exitMaximizedView(); + } + + this.partVisibility.panel = !hidden; + + // Adjust CSS - for hiding, defer adding the class until animation + // completes because `.nopanel .part.panel { display: none !important }` + // would instantly hide the panel content mid-animation. + if (!hidden) { + this.mainContainer.classList.remove(LayoutClasses.PANEL_HIDDEN); + } + + // Propagate to grid + this.workbenchGrid.setViewVisible( + this.panelPartView, + !hidden, + ); + + // If panel becomes visible, show last active panel + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { + const panelToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Panel); + if (panelToOpen) { + this.paneCompositeService.openPaneComposite(panelToOpen, ViewContainerLocation.Panel); + } + } + } + + private setChatBarHidden(hidden: boolean): void { + if (this.partVisibility.chatBar === !hidden) { + return; + } + + this.partVisibility.chatBar = !hidden; + + // Adjust CSS + if (hidden) { + this.mainContainer.classList.add(LayoutClasses.CHATBAR_HIDDEN); + } else { + this.mainContainer.classList.remove(LayoutClasses.CHATBAR_HIDDEN); + } + + // Propagate to grid + this.workbenchGrid.setViewVisible(this.chatBarPartView, !hidden); + + // If chat bar becomes hidden, also hide the current active pane composite + if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.ChatBar)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.ChatBar); + } + + // If chat bar becomes visible, show last active pane composite or default + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.ChatBar)) { + const paneCompositeToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.ChatBar) ?? + this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.ChatBar)?.id; + if (paneCompositeToOpen) { + this.paneCompositeService.openPaneComposite(paneCompositeToOpen, ViewContainerLocation.ChatBar); + } + } + } + + //#endregion + + //#region Position Methods (Fixed - Not Configurable) + + getSideBarPosition(): Position { + return Position.LEFT; // Always left in this layout + } + + getPanelPosition(): Position { + return Position.BOTTOM; // Always bottom in this layout + } + + setPanelPosition(_position: Position): void { + // No-op: Panel position is fixed in this layout + } + + getPanelAlignment(): PanelAlignment { + return 'justify'; // Full width panel + } + + setPanelAlignment(_alignment: PanelAlignment): void { + // No-op: Panel alignment is fixed in this layout + } + + //#endregion + + //#region Size Methods + + getSize(part: Parts): IViewSize { + const view = this.getPartView(part); + if (!view) { + return { width: 0, height: 0 }; + } + return this.workbenchGrid.getViewSize(view); + } + + setSize(part: Parts, size: IViewSize): void { + const view = this.getPartView(part); + if (view) { + this.workbenchGrid.resizeView(view, size); + } + } + + resizePart(part: Parts, sizeChangeWidth: number, sizeChangeHeight: number): void { + const view = this.getPartView(part); + if (!view) { + return; + } + + const currentSize = this.workbenchGrid.getViewSize(view); + this.workbenchGrid.resizeView(view, { + width: currentSize.width + sizeChangeWidth, + height: currentSize.height + sizeChangeHeight + }); + } + + private getPartView(part: Parts): ISerializableView | undefined { + switch (part) { + case Parts.TITLEBAR_PART: + return this.titleBarPartView; + case Parts.SIDEBAR_PART: + return this.sideBarPartView; + case Parts.AUXILIARYBAR_PART: + return this.auxiliaryBarPartView; + case Parts.EDITOR_PART: + return undefined; // Editor is not in the grid, it's a modal + case Parts.PANEL_PART: + return this.panelPartView; + case Parts.CHATBAR_PART: + return this.chatBarPartView; + default: + return undefined; + } + } + + getMaximumEditorDimensions(_container: HTMLElement): IDimension { + // Return the available space for editor (excluding other parts) + const sidebarWidth = this.partVisibility.sidebar ? this.workbenchGrid.getViewSize(this.sideBarPartView).width : 0; + const auxiliaryBarWidth = this.partVisibility.auxiliaryBar ? this.workbenchGrid.getViewSize(this.auxiliaryBarPartView).width : 0; + const panelHeight = this.partVisibility.panel ? this.workbenchGrid.getViewSize(this.panelPartView).height : 0; + const titleBarHeight = this.workbenchGrid.getViewSize(this.titleBarPartView).height; + + return new Dimension( + this._mainContainerDimension.width - sidebarWidth - auxiliaryBarWidth, + this._mainContainerDimension.height - titleBarHeight - panelHeight + ); + } + + //#endregion + + //#region Unsupported Features (No-ops) + + toggleMaximizedPanel(): void { + if (!this.workbenchGrid) { + return; + } + + if (this.isPanelMaximized()) { + this.workbenchGrid.exitMaximizedView(); + } else { + this.workbenchGrid.maximizeView(this.panelPartView, [this.titleBarPartView, this.sideBarPartView]); + } + } + + isPanelMaximized(): boolean { + if (!this.workbenchGrid) { + return false; + } + + return this.workbenchGrid.isViewMaximized(this.panelPartView); + } + + toggleMaximizedAuxiliaryBar(): void { + // No-op: Maximize not supported in this layout + } + + setAuxiliaryBarMaximized(_maximized: boolean): boolean { + return false; // Maximize not supported + } + + isAuxiliaryBarMaximized(): boolean { + return false; // Maximize not supported + } + + toggleZenMode(): void { + // No-op: Zen mode not supported in this layout + } + + toggleMenuBar(): void { + // No-op: Menu bar toggle not supported in this layout + } + + isMainEditorLayoutCentered(): boolean { + return false; // Centered layout not supported + } + + centerMainEditorLayout(_active: boolean): void { + // No-op: Centered layout not supported in this layout + } + + hasMainWindowBorder(): boolean { + return false; + } + + getMainWindowBorderRadius(): string | undefined { + return undefined; + } + + //#endregion + + //#region Window Maximized State + + isWindowMaximized(targetWindow: Window): boolean { + return this.maximized.has(getWindowId(targetWindow)); + } + + updateWindowMaximizedState(targetWindow: Window, maximized: boolean): void { + const windowId = getWindowId(targetWindow); + if (maximized) { + this.maximized.add(windowId); + if (targetWindow === mainWindow) { + this.mainContainer.classList.add(LayoutClasses.MAXIMIZED); + } + } else { + this.maximized.delete(windowId); + if (targetWindow === mainWindow) { + this.mainContainer.classList.remove(LayoutClasses.MAXIMIZED); + } + } + + this._onDidChangeWindowMaximized.fire({ windowId, maximized }); + } + + //#endregion + + //#region Neighbor Parts + + getVisibleNeighborPart(part: Parts, direction: Direction): Parts | undefined { + if (!this.workbenchGrid) { + return undefined; + } + + const view = this.getPartView(part); + if (!view) { + return undefined; + } + + const neighbor = this.workbenchGrid.getNeighborViews(view, direction, false); + if (neighbor.length === 0) { + return undefined; + } + + const neighborView = neighbor[0]; + + if (neighborView === this.titleBarPartView) { + return Parts.TITLEBAR_PART; + } + if (neighborView === this.sideBarPartView) { + return Parts.SIDEBAR_PART; + } + if (neighborView === this.auxiliaryBarPartView) { + return Parts.AUXILIARYBAR_PART; + } + // Editor is not in the grid - it's rendered as a modal + if (neighborView === this.panelPartView) { + return Parts.PANEL_PART; + } + if (neighborView === this.chatBarPartView) { + return Parts.CHATBAR_PART; + } + + return undefined; + } + + //#endregion + + //#region Restore + + isRestored(): boolean { + return this.restored; + } + + setRestored(): void { + this.restored = true; + this.restoredPromise.complete(); + } + + //#endregion + + //#region Notifications Registration + + registerNotifications(delegate: { onDidChangeNotificationsVisibility: Event }): void { + this._register(delegate.onDidChangeNotificationsVisibility(visible => this._onDidChangeNotificationsVisibility.fire(visible))); + } + + //#endregion +} diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts new file mode 100644 index 0000000000000..f07b24f2ff607 --- /dev/null +++ b/src/vs/sessions/common/contextkeys.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../nls.js'; +import { RawContextKey } from '../../platform/contextkey/common/contextkey.js'; + +//#region < --- Chat Bar --- > + +export const ActiveChatBarContext = new RawContextKey('activeChatBar', '', localize('activeChatBar', "The identifier of the active chat bar panel")); +export const ChatBarFocusContext = new RawContextKey('chatBarFocus', false, localize('chatBarFocus', "Whether the chat bar has keyboard focus")); +export const ChatBarVisibleContext = new RawContextKey('chatBarVisible', false, localize('chatBarVisible', "Whether the chat bar is visible")); + +//#endregion diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts new file mode 100644 index 0000000000000..fc9b4c7aedc8a --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -0,0 +1,281 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/accountWidget.css'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuRegistry, registerAction2, IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js'; +import { Menus } from '../../../browser/menus.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { AnchorAlignment } from '../../../../base/browser/ui/contextview/contextview.js'; +import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { $, append } from '../../../../base/browser/dom.js'; +import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; + +// --- Account Menu Items --- // +const AccountMenu = new MenuId('SessionsAccountMenu'); + +// Sign In (shown when signed out) +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agenticSignIn', + title: localize2('signIn', 'Sign In'), + menu: { + id: AccountMenu, + when: ContextKeyExpr.notEquals('defaultAccountStatus', 'available'), + group: '1_account', + order: 1, + } + }); + } + async run(accessor: ServicesAccessor): Promise { + const defaultAccountService = accessor.get(IDefaultAccountService); + await defaultAccountService.signIn(); + } +}); + +// Sign Out (shown when signed in) +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agenticSignOut', + title: localize2('signOut', 'Sign Out'), + menu: { + id: AccountMenu, + when: ContextKeyExpr.equals('defaultAccountStatus', 'available'), + group: '1_account', + order: 1, + } + }); + } + async run(accessor: ServicesAccessor): Promise { + const defaultAccountService = accessor.get(IDefaultAccountService); + await defaultAccountService.signOut(); + } +}); + +// Settings +MenuRegistry.appendMenuItem(AccountMenu, { + command: { + id: 'workbench.action.openSettings', + title: localize('settings', "Settings"), + }, + group: '2_settings', + order: 1, +}); + +// Update actions +registerUpdateMenuItems(AccountMenu, '3_updates'); + +class AccountWidget extends ActionViewItem { + + private accountButton: Button | undefined; + private updateButton: Button | undefined; + private readonly viewItemDisposables = this._register(new DisposableStore()); + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IUpdateService private readonly updateService: IUpdateService, + ) { + super(undefined, action, { ...options, icon: false, label: false }); + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('account-widget'); + + // Account button (left) + const accountContainer = append(container, $('.account-widget-account')); + this.accountButton = this.viewItemDisposables.add(new Button(accountContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this.accountButton.element.classList.add('account-widget-account-button'); + + this.updateAccountButton(); + this.viewItemDisposables.add(this.defaultAccountService.onDidChangeDefaultAccount(() => this.updateAccountButton())); + + this.viewItemDisposables.add(this.accountButton.onDidClick(e => { + e?.preventDefault(); + e?.stopPropagation(); + this.showAccountMenu(this.accountButton!.element); + })); + + // Update button (shown for progress and restart-to-update states) + const updateContainer = append(container, $('.account-widget-update')); + this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this.updateButton.element.classList.add('account-widget-update-button'); + this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; + this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); + + this.updateUpdateButton(); + this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); + } + + private isUpdateAvailable(): boolean { + return this.updateService.state.type === StateType.Ready; + } + + private isUpdateInProgress(): boolean { + const type = this.updateService.state.type; + return type === StateType.CheckingForUpdates + || type === StateType.Downloading + || type === StateType.Downloaded + || type === StateType.Updating + || type === StateType.Overwriting; + } + + private showAccountMenu(anchor: HTMLElement): void { + const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); + const actions: IAction[] = []; + fillInActionBarActions(menu.getActions(), actions); + menu.dispose(); + + if (this.isUpdateAvailable()) { + // Update button visible: open above the button + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => actions, + anchorAlignment: AnchorAlignment.LEFT, + }); + } else { + // No update button: open to the right of the button + const rect = anchor.getBoundingClientRect(); + this.contextMenuService.showContextMenu({ + getAnchor: () => ({ x: rect.right, y: rect.top }), + getActions: () => actions, + }); + } + } + + private async updateAccountButton(): Promise { + if (!this.accountButton) { + return; + } + this.accountButton.label = `$(${Codicon.loading.id}~spin) ${localize('loadingAccount', "Loading account...")}`; + this.accountButton.enabled = false; + const account = await this.defaultAccountService.getDefaultAccount(); + this.accountButton.enabled = true; + this.accountButton.label = account + ? `$(${Codicon.account.id}) ${account.accountName} (${account.authenticationProvider.name})` + : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; + } + + private updateUpdateButton(): void { + if (!this.updateButton) { + return; + } + + const state = this.updateService.state; + if (this.isUpdateInProgress()) { + this.updateButton.element.parentElement!.style.display = ''; + this.updateButton.enabled = false; + this.updateButton.label = `$(${Codicon.loading.id}~spin) ${this.getUpdateProgressMessage(state.type)}`; + } else if (this.isUpdateAvailable()) { + this.updateButton.element.parentElement!.style.display = ''; + this.updateButton.enabled = true; + this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; + } else { + this.updateButton.element.parentElement!.style.display = 'none'; + } + } + + private getUpdateProgressMessage(type: StateType): string { + switch (type) { + case StateType.CheckingForUpdates: + return localize('checkingForUpdates', "Checking for Updates..."); + case StateType.Downloading: + return localize('downloadingUpdate', "Downloading Update..."); + case StateType.Downloaded: + return localize('installingUpdate', "Installing Update..."); + case StateType.Updating: + return localize('updatingApp', "Updating..."); + case StateType.Overwriting: + return localize('overwritingUpdate', "Downloading Update..."); + default: + return localize('updating', "Updating..."); + } + } + + private async update(): Promise { + await this.updateService.quitAndInstall(); + } + + override onClick(): void { + // Handled by custom click handlers + } +} + +// --- Register custom view item --- // + +class AccountWidgetContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsWidget'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const sessionsAccountWidgetAction = 'sessions.action.accountWidget'; + this._register(actionViewItemService.register(Menus.SidebarFooter, sessionsAccountWidgetAction, (action, options) => { + return instantiationService.createInstance(AccountWidget, action, options); + }, undefined)); + + // Register the action with menu item after the view item provider + // so the toolbar picks up the custom widget + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: sessionsAccountWidgetAction, + title: localize2('sessionsAccountWidget', 'Sessions Account'), + menu: { + id: Menus.SidebarFooter, + group: 'navigation', + order: 1, + } + }); + } + async run(): Promise { + // Handled by the custom view item + } + })); + } +} + +registerWorkbenchContribution2(AccountWidgetContribution.ID, AccountWidgetContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css new file mode 100644 index 0000000000000..ad72846d5c533 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Account Widget */ +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget > .action-label { + display: none; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 8px; +} + +/* Account Button */ +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account { + overflow: hidden; + min-width: 0; + flex: 1; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account-button { + border: none; + padding: 4px 8px; + font-size: 12px; + height: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: transparent; + color: var(--vscode-sideBar-foreground); + width: 100%; + text-align: left; + justify-content: flex-start; + border-radius: 4px; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Update Button */ +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update { + overflow: hidden; + min-width: 0; + flex-shrink: 1; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update-button { + border: none; + padding: 4px 8px; + font-size: 12px; + height: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: transparent; + color: var(--vscode-sideBar-foreground); + width: 100%; + text-align: left; + justify-content: flex-start; + border-radius: 4px; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update-button:hover:not(:disabled) { + background-color: var(--vscode-toolbar-hoverBackground); +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md b/src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md new file mode 100644 index 0000000000000..edfbe10fbc22a --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md @@ -0,0 +1,179 @@ +# AI Customization Management Editor Specification + +## Overview + +The AI Customization Management Editor is a global management surface for AI customizations. It provides sectioned navigation and a content area that switches between prompt lists, MCP servers, models, and an embedded editor. + +**Location:** `src/vs/sessions/contrib/aiCustomizationManagement/browser/` + +**Purpose:** Centralized discovery and management across worktree, user, and extension sources, optimized for agent sessions. + +## Architecture + +### Component Hierarchy + +``` +AICustomizationManagementEditor (EditorPane) +├── SplitView (Horizontal orientation) +│ ├── Sidebar Panel (Left) +│ │ └── WorkbenchList (sections) +│ └── Content Panel (Right) +│ ├── PromptsContent (AICustomizationListWidget) +│ ├── MCP Content (McpListWidget) +│ ├── Models Content (ChatModelsWidget) +│ └── Embedded Editor (CodeEditorWidget) +``` + +### File Structure + +``` +aiCustomizationManagement/browser/ +├── aiCustomizationManagement.ts # IDs + context keys +├── aiCustomizationManagement.contribution.ts # Commands + context menus +├── aiCustomizationManagementEditor.ts # SplitView list/editor +├── aiCustomizationManagementEditorInput.ts # Singleton input +├── aiCustomizationListWidget.ts # Search + grouped list +├── customizationCreatorService.ts # AI-guided creation flow +├── mcpListWidget.ts # MCP servers list +├── aiCustomizationOverviewView.ts # Overview view +└── media/ + └── aiCustomizationManagement.css +``` + +## Key Components + +### AICustomizationManagementEditorInput + +**Pattern:** Singleton editor input with dynamic tab title (section label). + +### AICustomizationManagementEditor + +**Responsibilities:** +- Manages section navigation and content swapping. +- Hosts embedded editor view for prompt files. +- Persists selected section and sidebar width. + +**Sections:** +- Agents, Skills, Instructions, Prompts, Hooks, MCP Servers, Models. + +**Embedded Editor:** +- Uses `CodeEditorWidget` for full editor UX. +- Auto-commits worktree files on exit via agent session command. + +**Overview View:** +- A compact view (`AICustomizationOverviewView`) shows counts and deep-links to sections. + +**Creation flows:** +- Manual create (worktree/user) with snippet templates. +- AI-guided create opens a new chat with hidden system instructions. + +### AICustomizationListWidget + +**Responsibilities:** +- Search + grouped list of prompt files by storage (Worktree/User/Extensions). +- Collapsible group headers. +- Storage badges and git status badges. + - Empty state UI with icon, title, and description. + - Section footer with description + docs link. + +**Search behavior:** +- Fuzzy matches across name, description, and filename. +- Debounced (200ms) filtering. + +**Active session scoping:** +- The active worktree comes from `IActiveSessionService` and is the source of truth for scoping. +- Prompt discovery is scoped by the agentic prompt service override using the active session root. +- Views refresh counts/filters when the active session changes. + +**Context menu actions:** +- Open, Run Prompt (prompts), Reveal in OS, Delete. +- Copy full path / relative path actions. + +**Add button behavior:** +- Primary action targets worktree when available, otherwise user. +- Dropdown offers User creation and AI-generated creation. +- Hooks use the built-in Configure Hooks flow and do not offer user-scoped creation. + +### McpListWidget + +**Responsibilities:** +- Lists MCP servers with status and actions. +- Provides add server flow and docs link. + - Search input with debounced filtering and an empty state. + +### Models Widget + +**Responsibilities:** +- Hosts the chat models management widget with a footer link. + +## Registration & Commands + +- Editor pane registered under `AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID`. +- Command `aiCustomization.openManagementEditor` opens the singleton editor. +- Command visibility and actions are gated by `ChatContextKeys.enabled`. + +## State and Context + +- Selected section and sidebar width are persisted to profile storage. +- Context keys: + - `aiCustomizationManagementEditorFocused` + - `aiCustomizationManagementSection` + +## User Workflows + +### Open Management Editor + +1. Run "Open AI Customizations" from the command palette. +2. Editor opens with the last selected section. + +### Create Items + +1. Use the Add button in the list header. +2. Choose worktree or user location (if available). +3. Optionally use "Generate" to start AI-guided creation. + +This is the only UI surface for creating new customizations. + +### Edit Items + +1. Click an item to open the embedded editor. +2. Use back to return to list; worktree files auto-commit. + +### Context Menu Actions + +1. Right-click a list item. +2. Choose Open, Run Prompt (prompts only), Reveal in OS, or Delete. +3. Use Copy Full Path / Copy Relative Path for quick path access. + +## Integration Points + +- `IPromptsService` for agent/skill/prompt/instructions discovery. +- `parseAllHookFiles` for hooks. +- `IActiveSessionService` for worktree filtering. +- `ISCMService` for git status badges. +- `ITextModelService` and `IFileService` for embedded editor I/O. +- `IDialogService` for delete confirmation and extension-file guardrails. +- `IOpenerService` for docs links and external navigation. + +## Service Alignment (Required) + +AI customizations must lean on existing VS Code services with well-defined interfaces. The management surface should not reimplement discovery, storage rules, or MCP lifecycle behavior. + +Browser compatibility is required. Do not use Node.js APIs; rely on VS Code services that work in browser contexts. + +Required services to prefer: +- Prompt discovery and metadata: [src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts](../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) +- Active session scoping for worktrees: [src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts](../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts) +- MCP servers and connections: [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: [src/vs/workbench/contrib/chat/common/chatService/chatService.ts](../../../../workbench/contrib/chat/common/chatService/chatService.ts) + +## Known Gaps + +- No bulk operations or sorting. +- Search query is not persisted between sessions. +- Hooks docs link is a placeholder and should be updated when available. + +--- + +*This specification documents the AI Customization Management Editor in `src/vs/sessions/contrib/aiCustomizationManagement/browser/`.* diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts new file mode 100644 index 0000000000000..9c05cd61f7694 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts @@ -0,0 +1,1066 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { basename, dirname } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; +import { IPromptsService, PromptsStorage, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { AICustomizationManagementItemMenuId, AICustomizationManagementSection, getActiveSessionRoot } from './aiCustomizationManagement.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { Delayer } from '../../../../base/common/async.js'; +import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { HighlightedLabel } from '../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; +import { matchesFuzzy, IMatch } from '../../../../base/common/filters.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ButtonWithDropdown } from '../../../../base/browser/ui/button/button.js'; +import { IMenuService } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { getFlatContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { parseAllHookFiles } from '../../../../workbench/contrib/chat/browser/promptSyntax/hookUtils.js'; +import { OS } from '../../../../base/common/platform.js'; +import { IRemoteAgentService } from '../../../../workbench/services/remote/common/remoteAgentService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Action, Separator } from '../../../../base/common/actions.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { ISCMService } from '../../../../workbench/contrib/scm/common/scm.js'; + +const $ = DOM.$; + +const ITEM_HEIGHT = 44; +const GROUP_HEADER_HEIGHT = 32; +const GROUP_HEADER_HEIGHT_WITH_SEPARATOR = 40; + +/** + * Represents an AI customization item in the list. + */ +export interface IAICustomizationListItem { + readonly id: string; + readonly uri: URI; + readonly name: string; + readonly filename: string; + readonly description?: string; + readonly storage: PromptsStorage; + readonly promptType: PromptsType; + gitStatus?: 'uncommitted' | 'committed'; + nameMatches?: IMatch[]; + descriptionMatches?: IMatch[]; +} + +/** + * Represents a collapsible group header in the list. + */ +interface IGroupHeaderEntry { + readonly type: 'group-header'; + readonly id: string; + readonly storage: PromptsStorage; + readonly label: string; + readonly icon: ThemeIcon; + readonly count: number; + readonly isFirst: boolean; + collapsed: boolean; +} + +/** + * Represents an individual file item in the list. + */ +interface IFileItemEntry { + readonly type: 'file-item'; + readonly item: IAICustomizationListItem; +} + +type IListEntry = IGroupHeaderEntry | IFileItemEntry; + +/** + * Delegate for the AI Customization list. + */ +class AICustomizationListDelegate implements IListVirtualDelegate { + getHeight(element: IListEntry): number { + if (element.type === 'group-header') { + return element.isFirst ? GROUP_HEADER_HEIGHT : GROUP_HEADER_HEIGHT_WITH_SEPARATOR; + } + return ITEM_HEIGHT; + } + + getTemplateId(element: IListEntry): string { + return element.type === 'group-header' ? 'groupHeader' : 'aiCustomizationItem'; + } +} + +interface IAICustomizationItemTemplateData { + readonly container: HTMLElement; + readonly actionsContainer: HTMLElement; + readonly nameLabel: HighlightedLabel; + readonly description: HighlightedLabel; + readonly storageBadge: HTMLElement; + readonly gitStatusBadge: HTMLElement; + readonly disposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +interface IGroupHeaderTemplateData { + readonly container: HTMLElement; + readonly chevron: HTMLElement; + readonly icon: HTMLElement; + readonly label: HTMLElement; + readonly count: HTMLElement; + readonly disposables: DisposableStore; +} + +/** + * Renderer for collapsible group headers (Workspace, User, Extensions). + * Note: Click handling is done via the list's onDidOpen event, not here. + */ +class GroupHeaderRenderer implements IListRenderer { + readonly templateId = 'groupHeader'; + + renderTemplate(container: HTMLElement): IGroupHeaderTemplateData { + const disposables = new DisposableStore(); + container.classList.add('ai-customization-group-header'); + + const chevron = DOM.append(container, $('.group-chevron')); + const icon = DOM.append(container, $('.group-icon')); + const label = DOM.append(container, $('.group-label')); + const count = DOM.append(container, $('.group-count')); + + return { container, chevron, icon, label, count, disposables }; + } + + renderElement(element: IGroupHeaderEntry, _index: number, templateData: IGroupHeaderTemplateData): void { + // Chevron + templateData.chevron.className = 'group-chevron'; + templateData.chevron.classList.add(...ThemeIcon.asClassNameArray(element.collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Icon + templateData.icon.className = 'group-icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(element.icon)); + + // Label + count + templateData.label.textContent = element.label; + templateData.count.textContent = `${element.count}`; + + // Collapsed state and separator for non-first groups + templateData.container.classList.toggle('collapsed', element.collapsed); + templateData.container.classList.toggle('has-previous-group', !element.isFirst); + } + + disposeTemplate(templateData: IGroupHeaderTemplateData): void { + templateData.disposables.dispose(); + } +} + +/** + * Renderer for AI customization list items. + */ +class AICustomizationItemRenderer implements IListRenderer { + readonly templateId = 'aiCustomizationItem'; + + renderTemplate(container: HTMLElement): IAICustomizationItemTemplateData { + const disposables = new DisposableStore(); + const elementDisposables = new DisposableStore(); + + container.classList.add('ai-customization-list-item'); + + const leftSection = DOM.append(container, $('.item-left')); + // Storage badge on left (shows workspace/user/extension) + const storageBadge = DOM.append(leftSection, $('.storage-badge')); + const textContainer = DOM.append(leftSection, $('.item-text')); + const nameLabel = disposables.add(new HighlightedLabel(DOM.append(textContainer, $('.item-name')))); + const description = disposables.add(new HighlightedLabel(DOM.append(textContainer, $('.item-description')))); + + // Git status badge (always visible, outside item-right hover container) + const gitStatusBadge = DOM.append(container, $('.git-status-badge')); + + // Right section for actions (hover-visible) + const actionsContainer = DOM.append(container, $('.item-right')); + + return { + container, + actionsContainer, + nameLabel, + description, + storageBadge, + gitStatusBadge, + disposables, + elementDisposables, + }; + } + + renderElement(entry: IFileItemEntry, index: number, templateData: IAICustomizationItemTemplateData): void { + templateData.elementDisposables.clear(); + const element = entry.item; + + // Name with highlights + templateData.nameLabel.set(element.name, element.nameMatches); + + // Description - show either description or filename as secondary text + const secondaryText = element.description || element.filename; + if (secondaryText) { + templateData.description.set(secondaryText, element.description ? element.descriptionMatches : undefined); + templateData.description.element.style.display = ''; + // Style differently for filename vs description + templateData.description.element.classList.toggle('is-filename', !element.description); + } else { + templateData.description.set('', undefined); + templateData.description.element.style.display = 'none'; + } + + // Storage badge + let storageBadgeIcon: ThemeIcon; + let storageBadgeLabel: string; + switch (element.storage) { + case PromptsStorage.local: + storageBadgeIcon = workspaceIcon; + storageBadgeLabel = localize('worktree', "Worktree"); + break; + case PromptsStorage.user: + storageBadgeIcon = userIcon; + storageBadgeLabel = localize('user', "User"); + break; + case PromptsStorage.extension: + storageBadgeIcon = extensionIcon; + storageBadgeLabel = localize('extension', "Extension"); + break; + } + + templateData.storageBadge.className = 'storage-badge'; + templateData.storageBadge.classList.add(...ThemeIcon.asClassNameArray(storageBadgeIcon)); + templateData.storageBadge.title = storageBadgeLabel; + + // Git status badge + const gitBadge = templateData.gitStatusBadge; + gitBadge.className = 'git-status-badge'; + if (element.gitStatus === 'committed') { + gitBadge.classList.add(...ThemeIcon.asClassNameArray(Codicon.check)); + gitBadge.classList.add('committed'); + gitBadge.textContent = ''; + gitBadge.title = localize('committedStatus', "Committed"); + gitBadge.style.display = ''; + } else { + gitBadge.style.display = 'none'; + } + } + + disposeTemplate(templateData: IAICustomizationItemTemplateData): void { + templateData.elementDisposables.dispose(); + templateData.disposables.dispose(); + } +} + +/** + * Maps section ID to prompt type. + */ +export function sectionToPromptType(section: AICustomizationManagementSection): PromptsType { + switch (section) { + case AICustomizationManagementSection.Agents: + return PromptsType.agent; + case AICustomizationManagementSection.Skills: + return PromptsType.skill; + case AICustomizationManagementSection.Instructions: + return PromptsType.instructions; + case AICustomizationManagementSection.Hooks: + return PromptsType.hook; + case AICustomizationManagementSection.Prompts: + default: + return PromptsType.prompt; + } +} + +/** + * Widget that displays a searchable list of AI customization items. + */ +export class AICustomizationListWidget extends Disposable { + + readonly element: HTMLElement; + + private sectionHeader!: HTMLElement; + private sectionDescription!: HTMLElement; + private sectionLink!: HTMLAnchorElement; + private searchAndButtonContainer!: HTMLElement; + private searchContainer!: HTMLElement; + private searchInput!: InputBox; + private addButton!: ButtonWithDropdown; + private listContainer!: HTMLElement; + private list!: WorkbenchList; + private emptyStateContainer!: HTMLElement; + private emptyStateIcon!: HTMLElement; + private emptyStateText!: HTMLElement; + private emptyStateSubtext!: HTMLElement; + + private currentSection: AICustomizationManagementSection = AICustomizationManagementSection.Agents; + private allItems: IAICustomizationListItem[] = []; + private displayEntries: IListEntry[] = []; + private searchQuery: string = ''; + private readonly collapsedGroups = new Set(); + + private readonly delayedFilter = new Delayer(200); + + private readonly _onDidSelectItem = this._register(new Emitter()); + readonly onDidSelectItem: Event = this._onDidSelectItem.event; + + private readonly _onDidChangeItemCount = this._register(new Emitter()); + readonly onDidChangeItemCount: Event = this._onDidChangeItemCount.event; + + private readonly _onDidRequestCreate = this._register(new Emitter()); + readonly onDidRequestCreate: Event = this._onDidRequestCreate.event; + + private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'worktree' | 'user' }>()); + readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'worktree' | 'user' }> = this._onDidRequestCreateManual.event; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IPromptsService private readonly promptsService: IPromptsService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IOpenerService private readonly openerService: IOpenerService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IFileService private readonly fileService: IFileService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IPathService private readonly pathService: IPathService, + @ILabelService private readonly labelService: ILabelService, + @IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + @ILogService private readonly logService: ILogService, + @IClipboardService private readonly clipboardService: IClipboardService, + @ISCMService private readonly scmService: ISCMService, + ) { + super(); + this.element = $('.ai-customization-list-widget'); + this.create(); + + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.refresh())); + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + this.updateAddButton(); + this.refresh(); + })); + + // Re-filter when SCM repositories change (updates git status badges after commits) + const trackRepoChanges = (repo: { provider: { onDidChangeResources: Event } }) => { + this._register(repo.provider.onDidChangeResources(() => { + this.updateGitStatus(this.allItems); + this.filterItems(); + })); + }; + for (const repo of [...this.scmService.repositories]) { + trackRepoChanges(repo); + } + this._register(this.scmService.onDidAddRepository(repo => trackRepoChanges(repo))); + + } + + private create(): void { + // Search and button container + this.searchAndButtonContainer = DOM.append(this.element, $('.list-search-and-button-container')); + + // Search container + this.searchContainer = DOM.append(this.searchAndButtonContainer, $('.list-search-container')); + this.searchInput = this._register(new InputBox(this.searchContainer, this.contextViewService, { + placeholder: localize('searchPlaceholder', "Type to search..."), + inputBoxStyles: defaultInputBoxStyles, + })); + + this._register(this.searchInput.onDidChange(() => { + this.searchQuery = this.searchInput.value; + this.delayedFilter.trigger(() => this.filterItems()); + })); + + // Add button with dropdown next to search + const addButtonContainer = DOM.append(this.searchAndButtonContainer, $('.list-add-button-container')); + this.addButton = this._register(new ButtonWithDropdown(addButtonContainer, { + ...defaultButtonStyles, + supportIcons: true, + contextMenuProvider: this.contextMenuService, + addPrimaryActionToDropdown: false, + actions: { getActions: () => this.getDropdownActions() }, + })); + this.addButton.element.classList.add('list-add-button'); + this._register(this.addButton.onDidClick(() => this.executePrimaryCreateAction())); + this.updateAddButton(); + + // List container + this.listContainer = DOM.append(this.element, $('.list-container')); + + // Empty state container + this.emptyStateContainer = DOM.append(this.element, $('.list-empty-state')); + this.emptyStateIcon = DOM.append(this.emptyStateContainer, $('.empty-state-icon')); + this.emptyStateText = DOM.append(this.emptyStateContainer, $('.empty-state-text')); + this.emptyStateSubtext = DOM.append(this.emptyStateContainer, $('.empty-state-subtext')); + this.emptyStateContainer.style.display = 'none'; + + // Create list + this.list = this._register(this.instantiationService.createInstance( + WorkbenchList, + 'AICustomizationManagementList', + this.listContainer, + new AICustomizationListDelegate(), + [ + new GroupHeaderRenderer(), + this.instantiationService.createInstance(AICustomizationItemRenderer), + ], + { + identityProvider: { + getId: (entry: IListEntry) => entry.type === 'group-header' ? entry.id : entry.item.id, + }, + accessibilityProvider: { + getAriaLabel: (entry: IListEntry) => { + if (entry.type === 'group-header') { + return localize('groupAriaLabel', "{0}, {1} items, {2}", entry.label, entry.count, entry.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); + } + return entry.item.description + ? localize('itemAriaLabel', "{0}, {1}", entry.item.name, entry.item.description) + : entry.item.name; + }, + getWidgetAriaLabel: () => localize('listAriaLabel', "AI Customizations"), + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: (entry: IListEntry) => entry.type === 'group-header' ? entry.label : entry.item.name, + }, + multipleSelectionSupport: false, + openOnSingleClick: true, + } + )); + + // Handle item selection (single click opens item, group header toggles) + this._register(this.list.onDidOpen(e => { + if (e.element) { + if (e.element.type === 'group-header') { + this.toggleGroup(e.element); + } else { + this._onDidSelectItem.fire(e.element.item); + } + } + })); + + // Handle context menu + this._register(this.list.onContextMenu(e => this.onContextMenu(e))); + + // Subscribe to prompt service changes + this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh())); + this._register(this.promptsService.onDidChangeSlashCommands(() => this.refresh())); + + // Section footer at bottom with description and link + this.sectionHeader = DOM.append(this.element, $('.section-footer')); + this.sectionDescription = DOM.append(this.sectionHeader, $('p.section-footer-description')); + this.sectionLink = DOM.append(this.sectionHeader, $('a.section-footer-link')) as HTMLAnchorElement; + this._register(DOM.addDisposableListener(this.sectionLink, 'click', (e) => { + e.preventDefault(); + const href = this.sectionLink.href; + if (href) { + this.openerService.open(URI.parse(href)); + } + })); + this.updateSectionHeader(); + } + + /** + * Handles context menu for list items. + */ + private onContextMenu(e: IListContextMenuEvent): void { + if (!e.element || e.element.type !== 'file-item') { + return; + } + + const item = e.element.item; + + // Create context for the menu actions + const context = { + uri: item.uri.toString(), + name: item.name, + promptType: item.promptType, + storage: item.storage, + }; + + // Get menu actions + const actions = this.menuService.getMenuActions(AICustomizationManagementItemMenuId, this.contextKeyService, { + arg: context, + shouldForwardArgs: true, + }); + + const flatActions = getFlatContextMenuActions(actions); + + // Add copy path actions + const copyActions = [ + new Separator(), + new Action('copyFullPath', localize('copyFullPath', "Copy Full Path"), undefined, true, async () => { + await this.clipboardService.writeText(item.uri.fsPath); + }), + new Action('copyRelativePath', localize('copyRelativePath', "Copy Relative Path"), undefined, true, async () => { + const basePath = getActiveSessionRoot(this.activeSessionService); + if (basePath && item.uri.fsPath.startsWith(basePath.fsPath)) { + const relative = item.uri.fsPath.substring(basePath.fsPath.length + 1); + await this.clipboardService.writeText(relative); + } else { + // Fallback to workspace-relative via label service + const relativePath = this.labelService.getUriLabel(item.uri, { relative: true }); + await this.clipboardService.writeText(relativePath); + } + }), + ]; + + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => [...flatActions, ...copyActions], + }); + } + + /** + * Sets the current section and loads items for that section. + */ + async setSection(section: AICustomizationManagementSection): Promise { + this.currentSection = section; + this.updateSectionHeader(); + this.updateAddButton(); + await this.loadItems(); + } + + /** + * Updates the section header based on the current section. + */ + private updateSectionHeader(): void { + let description: string; + let docsUrl: string; + let learnMoreLabel: string; + switch (this.currentSection) { + case AICustomizationManagementSection.Agents: + description = localize('agentsDescription', "Configure the AI to adopt different personas tailored to specific development tasks. Each agent has its own instructions, tools, and behavior."); + docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/custom-agents'; + learnMoreLabel = localize('learnMoreAgents', "Learn more about custom agents"); + break; + case AICustomizationManagementSection.Skills: + description = localize('skillsDescription', "Folders of instructions, scripts, and resources that Copilot loads when relevant to perform specialized tasks."); + docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/agent-skills'; + learnMoreLabel = localize('learnMoreSkills', "Learn more about agent skills"); + break; + case AICustomizationManagementSection.Instructions: + description = localize('instructionsDescription', "Define common guidelines and rules that automatically influence how AI generates code and handles development tasks."); + docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/custom-instructions'; + learnMoreLabel = localize('learnMoreInstructions', "Learn more about custom instructions"); + break; + case AICustomizationManagementSection.Hooks: + description = localize('hooksDescription', "Prompts executed at specific points during an agentic lifecycle."); + docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/hooks'; + learnMoreLabel = localize('learnMoreHooks', "Learn more about hooks"); + break; + case AICustomizationManagementSection.Prompts: + default: + description = localize('promptsDescription', "Reusable prompts for common development tasks like generating code, performing reviews, or scaffolding components."); + docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/prompt-files'; + learnMoreLabel = localize('learnMorePrompts', "Learn more about prompt files"); + break; + } + this.sectionDescription.textContent = description; + this.sectionLink.textContent = learnMoreLabel; + this.sectionLink.href = docsUrl; + } + + /** + * Updates the add button label based on the current section. + */ + private updateAddButton(): void { + const typeLabel = this.getTypeLabel(); + if (this.currentSection === AICustomizationManagementSection.Hooks) { + this.addButton.label = `$(${Codicon.add.id}) New ${typeLabel}`; + const hasWorktree = !!this.activeSessionService.getActiveSession()?.worktree; + this.addButton.enabled = hasWorktree; + const disabledTitle = hasWorktree + ? '' + : localize('hooksCreateDisabled', "Open a session with a worktree to configure hooks."); + this.addButton.primaryButton.setTitle(disabledTitle); + this.addButton.dropdownButton.setTitle(disabledTitle); + return; + } + this.addButton.primaryButton.setTitle(''); + this.addButton.dropdownButton.setTitle(''); + this.addButton.enabled = true; + const hasWorktree = this.hasActiveWorktree(); + if (hasWorktree) { + this.addButton.label = `$(${Codicon.add.id}) New ${typeLabel} (Worktree)`; + } else { + this.addButton.label = `$(${Codicon.add.id}) New ${typeLabel} (User)`; + } + } + + /** + * Gets the dropdown actions for the add button. + */ + private getDropdownActions(): Action[] { + const typeLabel = this.getTypeLabel(); + const actions: Action[] = []; + const promptType = sectionToPromptType(this.currentSection); + const hasWorktree = this.hasActiveWorktree(); + + if (hasWorktree && promptType !== PromptsType.hook) { + // Primary is worktree - dropdown shows user + generate + actions.push(new Action('createUser', `$(${Codicon.account.id}) New ${typeLabel} (User)`, undefined, true, () => { + this._onDidRequestCreateManual.fire({ type: promptType, target: 'user' }); + })); + } + + actions.push(new Action('createWithAI', `$(${Codicon.sparkle.id}) Generate ${typeLabel}`, undefined, true, () => { + this._onDidRequestCreate.fire(promptType); + })); + + return actions; + } + + /** + * Checks if there's an active session root (worktree or repository). + */ + private hasActiveWorktree(): boolean { + return !!getActiveSessionRoot(this.activeSessionService); + } + + /** + * Executes the primary create action based on context. + */ + private executePrimaryCreateAction(): void { + const promptType = sectionToPromptType(this.currentSection); + if (promptType === PromptsType.hook && !this.activeSessionService.getActiveSession()?.worktree) { + return; + } + const target = this.hasActiveWorktree() || promptType === PromptsType.hook ? 'worktree' : 'user'; + this._onDidRequestCreateManual.fire({ type: promptType, target }); + } + + /** + * Gets the type label for the current section. + */ + private getTypeLabel(): string { + switch (this.currentSection) { + case AICustomizationManagementSection.Agents: + return localize('agent', "Agent"); + case AICustomizationManagementSection.Skills: + return localize('skill', "Skill"); + case AICustomizationManagementSection.Instructions: + return localize('instructions', "Instructions"); + case AICustomizationManagementSection.Hooks: + return localize('hook', "Hook"); + case AICustomizationManagementSection.Prompts: + default: + return localize('prompt', "Prompt"); + } + } + + /** + * Refreshes the current section's items. + */ + async refresh(): Promise { + this.updateAddButton(); + await this.loadItems(); + } + + /** + * Loads items for the current section. + */ + private async loadItems(): Promise { + const promptType = sectionToPromptType(this.currentSection); + const items: IAICustomizationListItem[] = []; + + const folders = this.workspaceContextService.getWorkspace().folders; + const activeRepo = getActiveSessionRoot(this.activeSessionService); + this.logService.info(`[AICustomizationListWidget] loadItems: section=${this.currentSection}, promptType=${promptType}, workspaceFolders=[${folders.map(f => f.uri.toString()).join(', ')}], activeRepo=${activeRepo?.toString() ?? 'none'}`); + + + if (promptType === PromptsType.agent) { + // Use getCustomAgents which has parsed name/description from frontmatter + const agents = await this.promptsService.getCustomAgents(CancellationToken.None); + for (const agent of agents) { + const filename = basename(agent.uri); + items.push({ + id: agent.uri.toString(), + uri: agent.uri, + name: agent.name, + filename, + description: agent.description, + storage: agent.source.storage, + promptType, + }); + } + } else if (promptType === PromptsType.skill) { + // Use findAgentSkills which has parsed name/description from frontmatter + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + for (const skill of skills || []) { + const filename = basename(skill.uri); + const skillName = skill.name || basename(dirname(skill.uri)) || filename; + items.push({ + id: skill.uri.toString(), + uri: skill.uri, + name: skillName, + filename, + description: skill.description, + storage: skill.storage, + promptType, + }); + } + } else if (promptType === PromptsType.prompt) { + // Use getPromptSlashCommands which has parsed name/description from frontmatter + const commands = await this.promptsService.getPromptSlashCommands(CancellationToken.None); + for (const command of commands) { + const filename = basename(command.promptPath.uri); + items.push({ + id: command.promptPath.uri.toString(), + uri: command.promptPath.uri, + name: command.name, + filename, + description: command.description, + storage: command.promptPath.storage, + promptType, + }); + } + } else if (promptType === PromptsType.hook) { + // Parse hook files and display individual hooks + const workspaceFolder = this.workspaceContextService.getWorkspace().folders[0]; + const workspaceRootUri = workspaceFolder?.uri; + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.fsPath ?? userHomeUri.path; + const remoteEnv = await this.remoteAgentService.getEnvironment(); + const targetOS = remoteEnv?.os ?? OS; + + const parsedHooks = await parseAllHookFiles( + this.promptsService, + this.fileService, + this.labelService, + workspaceRootUri, + userHome, + targetOS, + CancellationToken.None + ); + + for (const hook of parsedHooks) { + // Determine storage from the file path + const storage = hook.filePath.startsWith('~') ? PromptsStorage.user : PromptsStorage.local; + + items.push({ + id: `${hook.fileUri.toString()}#${hook.hookType}-${hook.index}`, + uri: hook.fileUri, + name: `${hook.hookTypeLabel}: ${hook.commandLabel}`, + filename: basename(hook.fileUri), + description: hook.filePath, + storage, + promptType, + }); + } + } else { + // For instructions, fetch once and group by storage + const allItems = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); + const workspaceItems = allItems.filter(item => item.storage === PromptsStorage.local); + const userItems = allItems.filter(item => item.storage === PromptsStorage.user); + const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); + + const mapToListItem = (item: IPromptPath): IAICustomizationListItem => { + const filename = basename(item.uri); + // For instructions, derive a friendly name from filename + const friendlyName = item.name || this.getFriendlyName(filename); + return { + id: item.uri.toString(), + uri: item.uri, + name: friendlyName, + filename, + description: item.description, + storage: item.storage, + promptType, + }; + }; + + items.push(...workspaceItems.map(mapToListItem)); + items.push(...userItems.map(mapToListItem)); + items.push(...extensionItems.map(mapToListItem)); + } + + // Sort items by name + items.sort((a, b) => a.name.localeCompare(b.name)); + + // Set git status for worktree (local) items + this.updateGitStatus(items); + + this.logService.info(`[AICustomizationListWidget] loadItems complete: ${items.length} items loaded [${items.map(i => `${i.name}(${i.storage}:${i.uri.toString()})`).join(', ')}]`); + + this.allItems = items; + this.filterItems(); + this._onDidChangeItemCount.fire(items.length); + } + + /** + * Updates git status on worktree items by checking SCM resource groups. + * Files found in resource groups have uncommitted changes; others are committed. + */ + private updateGitStatus(items: IAICustomizationListItem[]): void { + // Build a set of URIs that have uncommitted changes in SCM + const uncommittedUris = new Set(); + for (const repo of [...this.scmService.repositories]) { + for (const group of repo.provider.groups) { + for (const resource of group.resources) { + uncommittedUris.add(resource.sourceUri.toString()); + } + } + } + + for (const item of items) { + if (item.storage === PromptsStorage.local) { + item.gitStatus = uncommittedUris.has(item.uri.toString()) ? 'uncommitted' : 'committed'; + } + } + } + + /** + * Derives a friendly name from a filename by removing extension suffixes. + */ + private getFriendlyName(filename: string): string { + // Remove common prompt file extensions like .instructions.md, .prompt.md, etc. + let name = filename + .replace(/\.instructions\.md$/i, '') + .replace(/\.prompt\.md$/i, '') + .replace(/\.agent\.md$/i, '') + .replace(/\.md$/i, ''); + + // Convert kebab-case or snake_case to Title Case + name = name + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + + return name || filename; + } + + /** + * Filters items based on the current search query and builds grouped display entries. + */ + private filterItems(): void { + let matchedItems: IAICustomizationListItem[]; + + if (!this.searchQuery.trim()) { + matchedItems = this.allItems.map(item => ({ ...item, nameMatches: undefined, descriptionMatches: undefined })); + } else { + const query = this.searchQuery.toLowerCase(); + matchedItems = []; + + for (const item of this.allItems) { + const nameMatches = matchesFuzzy(query, item.name, true); + const descriptionMatches = item.description ? matchesFuzzy(query, item.description, true) : null; + const filenameMatches = matchesFuzzy(query, item.filename, true); + + if (nameMatches || descriptionMatches || filenameMatches) { + matchedItems.push({ + ...item, + nameMatches: nameMatches || undefined, + descriptionMatches: descriptionMatches || undefined, + }); + } + } + } + + const totalBeforeFilter = matchedItems.length; + this.logService.info(`[AICustomizationListWidget] filterItems: allItems=${this.allItems.length}, matched=${totalBeforeFilter}`); + + // Group items by storage + const groups: { storage: PromptsStorage; label: string; icon: ThemeIcon; items: IAICustomizationListItem[] }[] = [ + { storage: PromptsStorage.local, label: localize('worktreeGroup', "Worktree"), icon: workspaceIcon, items: [] }, + { storage: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, items: [] }, + { storage: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, items: [] }, + ]; + + for (const item of matchedItems) { + const group = groups.find(g => g.storage === item.storage); + if (group) { + group.items.push(item); + } + } + + // Sort items within each group + for (const group of groups) { + group.items.sort((a, b) => a.name.localeCompare(b.name)); + } + + // Build display entries: group header + items (hidden if collapsed) + this.displayEntries = []; + let isFirstGroup = true; + for (const group of groups) { + if (group.items.length === 0) { + continue; + } + + const collapsed = this.collapsedGroups.has(group.storage); + + this.displayEntries.push({ + type: 'group-header', + id: `group-${group.storage}`, + storage: group.storage, + label: group.label, + icon: group.icon, + count: group.items.length, + isFirst: isFirstGroup, + collapsed, + }); + isFirstGroup = false; + + if (!collapsed) { + for (const item of group.items) { + this.displayEntries.push({ type: 'file-item', item }); + } + } + } + + this.list.splice(0, this.list.length, this.displayEntries); + this.logService.info(`[AICustomizationListWidget] filterItems complete: ${this.displayEntries.length} display entries spliced into list`); + this.updateEmptyState(); + } + + /** + * Toggles the collapsed state of a group. + */ + private toggleGroup(entry: IGroupHeaderEntry): void { + if (this.collapsedGroups.has(entry.storage)) { + this.collapsedGroups.delete(entry.storage); + } else { + this.collapsedGroups.add(entry.storage); + } + this.filterItems(); + } + + private updateEmptyState(): void { + const hasItems = this.displayEntries.length > 0; + if (!hasItems) { + this.emptyStateContainer.style.display = 'flex'; + this.listContainer.style.display = 'none'; + + // Update icon based on section + this.emptyStateIcon.className = 'empty-state-icon'; + const sectionIcon = this.getSectionIcon(); + this.emptyStateIcon.classList.add(...ThemeIcon.asClassNameArray(sectionIcon)); + + if (this.searchQuery.trim()) { + // Search with no results + this.emptyStateText.textContent = localize('noMatchingItems', "No items match '{0}'", this.searchQuery); + this.emptyStateSubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); + } else { + // No items at all - show empty state with create hint + const emptyInfo = this.getEmptyStateInfo(); + this.emptyStateText.textContent = emptyInfo.title; + this.emptyStateSubtext.textContent = emptyInfo.description; + } + } else { + this.emptyStateContainer.style.display = 'none'; + this.listContainer.style.display = ''; + } + } + + private getSectionIcon(): ThemeIcon { + switch (this.currentSection) { + case AICustomizationManagementSection.Agents: + return agentIcon; + case AICustomizationManagementSection.Skills: + return skillIcon; + case AICustomizationManagementSection.Instructions: + return instructionsIcon; + case AICustomizationManagementSection.Hooks: + return hookIcon; + case AICustomizationManagementSection.Prompts: + default: + return promptIcon; + } + } + + private getEmptyStateInfo(): { title: string; description: string } { + switch (this.currentSection) { + case AICustomizationManagementSection.Agents: + return { + title: localize('noAgents', "No agents yet"), + description: localize('createFirstAgent', "Create your first custom agent to get started"), + }; + case AICustomizationManagementSection.Skills: + return { + title: localize('noSkills', "No skills yet"), + description: localize('createFirstSkill', "Create your first skill to extend agent capabilities"), + }; + case AICustomizationManagementSection.Instructions: + return { + title: localize('noInstructions', "No instructions yet"), + description: localize('createFirstInstructions', "Add instructions to teach Copilot about your codebase"), + }; + case AICustomizationManagementSection.Hooks: + return { + title: localize('noHooks', "No hooks yet"), + description: localize('createFirstHook', "Create hooks to execute commands at agent lifecycle events"), + }; + case AICustomizationManagementSection.Prompts: + default: + return { + title: localize('noPrompts', "No prompts yet"), + description: localize('createFirstPrompt', "Create reusable prompts for common tasks"), + }; + } + } + + /** + * Sets the search query programmatically. + */ + setSearchQuery(query: string): void { + this.searchInput.value = query; + } + + /** + * Clears the search query. + */ + clearSearch(): void { + this.searchInput.value = ''; + } + + /** + * Focuses the search input. + */ + focusSearch(): void { + this.searchInput.focus(); + } + + /** + * Focuses the list. + */ + focusList(): void { + this.list.domFocus(); + if (this.displayEntries.length > 0) { + this.list.setFocus([0]); + } + } + + /** + * Layouts the widget. + */ + layout(height: number, width: number): void { + const sectionFooterHeight = this.sectionHeader.offsetHeight || 100; + const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 40; + const margins = 12; // search margin (6+6), not included in offsetHeight + const listHeight = height - sectionFooterHeight - searchBarHeight - margins; + + this.searchInput.layout(); + this.listContainer.style.height = `${Math.max(0, listHeight)}px`; + this.list.layout(Math.max(0, listHeight), width); + } + + /** + * Gets the total item count (before filtering). + */ + get itemCount(): number { + return this.allItems.length; + } +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts new file mode 100644 index 0000000000000..9450cd347d37b --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.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'; +import { IEditorPaneRegistry, EditorPaneDescriptor } from '../../../../workbench/browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; +import { + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, + AICustomizationManagementCommands, + AICustomizationManagementItemMenuId, +} from './aiCustomizationManagement.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { basename } from '../../../../base/common/resources.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { isWindows, isMacintosh } from '../../../../base/common/platform.js'; + +//#region Editor Registration + +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + AICustomizationManagementEditor, + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, + localize('aiCustomizationManagementEditor', "AI Customizations Editor") + ), + [ + // Note: Using the class directly since we use a singleton pattern + new SyncDescriptor(AICustomizationManagementEditorInput as unknown as { new(): AICustomizationManagementEditorInput }) + ] +); + +//#endregion + +//#region Editor Serializer + +class AICustomizationManagementEditorInputSerializer implements IEditorSerializer { + + canSerialize(editorInput: EditorInput): boolean { + return editorInput instanceof AICustomizationManagementEditorInput; + } + + serialize(input: AICustomizationManagementEditorInput): string { + return ''; + } + + deserialize(instantiationService: IInstantiationService): AICustomizationManagementEditorInput { + return AICustomizationManagementEditorInput.getOrCreate(); + } +} + +Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer( + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, + AICustomizationManagementEditorInputSerializer +); + +//#endregion + +//#region Context Menu Actions + +/** + * Type for context passed to actions from list context menus. + * Handles both direct URI arguments and serialized context objects. + */ +type AICustomizationContext = { + uri: URI | string; + name?: string; + promptType?: PromptsType; + storage?: PromptsStorage; + [key: string]: unknown; +} | URI | string; + +/** + * Extracts a URI from various context formats. + */ +function extractURI(context: AICustomizationContext): URI { + if (URI.isUri(context)) { + return context; + } + if (typeof context === 'string') { + return URI.parse(context); + } + if (URI.isUri(context.uri)) { + return context.uri; + } + return URI.parse(context.uri as string); +} + +/** + * Extracts storage type from context. + */ +function extractStorage(context: AICustomizationContext): PromptsStorage | undefined { + if (URI.isUri(context) || typeof context === 'string') { + return undefined; + } + return context.storage; +} + +// Open file action +const OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID = 'aiCustomizationManagement.openFile'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID, + title: localize2('open', "Open"), + icon: Codicon.goToFile, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const editorService = accessor.get(IEditorService); + await editorService.openEditor({ + resource: extractURI(context) + }); + } +}); + + +// Run prompt action +const RUN_PROMPT_MGMT_ID = 'aiCustomizationManagement.runPrompt'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: RUN_PROMPT_MGMT_ID, + title: localize2('runPrompt', "Run Prompt"), + icon: Codicon.play, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand('workbench.action.chat.run.prompt.current', extractURI(context)); + } +}); + +// Reveal in Finder/Explorer action +const REVEAL_IN_OS_LABEL = isWindows + ? localize2('revealInWindows', "Reveal in File Explorer") + : isMacintosh + ? localize2('revealInMac', "Reveal in Finder") + : localize2('openContainer', "Open Containing Folder"); + +const REVEAL_AI_CUSTOMIZATION_IN_OS_ID = 'aiCustomizationManagement.revealInOS'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: REVEAL_AI_CUSTOMIZATION_IN_OS_ID, + title: REVEAL_IN_OS_LABEL, + icon: Codicon.folderOpened, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const commandService = accessor.get(ICommandService); + const uri = extractURI(context); + // Use existing reveal command + await commandService.executeCommand('revealFileInOS', uri); + } +}); + +// Delete action +const DELETE_AI_CUSTOMIZATION_ID = 'aiCustomizationManagement.delete'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: DELETE_AI_CUSTOMIZATION_ID, + title: localize2('delete', "Delete"), + icon: Codicon.trash, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const fileService = accessor.get(IFileService); + const dialogService = accessor.get(IDialogService); + + const uri = extractURI(context); + const fileName = basename(uri); + const storage = extractStorage(context); + + // Extension files cannot be deleted + if (storage === PromptsStorage.extension) { + 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.") + ); + return; + } + + // Confirm deletion + const confirmation = await dialogService.confirm({ + message: localize('confirmDelete', "Are you sure you want to delete '{0}'?", fileName), + detail: localize('confirmDeleteDetail', "This action cannot be undone."), + primaryButton: localize('delete', "Delete"), + type: 'warning', + }); + + if (confirmation.confirmed) { + await fileService.del(uri, { useTrash: true }); + } + } +}); + +// Context Key for prompt type to conditionally show "Run Prompt" +const AI_CUSTOMIZATION_ITEM_TYPE_KEY = 'aiCustomizationManagementItemType'; + +// Register context menu items +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID, title: localize('open', "Open") }, + group: '1_open', + order: 1, +}); + +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: RUN_PROMPT_MGMT_ID, title: localize('runPrompt', "Run Prompt"), icon: Codicon.play }, + group: '2_run', + order: 1, + when: ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.prompt), +}); + +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: REVEAL_AI_CUSTOMIZATION_IN_OS_ID, title: REVEAL_IN_OS_LABEL.value }, + group: '3_file', + order: 1, + when: ContextKeyExpr.or( + ContextKeyExpr.regex('aiCustomizationManagementItemUri', new RegExp(`^${Schemas.file}:`)), + ContextKeyExpr.regex('aiCustomizationManagementItemUri', new RegExp(`^${Schemas.vscodeUserData}:`)) + ), +}); + +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: DELETE_AI_CUSTOMIZATION_ID, title: localize('delete', "Delete") }, + group: '4_modify', + order: 1, +}); + +//#endregion + +//#region Actions + +class AICustomizationManagementActionsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.aiCustomizationManagementActions'; + + constructor() { + super(); + this.registerActions(); + } + + private registerActions(): void { + // Open AI Customizations Editor + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: AICustomizationManagementCommands.OpenEditor, + title: localize2('openAICustomizations', "Open AI Customizations"), + category: CHAT_CATEGORY, + precondition: ChatContextKeys.enabled, + f1: true, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + const input = AICustomizationManagementEditorInput.getOrCreate(); + await editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + } + })); + } +} + +registerWorkbenchContribution2( + AICustomizationManagementActionsContribution.ID, + AICustomizationManagementActionsContribution, + WorkbenchPhase.AfterRestored +); + +//#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts new file mode 100644 index 0000000000000..131fbeff9e96d --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { localize } from '../../../../nls.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; + +/** + * Editor pane ID for the AI Customizations Management Editor. + */ +export const AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID = 'workbench.editor.aiCustomizationManagement'; + +/** + * Editor input type ID for serialization. + */ +export const AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID = 'workbench.input.aiCustomizationManagement'; + +/** + * Command IDs for the AI Customizations Management Editor. + */ +export const AICustomizationManagementCommands = { + OpenEditor: 'aiCustomization.openManagementEditor', + CreateNewAgent: 'aiCustomization.createNewAgent', + CreateNewSkill: 'aiCustomization.createNewSkill', + CreateNewInstructions: 'aiCustomization.createNewInstructions', + CreateNewPrompt: 'aiCustomization.createNewPrompt', +} as const; + +/** + * Section IDs for the sidebar navigation. + */ +export const AICustomizationManagementSection = { + Agents: 'agents', + Skills: 'skills', + Instructions: 'instructions', + Prompts: 'prompts', + Hooks: 'hooks', + McpServers: 'mcpServers', + Models: 'models', +} as const; + +export type AICustomizationManagementSection = typeof AICustomizationManagementSection[keyof typeof AICustomizationManagementSection]; + +/** + * Context key indicating the AI Customization Management Editor is focused. + */ +export const CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR = new RawContextKey( + 'aiCustomizationManagementEditorFocused', + false, + localize('aiCustomizationManagementEditorFocused', "Whether the AI Customizations editor is focused") +); + +/** + * Context key for the currently selected section. + */ +export const CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION = new RawContextKey( + 'aiCustomizationManagementSection', + AICustomizationManagementSection.Agents, + localize('aiCustomizationManagementSection', "The currently selected section in the AI Customizations editor") +); + +/** + * Menu ID for the AI Customization Management Editor title bar actions. + */ +export const AICustomizationManagementTitleMenuId = MenuId.for('AICustomizationManagementEditorTitle'); + +/** + * Menu ID for the AI Customization Management Editor item context menu. + */ +export const AICustomizationManagementItemMenuId = MenuId.for('AICustomizationManagementEditorItem'); + +/** + * Storage key for persisting the selected section. + */ +export const AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY = 'aiCustomizationManagement.selectedSection'; + +/** + * Storage key for persisting the sidebar width. + */ +export const AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY = 'aiCustomizationManagement.sidebarWidth'; + +/** + * Storage key for persisting the search query. + */ +export const AI_CUSTOMIZATION_MANAGEMENT_SEARCH_KEY = 'aiCustomizationManagement.searchQuery'; + +/** + * Layout constants for the editor. + */ +export const SIDEBAR_DEFAULT_WIDTH = 200; +export const SIDEBAR_MIN_WIDTH = 150; +export const SIDEBAR_MAX_WIDTH = 350; +export const CONTENT_MIN_WIDTH = 400; + +export function getActiveSessionRoot(activeSessionService: ISessionsManagementService): URI | undefined { + const session = activeSessionService.getActiveSession(); + return session?.worktree ?? session?.repository; +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts new file mode 100644 index 0000000000000..4571e7b0e0eca --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts @@ -0,0 +1,804 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Event } from '../../../../base/common/event.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { Orientation, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; +import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; +import { localize } from '../../../../nls.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { EditorPane } from '../../../../workbench/browser/parts/editor/editorPane.js'; +import { IEditorOpenContext } from '../../../../workbench/common/editor.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { IListVirtualDelegate, IListRenderer } from '../../../../base/browser/ui/list/list.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { registerColor } from '../../../../platform/theme/common/colorRegistry.js'; +import { PANEL_BORDER } from '../../../../workbench/common/theme.js'; +import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; +import { AICustomizationListWidget, IAICustomizationListItem } from './aiCustomizationListWidget.js'; +import { McpListWidget } from './mcpListWidget.js'; +import { + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, + AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, + AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, + AICustomizationManagementSection, + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR, + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION, + SIDEBAR_DEFAULT_WIDTH, + SIDEBAR_MIN_WIDTH, + SIDEBAR_MAX_WIDTH, + CONTENT_MIN_WIDTH, + getActiveSessionRoot, +} from './aiCustomizationManagement.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { ChatModelsWidget } from '../../../../workbench/contrib/chat/browser/chatManagement/chatModelsWidget.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { INewPromptOptions, NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../../../../workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.js'; +import { showConfigureHooksQuickPick } from '../../../../workbench/contrib/chat/browser/promptSyntax/hookActions.js'; +import { CustomizationCreatorService } from './customizationCreatorService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IWorkingCopyService } from '../../../../workbench/services/workingCopy/common/workingCopyService.js'; + +const $ = DOM.$; + +export const aiCustomizationManagementSashBorder = registerColor( + 'aiCustomizationManagement.sashBorder', + PANEL_BORDER, + localize('aiCustomizationManagementSashBorder', "The color of the AI Customization Management editor splitview sash border.") +); + +//#region Sidebar Section Item + +interface ISectionItem { + readonly id: AICustomizationManagementSection; + readonly label: string; + readonly icon: ThemeIcon; +} + +class SectionItemDelegate implements IListVirtualDelegate { + getHeight(): number { + return 26; + } + + getTemplateId(): string { + return 'sectionItem'; + } +} + +interface ISectionItemTemplateData { + readonly container: HTMLElement; + readonly icon: HTMLElement; + readonly label: HTMLElement; +} + +class SectionItemRenderer implements IListRenderer { + readonly templateId = 'sectionItem'; + + renderTemplate(container: HTMLElement): ISectionItemTemplateData { + container.classList.add('section-list-item'); + const icon = DOM.append(container, $('.section-icon')); + const label = DOM.append(container, $('.section-label')); + return { container, icon, label }; + } + + renderElement(element: ISectionItem, index: number, templateData: ISectionItemTemplateData): void { + templateData.icon.className = 'section-icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(element.icon)); + templateData.label.textContent = element.label; + } + + disposeTemplate(): void { } +} + +//#endregion + +/** + * Editor pane for the AI Customizations Management Editor. + * Provides a global view of all AI customizations with a sidebar for navigation + * and a content area showing a searchable list of items. + */ +export class AICustomizationManagementEditor extends EditorPane { + + static readonly ID = AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID; + + private container!: HTMLElement; + private splitViewContainer!: HTMLElement; + private splitView!: SplitView; + private sidebarContainer!: HTMLElement; + private sectionsList!: WorkbenchList; + private contentContainer!: HTMLElement; + private listWidget!: AICustomizationListWidget; + private mcpListWidget!: McpListWidget; + private modelsWidget!: ChatModelsWidget; + private promptsContentContainer!: HTMLElement; + private mcpContentContainer!: HTMLElement; + private modelsContentContainer!: HTMLElement; + private modelsFooterElement!: HTMLElement; + + // Embedded editor state + private editorContentContainer!: HTMLElement; + private embeddedEditorContainer!: HTMLElement; + private embeddedEditor!: CodeEditorWidget; + private editorItemNameElement!: HTMLElement; + private editorItemPathElement!: HTMLElement; + private editorSaveIndicator!: HTMLElement; + private readonly editorModelChangeDisposables = this._register(new DisposableStore()); + private currentEditingUri: URI | undefined; + private currentWorktreeUri: URI | undefined; + private currentEditingIsWorktree = false; + private currentModelRef: IReference | undefined; + private viewMode: 'list' | 'editor' = 'list'; + + private dimension: DOM.Dimension | undefined; + private readonly sections: ISectionItem[] = []; + private selectedSection: AICustomizationManagementSection = AICustomizationManagementSection.Agents; + + private readonly editorDisposables = this._register(new DisposableStore()); + private readonly inputDisposables = this._register(new MutableDisposable()); + private readonly customizationCreator: CustomizationCreatorService; + + private readonly inEditorContextKey: IContextKey; + private readonly sectionContextKey: IContextKey; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService private readonly storageService: IStorageService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IOpenerService private readonly openerService: IOpenerService, + @ITextModelService private readonly textModelService: ITextModelService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILayoutService private readonly layoutService: ILayoutService, + @ICommandService private readonly commandService: ICommandService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, + ) { + super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); + + this.inEditorContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR.bindTo(contextKeyService); + this.sectionContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION.bindTo(contextKeyService); + + this.customizationCreator = this.instantiationService.createInstance(CustomizationCreatorService); + + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + if (this.viewMode !== 'editor' || !this.currentEditingIsWorktree) { + return; + } + this.currentWorktreeUri = getActiveSessionRoot(this.activeSessionService); + })); + + // Safety disposal for the embedded editor model reference + this._register(toDisposable(() => { + this.currentModelRef?.dispose(); + this.currentModelRef = undefined; + })); + + this.sections.push( + { id: AICustomizationManagementSection.Agents, label: localize('agents', "Agents"), icon: agentIcon }, + { id: AICustomizationManagementSection.Skills, label: localize('skills', "Skills"), icon: skillIcon }, + { id: AICustomizationManagementSection.Instructions, label: localize('instructions', "Instructions"), icon: instructionsIcon }, + { id: AICustomizationManagementSection.Prompts, label: localize('prompts', "Prompts"), icon: promptIcon }, + { id: AICustomizationManagementSection.Hooks, label: localize('hooks', "Hooks"), icon: hookIcon }, + { id: AICustomizationManagementSection.McpServers, label: localize('mcpServers', "MCP Servers"), icon: Codicon.server }, + { id: AICustomizationManagementSection.Models, label: localize('models', "Models"), icon: Codicon.vm }, + ); + + // Restore selected section from storage + const savedSection = this.storageService.get(AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, StorageScope.PROFILE); + if (savedSection && Object.values(AICustomizationManagementSection).includes(savedSection as AICustomizationManagementSection)) { + this.selectedSection = savedSection as AICustomizationManagementSection; + } + } + + protected override createEditor(parent: HTMLElement): void { + this.editorDisposables.clear(); + this.container = DOM.append(parent, $('.ai-customization-management-editor')); + + this.createSplitView(); + this.updateStyles(); + } + + private createSplitView(): void { + this.splitViewContainer = DOM.append(this.container, $('.management-split-view')); + + this.sidebarContainer = $('.management-sidebar'); + this.contentContainer = $('.management-content'); + + this.createSidebar(); + this.createContent(); + + this.splitView = this.editorDisposables.add(new SplitView(this.splitViewContainer, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + })); + + const savedWidth = this.storageService.getNumber(AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, StorageScope.PROFILE, SIDEBAR_DEFAULT_WIDTH); + + // Sidebar view + this.splitView.addView({ + onDidChange: Event.None, + element: this.sidebarContainer, + minimumSize: SIDEBAR_MIN_WIDTH, + maximumSize: SIDEBAR_MAX_WIDTH, + layout: (width, _, height) => { + this.sidebarContainer.style.width = `${width}px`; + if (height !== undefined) { + const listHeight = height - 24; + this.sectionsList.layout(listHeight, width); + } + }, + }, savedWidth, undefined, true); + + // Content view + this.splitView.addView({ + onDidChange: Event.None, + element: this.contentContainer, + minimumSize: CONTENT_MIN_WIDTH, + maximumSize: Number.POSITIVE_INFINITY, + layout: (width, _, height) => { + this.contentContainer.style.width = `${width}px`; + if (height !== undefined) { + this.listWidget.layout(height - 16, width - 24); // Account for padding + this.mcpListWidget.layout(height - 16, width - 24); // Account for padding + // Models widget has footer, subtract footer height + const modelsFooterHeight = this.modelsFooterElement?.offsetHeight || 80; + this.modelsWidget.layout(height - 16 - modelsFooterHeight, width); + + // Layout embedded editor when in editor mode + if (this.viewMode === 'editor' && this.embeddedEditor) { + const editorHeaderHeight = 50; // Back button + item info header + const padding = 24; // Content inner padding + const editorHeight = height - editorHeaderHeight - padding; + const editorWidth = width - padding; + this.embeddedEditor.layout({ width: Math.max(0, editorWidth), height: Math.max(0, editorHeight) }); + } + } + }, + }, Sizing.Distribute, undefined, true); + + // Persist sidebar width + this.editorDisposables.add(this.splitView.onDidSashChange(() => { + const width = this.splitView.getViewSize(0); + this.storageService.store(AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, width, StorageScope.PROFILE, StorageTarget.USER); + })); + + // Reset on double-click + this.editorDisposables.add(this.splitView.onDidSashReset(() => { + const totalWidth = this.splitView.getViewSize(0) + this.splitView.getViewSize(1); + this.splitView.resizeView(0, SIDEBAR_DEFAULT_WIDTH); + this.splitView.resizeView(1, totalWidth - SIDEBAR_DEFAULT_WIDTH); + })); + } + + private createSidebar(): void { + const sidebarContent = DOM.append(this.sidebarContainer, $('.sidebar-content')); + + // Main sections list container (takes remaining space) + const sectionsListContainer = DOM.append(sidebarContent, $('.sidebar-sections-list')); + + this.sectionsList = this.editorDisposables.add(this.instantiationService.createInstance( + WorkbenchList, + 'AICustomizationManagementSections', + sectionsListContainer, + new SectionItemDelegate(), + [new SectionItemRenderer()], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel: (item: ISectionItem) => item.label, + getWidgetAriaLabel: () => localize('sectionsAriaLabel', "AI Customization Sections"), + }, + openOnSingleClick: true, + identityProvider: { + getId: (item: ISectionItem) => item.id, + }, + } + )); + + this.sectionsList.splice(0, this.sectionsList.length, this.sections); + + // Select the saved section + const selectedIndex = this.sections.findIndex(s => s.id === this.selectedSection); + if (selectedIndex >= 0) { + this.sectionsList.setSelection([selectedIndex]); + } + + this.editorDisposables.add(this.sectionsList.onDidChangeSelection(e => { + if (e.elements.length > 0) { + this.selectSection(e.elements[0].id); + } + })); + } + + private createContent(): void { + const contentInner = DOM.append(this.contentContainer, $('.content-inner')); + + // Container for prompts-based content (Agents, Skills, Instructions, Prompts) + this.promptsContentContainer = DOM.append(contentInner, $('.prompts-content-container')); + this.listWidget = this.editorDisposables.add(this.instantiationService.createInstance(AICustomizationListWidget)); + this.promptsContentContainer.appendChild(this.listWidget.element); + + // Handle item selection - open in embedded editor + this.editorDisposables.add(this.listWidget.onDidSelectItem(item => { + this.openItem(item); + })); + + // Handle create actions - AI-guided creation + this.editorDisposables.add(this.listWidget.onDidRequestCreate(promptType => { + this.createNewItemWithAI(promptType); + })); + + // Handle manual create actions - open editor directly + this.editorDisposables.add(this.listWidget.onDidRequestCreateManual(({ type, target }) => { + this.createNewItemManual(type, target); + })); + + // Container for Models content + this.modelsContentContainer = DOM.append(contentInner, $('.models-content-container')); + + this.modelsWidget = this.editorDisposables.add(this.instantiationService.createInstance(ChatModelsWidget)); + this.modelsContentContainer.appendChild(this.modelsWidget.element); + + // Models description footer + this.modelsFooterElement = DOM.append(this.modelsContentContainer, $('.section-footer')); + const modelsDescription = DOM.append(this.modelsFooterElement, $('p.section-footer-description')); + modelsDescription.textContent = localize('modelsDescription', "Browse and manage language models from different providers. Select models for use in chat, code completion, and other AI features."); + const modelsLink = DOM.append(this.modelsFooterElement, $('a.section-footer-link')) as HTMLAnchorElement; + modelsLink.textContent = localize('learnMoreModels', "Learn more about language models"); + modelsLink.href = 'https://code.visualstudio.com/docs/copilot/customization/language-models'; + this.editorDisposables.add(DOM.addDisposableListener(modelsLink, 'click', (e) => { + e.preventDefault(); + this.openerService.open(URI.parse(modelsLink.href)); + })); + + // Container for MCP content + this.mcpContentContainer = DOM.append(contentInner, $('.mcp-content-container')); + this.mcpListWidget = this.editorDisposables.add(this.instantiationService.createInstance(McpListWidget)); + this.mcpContentContainer.appendChild(this.mcpListWidget.element); + + // Container for embedded editor view + this.editorContentContainer = DOM.append(contentInner, $('.editor-content-container')); + this.createEmbeddedEditor(); + + // Set initial visibility based on selected section + this.updateContentVisibility(); + + // Load items for the initial section + if (this.isPromptsSection(this.selectedSection)) { + void this.listWidget.setSection(this.selectedSection); + } + } + + private isPromptsSection(section: AICustomizationManagementSection): boolean { + return section === AICustomizationManagementSection.Agents || + section === AICustomizationManagementSection.Skills || + section === AICustomizationManagementSection.Instructions || + section === AICustomizationManagementSection.Prompts || + section === AICustomizationManagementSection.Hooks; + } + + private selectSection(section: AICustomizationManagementSection): void { + if (this.selectedSection === section) { + return; + } + + // If in editor view, go back to list first + if (this.viewMode === 'editor') { + this.goBackToList(); + } + + this.selectedSection = section; + this.sectionContextKey.set(section); + + // 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(); + + // Load items for the new section (only for prompts-based sections) + if (this.isPromptsSection(section)) { + void this.listWidget.setSection(section); + } + } + + 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 isPromptsSection = this.isPromptsSection(this.selectedSection); + const isModelsSection = this.selectedSection === AICustomizationManagementSection.Models; + const isMcpSection = this.selectedSection === AICustomizationManagementSection.McpServers; + + // Hide all list containers when in editor mode + this.promptsContentContainer.style.display = !isEditorMode && isPromptsSection ? '' : 'none'; + this.modelsContentContainer.style.display = !isEditorMode && isModelsSection ? '' : 'none'; + this.mcpContentContainer.style.display = !isEditorMode && isMcpSection ? '' : 'none'; + this.editorContentContainer.style.display = isEditorMode ? '' : 'none'; + + // Render and layout models widget when switching to it + if (isModelsSection) { + this.modelsWidget.render(); + if (this.dimension) { + this.layout(this.dimension); + } + } + } + + private openItem(item: IAICustomizationListItem): void { + const isWorktreeFile = item.storage === PromptsStorage.local; + const isReadOnly = item.storage === PromptsStorage.extension; + this.showEmbeddedEditor(item.uri, item.name, isWorktreeFile, isReadOnly); + } + + /** + * Creates the embedded editor container with back button and CodeEditorWidget. + */ + private createEmbeddedEditor(): void { + // Header with back button and item info + const editorHeader = DOM.append(this.editorContentContainer, $('.editor-header')); + + // Back button + const backButton = DOM.append(editorHeader, $('button.editor-back-button')); + backButton.setAttribute('aria-label', localize('backToList', "Back to list")); + const backIcon = DOM.append(backButton, $(`.codicon.codicon-${Codicon.arrowLeft.id}`)); + backIcon.setAttribute('aria-hidden', 'true'); + + this.editorDisposables.add(DOM.addDisposableListener(backButton, 'click', () => { + this.goBackToList(); + })); + + // Item info + const itemInfo = DOM.append(editorHeader, $('.editor-item-info')); + this.editorItemNameElement = DOM.append(itemInfo, $('.editor-item-name')); + this.editorItemPathElement = DOM.append(itemInfo, $('.editor-item-path')); + + // Save indicator (right-aligned in header) + this.editorSaveIndicator = DOM.append(editorHeader, $('.editor-save-indicator')); + + // Editor container + this.embeddedEditorContainer = DOM.append(this.editorContentContainer, $('.embedded-editor-container')); + + // Overflow widgets container - appended to the workbench root container so + // hovers, suggest widgets, etc. are not clipped by overflow:hidden parents. + const overflowWidgetsDomNode = this.layoutService.getContainer(DOM.getWindow(this.embeddedEditorContainer)).appendChild($('.embedded-editor-overflow-widgets.monaco-editor')); + this.editorDisposables.add(toDisposable(() => overflowWidgetsDomNode.remove())); + + // Create the CodeEditorWidget + const editorOptions = { + ...getSimpleEditorOptions(this.configurationService), + readOnly: false, + minimap: { enabled: false }, + lineNumbers: 'on' as const, + wordWrap: 'on' as const, + scrollBeyondLastLine: false, + automaticLayout: false, + folding: true, + renderLineHighlight: 'all' as const, + scrollbar: { + vertical: 'auto' as const, + horizontal: 'auto' as const, + }, + overflowWidgetsDomNode, + }; + + this.embeddedEditor = this.editorDisposables.add(this.instantiationService.createInstance( + CodeEditorWidget, + this.embeddedEditorContainer, + editorOptions, + { + isSimpleWidget: false, + // Use default contributions for full IntelliSense, completions, linting, etc. + } + )); + } + + /** + * Shows the embedded editor with the content of the given item. + */ + private async showEmbeddedEditor(uri: URI, displayName: string, isWorktreeFile = false, isReadOnly = false): Promise { + // Dispose previous model reference if any + this.currentModelRef?.dispose(); + this.currentModelRef = undefined; + this.currentEditingUri = uri; + + this.viewMode = 'editor'; + + // Update header info + this.editorItemNameElement.textContent = displayName; + this.editorItemPathElement.textContent = basename(uri); + + // Track worktree URI for auto-commit on close + const worktreeDir = getActiveSessionRoot(this.activeSessionService); + this.currentWorktreeUri = isWorktreeFile ? worktreeDir : undefined; + this.currentEditingIsWorktree = isWorktreeFile; + + // Update visibility + this.updateContentVisibility(); + + try { + // Get the text model for the file + const ref = await this.textModelService.createModelReference(uri); + this.currentModelRef = ref; + this.embeddedEditor.setModel(ref.object.textEditorModel); + this.embeddedEditor.updateOptions({ readOnly: isReadOnly }); + + // Layout the editor + if (this.dimension) { + this.layout(this.dimension); + } + + // Focus the editor + this.embeddedEditor.focus(); + + // Listen for content changes to show saving spinner + this.editorModelChangeDisposables.clear(); + this.editorModelChangeDisposables.add(ref.object.textEditorModel.onDidChangeContent(() => { + this.showSavingSpinner(); + })); + // Listen for actual save events to show checkmark + this.editorModelChangeDisposables.add(this.workingCopyService.onDidSave(e => { + if (isEqual(e.workingCopy.resource, uri)) { + this.showSavedCheckmark(); + } + })); + } catch (error) { + // If we can't load the model, go back to the list + console.error('Failed to load model for embedded editor:', error); + this.goBackToList(); + } + } + + /** + * Goes back from the embedded editor view to the list view. + */ + private goBackToList(): void { + // Auto-commit worktree files when leaving the embedded editor + const fileUri = this.currentEditingUri; + const worktreeUri = this.currentWorktreeUri; + if (fileUri && worktreeUri) { + this.commitWorktreeFile(worktreeUri, fileUri); + } + + // Dispose model reference + this.currentModelRef?.dispose(); + this.currentModelRef = undefined; + this.currentEditingUri = undefined; + this.currentWorktreeUri = undefined; + this.currentEditingIsWorktree = false; + this.editorModelChangeDisposables.clear(); + this.clearSaveIndicator(); + + // Clear editor model + this.embeddedEditor.setModel(null); + + this.viewMode = 'list'; + + // Update visibility + this.updateContentVisibility(); + + // Re-layout + if (this.dimension) { + this.layout(this.dimension); + } + + // Focus the list + this.listWidget?.focusSearch(); + } + + /** + * Creates a new customization using the AI-guided flow. + * Closes the management editor and opens a chat session with a hidden + * custom agent that guides the user through creating the customization. + */ + private async createNewItemWithAI(type: PromptsType): Promise { + // Close the management editor first so the chat is focused + if (this.input) { + await this.group.closeEditor(this.input); + } + + await this.customizationCreator.createWithAI(type); + } + + /** + * Creates a new prompt file. If there's an active worktree, asks the user + * whether to save in the worktree or user directory first. + */ + private async createNewItemManual(type: PromptsType, target: 'worktree' | 'user'): Promise { + // TODO: When creating a workspace customization file via 'New Workspace X', + // the file is written directly to the worktree but there is currently no way + // to commit it so it shows up in the Changes diff view for the worktree. + // We need integration with the git worktree to stage/commit these new files. + + if (type === PromptsType.hook) { + const isWorktree = target === 'worktree'; + await this.instantiationService.invokeFunction(showConfigureHooksQuickPick, { + openEditor: async (resource, options) => { + await this.showEmbeddedEditor(resource, basename(resource), isWorktree); + return; + }, + onHookFileCreated: isWorktree ? (_uri) => { + // Worktree tracking is handled via showEmbeddedEditor's isWorktreeFile param + } : undefined, + }); + return; + } + + const targetDir = target === 'worktree' + ? this.customizationCreator.resolveTargetDirectory(type) + : await this.customizationCreator.resolveUserDirectory(type); + + const isWorktree = target === 'worktree'; + const options: INewPromptOptions = { + targetFolder: targetDir, + targetStorage: target === 'user' ? PromptsStorage.user : PromptsStorage.local, + openFile: async (uri) => { + await this.showEmbeddedEditor(uri, basename(uri), isWorktree); + return this.embeddedEditor; + }, + }; + + let commandId: string; + switch (type) { + case PromptsType.prompt: commandId = NEW_PROMPT_COMMAND_ID; break; + case PromptsType.instructions: commandId = NEW_INSTRUCTIONS_COMMAND_ID; break; + case PromptsType.agent: commandId = NEW_AGENT_COMMAND_ID; break; + case PromptsType.skill: commandId = NEW_SKILL_COMMAND_ID; break; + default: return; + } + + await this.commandService.executeCommand(commandId, options); + + // Refresh the list so the new item appears + void this.listWidget.refresh(); + } + + override updateStyles(): void { + const borderColor = this.theme.getColor(aiCustomizationManagementSashBorder); + if (borderColor) { + this.splitView?.style({ separatorBorder: borderColor }); + } + } + + override async setInput(input: AICustomizationManagementEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + this.inEditorContextKey.set(true); + this.sectionContextKey.set(this.selectedSection); + + await super.setInput(input, options, context, token); + + // Set initial editor tab title + this.updateEditorTitle(); + + if (this.dimension) { + this.layout(this.dimension); + } + } + + override clearInput(): void { + this.inEditorContextKey.set(false); + this.inputDisposables.clear(); + + // Clean up embedded editor state + if (this.viewMode === 'editor') { + this.goBackToList(); + } + + super.clearInput(); + } + + override layout(dimension: DOM.Dimension): void { + this.dimension = dimension; + + if (this.container && this.splitView) { + this.splitViewContainer.style.height = `${dimension.height}px`; + this.splitView.layout(dimension.width, dimension.height); + } + } + + override focus(): void { + super.focus(); + // When in editor mode, focus the editor + if (this.viewMode === 'editor') { + this.embeddedEditor?.focus(); + return; + } + if (this.selectedSection === AICustomizationManagementSection.McpServers) { + this.mcpListWidget?.focusSearch(); + } else if (this.selectedSection === AICustomizationManagementSection.Models) { + this.modelsWidget?.focusSearch(); + } else { + this.listWidget?.focusSearch(); + } + } + + /** + * Selects a specific section programmatically. + */ + public selectSectionById(sectionId: AICustomizationManagementSection): void { + const index = this.sections.findIndex(s => s.id === sectionId); + if (index >= 0) { + this.sectionsList.setFocus([index]); + this.sectionsList.setSelection([index]); + } + } + + /** + * Shows the spinning loader to indicate unsaved changes. + */ + private showSavingSpinner(): void { + this.editorSaveIndicator.className = 'editor-save-indicator visible'; + this.editorSaveIndicator.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); + this.editorSaveIndicator.title = localize('saving', "Saving..."); + } + + /** + * Shows the checkmark after the file has been saved to disk. + */ + private showSavedCheckmark(): void { + this.editorSaveIndicator.className = 'editor-save-indicator visible saved'; + this.editorSaveIndicator.classList.add(...ThemeIcon.asClassNameArray(Codicon.check)); + this.editorSaveIndicator.title = localize('saved', "Saved"); + } + + private clearSaveIndicator(): void { + this.editorSaveIndicator.className = 'editor-save-indicator'; + this.editorSaveIndicator.title = ''; + } + + /** + * Commits a worktree file via the extension and refreshes the Changes view. + */ + private async commitWorktreeFile(worktreeUri: URI, fileUri: URI): Promise { + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToWorktree', + { worktreeUri, fileUri } + ); + await this.agentSessionsService.model.resolve(AgentSessionProviders.Background); + this.refreshList(); + } + + /** + * Refreshes the list widget. + */ + public refreshList(): void { + void this.listWidget.refresh(); + } +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.ts new file mode 100644 index 0000000000000..9c1f70b7a7fdf --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IUntypedEditorInput } from '../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; +import { AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID } from './aiCustomizationManagement.js'; + +/** + * Editor input for the AI Customizations Management Editor. + * This is a singleton-style input with no file resource. + */ +export class AICustomizationManagementEditorInput extends EditorInput { + + static readonly ID: string = AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID; + + readonly resource = undefined; + + private static _instance: AICustomizationManagementEditorInput | undefined; + + private _sectionLabel: string | undefined; + + /** + * Gets or creates the singleton instance of this input. + */ + static getOrCreate(): AICustomizationManagementEditorInput { + if (!AICustomizationManagementEditorInput._instance || AICustomizationManagementEditorInput._instance.isDisposed()) { + AICustomizationManagementEditorInput._instance = new AICustomizationManagementEditorInput(); + } + return AICustomizationManagementEditorInput._instance; + } + + constructor() { + super(); + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + return super.matches(otherInput) || otherInput instanceof AICustomizationManagementEditorInput; + } + + override get typeId(): string { + return AICustomizationManagementEditorInput.ID; + } + + 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(); + } + } + + override getIcon(): ThemeIcon { + return Codicon.settingsGear; + } + + override async resolve(): Promise { + return null; + } +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts new file mode 100644 index 0000000000000..677fdf24320b7 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { AICustomizationManagementSection } from './aiCustomizationManagement.js'; +import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; +import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; + +const $ = DOM.$; + +export const AI_CUSTOMIZATION_OVERVIEW_VIEW_ID = 'workbench.view.aiCustomizationOverview'; + +interface ISectionSummary { + readonly id: AICustomizationManagementSection; + readonly label: string; + readonly icon: ThemeIcon; + count: number; +} + +/** + * A compact overview view that shows a snapshot of AI customizations + * and provides deep-links to the management editor sections. + */ +export class AICustomizationOverviewView extends ViewPane { + + private bodyElement!: HTMLElement; + private container!: HTMLElement; + private sectionsContainer!: HTMLElement; + private readonly sections: ISectionSummary[] = []; + private readonly countElements = new Map(); + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IPromptsService private readonly promptsService: IPromptsService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // Initialize sections + this.sections.push( + { id: AICustomizationManagementSection.Agents, label: localize('agents', "Agents"), icon: agentIcon, count: 0 }, + { id: AICustomizationManagementSection.Skills, label: localize('skills', "Skills"), icon: skillIcon, count: 0 }, + { id: AICustomizationManagementSection.Instructions, label: localize('instructions', "Instructions"), icon: instructionsIcon, count: 0 }, + { id: AICustomizationManagementSection.Prompts, label: localize('prompts', "Prompts"), icon: promptIcon, count: 0 }, + ); + + // Listen to changes + this._register(this.promptsService.onDidChangeCustomAgents(() => this.loadCounts())); + this._register(this.promptsService.onDidChangeSlashCommands(() => this.loadCounts())); + + // Listen to workspace folder changes to update counts + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.loadCounts())); + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + this.loadCounts(); + })); + + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + this.bodyElement = container; + this.container = DOM.append(container, $('.ai-customization-overview')); + this.sectionsContainer = DOM.append(this.container, $('.overview-sections')); + + this.renderSections(); + void this.loadCounts(); + + // Force initial layout + this.layoutBody(this.bodyElement.offsetHeight, this.bodyElement.offsetWidth); + } + + private renderSections(): void { + DOM.clearNode(this.sectionsContainer); + this.countElements.clear(); + + for (const section of this.sections) { + const sectionElement = DOM.append(this.sectionsContainer, $('.overview-section')); + sectionElement.tabIndex = 0; + sectionElement.setAttribute('role', 'button'); + sectionElement.setAttribute('aria-label', `${section.label}: ${section.count} items`); + + const iconElement = DOM.append(sectionElement, $('.section-icon')); + iconElement.classList.add(...ThemeIcon.asClassNameArray(section.icon)); + + const textContainer = DOM.append(sectionElement, $('.section-text')); + const labelElement = DOM.append(textContainer, $('.section-label')); + labelElement.textContent = section.label; + + const countElement = DOM.append(sectionElement, $('.section-count')); + countElement.textContent = `${section.count}`; + this.countElements.set(section.id, countElement); + + // Click handler to open management editor at section + this._register(DOM.addDisposableListener(sectionElement, 'click', () => { + this.openSection(section.id); + })); + + // Keyboard support + this._register(DOM.addDisposableListener(sectionElement, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.openSection(section.id); + } + })); + + // Hover tooltip + this._register(this.hoverService.setupDelayedHoverAtMouse(sectionElement, () => ({ + content: localize('openSection', "Open {0} in AI Customizations editor", section.label), + appearance: { compact: true, skipFadeInAnimation: true } + }))); + } + } + + private async loadCounts(): Promise { + const sectionPromptTypes: Array<{ section: AICustomizationManagementSection; type: PromptsType }> = [ + { section: AICustomizationManagementSection.Agents, type: PromptsType.agent }, + { section: AICustomizationManagementSection.Skills, type: PromptsType.skill }, + { section: AICustomizationManagementSection.Instructions, type: PromptsType.instructions }, + { section: AICustomizationManagementSection.Prompts, type: PromptsType.prompt }, + ]; + + await Promise.all(sectionPromptTypes.map(async ({ section, type }) => { + let count = 0; + if (type === PromptsType.skill) { + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + if (skills) { + count = skills.length; + } + } else { + const allItems = await this.promptsService.listPromptFiles(type, CancellationToken.None); + count = allItems.length; + } + + const sectionData = this.sections.find(s => s.id === section); + if (sectionData) { + sectionData.count = count; + } + })); + + this.updateCountElements(); + } + + private updateCountElements(): void { + for (const section of this.sections) { + const countElement = this.countElements.get(section.id); + if (countElement) { + countElement.textContent = `${section.count}`; + } + } + } + + private async openSection(sectionId: AICustomizationManagementSection): Promise { + const input = AICustomizationManagementEditorInput.getOrCreate(); + const editor = await this.editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + + // Deep-link to the section + if (editor instanceof AICustomizationManagementEditor) { + editor.selectSectionById(sectionId); + } + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.container.style.height = `${height}px`; + } +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts new file mode 100644 index 0000000000000..e1e8717e6afaa --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { getPromptFileDefaultLocations } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { localize } from '../../../../nls.js'; +import { getActiveSessionRoot } from './aiCustomizationManagement.js'; + +/** + * Service that opens an AI-guided chat session to help the user create + * a new customization (agent, skill, instructions, prompt, hook). + * + * Opens a new chat in agent mode, then sends a request with hidden + * system instructions (modeInstructions) that guide the AI through + * the creation process. The user sees only their message. + */ +export class CustomizationCreatorService { + + constructor( + @ICommandService private readonly commandService: ICommandService, + @IChatService private readonly chatService: IChatService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + @IPromptsService private readonly promptsService: IPromptsService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + ) { } + + async createWithAI(type: PromptsType): Promise { + // Ask for the name before entering chat + const typeLabel = getTypeLabel(type); + const name = await this.quickInputService.input({ + prompt: localize('generateName', "Name for the new {0}", typeLabel), + placeHolder: localize('generateNamePlaceholder', "e.g., my-{0}", typeLabel), + validateInput: async (value) => { + if (!value || !value.trim()) { + return localize('nameRequired', "Name is required"); + } + return undefined; + } + }); + if (!name) { + return; + } + const trimmedName = name.trim(); + + // TODO: The 'Generate X' flow currently opens a new chat that is not connected + // to the active worktree. For this to fully work, the background agent needs to + // accept a worktree parameter so the new session can write files into the correct + // worktree directory and have those changes tracked in the session's diff view. + + // Capture worktree BEFORE opening new chat (which changes active session) + const targetDir = this.resolveTargetDirectory(type); + const systemInstructions = buildAgentInstructions(type, targetDir, trimmedName); + const userMessage = buildUserMessage(type, targetDir, trimmedName); + + // Start a new chat, then send the request with hidden instructions + await this.commandService.executeCommand('workbench.action.chat.newChat'); + + // Grab the now-active widget's session and send with hidden instructions + const widget = this.chatWidgetService.lastFocusedWidget; + const sessionResource = widget?.viewModel?.sessionResource; + if (!sessionResource) { + return; + } + + await this.chatService.sendRequest(sessionResource, userMessage, { + modeInfo: { + kind: ChatModeKind.Agent, + isBuiltin: false, + modeId: 'custom', + applyCodeBlockSuggestionId: undefined, + modeInstructions: { + name: 'customization-creator', + content: systemInstructions, + toolReferences: [], + }, + }, + }); + } + + /** + * Returns the worktree and repository URIs from the active session. + */ + /** + * Resolves the worktree directory for a new customization file based on the + * active session's worktree (preferred) or repository path. + * Falls back to the first local source folder from promptsService.getSourceFolders() + * if there's no active worktree. + */ + resolveTargetDirectory(type: PromptsType): URI | undefined { + const basePath = getActiveSessionRoot(this.activeSessionService); + if (!basePath) { + return undefined; + } + + // Compute the path within the worktree using default locations + const defaultLocations = getPromptFileDefaultLocations(type); + const localLocation = defaultLocations.find(loc => loc.storage === PromptsStorage.local); + if (!localLocation) { + return basePath; + } + + return URI.joinPath(basePath, localLocation.path); + } + + /** + * Resolves the user-level directory for a new customization file. + * Delegates to IPromptsService.getSourceFolders() which knows the correct + * user data profile path. + */ + async resolveUserDirectory(type: PromptsType): Promise { + const folders = await this.promptsService.getSourceFolders(type); + const userFolder = folders.find(f => f.storage === PromptsStorage.user); + return userFolder?.uri; + } +} + +//#region Agent Instructions + +/** + * Builds the hidden system instructions for the customization creator agent. + * Sent as modeInstructions - invisible to the user. + */ +function buildAgentInstructions(type: PromptsType, targetDir: URI | undefined, name: string): string { + const targetHint = targetDir + ? `\nIMPORTANT: Save the file to this directory: ${targetDir.fsPath}. The name is "${name}".` + : `\nThe name is "${name}".`; + + const writePolicy = ` + +CRITICAL WORKFLOW: +- In your VERY FIRST response, you MUST immediately create the file on disk from a starter template with placeholder content. Do not ask questions first -- write the file first so it appears in the diff view, then ask the user how they want to customize it. +- Every subsequent message from the user should result in you updating that same file on disk with the requested changes. +- Always write the complete file content, not partial diffs.${targetHint}`; + + switch (type) { + case PromptsType.agent: + return `You are a helpful assistant that guides users through creating a new custom AI agent.${writePolicy} + +Create a file named "${name}.agent.md" with YAML frontmatter (name, description, tools) and system instructions. Ask the user what it should do.`; + + case PromptsType.skill: + return `You are a helpful assistant that guides users through creating a new skill.${writePolicy} + +Create a directory named "${name}" with a SKILL.md file inside it. The file should have YAML frontmatter (name, description) and instructions. Ask the user what it does.`; + + case PromptsType.instructions: + return `You are a helpful assistant that guides users through creating a new instructions file.${writePolicy} + +Create a file named "${name}.instructions.md" with YAML frontmatter (description, optional applyTo) and actionable content. Ask the user what it should cover.`; + + case PromptsType.prompt: + return `You are a helpful assistant that guides users through creating a new reusable prompt.${writePolicy} + +Create a file named "${name}.prompt.md" with YAML frontmatter (name, description) and prompt content. Ask the user what it should do.`; + + case PromptsType.hook: + return `You are a helpful assistant that guides users through creating a new hook.${writePolicy} + +Ask the user when the hook should trigger and what it should do, then write the configuration file.`; + + default: + return `You are a helpful assistant that guides users through creating a new AI customization file.${writePolicy} + +Ask the user what they want to create, then guide them step by step.`; + } +} + +//#endregion + +//#region User Messages + +/** + * Builds the user-visible message that opens the chat. + * Includes the target path so the agent knows where to write the file. + */ +function buildUserMessage(type: PromptsType, targetDir: URI | undefined, name: string): string { + const pathHint = targetDir ? ` Write it to \`${targetDir.fsPath}\`.` : ''; + + switch (type) { + case PromptsType.agent: + return `Help me create a new custom agent called "${name}".${pathHint}`; + case PromptsType.skill: + return `Help me create a new skill called "${name}".${pathHint}`; + case PromptsType.instructions: + return `Help me create new instructions called "${name}".${pathHint}`; + case PromptsType.prompt: + return `Help me create a new prompt called "${name}".${pathHint}`; + case PromptsType.hook: + return `Help me create a new hook called "${name}".${pathHint}`; + default: + return `Help me create a new customization called "${name}".${pathHint}`; + } +} + +function getTypeLabel(type: PromptsType): string { + switch (type) { + case PromptsType.agent: return 'agent'; + case PromptsType.skill: return 'skill'; + case PromptsType.instructions: return 'instructions'; + case PromptsType.prompt: return 'prompt'; + case PromptsType.hook: return 'hook'; + default: return 'customization'; + } +} + +//#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/mcpListWidget.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/mcpListWidget.ts new file mode 100644 index 0000000000000..cf97aeb03f828 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/mcpListWidget.ts @@ -0,0 +1,363 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { Disposable, DisposableStore, isDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { McpCommandIds } from '../../../../workbench/contrib/mcp/common/mcpCommandIds.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { URI } from '../../../../base/common/uri.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { Delayer } from '../../../../base/common/async.js'; +import { IAction, Separator } from '../../../../base/common/actions.js'; +import { getContextMenuActions } from '../../../../workbench/contrib/mcp/browser/mcpServerActions.js'; + +const $ = DOM.$; + +const MCP_ITEM_HEIGHT = 60; + +/** + * Delegate for the MCP server list. + */ +class McpServerItemDelegate implements IListVirtualDelegate { + getHeight(): number { + return MCP_ITEM_HEIGHT; + } + + getTemplateId(): string { + return 'mcpServerItem'; + } +} + +interface IMcpServerItemTemplateData { + readonly container: HTMLElement; + readonly icon: HTMLElement; + readonly name: HTMLElement; + readonly description: HTMLElement; + readonly status: HTMLElement; + readonly disposables: DisposableStore; +} + +/** + * Renderer for MCP server list items. + */ +class McpServerItemRenderer implements IListRenderer { + readonly templateId = 'mcpServerItem'; + + constructor( + @IMcpService private readonly mcpService: IMcpService, + ) { } + + renderTemplate(container: HTMLElement): IMcpServerItemTemplateData { + container.classList.add('mcp-server-item'); + + const icon = DOM.append(container, $('.mcp-server-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.server)); + + const details = DOM.append(container, $('.mcp-server-details')); + const name = DOM.append(details, $('.mcp-server-name')); + const description = DOM.append(details, $('.mcp-server-description')); + + const status = DOM.append(container, $('.mcp-server-status')); + + return { container, icon, name, description, status, disposables: new DisposableStore() }; + } + + renderElement(element: IWorkbenchMcpServer, index: number, templateData: IMcpServerItemTemplateData): void { + templateData.disposables.clear(); + + templateData.name.textContent = element.label; + templateData.description.textContent = element.description || ''; + + // Find the server from IMcpService to get connection state + const server = this.mcpService.servers.get().find(s => s.definition.id === element.id); + templateData.disposables.add(autorun(reader => { + const connectionState = server?.connectionState.read(reader); + this.updateStatus(templateData.status, connectionState?.state); + })); + } + + private updateStatus(statusElement: HTMLElement, state: McpConnectionState.Kind | undefined): void { + statusElement.className = 'mcp-server-status'; + + switch (state) { + case McpConnectionState.Kind.Running: + statusElement.textContent = localize('running', "Running"); + statusElement.classList.add('running'); + break; + case McpConnectionState.Kind.Starting: + statusElement.textContent = localize('starting', "Starting"); + statusElement.classList.add('starting'); + break; + case McpConnectionState.Kind.Error: + statusElement.textContent = localize('error', "Error"); + statusElement.classList.add('error'); + break; + case McpConnectionState.Kind.Stopped: + default: + statusElement.textContent = localize('stopped', "Stopped"); + statusElement.classList.add('stopped'); + break; + } + } + + disposeTemplate(templateData: IMcpServerItemTemplateData): void { + templateData.disposables.dispose(); + } +} + +/** + * Widget that displays a list of MCP servers. + */ +export class McpListWidget extends Disposable { + + readonly element: HTMLElement; + + private sectionHeader!: HTMLElement; + private sectionDescription!: HTMLElement; + private sectionLink!: HTMLAnchorElement; + private searchAndButtonContainer!: HTMLElement; + private searchInput!: InputBox; + private listContainer!: HTMLElement; + private list!: WorkbenchList; + private emptyContainer!: HTMLElement; + private emptyText!: HTMLElement; + private emptySubtext!: HTMLElement; + + private filteredServers: IWorkbenchMcpServer[] = []; + private searchQuery: string = ''; + private readonly delayedFilter = new Delayer(200); + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService, + @IMcpService private readonly mcpService: IMcpService, + @ICommandService private readonly commandService: ICommandService, + @IOpenerService private readonly openerService: IOpenerService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + ) { + super(); + this.element = $('.mcp-list-widget'); + this.create(); + } + + private create(): void { + // Search and button container + this.searchAndButtonContainer = DOM.append(this.element, $('.list-search-and-button-container')); + + // Search container + const searchContainer = DOM.append(this.searchAndButtonContainer, $('.list-search-container')); + this.searchInput = this._register(new InputBox(searchContainer, this.contextViewService, { + placeholder: localize('searchMcpPlaceholder', "Type to search..."), + inputBoxStyles: defaultInputBoxStyles, + })); + + this._register(this.searchInput.onDidChange(() => { + this.searchQuery = this.searchInput.value; + this.delayedFilter.trigger(() => this.filterServers()); + })); + + // Add button next to search + const addButtonContainer = DOM.append(this.searchAndButtonContainer, $('.list-add-button-container')); + const addButton = this._register(new Button(addButtonContainer, { ...defaultButtonStyles, supportIcons: true })); + addButton.label = `$(${Codicon.add.id}) ${localize('addServer', "Add Server")}`; + addButton.element.classList.add('list-add-button'); + this._register(addButton.onDidClick(() => { + this.commandService.executeCommand(McpCommandIds.AddConfiguration); + })); + + // Empty state + this.emptyContainer = DOM.append(this.element, $('.mcp-empty-state')); + const emptyIcon = DOM.append(this.emptyContainer, $('.empty-icon')); + emptyIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.server)); + this.emptyText = DOM.append(this.emptyContainer, $('.empty-text')); + this.emptySubtext = DOM.append(this.emptyContainer, $('.empty-subtext')); + + // List container + this.listContainer = DOM.append(this.element, $('.mcp-list-container')); + + // Section footer at bottom with description and link + this.sectionHeader = DOM.append(this.element, $('.section-footer')); + this.sectionDescription = DOM.append(this.sectionHeader, $('p.section-footer-description')); + this.sectionDescription.textContent = localize('mcpServersDescription', "An open standard that lets AI use external tools and services. MCP servers provide tools for file operations, databases, APIs, and more."); + this.sectionLink = DOM.append(this.sectionHeader, $('a.section-footer-link')) as HTMLAnchorElement; + this.sectionLink.textContent = localize('learnMoreMcp', "Learn more about MCP servers"); + this.sectionLink.href = 'https://code.visualstudio.com/docs/copilot/chat/mcp-servers'; + this._register(DOM.addDisposableListener(this.sectionLink, 'click', (e) => { + e.preventDefault(); + const href = this.sectionLink.href; + if (href) { + this.openerService.open(URI.parse(href)); + } + })); + + // Create list + const delegate = new McpServerItemDelegate(); + const renderer = this.instantiationService.createInstance(McpServerItemRenderer); + + this.list = this._register(this.instantiationService.createInstance( + WorkbenchList, + 'McpManagementList', + this.listContainer, + delegate, + [renderer], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel(element: IWorkbenchMcpServer) { + return element.label; + }, + getWidgetAriaLabel() { + return localize('mcpServersListAriaLabel', "MCP Servers"); + } + }, + openOnSingleClick: true, + identityProvider: { + getId(element: IWorkbenchMcpServer) { + return element.id; + } + } + } + )); + + this._register(this.list.onDidOpen(e => { + if (e.element) { + this.mcpWorkbenchService.open(e.element); + } + })); + + // Handle context menu + this._register(this.list.onContextMenu(e => this.onContextMenu(e))); + + // Listen to MCP service changes + this._register(this.mcpWorkbenchService.onChange(() => this.refresh())); + this._register(autorun(reader => { + this.mcpService.servers.read(reader); + this.refresh(); + })); + + // Initial refresh + void this.refresh(); + } + + private async refresh(): Promise { + this.filterServers(); + } + + private filterServers(): void { + const query = this.searchQuery.toLowerCase().trim(); + + if (query) { + this.filteredServers = this.mcpWorkbenchService.local.filter(server => + server.label.toLowerCase().includes(query) || + (server.description?.toLowerCase().includes(query)) + ); + } else { + this.filteredServers = [...this.mcpWorkbenchService.local]; + } + + // Show empty state only when there are no servers at all (not when filtered to empty) + if (this.filteredServers.length === 0) { + this.emptyContainer.style.display = 'flex'; + this.listContainer.style.display = 'none'; + + if (this.searchQuery.trim()) { + // Search with no results + this.emptyText.textContent = localize('noMatchingServers', "No servers match '{0}'", this.searchQuery); + this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); + } else { + // No servers configured + this.emptyText.textContent = localize('noMcpServers', "No MCP servers configured"); + this.emptySubtext.textContent = localize('addMcpServer', "Add an MCP server configuration to get started"); + } + } else { + this.emptyContainer.style.display = 'none'; + this.listContainer.style.display = ''; + } + + this.list.splice(0, this.list.length, this.filteredServers); + } + + /** + * Layouts the widget. + */ + layout(height: number, width: number): void { + const sectionFooterHeight = this.sectionHeader.offsetHeight || 100; + const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 40; + const margins = 12; // search margin (6+6), not included in offsetHeight + const listHeight = height - sectionFooterHeight - searchBarHeight - margins; + + this.listContainer.style.height = `${Math.max(0, listHeight)}px`; + this.list.layout(Math.max(0, listHeight), width); + } + + /** + * Focuses the search input. + */ + focusSearch(): void { + this.searchInput.focus(); + } + + /** + * Focuses the list. + */ + focus(): void { + this.list.domFocus(); + const servers = this.list.length; + if (servers > 0) { + this.list.setFocus([0]); + } + } + + /** + * Handles context menu for MCP server items. + */ + private onContextMenu(e: IListContextMenuEvent): void { + if (!e.element) { + return; + } + + const disposables = new DisposableStore(); + const mcpServer = this.mcpWorkbenchService.local.find(local => local.id === e.element!.id) || e.element; + + // Get context menu actions from the MCP module + const groups: IAction[][] = getContextMenuActions(mcpServer, false, this.instantiationService); + const actions: IAction[] = []; + for (const menuActions of groups) { + for (const menuAction of menuActions) { + actions.push(menuAction); + if (isDisposable(menuAction)) { + disposables.add(menuAction); + } + } + actions.push(new Separator()); + } + // Remove trailing separator + if (actions.length > 0 && actions[actions.length - 1] instanceof Separator) { + actions.pop(); + } + + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => actions, + onHide: () => disposables.dispose() + }); + } +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css new file mode 100644 index 0000000000000..a0a97bea3bf1d --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css @@ -0,0 +1,763 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* AI Customization Management Editor */ +.ai-customization-management-editor { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + border-top: 1px solid var(--vscode-panel-border); +} + +/* Sidebar */ +.ai-customization-management-editor .management-sidebar { + background-color: var(--vscode-sideBar-background); + height: 100%; + overflow: hidden; + border-right: 1px solid var(--vscode-panel-border); +} + +.ai-customization-management-editor .sidebar-content { + height: 100%; + padding: 12px 0 12px 4px; + display: flex; + flex-direction: column; +} + +.ai-customization-management-editor .sidebar-sections-list { + flex: 1; + overflow: hidden; +} + + + +/* Section list items */ +.ai-customization-management-editor .section-list-item { + display: flex; + align-items: center; + padding: 8px 16px; + gap: 10px; + cursor: pointer; + margin: 2px 6px; + border-radius: 6px; + transition: background-color 0.1s ease, opacity 0.1s ease; +} + +.ai-customization-management-editor .section-list-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +/* Selected section - using list selection */ +.ai-customization-management-editor .monaco-list .monaco-list-row.selected .section-list-item { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.ai-customization-management-editor .monaco-list .monaco-list-row.selected .section-list-item .section-icon { + color: var(--vscode-list-activeSelectionIconForeground, var(--vscode-list-activeSelectionForeground)); +} + +.ai-customization-management-editor .monaco-list .monaco-list-row.selected .section-list-item .section-label { + color: var(--vscode-list-activeSelectionForeground); +} + +.ai-customization-management-editor .section-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.85; +} + +.ai-customization-management-editor .section-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 400; +} + +/* Content area */ +.ai-customization-management-editor .management-content { + background-color: var(--vscode-editor-background); + height: 100%; + overflow: hidden; +} + +.ai-customization-management-editor .content-inner { + height: 100%; + padding: 8px 12px; + box-sizing: border-box; +} + +/* List Widget */ +.ai-customization-list-widget { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Search and button container - consistent with Models view */ +.ai-customization-list-widget .list-search-and-button-container, +.mcp-list-widget .list-search-and-button-container { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin: 6px 0px; +} + +.ai-customization-list-widget .list-search-container, +.mcp-list-widget .list-search-container { + flex: 1; +} + +.ai-customization-list-widget .list-search-container .monaco-inputbox, +.mcp-list-widget .list-search-container .monaco-inputbox { + width: 100%; +} + +.ai-customization-list-widget .list-add-button-container, +.mcp-list-widget .list-add-button-container { + flex-shrink: 0; +} + +.ai-customization-list-widget .list-add-button, +.mcp-list-widget .list-add-button { + white-space: nowrap; +} + +.ai-customization-list-widget .list-container { + flex: 1; + overflow: hidden; +} + +.ai-customization-list-widget .list-empty-message { + padding: 12px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +/* Empty state - engaging design */ +.ai-customization-list-widget .list-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + gap: 12px; + text-align: center; + flex: 1; +} + +.ai-customization-list-widget .list-empty-state .empty-state-icon { + font-size: 48px; + opacity: 0.4; + margin-bottom: 8px; +} + +.ai-customization-list-widget .list-empty-state .empty-state-text { + font-size: 16px; + font-weight: 500; + color: var(--vscode-foreground); +} + +.ai-customization-list-widget .list-empty-state .empty-state-subtext { + font-size: 13px; + color: var(--vscode-descriptionForeground); + max-width: 300px; + line-height: 1.4; +} + +.ai-customization-list-widget .list-empty-state .empty-state-button { + margin-top: 16px; +} + +/* Group header styling */ +.ai-customization-group-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 8px 4px 8px; + cursor: pointer; + user-select: none; + border-radius: 4px; +} + +/* Separator line above non-first group headers */ +.ai-customization-group-header.has-previous-group { + border-top: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border)); + margin-top: 4px; + padding-top: 12px; +} + +.ai-customization-group-header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.ai-customization-group-header .group-chevron { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + opacity: 0.7; +} + +.ai-customization-group-header .group-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; +} + +.ai-customization-group-header .group-label { + flex: 1; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-customization-group-header .group-count { + flex-shrink: 0; + font-size: 10px; + font-weight: 500; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + padding: 0 5px; + border-radius: 8px; + min-width: 14px; + text-align: center; + line-height: 16px; +} + +.ai-customization-group-header.collapsed .group-label { + opacity: 0.7; +} + +/* List item styling */ +.ai-customization-list-item { + display: flex; + align-items: flex-start; + padding: 6px 8px 6px 24px; + cursor: pointer; + border-radius: 4px; + margin: 2px 0; + min-height: 32px; +} + +.ai-customization-list-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.ai-customization-list-item .item-left { + display: flex; + align-items: center; + flex: 1; + overflow: hidden; + gap: 10px; + min-width: 0; +} + +.ai-customization-list-item .storage-badge { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.7; +} + +.ai-customization-list-item:hover .storage-badge { + opacity: 1; +} + +.ai-customization-list-item .git-status-badge { + flex-shrink: 0; + font-size: 10px; + font-weight: 600; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + margin-right: 4px; + opacity: 1; +} + +.ai-customization-list-item .git-status-badge.committed { + color: var(--vscode-charts-green, #89d185); + opacity: 0.6; +} + +.ai-customization-list-item .item-text { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + min-width: 0; +} + +.ai-customization-list-item .item-name { + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 18px; +} + +.ai-customization-list-item .item-description { + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 14px; +} + +.ai-customization-list-item .item-description.is-filename { + font-family: monospace; + font-size: 10px; + opacity: 0.7; +} + +.ai-customization-list-item .item-right { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: 8px; + gap: 4px; + opacity: 0; + transition: opacity 0.1s ease; +} + +.ai-customization-list-item:hover .item-right, +.ai-customization-list-item:focus-within .item-right { + opacity: 1; +} + +/* Highlighted matches */ +.ai-customization-list-item .highlight { + font-weight: bold; + color: var(--vscode-list-highlightForeground); +} + +/* Section footer at bottom of list widget */ +.ai-customization-list-widget .section-footer { + flex-shrink: 0; + padding: 12px 4px 6px 4px; + border-top: 1px solid var(--vscode-widget-border); +} + +.ai-customization-list-widget .section-footer .section-footer-description { + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.5; + margin: 0 0 8px 0; +} + +.ai-customization-list-widget .section-footer .section-footer-link { + font-size: 13px; + color: var(--vscode-textLink-foreground); + text-decoration: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.ai-customization-list-widget .section-footer .section-footer-link:hover { + text-decoration: underline; + color: var(--vscode-textLink-activeForeground); +} + +/* Overview View (compact snapshot) */ +.ai-customization-overview { + display: flex; + flex-direction: column; + height: 100%; + padding: 8px; +} + +.ai-customization-overview .overview-sections { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + + +.ai-customization-overview .overview-section { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background-color: var(--vscode-sideBar-background); + border: 1px solid var(--vscode-widget-border); + border-radius: 6px; + cursor: pointer; + transition: background-color 0.1s ease; + flex: 1 1 120px; + min-width: 100px; +} + +.ai-customization-overview .overview-section:hover { + background-color: var(--vscode-list-hoverBackground); + border-color: var(--vscode-focusBorder); +} + +.ai-customization-overview .overview-section:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.ai-customization-overview .overview-section .section-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + opacity: 0.9; +} + +.ai-customization-overview .overview-section .section-text { + flex: 1; + min-width: 0; +} + +.ai-customization-overview .overview-section .section-label { + font-size: 12px; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + + +.ai-customization-overview .overview-section .section-count { + flex-shrink: 0; + font-size: 9px; + font-weight: 600; + padding: 1px 5px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border-radius: 8px; + border: 1px solid transparent; + min-width: 14px; + text-align: center; +} + +/* Content container visibility */ +.ai-customization-management-editor .prompts-content-container, +.ai-customization-management-editor .mcp-content-container, +.ai-customization-management-editor .models-content-container { + height: 100%; + display: flex; + flex-direction: column; +} + +/* Models section footer */ +.ai-customization-management-editor .models-content-container .section-footer { + flex-shrink: 0; + padding: 12px 4px 6px 4px; + border-top: 1px solid var(--vscode-widget-border); +} + +.ai-customization-management-editor .models-content-container .section-footer-description { + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.5; + margin: 0 0 8px 0; +} + +.ai-customization-management-editor .models-content-container .section-footer-link { + font-size: 13px; + color: var(--vscode-textLink-foreground); + text-decoration: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.ai-customization-management-editor .models-content-container .section-footer-link:hover { + text-decoration: underline; + color: var(--vscode-textLink-activeForeground); +} + +/* Override models widget padding when embedded in management editor */ +.ai-customization-management-editor .models-content-container .models-widget { + padding-right: 0; + flex: 1; + overflow: hidden; +} + +/* MCP List Widget */ +.mcp-list-widget { + display: flex; + flex-direction: column; + height: 100%; +} + +.mcp-list-widget .section-footer { + flex-shrink: 0; + padding: 12px 4px 6px 4px; + border-top: 1px solid var(--vscode-widget-border); +} + +.mcp-list-widget .section-footer .section-footer-description { + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.5; + margin: 0 0 8px 0; +} + +.mcp-list-widget .section-footer .section-footer-link { + font-size: 13px; + color: var(--vscode-textLink-foreground); + text-decoration: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.mcp-list-widget .section-footer .section-footer-link:hover { + text-decoration: underline; + color: var(--vscode-textLink-activeForeground); +} + +.mcp-list-widget .mcp-list-container { + flex: 1; + overflow: hidden; +} + +/* MCP Empty State */ +.mcp-list-widget .mcp-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + gap: 12px; + text-align: center; + flex: 1; +} + +.mcp-list-widget .mcp-empty-state .empty-icon { + font-size: 48px; + opacity: 0.4; + margin-bottom: 8px; +} + +.mcp-list-widget .mcp-empty-state .empty-text { + font-size: 16px; + font-weight: 500; + color: var(--vscode-foreground); +} + +.mcp-list-widget .mcp-empty-state .empty-subtext { + font-size: 13px; + color: var(--vscode-descriptionForeground); + max-width: 300px; + line-height: 1.4; +} + +.mcp-list-widget .mcp-empty-state .monaco-button { + margin-top: 16px; +} + +/* MCP Server Item */ +.mcp-server-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + border-radius: 4px; + margin: 2px 0; + gap: 12px; +} + +.mcp-server-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.mcp-server-item .mcp-server-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; +} + +.mcp-server-item .mcp-server-details { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.mcp-server-item .mcp-server-name { + font-size: 13px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mcp-server-item .mcp-server-description { + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mcp-server-item .mcp-server-status { + flex-shrink: 0; + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 500; +} + +.mcp-server-item .mcp-server-status.running { + background-color: var(--vscode-terminal-ansiGreen); + color: var(--vscode-editor-background); +} + +.mcp-server-item .mcp-server-status.starting { + background-color: var(--vscode-terminal-ansiYellow); + color: var(--vscode-editor-background); +} + +.mcp-server-item .mcp-server-status.error { + background-color: var(--vscode-terminal-ansiRed); + color: var(--vscode-editor-background); +} + +.mcp-server-item .mcp-server-status.stopped { + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +/* Embedded Editor View */ +.ai-customization-management-editor .editor-content-container { + height: 100%; + display: flex; + flex-direction: column; +} + +.ai-customization-management-editor .editor-header { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 4px 12px 4px; + border-bottom: 1px solid var(--vscode-widget-border); + flex-shrink: 0; +} + +.ai-customization-management-editor .editor-back-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; + cursor: pointer; + background: transparent; + border: none; + color: var(--vscode-foreground); + opacity: 0.8; + transition: background-color 0.1s ease, opacity 0.1s ease; +} + +.ai-customization-management-editor .editor-back-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); + opacity: 1; +} + +.ai-customization-management-editor .editor-back-button:active { + background-color: var(--vscode-toolbar-activeBackground); +} + +.ai-customization-management-editor .editor-item-info { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + min-width: 0; +} + +.ai-customization-management-editor .editor-item-name { + font-size: 14px; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-customization-management-editor .editor-item-path { + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--monaco-monospace-font); +} + +.ai-customization-management-editor .editor-save-indicator { + flex-shrink: 0; + width: 16px; + height: 16px; + font-size: 16px; + line-height: 16px; + text-align: center; + color: var(--vscode-descriptionForeground); + opacity: 0; + transition: opacity 0.2s ease; + margin-right: 4px; +} + +.ai-customization-management-editor .editor-save-indicator.visible { + opacity: 0.7; +} + +.ai-customization-management-editor .editor-save-indicator.saved { + color: var(--vscode-testing-iconPassed, #73c991); + opacity: 1; +} + +.ai-customization-management-editor .embedded-editor-container { + flex: 1; + overflow: hidden; + margin-top: 8px; + border: 1px solid var(--vscode-widget-border); + border-radius: 4px; +} + +.ai-customization-management-editor .embedded-editor-container .monaco-editor { + border-radius: 4px; +} diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md new file mode 100644 index 0000000000000..77434957f9a6a --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md @@ -0,0 +1,116 @@ +# AI Customization Tree View Specification + +## Overview + +The AI Customization Tree View is a sidebar tree that groups AI customization files by type and storage. It is optimized for agent sessions and filters worktree items to the active session repository. + +**Location:** `src/vs/sessions/contrib/aiCustomizationTreeView/browser/` + +## Architecture + +### Component Hierarchy + +``` +View Container (Sidebar) +└── AICustomizationViewPane + └── WorkbenchAsyncDataTree + ├── UnifiedAICustomizationDataSource + ├── AICustomizationTreeDelegate + └── Renderers (category, group, file) +``` + +### Tree Structure + +``` +ROOT +├── Custom Agents +│ ├── Workspace (N) +│ ├── User (N) +│ └── Extensions (N) +├── Skills +│ ├── Workspace (N) +│ ├── User (N) +│ └── Extensions (N) +├── Instructions +└── Prompts +``` + +### File Structure + +``` +aiCustomizationTreeView/browser/ +├── aiCustomizationTreeView.ts +├── aiCustomizationTreeView.contribution.ts +├── aiCustomizationTreeViewViews.ts +├── aiCustomizationTreeViewIcons.ts +└── media/ + └── aiCustomizationTreeView.css +``` + +## Key Components + +### AICustomizationViewPane + +**Responsibilities:** +- Creates the tree and renderers. +- Auto-expands categories on load/refresh. +- Refreshes on prompt service changes, workspace changes, and active session changes. +- Updates `aiCustomization.isEmpty` based on total item count. +- Worktree scoping comes from the agentic prompt service override. + +### UnifiedAICustomizationDataSource + +**Responsibilities:** +- Caches per-type data for efficient expansion. +- Builds storage groups only when items exist. +- Labels groups with counts (e.g., "Workspace (3)"). +- Uses `findAgentSkills()` to derive skill names. + - Logs errors via `ILogService` when fetching children fails. + +## Actions + +### View Title + +- **Refresh** reloads data and re-expands categories. +- **Collapse All** collapses the tree. + +### Context Menu (file items) + +- Open +- Run Prompt (prompts only) + +## Context Keys + +- `aiCustomization.isEmpty` is set based on total items for welcome content. +- `aiCustomizationItemType` controls prompt-specific context menu actions. + +## Accessibility + +- Category/group/file items provide aria labels. +- File item aria labels include description when present. + +## Integration Points + +- `IPromptsService` for agents/skills/instructions/prompts. +- `IActiveSessionService` for worktree filtering. +- `IWorkspaceContextService` to refresh on workspace changes. +- `ILogService` for error reporting during data fetch. + +## Service Alignment (Required) + +AI customizations must lean on existing VS Code services with well-defined interfaces. The tree view should rely on the prompt discovery service rather than scanning the file system directly. + +Required services to prefer: +- Prompt discovery and metadata: [src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts](../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) +- Active session scoping for worktrees: [src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts](../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts) + +## Notes + +- Storage groups are labeled with counts; icons are not shown for group rows. +- Skills display the frontmatter name when available, falling back to the folder name. +- Creation actions are intentionally centralized in the Management Editor. +- Refresh clears cached data before rebuilding the tree. + +--- + +*This specification documents the AI Customization Tree View in `src/vs/sessions/contrib/aiCustomizationTreeView/browser/`.* diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts new file mode 100644 index 0000000000000..bf2487a9e37ea --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; +import { AICustomizationItemTypeContextKey } from './aiCustomizationTreeViewViews.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; + +//#region Utilities + +/** + * Type for context passed to actions from tree context menus. + * Handles both direct URI arguments and serialized context objects. + */ +type URIContext = { uri: URI | string;[key: string]: unknown } | URI | string; + +/** + * Extracts a URI from various context formats. + * Context can be a URI, string, or an object with uri property. + */ +function extractURI(context: URIContext): URI { + if (URI.isUri(context)) { + return context; + } + if (typeof context === 'string') { + return URI.parse(context); + } + if (URI.isUri(context.uri)) { + return context.uri; + } + return URI.parse(context.uri as string); +} + +//#endregion + +//#region Context Menu Actions + +// Open file action +const OPEN_AI_CUSTOMIZATION_FILE_ID = 'aiCustomization.openFile'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: OPEN_AI_CUSTOMIZATION_FILE_ID, + title: localize2('open', "Open"), + icon: Codicon.goToFile, + }); + } + async run(accessor: ServicesAccessor, context: URIContext): Promise { + const editorService = accessor.get(IEditorService); + await editorService.openEditor({ + resource: extractURI(context) + }); + } +}); + + +// Run prompt action +const RUN_PROMPT_FROM_VIEW_ID = 'aiCustomization.runPrompt'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: RUN_PROMPT_FROM_VIEW_ID, + title: localize2('runPrompt', "Run Prompt"), + icon: Codicon.play, + }); + } + async run(accessor: ServicesAccessor, context: URIContext): Promise { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand('workbench.action.chat.run.prompt.current', extractURI(context)); + } +}); + +// Register context menu items +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: OPEN_AI_CUSTOMIZATION_FILE_ID, title: localize('open', "Open") }, + group: '1_open', + order: 1, +}); + +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: RUN_PROMPT_FROM_VIEW_ID, title: localize('runPrompt', "Run Prompt"), icon: Codicon.play }, + group: '2_run', + order: 1, + when: ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.prompt), +}); + +//#endregion + +//#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.ts new file mode 100644 index 0000000000000..7a88e1d94bcfd --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize2 } from '../../../../nls.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; + +/** + * View container ID for the AI Customization sidebar. + */ +export const AI_CUSTOMIZATION_VIEWLET_ID = 'workbench.view.aiCustomization'; + +/** + * View ID for the unified AI Customization tree view. + */ +export const AI_CUSTOMIZATION_VIEW_ID = 'aiCustomization.view'; + +/** + * Storage IDs for view state persistence. + */ +export const AI_CUSTOMIZATION_STORAGE_ID = 'workbench.aiCustomization.views.state'; + +/** + * Category for AI Customization commands. + */ +export const AI_CUSTOMIZATION_CATEGORY = localize2('aiCustomization', "AI Customization"); + +//#region Menu IDs + +// Context menu for file items (right-click on items) +export const AICustomizationItemMenuId = new MenuId('aiCustomization.item'); +// Submenu for creating new items +export const AICustomizationNewMenuId = new MenuId('aiCustomization.new'); +//#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.ts new file mode 100644 index 0000000000000..fbe3fa1d95008 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; + +/** + * Icon for the AI Customization view container (sidebar). + */ +export const aiCustomizationViewIcon = registerIcon('ai-customization-view-icon', Codicon.sparkle, localize('aiCustomizationViewIcon', "Icon for the AI Customization view.")); + +/** + * Icon for custom agents. + */ +export const agentIcon = registerIcon('ai-customization-agent', Codicon.agent, localize('aiCustomizationAgentIcon', "Icon for custom agents.")); + +/** + * Icon for skills. + */ +export const skillIcon = registerIcon('ai-customization-skill', Codicon.lightbulb, localize('aiCustomizationSkillIcon', "Icon for skills.")); + +/** + * Icon for instructions. + */ +export const instructionsIcon = registerIcon('ai-customization-instructions', Codicon.book, localize('aiCustomizationInstructionsIcon', "Icon for instruction files.")); + +/** + * Icon for prompts. + */ +export const promptIcon = registerIcon('ai-customization-prompt', Codicon.bookmark, localize('aiCustomizationPromptIcon', "Icon for prompt files.")); + +/** + * Icon for hooks. + */ +export const hookIcon = registerIcon('ai-customization-hook', Codicon.zap, localize('aiCustomizationHookIcon', "Icon for hooks.")); + +/** + * Icon for adding a new item. + */ +export const addIcon = registerIcon('ai-customization-add', Codicon.add, localize('aiCustomizationAddIcon', "Icon for adding new items.")); + +/** + * Icon for the run action. + */ +export const runIcon = registerIcon('ai-customization-run', Codicon.play, localize('aiCustomizationRunIcon', "Icon for running a prompt or agent.")); + +/** + * Icon for workspace storage. + */ +export const workspaceIcon = registerIcon('ai-customization-workspace', Codicon.folder, localize('aiCustomizationWorkspaceIcon', "Icon for workspace items.")); + +/** + * Icon for user storage. + */ +export const userIcon = registerIcon('ai-customization-user', Codicon.account, localize('aiCustomizationUserIcon', "Icon for user items.")); + +/** + * Icon for extension storage. + */ +export const extensionIcon = registerIcon('ai-customization-extension', Codicon.extensions, localize('aiCustomizationExtensionIcon', "Icon for extension-contributed items.")); diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts new file mode 100644 index 0000000000000..b24b57c831fb0 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -0,0 +1,657 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationTreeView.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { basename, dirname } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { getContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IMenuService } from '../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { WorkbenchAsyncDataTree } from '../../../../platform/list/browser/listService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; +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 './aiCustomizationTreeViewIcons.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'; +import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; + +//#region Context Keys + +/** + * Context key indicating whether the AI Customization view has no items. + */ +export const AICustomizationIsEmptyContextKey = new RawContextKey('aiCustomization.isEmpty', true); + +/** + * Context key for the current item's prompt type in context menus. + */ +export const AICustomizationItemTypeContextKey = new RawContextKey('aiCustomizationItemType', ''); + +//#endregion + +//#region Tree Item Types + +/** + * Root element marker for the tree. + */ +const ROOT_ELEMENT = Symbol('root'); +type RootElement = typeof ROOT_ELEMENT; + +/** + * Represents a type category in the tree (e.g., "Custom Agents", "Skills"). + */ +interface IAICustomizationTypeItem { + readonly type: 'category'; + readonly id: string; + readonly label: string; + readonly promptType: PromptsType; + readonly icon: ThemeIcon; +} + +/** + * Represents a storage group header in the tree (e.g., "Workspace", "User", "Extensions"). + */ +interface IAICustomizationGroupItem { + readonly type: 'group'; + readonly id: string; + readonly label: string; + readonly storage: PromptsStorage; + readonly promptType: PromptsType; + readonly icon: ThemeIcon; +} + +/** + * Represents an individual AI customization item (agent, skill, instruction, or prompt). + */ +interface IAICustomizationFileItem { + readonly type: 'file'; + readonly id: string; + readonly uri: URI; + readonly name: string; + readonly description?: string; + readonly storage: PromptsStorage; + readonly promptType: PromptsType; +} + +type AICustomizationTreeItem = IAICustomizationTypeItem | IAICustomizationGroupItem | IAICustomizationFileItem; + +//#endregion + +//#region Tree Infrastructure + +class AICustomizationTreeDelegate implements IListVirtualDelegate { + getHeight(_element: AICustomizationTreeItem): number { + return 22; + } + + getTemplateId(element: AICustomizationTreeItem): string { + switch (element.type) { + case 'category': + return 'category'; + case 'group': + return 'group'; + case 'file': + return 'file'; + } + } +} + +interface ICategoryTemplateData { + readonly container: HTMLElement; + readonly icon: HTMLElement; + readonly label: HTMLElement; +} + +interface IGroupTemplateData { + readonly container: HTMLElement; + readonly label: HTMLElement; +} + +interface IFileTemplateData { + readonly container: HTMLElement; + readonly icon: HTMLElement; + readonly name: HTMLElement; +} + +class AICustomizationCategoryRenderer implements ITreeRenderer { + readonly templateId = 'category'; + + renderTemplate(container: HTMLElement): ICategoryTemplateData { + const element = dom.append(container, dom.$('.ai-customization-category')); + const icon = dom.append(element, dom.$('.icon')); + const label = dom.append(element, dom.$('.label')); + return { container: element, icon, label }; + } + + renderElement(node: ITreeNode, _index: number, templateData: ICategoryTemplateData): void { + templateData.icon.className = 'icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(node.element.icon)); + templateData.label.textContent = node.element.label; + } + + disposeTemplate(_templateData: ICategoryTemplateData): void { } +} + +class AICustomizationGroupRenderer implements ITreeRenderer { + readonly templateId = 'group'; + + renderTemplate(container: HTMLElement): IGroupTemplateData { + const element = dom.append(container, dom.$('.ai-customization-group-header')); + const label = dom.append(element, dom.$('.label')); + return { container: element, label }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IGroupTemplateData): void { + templateData.label.textContent = node.element.label; + } + + disposeTemplate(_templateData: IGroupTemplateData): void { } +} + +class AICustomizationFileRenderer implements ITreeRenderer { + readonly templateId = 'file'; + + renderTemplate(container: HTMLElement): IFileTemplateData { + const element = dom.append(container, dom.$('.ai-customization-tree-item')); + const icon = dom.append(element, dom.$('.icon')); + const name = dom.append(element, dom.$('.name')); + return { container: element, icon, name }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IFileTemplateData): void { + const item = node.element; + + // Set icon based on prompt type + let icon: ThemeIcon; + switch (item.promptType) { + case PromptsType.agent: + icon = agentIcon; + break; + case PromptsType.skill: + icon = skillIcon; + break; + case PromptsType.instructions: + icon = instructionsIcon; + break; + case PromptsType.prompt: + default: + icon = promptIcon; + break; + } + + templateData.icon.className = 'icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(icon)); + + templateData.name.textContent = item.name; + + // Set tooltip with name and description + const tooltip = item.description ? `${item.name} - ${item.description}` : item.name; + templateData.container.title = tooltip; + } + + disposeTemplate(_templateData: IFileTemplateData): void { } +} + +/** + * Cached data for a specific prompt type. + */ +interface ICachedTypeData { + skills?: IAgentSkill[]; + files?: Map; +} + +/** + * Data source for the AI Customization tree with efficient caching. + * Caches data per-type to avoid redundant fetches when expanding groups. + */ +class UnifiedAICustomizationDataSource implements IAsyncDataSource { + private cache = new Map(); + private totalItemCount = 0; + + constructor( + private readonly promptsService: IPromptsService, + private readonly logService: ILogService, + private readonly onItemCountChanged: (count: number) => void, + ) { } + + /** + * Clears the cache. Should be called when the view refreshes. + */ + clearCache(): void { + this.cache.clear(); + this.totalItemCount = 0; + } + + hasChildren(element: RootElement | AICustomizationTreeItem): boolean { + if (element === ROOT_ELEMENT) { + return true; + } + return element.type === 'category' || element.type === 'group'; + } + + async getChildren(element: RootElement | AICustomizationTreeItem): Promise { + try { + if (element === ROOT_ELEMENT) { + return this.getTypeCategories(); + } + + if (element.type === 'category') { + return this.getStorageGroups(element.promptType); + } + + if (element.type === 'group') { + return this.getFilesForStorageAndType(element.storage, element.promptType); + } + + return []; + } catch (error) { + this.logService.error('[AICustomization] Error fetching tree children:', error); + return []; + } + } + + private getTypeCategories(): IAICustomizationTypeItem[] { + return [ + { + type: 'category', + id: 'category-agents', + label: localize('customAgents', "Custom Agents"), + promptType: PromptsType.agent, + icon: agentIcon, + }, + { + type: 'category', + id: 'category-skills', + label: localize('skills', "Skills"), + promptType: PromptsType.skill, + icon: skillIcon, + }, + { + type: 'category', + id: 'category-instructions', + label: localize('instructions', "Instructions"), + promptType: PromptsType.instructions, + icon: instructionsIcon, + }, + { + type: 'category', + id: 'category-prompts', + label: localize('prompts', "Prompts"), + promptType: PromptsType.prompt, + icon: promptIcon, + }, + ]; + } + + /** + * Fetches and caches data for a prompt type, returning storage groups with items. + */ + private async getStorageGroups(promptType: PromptsType): Promise { + const groups: IAICustomizationGroupItem[] = []; + + // Check cache first + let cached = this.cache.get(promptType); + if (!cached) { + cached = {}; + this.cache.set(promptType, cached); + } + + // For skills, use findAgentSkills which has the proper names from frontmatter + if (promptType === PromptsType.skill) { + if (!cached.skills) { + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + cached.skills = skills || []; + this.totalItemCount += cached.skills.length; + this.onItemCountChanged(this.totalItemCount); + } + + const workspaceSkills = cached.skills.filter(s => s.storage === PromptsStorage.local); + const userSkills = cached.skills.filter(s => s.storage === PromptsStorage.user); + const extensionSkills = cached.skills.filter(s => s.storage === PromptsStorage.extension); + + if (workspaceSkills.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceSkills.length)); + } + if (userSkills.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.user, userSkills.length)); + } + if (extensionSkills.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionSkills.length)); + } + + return groups; + } + + // For other types, fetch once and cache grouped by storage + if (!cached.files) { + const allItems = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); + const workspaceItems = allItems.filter(item => item.storage === PromptsStorage.local); + const userItems = allItems.filter(item => item.storage === PromptsStorage.user); + const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); + + cached.files = new Map([ + [PromptsStorage.local, workspaceItems], + [PromptsStorage.user, userItems], + [PromptsStorage.extension, extensionItems], + ]); + + const itemCount = allItems.length; + this.totalItemCount += itemCount; + this.onItemCountChanged(this.totalItemCount); + } + + const workspaceItems = cached.files!.get(PromptsStorage.local) || []; + const userItems = cached.files!.get(PromptsStorage.user) || []; + const extensionItems = cached.files!.get(PromptsStorage.extension) || []; + + if (workspaceItems.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceItems.length)); + } + if (userItems.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.user, userItems.length)); + } + if (extensionItems.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionItems.length)); + } + + return groups; + } + + /** + * Creates a group item with consistent structure. + */ + private createGroupItem(promptType: PromptsType, storage: PromptsStorage, count: number): IAICustomizationGroupItem { + const storageLabels: Record = { + [PromptsStorage.local]: localize('workspaceWithCount', "Workspace ({0})", count), + [PromptsStorage.user]: localize('userWithCount', "User ({0})", count), + [PromptsStorage.extension]: localize('extensionsWithCount', "Extensions ({0})", count), + }; + + const storageIcons: Record = { + [PromptsStorage.local]: workspaceIcon, + [PromptsStorage.user]: userIcon, + [PromptsStorage.extension]: extensionIcon, + }; + + const storageSuffixes: Record = { + [PromptsStorage.local]: 'workspace', + [PromptsStorage.user]: 'user', + [PromptsStorage.extension]: 'extensions', + }; + + return { + type: 'group', + id: `group-${promptType}-${storageSuffixes[storage]}`, + label: storageLabels[storage], + storage, + promptType, + icon: storageIcons[storage], + }; + } + + /** + * Returns files for a specific storage/type combination from cache. + * getStorageGroups must be called first to populate the cache. + */ + private async getFilesForStorageAndType(storage: PromptsStorage, promptType: PromptsType): Promise { + const cached = this.cache.get(promptType); + + // For skills, use the cached skills data + if (promptType === PromptsType.skill) { + const skills = cached?.skills || []; + const filtered = skills.filter(skill => skill.storage === storage); + return filtered + .map(skill => { + // Use skill name from frontmatter, or fallback to parent folder name + const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri); + return { + type: 'file' as const, + id: skill.uri.toString(), + uri: skill.uri, + name: skillName, + description: skill.description, + storage: skill.storage, + promptType, + }; + }); + } + + // Use cached files data (already fetched in getStorageGroups) + const items = [...(cached?.files?.get(storage) || [])]; + return items.map(item => ({ + type: 'file' as const, + id: item.uri.toString(), + uri: item.uri, + name: item.name || basename(item.uri), + description: item.description, + storage: item.storage, + promptType, + })); + } +} + +//#endregion + +//#region Unified View Pane + +/** + * Unified view pane for all AI Customization items (agents, skills, instructions, prompts). + */ +export class AICustomizationViewPane extends ViewPane { + static readonly ID = 'aiCustomization.view'; + + private tree: WorkbenchAsyncDataTree | undefined; + private dataSource: UnifiedAICustomizationDataSource | undefined; + private treeContainer: HTMLElement | undefined; + private readonly treeDisposables = this._register(new DisposableStore()); + + // Context keys for controlling menu visibility and welcome content + private readonly isEmptyContextKey: IContextKey; + private readonly itemTypeContextKey: IContextKey; + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @IPromptsService private readonly promptsService: IPromptsService, + @IEditorService private readonly editorService: IEditorService, + @IMenuService private readonly menuService: IMenuService, + @ILogService private readonly logService: ILogService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // Initialize context keys + this.isEmptyContextKey = AICustomizationIsEmptyContextKey.bindTo(contextKeyService); + this.itemTypeContextKey = AICustomizationItemTypeContextKey.bindTo(contextKeyService); + + // Subscribe to prompt service events to refresh tree + this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh())); + this._register(this.promptsService.onDidChangeSlashCommands(() => this.refresh())); + + // Listen to workspace folder changes to refresh tree + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.refresh())); + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + this.refresh(); + })); + + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + container.classList.add('ai-customization-view'); + this.treeContainer = dom.append(container, dom.$('.tree-container')); + + this.createTree(); + } + + private createTree(): void { + if (!this.treeContainer) { + return; + } + + // Create data source with callback for tracking item count + this.dataSource = new UnifiedAICustomizationDataSource( + this.promptsService, + this.logService, + (count) => this.isEmptyContextKey.set(count === 0), + ); + + this.tree = this.treeDisposables.add(this.instantiationService.createInstance( + WorkbenchAsyncDataTree, + 'AICustomization', + this.treeContainer, + new AICustomizationTreeDelegate(), + [ + new AICustomizationCategoryRenderer(), + new AICustomizationGroupRenderer(), + new AICustomizationFileRenderer(), + ], + this.dataSource, + { + identityProvider: { + getId: (element: AICustomizationTreeItem) => element.id, + }, + accessibilityProvider: { + getAriaLabel: (element: AICustomizationTreeItem) => { + if (element.type === 'category') { + return element.label; + } + if (element.type === 'group') { + return element.label; + } + // For files, include description if available + return element.description + ? localize('fileAriaLabel', "{0}, {1}", element.name, element.description) + : element.name; + }, + getWidgetAriaLabel: () => localize('aiCustomizationTree', "AI Customization Items"), + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: (element: AICustomizationTreeItem) => { + if (element.type === 'file') { + return element.name; + } + return element.label; + }, + }, + } + )); + + // Handle double-click to open file + this.treeDisposables.add(this.tree.onDidOpen(e => { + if (e.element && e.element.type === 'file') { + this.editorService.openEditor({ + resource: e.element.uri + }); + } + })); + + // Handle context menu + this.treeDisposables.add(this.tree.onContextMenu(e => this.onContextMenu(e))); + + // Initial load and auto-expand category nodes + void this.tree.setInput(ROOT_ELEMENT).then(() => this.autoExpandCategories()); + } + + private async autoExpandCategories(): Promise { + if (!this.tree) { + return; + } + // Auto-expand all category nodes to show storage groups + const rootNode = this.tree.getNode(ROOT_ELEMENT); + for (const child of rootNode.children) { + if (child.element !== ROOT_ELEMENT) { + await this.tree.expand(child.element); + } + } + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.tree?.layout(height, width); + } + + public refresh(): void { + // Clear the cache before refreshing + this.dataSource?.clearCache(); + this.isEmptyContextKey.set(true); // Reset until we know the count + void this.tree?.setInput(ROOT_ELEMENT).then(() => this.autoExpandCategories()); + } + + public collapseAll(): void { + this.tree?.collapseAll(); + } + + public expandAll(): void { + this.tree?.expandAll(); + } + + private onContextMenu(e: ITreeContextMenuEvent): void { + // Only show context menu for file items + if (!e.element || e.element.type !== 'file') { + return; + } + + const element = e.element; + + // Set context key for the item type so menu items can use `when` clauses + this.itemTypeContextKey.set(element.promptType); + + // Get menu actions from the menu service + const context = { + uri: element.uri.toString(), + name: element.name, + promptType: element.promptType, + }; + const menu = this.menuService.getMenuActions(AICustomizationItemMenuId, this.contextKeyService, { arg: context, shouldForwardArgs: true }); + const { secondary } = getContextMenuActions(menu, 'inline'); + + // Show the context menu + if (secondary.length > 0) { + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => secondary, + getActionsContext: () => context, + onHide: () => { + // Clear the context key when menu closes + this.itemTypeContextKey.reset(); + }, + }); + } + } +} + +//#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css new file mode 100644 index 0000000000000..0756725fc2b39 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.ai-customization-view { + height: 100%; +} + +.ai-customization-view .tree-container { + height: 100%; +} + +/* Tree item styling */ +.ai-customization-view .ai-customization-tree-item { + display: flex; + align-items: center; + height: 22px; + line-height: 22px; + padding-right: 8px; +} + +.ai-customization-view .ai-customization-tree-item .icon { + flex-shrink: 0; + width: 16px; + height: 16px; + margin-right: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.ai-customization-view .ai-customization-tree-item .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-customization-view .ai-customization-tree-item .description { + flex-shrink: 1; + color: var(--vscode-descriptionForeground); + font-size: 0.9em; + margin-left: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.8; +} + +/* Group headers */ +.ai-customization-view .ai-customization-group-header { + display: flex; + align-items: center; + height: 22px; + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +} + +.ai-customization-view .ai-customization-group-header .label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Category headers (top-level types like "Custom Agents", "Skills") */ +.ai-customization-view .ai-customization-category { + display: flex; + align-items: center; + height: 22px; + line-height: 22px; + font-weight: 600; +} + +.ai-customization-view .ai-customization-category .icon { + flex-shrink: 0; + width: 16px; + height: 16px; + margin-right: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.ai-customization-view .ai-customization-category .label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Empty state */ +.ai-customization-view .empty-message { + padding: 10px; + color: var(--vscode-descriptionForeground); + text-align: center; +} diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts new file mode 100644 index 0000000000000..3e69bba8dfa4f --- /dev/null +++ b/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize2 } from '../../../../nls.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; + +const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); + +const viewContainersRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); + +const changesViewContainer = viewContainersRegistry.registerViewContainer({ + id: CHANGES_VIEW_CONTAINER_ID, + title: localize2('changes', 'Changes'), + ctorDescriptor: new SyncDescriptor(ChangesViewPaneContainer), + icon: changesViewIcon, + order: 10, + hideIfEmpty: true, + windowVisibility: WindowVisibility.Sessions +}, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true, isDefault: true }); + +const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); + +viewsRegistry.registerViews([{ + id: CHANGES_VIEW_ID, + name: localize2('changes', 'Changes'), + containerIcon: changesViewIcon, + ctorDescriptor: new SyncDescriptor(ChangesViewPane), + canToggleVisibility: true, + canMoveView: true, + weight: 100, + order: 1, + windowVisibility: WindowVisibility.Sessions +}], changesViewContainer); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts new file mode 100644 index 0000000000000..d1d035676a929 --- /dev/null +++ b/src/vs/sessions/contrib/changesView/browser/changesView.ts @@ -0,0 +1,1031 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/changesView.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; +import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; +import { IObjectTreeElement, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Iterable } from '../../../../base/common/iterator.js'; +import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { basename, dirname } from '../../../../base/common/path.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { FileKind } from '../../../../platform/files/common/files.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { WorkbenchCompressibleObjectTree } from '../../../../platform/list/browser/listService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { fillEditorsDragData } from '../../../../workbench/browser/dnd.js'; +import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; +import { ViewPane, IViewPaneOptions, ViewAction } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { chatEditingWidgetFileStateContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; +import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; +import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; + +const $ = dom.$; + +// --- Constants + +export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; +export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; + +// --- View Mode + +export const enum ChangesViewMode { + List = 'list', + Tree = 'tree' +} + +const changesViewModeContextKey = new RawContextKey('changesViewMode', ChangesViewMode.List); + +// --- List Item + +type ChangeType = 'added' | 'modified' | 'deleted'; + +interface IChangesFileItem { + readonly type: 'file'; + readonly uri: URI; + readonly originalUri?: URI; + readonly state: ModifiedFileEntryState; + readonly isDeletion: boolean; + readonly changeType: ChangeType; + readonly linesAdded: number; + readonly linesRemoved: number; +} + +interface IChangesFolderItem { + readonly type: 'folder'; + readonly uri: URI; + readonly name: string; +} + +type ChangesTreeElement = IChangesFileItem | IChangesFolderItem; + +function isChangesFileItem(element: ChangesTreeElement): element is IChangesFileItem { + return element.type === 'file'; +} + +/** + * Builds a tree of `IObjectTreeElement` from a flat list of file items. + * Groups files by their directory path segments to create a hierarchical tree structure. + */ +function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement[] { + if (items.length === 0) { + return []; + } + + interface FolderNode { + name: string; + uri: URI; + children: Map; + files: IChangesFileItem[]; + } + + const root: FolderNode = { name: '', uri: URI.file('/'), children: new Map(), files: [] }; + + for (const item of items) { + const dirPath = dirname(item.uri.path); + const segments = dirPath.split('/').filter(Boolean); + + let current = root; + let currentPath = ''; + for (const segment of segments) { + currentPath += '/' + segment; + if (!current.children.has(segment)) { + current.children.set(segment, { + name: segment, + uri: item.uri.with({ path: currentPath }), + children: new Map(), + files: [] + }); + } + current = current.children.get(segment)!; + } + current.files.push(item); + } + + function convert(node: FolderNode): IObjectTreeElement[] { + const result: IObjectTreeElement[] = []; + + for (const [, child] of node.children) { + const folderElement: IChangesFolderItem = { type: 'folder', uri: child.uri, name: child.name }; + const folderChildren = convert(child); + result.push({ + element: folderElement, + children: folderChildren, + collapsible: true, + collapsed: false, + }); + } + + for (const file of node.files) { + result.push({ + element: file, + collapsible: false, + }); + } + + return result; + } + + return convert(root); +} + +// --- View Pane + +export class ChangesViewPane extends ViewPane { + + private bodyContainer: HTMLElement | undefined; + private welcomeContainer: HTMLElement | undefined; + private contentContainer: HTMLElement | undefined; + private overviewContainer: HTMLElement | undefined; + private summaryContainer: HTMLElement | undefined; + private listContainer: HTMLElement | undefined; + // Actions container is positioned outside the card for this layout experiment + private actionsContainer: HTMLElement | undefined; + + private tree: WorkbenchCompressibleObjectTree | undefined; + + private readonly renderDisposables = this._register(new DisposableStore()); + + // Track current body dimensions for list layout + private currentBodyHeight = 0; + private currentBodyWidth = 0; + + // View mode (list vs tree) + private readonly viewModeObs: ReturnType>; + private readonly viewModeContextKey: IContextKey; + + get viewMode(): ChangesViewMode { return this.viewModeObs.get(); } + set viewMode(mode: ChangesViewMode) { + if (this.viewModeObs.get() === mode) { + return; + } + this.viewModeObs.set(mode, undefined); + this.viewModeContextKey.set(mode); + this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); + } + + // Track the active session's editing session resource + private readonly activeSessionResource = observableValue(this, undefined); + + // Badge for file count + private readonly badgeDisposable = this._register(new MutableDisposable()); + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @IChatEditingService private readonly chatEditingService: IChatEditingService, + @IEditorService private readonly editorService: IEditorService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IActivityService private readonly activityService: IActivityService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ILabelService private readonly labelService: ILabelService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // View mode + const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); + const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; + this.viewModeObs = observableValue(this, initialMode); + this.viewModeContextKey = changesViewModeContextKey.bindTo(contextKeyService); + this.viewModeContextKey.set(initialMode); + + // Setup badge tracking + this.registerBadgeTracking(); + + // Track active session from focused chat widgets + this.registerActiveSessionTracking(); + + // Set chatSessionType on the view's context key service so ViewTitle + // menu items can use it in their `when` clauses. Update reactively + // when the active session changes. + const viewSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); + this._register(autorun(reader => { + const sessionResource = this.activeSessionResource.read(reader); + viewSessionTypeKey.set(sessionResource ? getChatSessionType(sessionResource) : ''); + })); + } + + private registerActiveSessionTracking(): void { + // Initialize with the last focused widget's session if available + const lastFocused = this.chatWidgetService.lastFocusedWidget; + if (lastFocused?.viewModel?.sessionResource) { + this.activeSessionResource.set(lastFocused.viewModel.sessionResource, undefined); + } + + // Listen for new widgets and track their focus + this._register(this.chatWidgetService.onDidAddWidget(widget => { + this._register(widget.onDidFocus(() => { + if (widget.viewModel?.sessionResource) { + this.activeSessionResource.set(widget.viewModel.sessionResource, undefined); + } + })); + + // Also track view model changes (when a widget loads a different session) + this._register(widget.onDidChangeViewModel(({ currentSessionResource }) => { + // Only update if this widget is focused + if (this.chatWidgetService.lastFocusedWidget === widget && currentSessionResource) { + this.activeSessionResource.set(currentSessionResource, undefined); + } + })); + })); + + // Track focus changes on existing widgets + for (const widget of this.chatWidgetService.getAllWidgets()) { + this._register(widget.onDidFocus(() => { + if (widget.viewModel?.sessionResource) { + this.activeSessionResource.set(widget.viewModel.sessionResource, undefined); + } + })); + + this._register(widget.onDidChangeViewModel(({ currentSessionResource }) => { + if (this.chatWidgetService.lastFocusedWidget === widget && currentSessionResource) { + this.activeSessionResource.set(currentSessionResource, undefined); + } + })); + } + } + + private registerBadgeTracking(): void { + // Signal observable that triggers when sessions data changes + const sessionsChangedSignal = observableFromEvent( + this, + this.agentSessionsService.model.onDidChangeSessions, + () => ({}), + ); + + // Observable for session file changes from agentSessionsService (cloud/background sessions) + // Reactive to both activeSessionResource changes AND session data changes + const sessionFileChangesObs = derived(reader => { + const sessionResource = this.activeSessionResource.read(reader); + sessionsChangedSignal.read(reader); + if (!sessionResource) { + return Iterable.empty(); + } + const model = this.agentSessionsService.getSession(sessionResource); + return model?.changes instanceof Array ? model.changes : Iterable.empty(); + }); + + // Create observable for the number of files changed in the active session + // Combines both editing session entries and session file changes (for cloud/background sessions) + const fileCountObs = derived(reader => { + const sessionResource = this.activeSessionResource.read(reader); + if (!sessionResource) { + return 0; + } + + // Background chat sessions render the working set based on the session files, not the editing session + const isBackgroundSession = getChatSessionType(sessionResource) === AgentSessionProviders.Background; + + // Count from editing session entries (skip for background sessions) + let editingSessionCount = 0; + if (!isBackgroundSession) { + const sessions = this.chatEditingService.editingSessionsObs.read(reader); + const session = sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); + editingSessionCount = session ? session.entries.read(reader).length : 0; + } + + // Count from session file changes (cloud/background sessions) + const sessionFiles = [...sessionFileChangesObs.read(reader)]; + const sessionFilesCount = sessionFiles.length; + + return editingSessionCount + sessionFilesCount; + }); + + // Update badge when file count changes + this._register(autorun(reader => { + const fileCount = fileCountObs.read(reader); + this.updateBadge(fileCount); + })); + } + + private updateBadge(fileCount: number): void { + if (fileCount > 0) { + const message = fileCount === 1 + ? localize('changesView.oneFileChanged', '1 file changed') + : localize('changesView.filesChanged', '{0} files changed', fileCount); + this.badgeDisposable.value = this.activityService.showViewActivity(CHANGES_VIEW_ID, { badge: new NumberBadge(fileCount, () => message) }); + } else { + this.badgeDisposable.clear(); + } + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + this.bodyContainer = dom.append(container, $('.changes-view-body')); + + // Welcome message for empty state + this.welcomeContainer = dom.append(this.bodyContainer, $('.changes-welcome')); + const welcomeIcon = dom.append(this.welcomeContainer, $('.changes-welcome-icon')); + welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple)); + const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message')); + welcomeMessage.textContent = localize('changesView.noChanges', "No files have been changed."); + + // Actions container - positioned outside and above the card + this.actionsContainer = dom.append(this.bodyContainer, $('.chat-editing-session-actions.outside-card')); + + // Main container with file icons support (the "card") + this.contentContainer = dom.append(this.bodyContainer, $('.chat-editing-session-container.show-file-icons')); + this._register(createFileIconThemableTreeContainerScope(this.contentContainer, this.themeService)); + + // Toggle class based on whether the file icon theme has file icons + const updateHasFileIcons = () => { + this.contentContainer!.classList.toggle('has-file-icons', this.themeService.getFileIconTheme().hasFileIcons); + }; + updateHasFileIcons(); + this._register(this.themeService.onDidFileIconThemeChange(updateHasFileIcons)); + + // Overview section (header with summary only - actions moved outside card) + this.overviewContainer = dom.append(this.contentContainer, $('.chat-editing-session-overview')); + this.summaryContainer = dom.append(this.overviewContainer, $('.changes-summary')); + + // List container + this.listContainer = dom.append(this.contentContainer, $('.chat-editing-session-list')); + + this._register(this.onDidChangeBodyVisibility(visible => { + if (visible) { + this.onVisible(); + } else { + this.renderDisposables.clear(); + } + })); + + // Trigger initial render if already visible + if (this.isBodyVisible()) { + this.onVisible(); + } + } + + private onVisible(): void { + this.renderDisposables.clear(); + + // Create observable for the active editing session + // Note: We must read editingSessionsObs to establish a reactive dependency, + // so that the view updates when a new editing session is added (e.g., cloud sessions) + const activeEditingSessionObs = derived(reader => { + const sessionResource = this.activeSessionResource.read(reader); + if (!sessionResource) { + return undefined; + } + const sessions = this.chatEditingService.editingSessionsObs.read(reader); + return sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); + }); + + // Create observable for edit session entries from the ACTIVE session only (local editing sessions) + const editSessionEntriesObs = derived(reader => { + const sessionResource = this.activeSessionResource.read(reader); + + // Background chat sessions render the working set based on the session files, not the editing session + if (sessionResource && getChatSessionType(sessionResource) === AgentSessionProviders.Background) { + return []; + } + + const session = activeEditingSessionObs.read(reader); + if (!session) { + return []; + } + + const entries = session.entries.read(reader); + const items: IChangesFileItem[] = []; + + for (const entry of entries) { + const isDeletion = entry.isDeletion ?? false; + const linesAdded = entry.linesAdded?.read(reader) ?? 0; + const linesRemoved = entry.linesRemoved?.read(reader) ?? 0; + + items.push({ + type: 'file', + uri: entry.modifiedURI, + originalUri: entry.originalURI, + state: entry.state.read(reader), + isDeletion, + changeType: isDeletion ? 'deleted' : 'modified', + linesAdded, + linesRemoved, + }); + } + + return items; + }); + + // Signal observable that triggers when sessions data changes + const sessionsChangedSignal = observableFromEvent( + this.renderDisposables, + this.agentSessionsService.model.onDidChangeSessions, + () => ({}), + ); + + // Observable for session file changes from agentSessionsService (cloud/background sessions) + // Reactive to both activeSessionResource changes AND session data changes + const sessionFileChangesObs = derived(reader => { + const sessionResource = this.activeSessionResource.read(reader); + sessionsChangedSignal.read(reader); + if (!sessionResource) { + return Iterable.empty(); + } + const model = this.agentSessionsService.getSession(sessionResource); + return model?.changes instanceof Array ? model.changes : Iterable.empty(); + }); + + // Convert session file changes to list items (cloud/background sessions) + const sessionFilesObs = derived(reader => + [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { + const isDeletion = entry.modifiedUri === undefined; + const isAddition = entry.originalUri === undefined; + return { + type: 'file', + uri: isIChatSessionFileChange2(entry) + ? entry.modifiedUri ?? entry.uri + : entry.modifiedUri, + originalUri: entry.originalUri, + state: ModifiedFileEntryState.Accepted, + isDeletion, + changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', + linesAdded: entry.insertions, + linesRemoved: entry.deletions, + }; + }) + ); + + // Combine both entry sources for display + const combinedEntriesObs = derived(reader => { + const editEntries = editSessionEntriesObs.read(reader); + const sessionFiles = sessionFilesObs.read(reader); + return [...editEntries, ...sessionFiles]; + }); + + // Calculate stats from combined entries + const topLevelStats = derived(reader => { + const editEntries = editSessionEntriesObs.read(reader); + const sessionFiles = sessionFilesObs.read(reader); + const entries = combinedEntriesObs.read(reader); + + let added = 0, removed = 0; + + for (const entry of entries) { + added += entry.linesAdded; + removed += entry.linesRemoved; + } + + const files = entries.length; + const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; + + return { files, added, removed, isSessionMenu }; + }); + + // Setup context keys and actions toolbar + if (this.actionsContainer) { + dom.clearNode(this.actionsContainer); + + const scopedContextKeyService = this.renderDisposables.add(this.contextKeyService.createScoped(this.actionsContainer)); + const scopedInstantiationService = this.renderDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService]))); + + // Set the chat session type context key reactively so that menu items with + // `chatSessionType == copilotcli` (e.g. Create Pull Request) are shown + const chatSessionTypeKey = scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); + this.renderDisposables.add(autorun(reader => { + const sessionResource = this.activeSessionResource.read(reader); + chatSessionTypeKey.set(sessionResource ? getChatSessionType(sessionResource) : ''); + })); + + // Bind required context keys for the menu buttons + this.renderDisposables.add(bindContextKey(hasUndecidedChatEditingResourceContextKey, scopedContextKeyService, r => { + const session = activeEditingSessionObs.read(r); + if (!session) { + return false; + } + const entries = session.entries.read(r); + return entries.some(entry => entry.state.read(r) === ModifiedFileEntryState.Modified); + })); + + this.renderDisposables.add(bindContextKey(hasAppliedChatEditsContextKey, scopedContextKeyService, r => { + const session = activeEditingSessionObs.read(r); + if (!session) { + return false; + } + const entries = session.entries.read(r); + return entries.length > 0; + })); + + this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, scopedContextKeyService, r => { + const { files } = topLevelStats.read(r); + return files > 0; + })); + + this.renderDisposables.add(autorun(reader => { + const { isSessionMenu, added, removed } = topLevelStats.read(reader); + const sessionResource = this.activeSessionResource.read(reader); + reader.store.add(scopedInstantiationService.createInstance( + MenuWorkbenchButtonBar, + this.actionsContainer!, + isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar, + { + telemetrySource: 'changesView', + menuOptions: isSessionMenu && sessionResource + ? { args: [sessionResource, this.agentSessionsService.getSession(sessionResource)?.metadata] } + : { shouldForwardArgs: true }, + buttonConfigProvider: (action) => { + if (action.id === 'chatEditing.viewChanges' || action.id === 'chatEditing.viewPreviousEdits' || action.id === 'chatEditing.viewAllSessionChanges' || action.id === 'chat.openSessionWorktreeInVSCode') { + const diffStatsLabel = new MarkdownString( + `+${added} -${removed}`, + { supportHtml: true } + ); + return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel }; + } + if (action.id === 'github.createPullRequest') { + return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'flex-grow' }; + } + return undefined; + } + } + )); + })); + } + + // Update visibility based on entries + this.renderDisposables.add(autorun(reader => { + const { files } = topLevelStats.read(reader); + const hasEntries = files > 0; + + dom.setVisibility(hasEntries, this.contentContainer!); + dom.setVisibility(hasEntries, this.actionsContainer!); + dom.setVisibility(!hasEntries, this.welcomeContainer!); + })); + + // Update summary text (line counts only, file count is shown in badge) + if (this.summaryContainer) { + dom.clearNode(this.summaryContainer); + + const linesAddedSpan = dom.$('.working-set-lines-added'); + const linesRemovedSpan = dom.$('.working-set-lines-removed'); + + this.summaryContainer.appendChild(linesAddedSpan); + this.summaryContainer.appendChild(linesRemovedSpan); + + this.renderDisposables.add(autorun(reader => { + const { added, removed } = topLevelStats.read(reader); + + linesAddedSpan.textContent = `+${added}`; + linesRemovedSpan.textContent = `-${removed}`; + })); + } + + // Create the tree + if (!this.tree && this.listContainer) { + const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility })); + this.tree = this.instantiationService.createInstance( + WorkbenchCompressibleObjectTree, + 'ChangesViewTree', + this.listContainer, + new ChangesTreeDelegate(), + [this.instantiationService.createInstance(ChangesTreeRenderer, resourceLabels, MenuId.ChatEditingWidgetModifiedFilesToolbar)], + { + alwaysConsumeMouseWheel: false, + accessibilityProvider: { + getAriaLabel: (element: ChangesTreeElement) => isChangesFileItem(element) ? basename(element.uri.path) : element.name, + getWidgetAriaLabel: () => localize('changesViewTree', "Changes Tree") + }, + dnd: { + getDragURI: (element: ChangesTreeElement) => element.uri.toString(), + getDragLabel: (elements) => { + const uris = elements.map(e => e.uri); + if (uris.length === 1) { + return this.labelService.getUriLabel(uris[0], { relative: true }); + } + return `${uris.length}`; + }, + dispose: () => { }, + onDragOver: () => false, + drop: () => { }, + onDragStart: (data, originalEvent) => { + try { + const elements = data.getData() as ChangesTreeElement[]; + const uris = elements.filter(isChangesFileItem).map(e => e.uri); + this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent)); + } catch { + // noop + } + }, + }, + identityProvider: { + getId: (element: ChangesTreeElement) => element.uri.toString() + }, + compressionEnabled: true, + twistieAdditionalCssClass: (e: unknown) => { + if (this.viewMode === ChangesViewMode.List) { + return 'force-no-twistie'; + } + // In tree mode, hide twistie for file items (they are never collapsible) + return isChangesFileItem(e as ChangesTreeElement) ? 'force-no-twistie' : undefined; + }, + } + ); + } + + // Register tree event handlers + if (this.tree) { + const tree = this.tree; + + this.renderDisposables.add(tree.onDidOpen(async (e) => { + if (!e.element) { + return; + } + + // Ignore folder elements - only open files + if (!isChangesFileItem(e.element)) { + return; + } + + const { uri: modifiedFileUri, originalUri, isDeletion } = e.element; + + if (isDeletion && originalUri) { + await this.editorService.openEditor({ + resource: originalUri, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + + if (originalUri) { + await this.editorService.openEditor({ + original: { resource: originalUri }, + modified: { resource: modifiedFileUri }, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + + await this.editorService.openEditor({ + resource: modifiedFileUri, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + })); + } + + // Update tree data with combined entries + this.renderDisposables.add(autorun(reader => { + const entries = combinedEntriesObs.read(reader); + const viewMode = this.viewModeObs.read(reader); + + if (!this.tree) { + return; + } + + // Toggle list-mode class to remove tree indentation in list mode + this.listContainer?.classList.toggle('list-mode', viewMode === ChangesViewMode.List); + + if (viewMode === ChangesViewMode.Tree) { + // Tree mode: build hierarchical tree from file entries + const treeChildren = buildTreeChildren(entries); + this.tree.setChildren(null, treeChildren); + } else { + // List mode: flat list of file items + const listChildren: IObjectTreeElement[] = entries.map(item => ({ + element: item, + collapsible: false, + })); + this.tree.setChildren(null, listChildren); + } + + this.layoutTree(); + })); + } + + private layoutTree(): void { + if (!this.tree || !this.listContainer) { + return; + } + + // Calculate remaining height for the tree by subtracting other elements + const bodyHeight = this.currentBodyHeight; + if (bodyHeight <= 0) { + return; + } + + // Measure non-list elements height (padding, actions, overview) + const bodyPadding = 16; // 8px top + 8px bottom from .changes-view-body + const actionsHeight = this.actionsContainer?.offsetHeight ?? 0; + const actionsMargin = actionsHeight > 0 ? 8 : 0; // margin-bottom on actions container + const overviewHeight = this.overviewContainer?.offsetHeight ?? 0; + const containerPadding = 8; // 4px top + 4px bottom from .chat-editing-session-container + const containerBorder = 2; // 1px top + 1px bottom border + + const usedHeight = bodyPadding + actionsHeight + actionsMargin + overviewHeight + containerPadding + containerBorder; + const availableHeight = Math.max(0, bodyHeight - usedHeight); + + // Limit height to the content so the tree doesn't exceed its items + const contentHeight = this.tree.contentHeight; + const treeHeight = Math.min(availableHeight, contentHeight); + + this.tree.layout(treeHeight, this.currentBodyWidth); + this.tree.getHTMLElement().style.height = `${treeHeight}px`; + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.currentBodyHeight = height; + this.currentBodyWidth = width; + this.layoutTree(); + } + + override focus(): void { + super.focus(); + this.tree?.domFocus(); + } + + override dispose(): void { + this.tree?.dispose(); + this.tree = undefined; + super.dispose(); + } +} + +export class ChangesViewPaneContainer extends ViewPaneContainer { + constructor( + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @ITelemetryService telemetryService: ITelemetryService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextMenuService contextMenuService: IContextMenuService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IConfigurationService configurationService: IConfigurationService, + @IExtensionService extensionService: IExtensionService, + @IWorkspaceContextService contextService: IWorkspaceContextService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @ILogService logService: ILogService, + ) { + super(CHANGES_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }, instantiationService, configurationService, layoutService, contextMenuService, telemetryService, extensionService, themeService, storageService, contextService, viewDescriptorService, logService); + } + + override create(parent: HTMLElement): void { + super.create(parent); + parent.classList.add('changes-viewlet'); + } +} + +// --- Tree Delegate & Renderer + +class ChangesTreeDelegate implements IListVirtualDelegate { + getHeight(_element: ChangesTreeElement): number { + return 22; + } + + getTemplateId(_element: ChangesTreeElement): string { + return ChangesTreeRenderer.TEMPLATE_ID; + } +} + +interface IChangesTreeTemplate { + readonly label: IResourceLabel; + readonly templateDisposables: DisposableStore; + readonly toolbar: MenuWorkbenchToolBar | undefined; + readonly contextKeyService: IContextKeyService | undefined; + readonly decorationBadge: HTMLElement; + readonly addedSpan: HTMLElement; + readonly removedSpan: HTMLElement; + readonly lineCountsContainer: HTMLElement; +} + +class ChangesTreeRenderer implements ICompressibleTreeRenderer { + static TEMPLATE_ID = 'changesTreeRenderer'; + readonly templateId: string = ChangesTreeRenderer.TEMPLATE_ID; + + constructor( + private labels: ResourceLabels, + private menuId: MenuId | undefined, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ILabelService private readonly labelService: ILabelService, + ) { } + + renderTemplate(container: HTMLElement): IChangesTreeTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + + const lineCountsContainer = $('.working-set-line-counts'); + const addedSpan = dom.$('.working-set-lines-added'); + const removedSpan = dom.$('.working-set-lines-removed'); + lineCountsContainer.appendChild(addedSpan); + lineCountsContainer.appendChild(removedSpan); + label.element.appendChild(lineCountsContainer); + + const decorationBadge = dom.$('.changes-decoration-badge'); + label.element.appendChild(decorationBadge); + + let toolbar: MenuWorkbenchToolBar | undefined; + let contextKeyService: IContextKeyService | undefined; + if (this.menuId) { + const actionBarContainer = $('.chat-collapsible-list-action-bar'); + contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer)); + const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, this.menuId, { menuOptions: { shouldForwardArgs: true, arg: undefined } })); + label.element.appendChild(actionBarContainer); + } + + return { templateDisposables, label, toolbar, contextKeyService, decorationBadge, addedSpan, removedSpan, lineCountsContainer }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IChangesTreeTemplate): void { + const element = node.element; + templateData.label.element.style.display = 'flex'; + + if (isChangesFileItem(element)) { + this.renderFileElement(element, templateData); + } else { + this.renderFolderElement(element, templateData); + } + } + + renderCompressedElements(node: ITreeNode, void>, _index: number, templateData: IChangesTreeTemplate): void { + const compressed = node.element; + const lastElement = compressed.elements[compressed.elements.length - 1]; + + templateData.label.element.style.display = 'flex'; + + if (isChangesFileItem(lastElement)) { + // Shouldn't happen in practice - files don't get compressed + this.renderFileElement(lastElement, templateData); + } else { + // Compressed folder chain - show joined folder names + const label = compressed.elements.map(e => isChangesFileItem(e) ? basename(e.uri.path) : e.name); + templateData.label.setResource({ resource: lastElement.uri, name: label }, { + fileKind: FileKind.FOLDER, + separator: this.labelService.getSeparator(lastElement.uri.scheme), + }); + + // Hide file-specific decorations for folders + templateData.decorationBadge.style.display = 'none'; + templateData.lineCountsContainer.style.display = 'none'; + + if (templateData.toolbar) { + templateData.toolbar.context = undefined; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(undefined!); + } + } + } + + private renderFileElement(data: IChangesFileItem, templateData: IChangesTreeTemplate): void { + templateData.label.setFile(data.uri, { + fileKind: FileKind.FILE, + fileDecorations: undefined, + strikethrough: data.changeType === 'deleted', + hidePath: true, + }); + + // Show file-specific decorations + templateData.lineCountsContainer.style.display = ''; + templateData.decorationBadge.style.display = ''; + + // Update decoration badge (A/M/D) + const badge = templateData.decorationBadge; + badge.className = 'changes-decoration-badge'; + switch (data.changeType) { + case 'added': + badge.textContent = 'A'; + badge.classList.add('added'); + break; + case 'deleted': + badge.textContent = 'D'; + badge.classList.add('deleted'); + break; + case 'modified': + default: + badge.textContent = 'M'; + badge.classList.add('modified'); + break; + } + + templateData.addedSpan.textContent = `+${data.linesAdded}`; + templateData.removedSpan.textContent = `-${data.linesRemoved}`; + + // eslint-disable-next-line no-restricted-syntax + templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified'); + + if (templateData.toolbar) { + templateData.toolbar.context = data.uri; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state); + } + } + + private renderFolderElement(data: IChangesFolderItem, templateData: IChangesTreeTemplate): void { + templateData.label.setFile(data.uri, { + fileKind: FileKind.FOLDER, + }); + + // Hide file-specific decorations for folders + templateData.decorationBadge.style.display = 'none'; + templateData.lineCountsContainer.style.display = 'none'; + + if (templateData.toolbar) { + templateData.toolbar.context = undefined; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(undefined!); + } + } + + disposeTemplate(templateData: IChangesTreeTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +// --- View Mode Actions + +class SetChangesListViewModeAction extends ViewAction { + constructor() { + super({ + id: 'workbench.changesView.action.setListViewMode', + title: localize('setListViewMode', "View as List"), + viewId: CHANGES_VIEW_ID, + f1: false, + icon: Codicon.listTree, + toggled: changesViewModeContextKey.isEqualTo(ChangesViewMode.List), + menu: { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', CHANGES_VIEW_ID), + group: '1_viewmode', + order: 1 + } + }); + } + + async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { + view.viewMode = ChangesViewMode.List; + } +} + +class SetChangesTreeViewModeAction extends ViewAction { + constructor() { + super({ + id: 'workbench.changesView.action.setTreeViewMode', + title: localize('setTreeViewMode', "View as Tree"), + viewId: CHANGES_VIEW_ID, + f1: false, + icon: Codicon.listFlat, + toggled: changesViewModeContextKey.isEqualTo(ChangesViewMode.Tree), + menu: { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', CHANGES_VIEW_ID), + group: '1_viewmode', + order: 2 + } + }); + } + + async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { + view.viewMode = ChangesViewMode.Tree; + } +} + +registerAction2(SetChangesListViewModeAction); +registerAction2(SetChangesTreeViewModeAction); diff --git a/src/vs/sessions/contrib/changesView/browser/media/changesView.css b/src/vs/sessions/contrib/changesView/browser/media/changesView.css new file mode 100644 index 0000000000000..1300b886cbcd8 --- /dev/null +++ b/src/vs/sessions/contrib/changesView/browser/media/changesView.css @@ -0,0 +1,237 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.flex-grow { + flex-grow: 1; +} + +.changes-view-body { + display: flex; + flex-direction: column; + height: 100%; + padding: 8px; + box-sizing: border-box; +} + +/* Welcome/Empty state */ +.changes-view-body .changes-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + padding: 20px; + text-align: center; + gap: 8px; +} + +.changes-view-body .changes-welcome-icon.codicon { + font-size: 48px !important; + color: var(--vscode-descriptionForeground); + opacity: 0.6; +} + +.changes-view-body .changes-welcome-message { + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +/* Main container - matches chat editing session styling */ +.changes-view-body .chat-editing-session-container { + padding: 4px 3px; + box-sizing: border-box; + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} + +/* Overview section (header) - hidden since actions moved outside card */ +.changes-view-body .chat-editing-session-overview { + display: none; +} + +/* Summary container */ +.changes-view-body .changes-summary { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + padding: 0 6px; + color: var(--vscode-descriptionForeground); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; +} + +/* Line counts in header */ +.changes-view-body .changes-summary .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); + font-size: 11px; + font-weight: 500; +} + +.changes-view-body .changes-summary .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); + font-size: 11px; + font-weight: 500; +} + +/* Actions container */ +.changes-view-body .chat-editing-session-actions { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: 6px; + align-items: center; +} + +/* Actions container outside the card - new layout experiment */ +.changes-view-body .chat-editing-session-actions.outside-card { + margin-bottom: 8px; + justify-content: flex-end; +} + +/* Larger action buttons matching SCM ActionButton style */ +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button { + height: 26px; + padding: 4px 14px; + border-radius: 4px; + font-size: 12px; + line-height: 18px; +} + +/* Primary button grows to fill available space */ +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button:not(.secondary) { + flex: 1; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button.secondary.monaco-text-button.codicon { + padding: 4px 8px; + font-size: 16px !important; +} +.changes-view-body .chat-editing-session-actions .monaco-button { + width: fit-content; + overflow: hidden; + text-wrap: nowrap; +} + +.changes-view-body .chat-editing-session-actions .monaco-button.secondary.monaco-text-button.codicon { + cursor: pointer; + padding: 2px; + border-radius: 4px; + display: inline-flex; +} + +.changes-view-body .chat-editing-session-actions .monaco-button.secondary.monaco-text-button { + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); +} + +.changes-view-body .chat-editing-session-actions .monaco-button.secondary:hover { + background-color: var(--vscode-button-secondaryHoverBackground); + color: var(--vscode-button-secondaryForeground); +} + +/* List container */ +.changes-view-body .chat-editing-session-list { + overflow: hidden; +} + +/* Make the vertical scrollbar overlay on top of content instead of shifting it */ +.changes-view-body .chat-editing-session-list .monaco-scrollable-element > .scrollbar.vertical { + z-index: 1; +} + +.changes-view-body .chat-editing-session-list .monaco-scrollable-element > .monaco-list-rows { + width: 100% !important; +} + +/* Remove tree indentation padding for hidden twisties (both list and tree mode) */ +.changes-view-body .chat-editing-session-list .monaco-tl-twistie.force-no-twistie { + padding-left: 0 !important; +} +/* List rows */ +.changes-view-body .chat-editing-session-container:not(.has-file-icons) .monaco-list-row .monaco-icon-label { + margin-left: 6px; +} + +.changes-view-body .chat-editing-session-container.show-file-icons .monaco-scrollable-element .monaco-list-rows .monaco-list-row { + border-radius: 2px; +} + +/* Action bar in list rows */ +.changes-view-body .monaco-list-row .chat-collapsible-list-action-bar { + padding-left: 5px; + display: none; +} + +.changes-view-body .monaco-list-row:hover .chat-collapsible-list-action-bar:not(.has-no-actions), +.changes-view-body .monaco-list-row.focused .chat-collapsible-list-action-bar:not(.has-no-actions), +.changes-view-body .monaco-list-row.selected .chat-collapsible-list-action-bar:not(.has-no-actions) { + display: inherit; +} + +/* Decoration badges (A/M/D) */ +.changes-view-body .chat-editing-session-list .changes-decoration-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + min-width: 16px; + font-size: 11px; + font-weight: 600; + line-height: 1; + margin-right: 2px; + opacity: 0.9; +} + +.changes-view-body .chat-editing-session-list .changes-decoration-badge.added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.changes-view-body .chat-editing-session-list .changes-decoration-badge.modified { + color: var(--vscode-gitDecoration-modifiedResourceForeground); +} + +.changes-view-body .chat-editing-session-list .changes-decoration-badge.deleted { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} +/* Line counts in list items */ +.changes-view-body .chat-editing-session-list .working-set-line-counts { + margin: 0 6px; + display: inline-flex; + gap: 4px; + font-size: 11px; +} + +.changes-view-body .chat-editing-session-list .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); +} + +.changes-view-body .chat-editing-session-list .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); +} + +/* Line counts in buttons */ +.changes-view-body .chat-editing-session-actions .monaco-button.working-set-diff-stats { + flex-shrink: 0; + padding-left: 4px; + padding-right: 8px; +} + +.changes-view-body .chat-editing-session-actions .monaco-button .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); +} + +.changes-view-body .chat-editing-session-actions .monaco-button .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); +} diff --git a/src/vs/sessions/contrib/chat/browser/branchChatSessionAction.ts b/src/vs/sessions/contrib/chat/browser/branchChatSessionAction.ts new file mode 100644 index 0000000000000..f5575ce5a9bb8 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/branchChatSessionAction.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize2 } from '../../../../nls.js'; +import { Action2, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ChatTreeItem, ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatModel, ISerializableChatData } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { isRequestVM, isResponseVM } from '../../../../workbench/contrib/chat/common/model/chatViewModel.js'; +import { revive } from '../../../../base/common/marshalling.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; + + +/** + * Action ID for branching chat session to a new local session. + */ +export const ACTION_ID_BRANCH_CHAT_SESSION = 'workbench.action.chat.branchChatSession'; + +/** + * Action that allows users to branch the current chat session from a specific checkpoint. + * This creates a copy of the conversation up to the selected checkpoint, allowing users + * to explore alternative paths from any point in the conversation. + */ +export class BranchChatSessionAction extends Action2 { + + static readonly ID = ACTION_ID_BRANCH_CHAT_SESSION; + + constructor() { + super({ + id: BranchChatSessionAction.ID, + title: localize2('branchChatSession', "Branch Chat"), + tooltip: localize2('branchChatSessionTooltip', "Branch to new session"), + icon: Codicon.reply, + f1: false, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.requestInProgress.negate(), + ), + menu: [{ + id: MenuId.ChatMessageCheckpoint, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and( + ChatContextKeys.isRequest, + ChatContextKeys.lockedToCodingAgent.negate(), + ), + }] + }); + } + + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const item = args[0] as ChatTreeItem | undefined; + const widgetService = accessor.get(IChatWidgetService); + const chatService = accessor.get(IChatService); + + // Item must be a valid request or response from the checkpoint toolbar context + if (!item || (!isRequestVM(item) && !isResponseVM(item))) { + return; + } + + const widget = widgetService.getWidgetBySessionResource(item.sessionResource); + if (!widget || !widget.viewModel) { + return; + } + + // Get the current chat model + const chatModel = widget.viewModel.model as ChatModel; + if (!chatModel) { + return; + } + + const checkpointRequestId = isRequestVM(item) ? item.id : item.requestId; + const serializedData = revive(structuredClone(chatModel.toJSON())) as ISerializableChatData; + serializedData.sessionId = generateUuid(); + + delete serializedData.customTitle; + + const checkpointIndex = serializedData.requests.findIndex(r => r.requestId === checkpointRequestId); + if (checkpointIndex === -1) { + return; + } + + serializedData.requests = serializedData.requests.slice(0, checkpointIndex); + + // Clear shouldBeRemovedOnSend for all requests in the branched session + // This ensures all requests are visible in the new session + for (const request of serializedData.requests) { + delete request.shouldBeRemovedOnSend; + delete (request as { isHidden?: boolean }).isHidden; + } + + // If there's no conversation history to branch, don't proceed + if (serializedData.requests.length === 0) { + return; + } + + // Load the branched data into a new session model + const modelRef = chatService.loadSessionFromContent(serializedData); + if (!modelRef) { + return; + } + + // Open the branched session in the chat view pane + await widgetService.openSession(modelRef.object.sessionResource, ChatViewPaneTarget); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts new file mode 100644 index 0000000000000..afabe96726c78 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IHostService } from '../../../../workbench/services/host/browser/host.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ISessionsManagementService, IsNewChatSessionContext } from '../../sessions/browser/sessionsManagementService.js'; +import { ITerminalService, ITerminalGroupService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { Menus } from '../../../browser/menus.js'; +import { BranchChatSessionAction } from './branchChatSessionAction.js'; +import { RunScriptContribution } from './runScriptAction.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { AgenticPromptsService } from './promptsService.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { ChatViewContainerId, ChatViewId } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { NewChatViewPane, SessionsViewId } from './newChatViewPane.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { ChatViewPane } from '../../../../workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.js'; + +export class OpenSessionWorktreeInVSCodeAction extends Action2 { + static readonly ID = 'chat.openSessionWorktreeInVSCode'; + + constructor() { + super({ + id: OpenSessionWorktreeInVSCodeAction.ID, + title: localize2('openInVSCode', 'Open in VS Code'), + icon: Codicon.vscodeInsiders, + menu: [{ + id: Menus.OpenSubMenu, + group: 'navigation', + order: 2, + }] + }); + } + + override async run(accessor: ServicesAccessor,): Promise { + const hostService = accessor.get(IHostService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + + const activeSession = sessionsManagementService.activeSession.get(); + if (!activeSession) { + return; + } + + const folderUri = isAgentSession(activeSession) && activeSession.providerType !== AgentSessionProviders.Cloud ? activeSession.worktree : undefined; + + if (!folderUri) { + return; + } + + await hostService.openWindow([{ folderUri }], { forceNewWindow: true }); + } +} +registerAction2(OpenSessionWorktreeInVSCodeAction); + +export class OpenSessionInTerminalAction extends Action2 { + + constructor() { + super({ + id: 'agentSession.openInTerminal', + title: localize2('openInTerminal', "Open Terminal"), + icon: Codicon.terminal, + menu: [{ + id: Menus.OpenSubMenu, + group: 'navigation', + order: 1, + }] + }); + } + + override async run(accessor: ServicesAccessor,): Promise { + const terminalService = accessor.get(ITerminalService); + const terminalGroupService = accessor.get(ITerminalGroupService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + + const activeSession = sessionsManagementService.activeSession.get(); + const repository = isAgentSession(activeSession) && activeSession.providerType !== AgentSessionProviders.Cloud + ? activeSession.worktree + : undefined; + if (repository) { + const instance = await terminalService.createTerminal({ config: { cwd: repository } }); + if (instance) { + terminalService.setActiveInstance(instance); + } + } + terminalGroupService.showPanel(true); + } +} + +registerAction2(OpenSessionInTerminalAction); + +// Register the split button menu item that combines Open in VS Code and Open in Terminal +MenuRegistry.appendMenuItem(Menus.TitleBarRight, { + submenu: Menus.OpenSubMenu, + isSplitButton: { togglePrimaryAction: true }, + title: localize2('open', "Open..."), + icon: Codicon.folderOpened, + group: 'navigation', + order: 9, +}); + + + +// --- Sessions New Chat View Registration --- +// Registers in the same ChatBar container as the existing ChatViewPane. +// The `when` clause ensures only the new-session pane shows when no active session exists. + +const chatViewIcon = registerIcon('chat-view-icon', Codicon.chatSparkle, localize('chatViewIcon', 'View icon of the chat view.')); + +class RegisterChatViewContainerContribution implements IWorkbenchContribution { + + static ID = 'sessions.registerChatViewContainer'; + + constructor() { + const viewContainerRegistry = Registry.as(ViewExtensions.ViewContainersRegistry); + const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); + let chatViewContainer = viewContainerRegistry.get(ChatViewContainerId); + if (chatViewContainer) { + viewContainerRegistry.deregisterViewContainer(chatViewContainer); + const view = viewsRegistry.getView(ChatViewId); + if (view) { + viewsRegistry.deregisterViews([view], chatViewContainer); + } + } + + chatViewContainer = viewContainerRegistry.registerViewContainer({ + id: ChatViewContainerId, + title: localize2('chat.viewContainer.label', "Chat"), + icon: chatViewIcon, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [ChatViewContainerId, { mergeViewWithContainerWhenSingleView: true }]), + storageId: ChatViewContainerId, + hideIfEmpty: true, + order: 1, + windowVisibility: WindowVisibility.Sessions, + }, ViewContainerLocation.ChatBar, { isDefault: true, doNotRegisterOpenCommand: true }); + + viewsRegistry.registerViews([{ + id: ChatViewId, + containerIcon: chatViewContainer.icon, + containerTitle: chatViewContainer.title.value, + singleViewPaneContainerTitle: chatViewContainer.title.value, + name: localize2('chat.viewContainer.label', "Chat"), + canToggleVisibility: false, + canMoveView: false, + ctorDescriptor: new SyncDescriptor(ChatViewPane), + when: IsNewChatSessionContext.negate(), + windowVisibility: WindowVisibility.Sessions + }, { + id: SessionsViewId, + containerIcon: chatViewContainer.icon, + containerTitle: chatViewContainer.title.value, + singleViewPaneContainerTitle: chatViewContainer.title.value, + name: localize2('sessions.newChat.view', "New Session"), + canToggleVisibility: false, + canMoveView: false, + ctorDescriptor: new SyncDescriptor(NewChatViewPane), + when: IsNewChatSessionContext, + windowVisibility: WindowVisibility.Sessions, + }], chatViewContainer); + } +} + + +// register actions +registerAction2(BranchChatSessionAction); + +// register workbench contributions +registerWorkbenchContribution2(RegisterChatViewContainerContribution.ID, RegisterChatViewContainerContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution, WorkbenchPhase.AfterRestored); + +// register services +registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css new file mode 100644 index 0000000000000..6316384afb338 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -0,0 +1,268 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-full-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-sizing: border-box; + overflow-x: hidden; + transition: justify-content 0.4s ease; +} + +.chat-full-welcome.revealed { + justify-content: center; +} + +/* Header */ +.chat-full-welcome-header { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 800px; + overflow: visible; +} + +/* Mascot */ +.chat-full-welcome-mascot { + width: 80px; + height: 80px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-top: 8px; + margin-bottom: 12px; + animation: chat-full-welcome-mascot-bounce 1s ease-in-out infinite; + transition: animation-duration 0.3s ease; + background-image: url('../../../../../workbench/browser/media/code-icon.svg'); +} + +.chat-full-welcome.revealed .chat-full-welcome-mascot { + animation-duration: 2s; +} + +@keyframes chat-full-welcome-mascot-bounce { + 0%, 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-6px); + } +} + +/* Input slot */ +.chat-full-welcome-inputSlot { + width: 100%; + max-width: 800px; + margin-top: 12px; + box-sizing: border-box; + display: none; +} + +.chat-full-welcome.revealed .chat-full-welcome-inputSlot { + display: block; + animation: chat-full-welcome-fade-in 0.35s ease 0.15s both; +} + +/* Option group pickers container (below the input) */ +.chat-full-welcome-pickers-container { + display: none; + justify-content: center; + width: 100%; + max-width: 800px; + margin: 12px; + box-sizing: border-box; +} + +.chat-full-welcome.revealed .chat-full-welcome-pickers-container { + display: flex; + animation: chat-full-welcome-fade-in 0.35s ease 0.1s both; +} + +@keyframes chat-full-welcome-fade-in { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-full-welcome-pickers-container:empty { + display: none; + margin-bottom: 0; +} + +/* Ensure the input editor fits properly */ +.chat-full-welcome-inputSlot .interactive-input-part { + margin: 0; + padding: 0; + max-width: 100%; + box-sizing: border-box; +} + +.chat-full-welcome-inputSlot .interactive-input-part .monaco-editor { + min-height: 0; +} + +.chat-full-welcome-inputSlot .interactive-input-part .monaco-editor .view-lines { + min-height: 0; +} + +.chat-full-welcome-inputSlot .chat-input-container { + overflow: hidden; + border-color: var(--vscode-contrastBorder, var(--vscode-editorWidget-border)); +} + +.chat-controls-container .monaco-editor-background { + background-color: var(--vscode-input-background) !important; +} + +/* Pickers row - flat horizontal bar below the input */ +.chat-full-welcome-pickers { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 0 2px; +} + +.chat-full-welcome-pickers:empty { + display: none; +} + +/* Left group (target dropdown + left-side extension pickers) */ +.sessions-chat-pickers-left { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + +/* Spacer between left and right groups */ +.sessions-chat-pickers-spacer { + flex: 1; +} + +/* Right group (repo/folder pickers) */ +.sessions-chat-extension-pickers-right { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + +.sessions-chat-extension-pickers-right:empty { + display: none; +} + +/* Left extension pickers container */ +.sessions-chat-extension-pickers-left { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + +.sessions-chat-extension-pickers-left:empty { + display: none; +} + +/* Target dropdown button */ +.sessions-chat-dropdown-button { + display: flex; + align-items: center; + height: 16px; + padding: 3px 0 3px 6px; + cursor: pointer; + font-size: 13px; + color: var(--vscode-descriptionForeground); + background-color: transparent; + border: none; + white-space: nowrap; + border-radius: 4px; +} + +.sessions-chat-dropdown-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sessions-chat-dropdown-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.sessions-chat-dropdown-button .codicon { + font-size: 14px; + flex-shrink: 0; +} + +.sessions-chat-dropdown-button .codicon.codicon-chevron-down { + font-size: 12px; + margin-left: 2px; +} + +.sessions-chat-dropdown-label { + margin-left: 4px; +} + +/* Extension picker slots (rendered inline in the row) */ +.sessions-chat-picker-slot { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.sessions-chat-picker-slot .action-label { + display: flex; + align-items: center; + height: 16px; + padding: 3px 0 3px 6px; + background-color: transparent; + border: none; + color: var(--vscode-descriptionForeground); + font-size: 13px; + cursor: pointer; + white-space: nowrap; + border-radius: 4px; + min-width: 0; + overflow: hidden; +} + +.sessions-chat-picker-slot .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sessions-chat-picker-slot .action-label .codicon { + font-size: 14px; + flex-shrink: 0; +} + +.sessions-chat-picker-slot .action-label .codicon-chevron-down { + font-size: 12px; + margin-left: 2px; +} + +.sessions-chat-picker-slot .action-label .chat-session-option-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.sessions-chat-picker-slot .action-label span + .chat-session-option-label { + margin-left: 2px; +} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css new file mode 100644 index 0000000000000..e3d76a95b72c6 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-chat-widget { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +/* Welcome container fills available space and centers content */ +.sessions-chat-widget .agent-chat-welcome-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +/* Input area */ +.sessions-chat-input-area { + width: 100%; + max-width: 800px; + box-sizing: border-box; + border: 1px solid var(--vscode-input-border, var(--vscode-contrastBorder, transparent)); + border-radius: 8px; + background-color: var(--vscode-input-background); + overflow: hidden; +} + +.sessions-chat-input-area:focus-within { + border-color: var(--vscode-focusBorder); +} + +/* Editor */ +.sessions-chat-editor { + padding: 0 6px 6px 6px; + height: 72px; + min-height: 72px; + max-height: 200px; +} + +.sessions-chat-editor .monaco-editor, +.sessions-chat-editor .monaco-editor .overflow-guard, +.sessions-chat-editor .monaco-editor-background { + background-color: var(--vscode-input-background) !important; + border-radius: 8px 8px 0 0; +} + +/* Toolbar */ +.sessions-chat-toolbar { + display: flex; + align-items: center; + padding: 4px 8px; + gap: 4px; +} + +.sessions-chat-toolbar-spacer { + flex: 1; +} + +/* Model picker - uses workbench ModelPickerActionItem */ +.sessions-chat-model-picker { + display: flex; + align-items: center; +} + +.sessions-chat-model-picker .action-label { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + color: var(--vscode-descriptionForeground); +} + +.sessions-chat-model-picker .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sessions-chat-model-picker .action-label .codicon { + font-size: 12px; +} + +/* Send button */ +.sessions-chat-send-button { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-descriptionForeground); + background: transparent; + border: none; + outline: none; +} + +.sessions-chat-send-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sessions-chat-send-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.sessions-chat-send-button .codicon { + font-size: 16px; +} diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg new file mode 100644 index 0000000000000..81991ee80fa80 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg new file mode 100644 index 0000000000000..55db4d45e46fb --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg new file mode 100644 index 0000000000000..e26c10e038aa0 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg new file mode 100644 index 0000000000000..e26c10e038aa0 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts new file mode 100644 index 0000000000000..dbfcb58ff7d9e --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -0,0 +1,935 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatWidget.css'; +import './media/chatWelcomePart.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Separator, toAction } from '../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; + +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; +import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; +import { asCSSUrl } from '../../../../base/browser/cssValue.js'; +import { FileAccess } from '../../../../base/common/network.js'; +import { localize } from '../../../../nls.js'; +import { AgentSessionProviders, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; +import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; +import { SearchableOptionPickerActionItem } from '../../../../workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IModelPickerDelegate } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; +import { EnhancedModelPickerActionItem } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.js'; +import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; +import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; +import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; +import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; +import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; + +// #region --- Target Config --- + +/** + * Tracks which agent session targets are available and which is selected. + * Targets are fixed at construction time; only the selection changes. + */ +export interface ITargetConfig { + readonly allowedTargets: IObservable>; + readonly selectedTarget: IObservable; + readonly onDidChangeSelectedTarget: Event; + readonly onDidChangeAllowedTargets: Event>; + setSelectedTarget(target: AgentSessionProviders): void; +} + +export interface ITargetConfigOptions { + allowedTargets: AgentSessionProviders[]; + defaultTarget?: AgentSessionProviders; +} + +class TargetConfig extends Disposable implements ITargetConfig { + + private readonly _allowedTargets = observableValue>('allowedTargets', new Set()); + readonly allowedTargets: IObservable> = this._allowedTargets; + + private readonly _selectedTarget = observableValue('selectedTarget', undefined); + readonly selectedTarget: IObservable = this._selectedTarget; + + private readonly _onDidChangeSelectedTarget = this._register(new Emitter()); + readonly onDidChangeSelectedTarget = this._onDidChangeSelectedTarget.event; + + private readonly _onDidChangeAllowedTargets = this._register(new Emitter>()); + readonly onDidChangeAllowedTargets = this._onDidChangeAllowedTargets.event; + + constructor(options: ITargetConfigOptions) { + super(); + const initialSet = new Set(options.allowedTargets); + this._allowedTargets.set(initialSet, undefined); + const defaultTarget = options.defaultTarget && initialSet.has(options.defaultTarget) + ? options.defaultTarget + : initialSet.values().next().value; + this._selectedTarget.set(defaultTarget, undefined); + } + + setSelectedTarget(target: AgentSessionProviders): void { + const allowed = this._allowedTargets.get(); + if (!allowed.has(target)) { + throw new Error(`Target "${target}" is not in the allowed set`); + } + if (this._selectedTarget.get() !== target) { + this._selectedTarget.set(target, undefined); + this._onDidChangeSelectedTarget.fire(target); + } + } + + setAllowedTargets(targets: AgentSessionProviders[]): void { + const newSet = new Set(targets); + this._allowedTargets.set(newSet, undefined); + this._onDidChangeAllowedTargets.fire(newSet); + + // If the currently selected target is no longer allowed, switch to the first allowed target + const current = this._selectedTarget.get(); + if (current && !newSet.has(current)) { + const fallback = newSet.values().next().value; + this._selectedTarget.set(fallback, undefined); + this._onDidChangeSelectedTarget.fire(fallback); + } + } +} + +// #endregion + +// #region --- Chat Welcome Widget --- + +/** + * Data passed to the `onSendRequest` callback when the user submits a query. + */ +export interface INewChatSendRequestData { + readonly resource: URI; + readonly target: AgentSessionProviders; + readonly query: string; + readonly sendOptions: IChatSendRequestOptions; + readonly selectedOptions: ReadonlyMap; +} + +/** + * Options for creating a `NewChatWidget`. + */ +export interface INewChatWidgetOptions { + readonly targetConfig: ITargetConfigOptions; + readonly onSendRequest?: (data: INewChatSendRequestData) => void; + readonly sessionPosition?: ChatSessionPosition; +} + +/** + * A self-contained new-session chat widget with a welcome view (mascot, target + * buttons, option pickers), an input editor, model picker, and send button. + * + * This widget is shown only in the empty/welcome state. Once the user sends + * a message, a session is created and the workbench ChatViewPane takes over. + */ +class NewChatWidget extends Disposable { + + private readonly _targetConfig: TargetConfig; + private readonly _options: INewChatWidgetOptions; + + // Input + private _editor!: CodeEditorWidget; + private readonly _currentLanguageModel = observableValue('currentLanguageModel', undefined); + private readonly _modelPickerDisposable = this._register(new MutableDisposable()); + private _pendingSessionResource: URI | undefined; + + // Welcome part + private readonly _welcomeContentDisposables = this._register(new DisposableStore()); + private _pickersContainer: HTMLElement | undefined; + private _targetDropdownContainer: HTMLElement | undefined; + private _extensionPickersLeftContainer: HTMLElement | undefined; + private _extensionPickersRightContainer: HTMLElement | undefined; + private _inputSlot: HTMLElement | undefined; + private readonly _pickerWidgets = new Map(); + private readonly _pickerWidgetDisposables = this._register(new DisposableStore()); + private readonly _optionEmitters = new Map>(); + private readonly _selectedOptions = new Map(); + private readonly _optionContextKeys = new Map>(); + private readonly _whenClauseKeys = new Set(); + + constructor( + options: INewChatWidgetOptions, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IModelService private readonly modelService: IModelService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IProductService private readonly productService: IProductService, + @ILogService private readonly logService: ILogService, + @IHoverService _hoverService: IHoverService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, + @IWorkspacesService private readonly workspacesService: IWorkspacesService, + ) { + super(); + this._targetConfig = this._register(new TargetConfig(options.targetConfig)); + this._options = options; + + // When target changes, regenerate pending resource + this._register(this._targetConfig.onDidChangeSelectedTarget(() => { + this._generatePendingSessionResource(); + this._updateTargetDropdown(); + this._renderExtensionPickers(true); + })); + + this._register(this._targetConfig.onDidChangeAllowedTargets(() => { + if (this._targetDropdownContainer) { + dom.clearNode(this._targetDropdownContainer); + this._renderTargetDropdown(this._targetDropdownContainer); + } + this._renderExtensionPickers(true); + })); + + // Listen for option group changes to re-render pickers + this._register(this.chatSessionsService.onDidChangeOptionGroups(() => { + this._renderExtensionPickers(); + })); + + // React to chat session option changes + this._register(this.chatSessionsService.onDidChangeSessionOptions((e: URI | undefined) => { + if (this._pendingSessionResource && isEqual(this._pendingSessionResource, e)) { + this._syncOptionsFromSession(this._pendingSessionResource); + this._renderExtensionPickers(); + } + })); + + const workspaceFolderCountKey = new Set([WorkspaceFolderCountContext.key]); + this._register(this.contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(workspaceFolderCountKey)) { + this._renderExtensionPickers(true); + } + if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) { + this._renderExtensionPickers(true); + } + })); + } + + // --- Rendering --- + + render(container: HTMLElement): void { + const wrapper = dom.append(container, dom.$('.sessions-chat-widget')); + const welcomeElement = dom.append(wrapper, dom.$('.chat-full-welcome')); + + // Mascot + const header = dom.append(welcomeElement, dom.$('.chat-full-welcome-header')); + const quality = this.productService.quality ?? 'stable'; + const mascot = dom.append(header, dom.$('.chat-full-welcome-mascot')); + mascot.style.backgroundImage = asCSSUrl(FileAccess.asBrowserUri(`vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-${quality}.svg`)); + + // Input slot + this._inputSlot = dom.append(welcomeElement, dom.$('.chat-full-welcome-inputSlot')); + + // Input area inside the input slot + const inputArea = dom.$('.sessions-chat-input-area'); + this._createEditor(inputArea); + this._createToolbar(inputArea); + this._inputSlot.appendChild(inputArea); + + // Option group pickers (below the input) + this._pickersContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-pickers-container')); + + // Render target buttons & extension pickers + this._renderOptionGroupPickers(); + + // Initialize model picker + this._initDefaultModel(); + + // Generate pending resource for option changes + this._generatePendingSessionResource(); + + // Reveal + welcomeElement.classList.add('revealed'); + } + + private _generatePendingSessionResource(): void { + const target = this._targetConfig.selectedTarget.get(); + if (!target || target === AgentSessionProviders.Local) { + this._pendingSessionResource = undefined; + return; + } + this._pendingSessionResource = getResourceForNewChatSession({ + type: target, + position: this._options.sessionPosition ?? ChatSessionPosition.Sidebar, + displayName: '', + }); + } + + // --- Editor --- + + private _createEditor(container: HTMLElement): void { + const editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); + + const uri = URI.from({ scheme: 'sessions-chat', path: `input-${Date.now()}` }); + const textModel = this._register(this.modelService.createModel('', null, uri, true)); + + const editorOptions: IEditorConstructionOptions = { + ...getSimpleEditorOptions(this.configurationService), + readOnly: false, + ariaLabel: localize('chatInput', "Chat input"), + placeholder: localize('chatPlaceholder', "Run tasks in the background, type '#' for adding context"), + fontFamily: 'system-ui, -apple-system, sans-serif', + fontSize: 13, + lineHeight: 20, + padding: { top: 8, bottom: 2 }, + wrappingStrategy: 'advanced', + stickyScroll: { enabled: false }, + renderWhitespace: 'none', + }; + + const widgetOptions: ICodeEditorWidgetOptions = { + isSimpleWidget: true, + contributions: EditorExtensionsRegistry.getSomeEditorContributions([ + ContextMenuController.ID, + ]), + }; + + this._editor = this._register(this.instantiationService.createInstance( + CodeEditorWidget, editorContainer, editorOptions, widgetOptions, + )); + this._editor.setModel(textModel); + + this._register(this._editor.onKeyDown(e => { + if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + e.stopPropagation(); + this._send(); + } + })); + + this._register(this._editor.onDidContentSizeChange(() => { + const contentHeight = this._editor.getContentHeight(); + const clampedHeight = Math.min(Math.max(contentHeight, 36), 200); + editorContainer.style.height = `${clampedHeight}px`; + this._editor.layout(); + })); + } + + private _createToolbar(container: HTMLElement): void { + const toolbar = dom.append(container, dom.$('.sessions-chat-toolbar')); + + const modelPickerContainer = dom.append(toolbar, dom.$('.sessions-chat-model-picker')); + this._createModelPicker(modelPickerContainer); + + dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); + + const sendButton = dom.append(toolbar, dom.$('.sessions-chat-send-button')); + sendButton.tabIndex = 0; + sendButton.role = 'button'; + sendButton.title = localize('send', "Send"); + dom.append(sendButton, renderIcon(Codicon.send)); + this._register(dom.addDisposableListener(sendButton, dom.EventType.CLICK, () => this._send())); + } + + // --- Model picker --- + + private _createModelPicker(container: HTMLElement): void { + const delegate: IModelPickerDelegate = { + currentModel: this._currentLanguageModel, + setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { + this._currentLanguageModel.set(model, undefined); + }, + getModels: () => this._getAvailableModels(), + canManageModels: () => true, + }; + + const pickerOptions: IChatInputPickerOptions = { + onlyShowIconsForDefaultActions: observableValue('onlyShowIcons', false), + hoverPosition: { hoverPosition: HoverPosition.ABOVE }, + }; + + const action = { id: 'sessions.modelPicker', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }; + + const modelPicker = this.instantiationService.createInstance( + EnhancedModelPickerActionItem, action, delegate, pickerOptions, + ); + this._modelPickerDisposable.value = modelPicker; + modelPicker.render(container); + } + + private _initDefaultModel(): void { + const models = this._getAvailableModels(); + if (models.length > 0) { + this._currentLanguageModel.set(models[0], undefined); + } + + this._register(this.languageModelsService.onDidChangeLanguageModels(() => { + if (!this._currentLanguageModel.get()) { + const models = this._getAvailableModels(); + if (models.length > 0) { + this._currentLanguageModel.set(models[0], undefined); + } + } + })); + } + + private _getAvailableModels(): ILanguageModelChatMetadataAndIdentifier[] { + return this.languageModelsService.getLanguageModelIds() + .map(id => { + const metadata = this.languageModelsService.lookupLanguageModel(id); + return metadata ? { metadata, identifier: id } : undefined; + }) + .filter((m): m is ILanguageModelChatMetadataAndIdentifier => !!m && !!m.metadata.isUserSelectable); + } + + // --- Welcome: Target & option pickers (dropdown row below input) --- + + private _renderOptionGroupPickers(): void { + if (!this._pickersContainer) { + return; + } + + this._disposePickerWidgets(); + dom.clearNode(this._pickersContainer); + + const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers')); + + // Left group: target dropdown (agent/worktree picker) + const leftGroup = dom.append(pickersRow, dom.$('.sessions-chat-pickers-left')); + this._targetDropdownContainer = dom.append(leftGroup, dom.$('.sessions-chat-dropdown-wrapper')); + this._renderTargetDropdown(this._targetDropdownContainer); + + // Spacer + dom.append(pickersRow, dom.$('.sessions-chat-pickers-spacer')); + + // Right group: all extension pickers (folder first, then others) + this._extensionPickersLeftContainer = undefined; + this._extensionPickersRightContainer = dom.append(pickersRow, dom.$('.sessions-chat-extension-pickers-right')); + + this._renderExtensionPickers(); + } + + private _renderTargetDropdown(container: HTMLElement): void { + const allowed = this._targetConfig.allowedTargets.get(); + if (allowed.size === 0) { + return; + } + + const activeType = this._targetConfig.selectedTarget.get() ?? AgentSessionProviders.Background; + const icon = getAgentSessionProviderIcon(activeType); + const name = getAgentSessionProviderName(activeType); + + const button = dom.append(container, dom.$('.sessions-chat-dropdown-button')); + button.tabIndex = 0; + button.role = 'button'; + button.ariaHasPopup = 'true'; + dom.append(button, renderIcon(icon)); + dom.append(button, dom.$('span.sessions-chat-dropdown-label', undefined, name)); + dom.append(button, renderIcon(Codicon.chevronDown)); + + this._welcomeContentDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, () => { + const currentAllowed = this._targetConfig.allowedTargets.get(); + const currentActive = this._targetConfig.selectedTarget.get(); + const actions = [...currentAllowed] + .map(sessionType => { + const label = getAgentSessionProviderName(sessionType); + return toAction({ + id: `target.${sessionType}`, + label, + checked: sessionType === currentActive, + run: () => this._targetConfig.setSelectedTarget(sessionType), + }); + }); + this.contextMenuService.showContextMenu({ + getAnchor: () => button, + getActions: () => actions, + }); + })); + } + + private _updateTargetDropdown(): void { + if (!this._targetDropdownContainer) { + return; + } + dom.clearNode(this._targetDropdownContainer); + this._renderTargetDropdown(this._targetDropdownContainer); + } + + // --- Welcome: Extension option pickers --- + + private _renderExtensionPickers(force?: boolean): void { + if (!this._extensionPickersRightContainer) { + return; + } + + const activeSessionType = this._targetConfig.selectedTarget.get(); + if (!activeSessionType) { + this._clearExtensionPickers(); + return; + } + + // For Local target, render a workspace folder picker instead of extension pickers + if (activeSessionType === AgentSessionProviders.Local) { + this._clearExtensionPickers(); + this._renderLocalFolderPicker(); + return; + } + + const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); + if (!optionGroups || optionGroups.length === 0) { + this._clearExtensionPickers(); + return; + } + + const visibleGroups: IChatSessionProviderOptionGroup[] = []; + this._whenClauseKeys.clear(); + for (const group of optionGroups) { + if (isModelOptionGroup(group)) { + continue; + } + if (group.when) { + const expr = ContextKeyExpr.deserialize(group.when); + if (expr) { + for (const key of expr.keys()) { + this._whenClauseKeys.add(key); + } + } + } + const hasItems = group.items.length > 0 || (group.commands || []).length > 0 || !!group.searchable; + const passesWhenClause = this._evaluateOptionGroupVisibility(group); + if (hasItems && passesWhenClause) { + visibleGroups.push(group); + } + } + + if (visibleGroups.length === 0) { + this._clearExtensionPickers(); + return; + } + + visibleGroups.sort((a, b) => { + // Repo/folder pickers first, then others + const aRepo = isRepoOrFolderGroup(a) ? 0 : 1; + const bRepo = isRepoOrFolderGroup(b) ? 0 : 1; + if (aRepo !== bRepo) { + return aRepo - bRepo; + } + return (a.when ? 1 : 0) - (b.when ? 1 : 0); + }); + + if (!force && this._pickerWidgets.size === visibleGroups.length) { + const allMatch = visibleGroups.every(g => this._pickerWidgets.has(g.id)); + if (allMatch) { + return; + } + } + + this._clearExtensionPickers(); + + for (const optionGroup of visibleGroups) { + const initialItem = this._getDefaultOptionForGroup(optionGroup); + const initialState = { group: optionGroup, item: initialItem }; + + if (initialItem) { + this._updateOptionContextKey(optionGroup.id, initialItem.id); + } + + const emitter = this._getOrCreateOptionEmitter(optionGroup.id); + const itemDelegate: IChatSessionPickerDelegate = { + getCurrentOption: () => this._selectedOptions.get(optionGroup.id) ?? this._getDefaultOptionForGroup(optionGroup), + onDidChangeOption: emitter.event, + setOption: (option: IChatSessionProviderOptionItem) => { + this._selectedOptions.set(optionGroup.id, option); + this._updateOptionContextKey(optionGroup.id, option.id); + emitter.fire(option); + + if (this._pendingSessionResource) { + this.chatSessionsService.notifySessionOptionsChange( + this._pendingSessionResource, + [{ optionId: optionGroup.id, value: option }] + ).catch((err) => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err)); + } + + this._renderExtensionPickers(true); + }, + getOptionGroup: () => { + const groups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); + return groups?.find((g: { id: string }) => g.id === optionGroup.id); + }, + getSessionResource: () => this._pendingSessionResource, + }; + + const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } }); + const widget = this.instantiationService.createInstance( + optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, + action, initialState, itemDelegate + ); + + this._pickerWidgetDisposables.add(widget); + this._pickerWidgets.set(optionGroup.id, widget); + + // All pickers go to the right + const targetContainer = this._extensionPickersRightContainer!; + + const slot = dom.append(targetContainer, dom.$('.sessions-chat-picker-slot')); + widget.render(slot); + } + } + + private _renderLocalFolderPicker(): void { + if (!this._extensionPickersRightContainer) { + return; + } + + const folders = this.workspaceContextService.getWorkspace().folders; + const currentFolder = folders[0]; + const folderName = currentFolder ? basename(currentFolder.uri) : localize('noFolder', "No Folder"); + + const slot = dom.append(this._extensionPickersRightContainer, dom.$('.sessions-chat-picker-slot')); + const button = dom.append(slot, dom.$('.sessions-chat-dropdown-button')); + button.tabIndex = 0; + button.role = 'button'; + button.ariaHasPopup = 'true'; + dom.append(button, renderIcon(Codicon.folder)); + dom.append(button, dom.$('span.sessions-chat-dropdown-label', undefined, folderName)); + dom.append(button, renderIcon(Codicon.chevronDown)); + + const switchFolder = async (folderUri: URI) => { + const foldersToDelete = this.workspaceContextService.getWorkspace().folders.map(f => f.uri); + await this.workspaceEditingService.updateFolders(0, foldersToDelete.length, [{ uri: folderUri }]); + this._renderExtensionPickers(true); + }; + + this._pickerWidgetDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, async () => { + const recentlyOpened = await this.workspacesService.getRecentlyOpened(); + const recentFolders = recentlyOpened.workspaces + .filter(isRecentFolder) + .filter(r => !currentFolder || !isEqual(r.folderUri, currentFolder.uri)) + .slice(0, 10); + + const actions = recentFolders.map(recent => toAction({ + id: recent.folderUri.toString(), + label: recent.label || basename(recent.folderUri), + run: () => switchFolder(recent.folderUri), + })); + + actions.push(new Separator()); + actions.push(toAction({ + id: 'browse', + label: localize('browseFolder', "Browse..."), + run: async () => { + const selected = await this.fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectFolder', "Select Folder"), + }); + if (selected?.[0]) { + await switchFolder(selected[0]); + } + }, + })); + + this.contextMenuService.showContextMenu({ + getAnchor: () => button, + getActions: () => actions, + }); + })); + } + + private _evaluateOptionGroupVisibility(optionGroup: { id: string; when?: string }): boolean { + if (!optionGroup.when) { + return true; + } + const expr = ContextKeyExpr.deserialize(optionGroup.when); + return !expr || this.contextKeyService.contextMatchesRules(expr); + } + + private _getDefaultOptionForGroup(optionGroup: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined { + return this._selectedOptions.get(optionGroup.id) ?? optionGroup.items.find((item) => item.default === true); + } + + private _syncOptionsFromSession(sessionResource: URI): void { + const activeSessionType = this._targetConfig.selectedTarget.get(); + if (!activeSessionType) { + return; + } + const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); + if (!optionGroups) { + return; + } + for (const optionGroup of optionGroups) { + if (isModelOptionGroup(optionGroup)) { + continue; + } + const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroup.id); + if (!currentOption) { + continue; + } + let item: IChatSessionProviderOptionItem | undefined; + if (typeof currentOption === 'string') { + item = optionGroup.items.find((m: { id: string }) => m.id === currentOption.trim()); + } else { + item = currentOption; + } + if (item) { + const { locked: _locked, ...unlocked } = item; + this._selectedOptions.set(optionGroup.id, unlocked as IChatSessionProviderOptionItem); + this._updateOptionContextKey(optionGroup.id, item.id); + this._optionEmitters.get(optionGroup.id)?.fire(item); + } + } + } + + private _updateOptionContextKey(optionGroupId: string, optionItemId: string): void { + let contextKey = this._optionContextKeys.get(optionGroupId); + if (!contextKey) { + const rawKey = new RawContextKey(`chatSessionOption.${optionGroupId}`, ''); + contextKey = rawKey.bindTo(this.contextKeyService); + this._optionContextKeys.set(optionGroupId, contextKey); + } + contextKey.set(optionItemId.trim()); + } + + private _getOrCreateOptionEmitter(optionGroupId: string): Emitter { + let emitter = this._optionEmitters.get(optionGroupId); + if (!emitter) { + emitter = new Emitter(); + this._optionEmitters.set(optionGroupId, emitter); + this._pickerWidgetDisposables.add(emitter); + } + return emitter; + } + + private _disposePickerWidgets(): void { + this._pickerWidgetDisposables.clear(); + this._pickerWidgets.clear(); + this._optionEmitters.clear(); + } + + private _clearExtensionPickers(): void { + this._pickerWidgetDisposables.clear(); + this._pickerWidgets.clear(); + this._optionEmitters.clear(); + if (this._extensionPickersLeftContainer) { + dom.clearNode(this._extensionPickersLeftContainer); + } + if (this._extensionPickersRightContainer) { + dom.clearNode(this._extensionPickersRightContainer); + } + } + + // --- Send --- + + private _send(): void { + const query = this._editor.getModel()?.getValue().trim(); + if (!query) { + return; + } + + const target = this._targetConfig.selectedTarget.get(); + if (!target) { + this.logService.warn('ChatWelcomeWidget: No target selected, cannot create session'); + return; + } + + const position = this._options.sessionPosition ?? ChatSessionPosition.Sidebar; + const resource = this._pendingSessionResource + ?? getResourceForNewChatSession({ type: target, position, displayName: '' }); + + const contribution = target !== AgentSessionProviders.Local + ? this.chatSessionsService.getChatSessionContribution(target) + : undefined; + + const sendOptions: IChatSendRequestOptions = { + location: ChatAgentLocation.Chat, + userSelectedModelId: this._currentLanguageModel.get()?.identifier, + modeInfo: { + kind: ChatModeKind.Agent, + isBuiltin: true, + modeInstructions: undefined, + modeId: 'agent', + applyCodeBlockSuggestionId: undefined, + }, + agentIdSilent: contribution?.type, + }; + + this._options.onSendRequest?.({ + resource, + target, + query, + sendOptions, + selectedOptions: new Map(this._selectedOptions), + }); + } + + // --- Layout --- + + layout(_height: number, _width: number): void { + this._editor?.layout(); + } + + setVisible(_visible: boolean): void { + // no-op + } + + focusInput(): void { + this._editor?.focus(); + } + + updateAllowedTargets(targets: AgentSessionProviders[]): void { + this._targetConfig.setAllowedTargets(targets); + } +} + +// #endregion + +// #region --- New Chat View Pane --- + +export const SessionsViewId = 'workbench.view.sessions.chat'; + +/** + * A view pane that hosts the new-session welcome widget. + */ +export class NewChatViewPane extends ViewPane { + + private _widget: NewChatWidget | undefined; + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @ILogService private readonly logService: ILogService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + this._widget = this._register(this.instantiationService.createInstance( + NewChatWidget, + { + targetConfig: { + allowedTargets: this.computeAllowedTargets(), + defaultTarget: AgentSessionProviders.Background, + }, + onSendRequest: (data) => { + this.activeSessionService.openSessionAndSend( + data.resource, data.query, data.sendOptions, data.selectedOptions + ).catch(e => this.logService.error('NewChatViewPane: Failed to open session and send request', e)); + }, + } satisfies INewChatWidgetOptions, + )); + + this._widget.render(container); + this._widget.focusInput(); + + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => { + this._widget?.updateAllowedTargets(this.computeAllowedTargets()); + })); + } + + private computeAllowedTargets(): AgentSessionProviders[] { + const targets: AgentSessionProviders[] = []; + if (this.workspaceContextService.getWorkspace().folders.length === 1) { + targets.push(AgentSessionProviders.Local); + } + targets.push(AgentSessionProviders.Background, AgentSessionProviders.Cloud); + return targets; + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this._widget?.layout(height, width); + } + + override focus(): void { + super.focus(); + this._widget?.focusInput(); + } + + override setVisible(visible: boolean): void { + super.setVisible(visible); + this._widget?.setVisible(visible); + } +} + +// #endregion + +/** + * Check whether an option group represents the model picker. + * The convention is `id: 'models'` but extensions may use different IDs + * per session type, so we also fall back to name matching. + */ +function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { + if (group.id === 'models') { + return true; + } + const nameLower = group.name.toLowerCase(); + return nameLower === 'model' || nameLower === 'models'; +} + +/** + * Check whether an option group represents a repository or folder picker. + * These are placed on the right side of the pickers row. + */ +function isRepoOrFolderGroup(group: IChatSessionProviderOptionGroup): boolean { + const idLower = group.id.toLowerCase(); + const nameLower = group.name.toLowerCase(); + return idLower === 'repositories' || idLower === 'folders' || + nameLower === 'repository' || nameLower === 'repositories' || + nameLower === 'folder' || nameLower === 'folders'; +} + +function getAgentSessionProviderName(provider: AgentSessionProviders): string { + switch (provider) { + case AgentSessionProviders.Local: + return localize('chat.session.providerLabel.local', "Local"); + case AgentSessionProviders.Background: + return localize('chat.session.providerLabel.background', "Worktree"); + case AgentSessionProviders.Cloud: + return localize('chat.session.providerLabel.cloud', "Cloud"); + case AgentSessionProviders.Claude: + return 'Claude'; + case AgentSessionProviders.Codex: + return 'Codex'; + case AgentSessionProviders.Growth: + return 'Growth'; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts new file mode 100644 index 0000000000000..69c0e8e2497dd --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.js'; +import { PromptFilesLocator } from '../../../../workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.js'; +import { Event } from '../../../../base/common/event.js'; +import { basename, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; +import { HOOKS_SOURCE_FOLDER } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { ISearchService } from '../../../../workbench/services/search/common/search.js'; +import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; + +export class AgenticPromptsService extends PromptsService { + protected override createPromptFilesLocator(): PromptFilesLocator { + return this.instantiationService.createInstance(AgenticPromptFilesLocator); + } +} + +class AgenticPromptFilesLocator extends PromptFilesLocator { + + constructor( + @IFileService fileService: IFileService, + @IConfigurationService configService: IConfigurationService, + @IWorkspaceContextService workspaceService: IWorkspaceContextService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @ISearchService searchService: ISearchService, + @IUserDataProfileService userDataService: IUserDataProfileService, + @ILogService logService: ILogService, + @IPathService pathService: IPathService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + ) { + super( + fileService, + configService, + workspaceService, + environmentService, + searchService, + userDataService, + logService, + pathService + ); + } + + protected override getWorkspaceFolders(): readonly IWorkspaceFolder[] { + const folder = this.getActiveWorkspaceFolder(); + return folder ? [folder] : []; + } + + protected override getWorkspaceFolder(resource: URI): IWorkspaceFolder | undefined { + const folder = this.getActiveWorkspaceFolder(); + if (!folder) { + return undefined; + } + return isEqualOrParent(resource, folder.uri) ? folder : undefined; + } + + protected override onDidChangeWorkspaceFolders(): Event { + return Event.fromObservableLight(this.activeSessionService.activeSession); + } + + public override async getHookSourceFolders(): Promise { + const configured = await super.getHookSourceFolders(); + if (configured.length > 0) { + return configured; + } + const folder = this.getActiveWorkspaceFolder(); + return folder ? [joinPath(folder.uri, HOOKS_SOURCE_FOLDER)] : []; + } + + private getActiveWorkspaceFolder(): IWorkspaceFolder | undefined { + const session = this.activeSessionService.getActiveSession(); + const root = session?.worktree ?? session?.repository; + if (!root) { + return undefined; + } + return { + uri: root, + name: basename(root), + index: 0, + toResource: relativePath => joinPath(root, relativePath), + }; + } +} + diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts new file mode 100644 index 0000000000000..6138f48d98e69 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableSignal } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { MenuId, registerAction2, Action2, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { Menus } from '../../../browser/menus.js'; + + +// Storage keys +const STORAGE_KEY_DEFAULT_RUN_ACTION = 'workbench.agentSessions.defaultRunAction'; + +// Menu IDs - exported for use in auxiliary bar part +export const RunScriptDropdownMenuId = MenuId.for('AgentSessionsRunScriptDropdown'); + +// Action IDs +const RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; +const CONFIGURE_DEFAULT_RUN_ACTION_ID = 'workbench.action.agentSessions.configureDefaultRunAction'; + +// Types for stored default action +interface IStoredRunAction { + readonly name: string; + readonly command: string; +} + +interface IRunScriptActionContext { + readonly storageKey: string; + readonly action: IStoredRunAction | undefined; + readonly cwd: URI; +} + +/** + * Workbench contribution that adds a split dropdown action to the auxiliary bar title + * for running a custom command. + */ +export class RunScriptContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentSessions.runScript'; + + private readonly _activeRunState: IObservable; + private readonly _updateSignal = observableSignal(this); + + constructor( + @IStorageService private readonly _storageService: IStorageService, + @ITerminalService private readonly _terminalService: ITerminalService, + @ISessionsManagementService activeSessionService: ISessionsManagementService, + @IQuickInputService private readonly _quickInputService: IQuickInputService, + ) { + super(); + + this._activeRunState = derived(this, reader => { + const activeSession = activeSessionService.activeSession.read(reader); + if (!activeSession || !activeSession.repository) { + return undefined; + } + + this._updateSignal.read(reader); + const storageKey = `${STORAGE_KEY_DEFAULT_RUN_ACTION}.${activeSession.repository.toString()}`; + const action = this._getStoredDefaultAction(storageKey); + + return { + storageKey, + action, + cwd: activeSession.worktree ?? activeSession.repository + }; + }); + + this._registerActions(); + } + + private _getStoredDefaultAction(storageKey: string): IStoredRunAction | undefined { + const stored = this._storageService.get(storageKey, StorageScope.WORKSPACE); + if (stored) { + try { + const parsed = JSON.parse(stored); + if (typeof parsed?.name === 'string' && typeof parsed?.command === 'string') { + return parsed; + } + } catch { + return undefined; + } + } + return undefined; + } + + private _setStoredDefaultAction(storageKey: string, action: IStoredRunAction): void { + this._storageService.store(storageKey, JSON.stringify(action), StorageScope.WORKSPACE, StorageTarget.MACHINE); + this._updateSignal.trigger(undefined); + } + + private _registerActions(): void { + const that = this; + + // Main play action + this._register(autorun(reader => { + const activeSession = this._activeRunState.read(reader); + if (!activeSession) { + return; + } + + const title = activeSession.action ? activeSession.action.name : localize('runScriptNoAction', "Run Script"); + const tooltip = activeSession.action ? + localize('runScriptTooltip', "Run '{0}' in terminal", activeSession.action.name) + : localize('runScriptTooltipNoAction', "Configure run action"); + + reader.store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: RUN_SCRIPT_ACTION_ID, + title: title, + tooltip: tooltip, + icon: Codicon.play, + category: localize2('agentSessions', 'Agent Sessions'), + menu: [{ + id: RunScriptDropdownMenuId, + group: 'navigation', + order: 0, + }] + }); + } + + async run(): Promise { + if (activeSession.action) { + await that._runScript(activeSession.cwd, activeSession.action); + } else { + // Open quick pick to configure run action + await that._showConfigureQuickPick(activeSession); + } + } + })); + + // Configure run action (shown in dropdown) + reader.store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: CONFIGURE_DEFAULT_RUN_ACTION_ID, + title: localize2('configureDefaultRunAction', "Configure Run Action..."), + category: localize2('agentSessions', 'Agent Sessions'), + icon: Codicon.play, + menu: [{ + id: RunScriptDropdownMenuId, + group: '0_configure', + order: 0 + }] + }); + } + + async run(): Promise { + await that._showConfigureQuickPick(activeSession); + } + })); + })); + } + + private async _showConfigureQuickPick(activeSession: IRunScriptActionContext): Promise { + + // Show input box for command + const command = await this._quickInputService.input({ + placeHolder: localize('enterCommandPlaceholder', "Enter command (e.g., npm run dev)"), + prompt: localize('enterCommandPrompt', "This command will be run in the integrated terminal") + }); + + if (command) { + const storedAction: IStoredRunAction = { + name: command, + command + }; + this._setStoredDefaultAction(activeSession.storageKey, storedAction); + await this._runScript(activeSession.cwd, storedAction); + } + } + + private async _runScript(cwd: URI, action: IStoredRunAction): Promise { + // Create a new terminal and run the command + const terminal = await this._terminalService.createTerminal({ + location: TerminalLocation.Panel, + config: { + name: action.name + }, + cwd + }); + + terminal.sendText(action.command, true); + await this._terminalService.revealTerminal(terminal); + } +} + +// Register the Run split button submenu on the workbench title bar +MenuRegistry.appendMenuItem(Menus.TitleBarRight, { + submenu: RunScriptDropdownMenuId, + isSplitButton: true, + title: localize2('run', "Run"), + icon: Codicon.play, + group: 'navigation', + order: 8, +}); diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts new file mode 100644 index 0000000000000..db053fcef2266 --- /dev/null +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; + +Registry.as(Extensions.Configuration).registerDefaultConfigurations([{ + overrides: { + 'chat.agentsControl.clickBehavior': 'focus', + 'chat.agentsControl.enabled': true, + 'chat.agent.maxRequests': 1000, + 'chat.restoreLastPanelSession': true, + 'chat.unifiedAgentsBar.enabled': true, + 'chat.viewSessions.enabled': false, + + 'diffEditor.renderSideBySide': false, + 'diffEditor.hideUnchangedRegions.enabled': true, + + 'files.autoSave': 'afterDelay', + + 'git.showProgress': false, + + 'github.copilot.chat.claudeCode.enabled': true, + 'github.copilot.chat.cli.branchSupport.enabled': true, + 'github.copilot.chat.languageContext.typescript.enabled': true, + + 'inlineChat.affordance': 'editor', + 'inlineChat.renderMode': 'hover', + + 'workbench.editor.restoreEditors': false, + 'workbench.editor.showTabs': 'single', + 'workbench.startupEditor': 'none', + 'workbench.tips.enabled': false, + 'workbench.layoutControl.type': 'toggles', + 'workbench.editor.allowOpenInModalEditor': false + }, + donotCache: true +}]); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css new file mode 100644 index 0000000000000..f8b2715466dbc --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.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. + *--------------------------------------------------------------------------------------------*/ + +/* Container - button style hover */ +.command-center .agent-sessions-titlebar-container { + display: flex; + width: 38vw; + max-width: 600px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + padding: 0 10px; + height: 22px; + border-radius: 4px; + cursor: pointer; + -webkit-app-region: no-drag; + overflow: hidden; + color: var(--vscode-commandCenter-foreground); + gap: 6px; +} + +.command-center .agent-sessions-titlebar-container:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.command-center .agent-sessions-titlebar-container:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Center group: icon + label + folder + changes */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-center { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + justify-content: center; +} + +/* Kind icon */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-icon { + display: flex; + align-items: center; + flex-shrink: 0; + font-size: 14px; +} + +/* Label (title) */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Repository/folder label */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-repo { + display: flex; + align-items: center; + flex-shrink: 0; + opacity: 0.7; +} + +/* Dot separator */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-separator { + opacity: 0.5; + flex-shrink: 0; +} + +/* Changes container */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes { + display: flex; + align-items: center; + gap: 3px; + cursor: pointer; + padding: 0 4px; + border-radius: 3px; +} + +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Changes icon */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-icon { + display: flex; + align-items: center; + flex-shrink: 0; + font-size: 14px; +} + +/* Insertions */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +/* Deletions */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css new file mode 100644 index 0000000000000..4b37cbc4323ab --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agent-sessions-viewpane { + + .agent-sessions-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + + /* Section headers - more prominent than time-based groupings */ + .ai-customization-header, + .agent-sessions-header { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-foreground)); + padding: 6px 20px 6px 12px; + letter-spacing: 0.05em; + } + + /* Customization header - clickable for collapse */ + .ai-customization-header { + display: flex; + align-items: center; + cursor: pointer; + user-select: none; + margin: 0 6px; + border-radius: 6px; + } + + .ai-customization-header:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .ai-customization-header:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .ai-customization-chevron, + .agent-sessions-chevron { + flex-shrink: 0; + margin-left: auto; + padding-right: 4px; + opacity: 0; + transition: opacity 0.1s ease-in-out; + } + + .ai-customization-header:hover .ai-customization-chevron, + .ai-customization-header:focus .ai-customization-chevron, + .agent-sessions-header:hover .agent-sessions-chevron, + .agent-sessions-header:focus .agent-sessions-chevron { + opacity: 0.7; + } + + /* AI Customization section - pinned to bottom */ + .ai-customization-shortcuts { + display: flex; + flex-direction: column; + flex-shrink: 0; + border-top: 1px solid var(--vscode-widget-border); + margin-top: 8px; + padding-top: 4px; + padding-bottom: 8px; + } + + .ai-customization-shortcuts .ai-customization-links { + display: flex; + flex-direction: column; + max-height: 500px; + overflow: hidden; + transition: max-height 0.2s ease-out; + } + + .ai-customization-shortcuts .ai-customization-links.collapsed { + max-height: 0; + } + + .ai-customization-shortcuts .ai-customization-link { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--vscode-foreground); + cursor: pointer; + text-decoration: none; + padding: 6px 14px; + margin: 0 6px; + line-height: 22px; + border-radius: 6px; + } + + .ai-customization-shortcuts .ai-customization-link:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .ai-customization-shortcuts .ai-customization-link:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .ai-customization-shortcuts .ai-customization-link .link-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.85; + } + + .ai-customization-shortcuts .ai-customization-link .link-label { + flex: 1; + } + + .ai-customization-shortcuts .ai-customization-link .link-counts { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + } + + .ai-customization-shortcuts .ai-customization-link .link-counts.hidden { + display: none; + } + + .ai-customization-shortcuts .ai-customization-link .source-count-badge { + display: flex; + align-items: center; + gap: 2px; + } + + .ai-customization-shortcuts .ai-customization-link .source-count-icon { + font-size: 12px; + opacity: 0.6; + } + + .ai-customization-shortcuts .ai-customization-link .source-count-num { + font-size: 11px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + } + + /* Sessions section - fills remaining space above customizations */ + .agent-sessions-section { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + min-height: 0; + } + + .agent-sessions-header { + display: flex; + align-items: center; + gap: 4px; + padding-top: 10px; + padding-right: 12px; + user-select: none; + } + + .agent-sessions-header .agent-sessions-header-toolbar { + margin-left: auto; + } + + .agent-sessions-header:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .agent-sessions-content { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + } + + .agent-sessions-content.collapsed { + display: none; + } + + .agent-sessions-new-button-container { + padding: 6px 12px 8px 12px; + } + + .agent-sessions-new-button-container .monaco-button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + } + + .agent-sessions-new-button-container .monaco-button .new-session-keybinding-hint { + position: absolute; + right: 10px; + font-size: 11px; + opacity: 0.5; + } + + .agent-sessions-control-container { + flex: 1; + overflow: hidden; + + /* Override section header padding to align with dot indicators */ + .agent-session-section { + padding-left: 12px; + } + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts new file mode 100644 index 0000000000000..52bd441711531 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IViewDescriptor, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility, ViewContainer, IViewContainersRegistry, ViewContainerLocation } from '../../../../workbench/common/views.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; +import { AgenticSessionsViewPane, SessionsViewId } from './sessionsViewPane.js'; +import { SessionsManagementService, ISessionsManagementService } from './sessionsManagementService.js'; + +const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); +const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); +const SessionsContainerId = 'agentic.workbench.view.sessionsContainer'; + +const agentSessionsViewContainer: ViewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({ + id: SessionsContainerId, + title: AGENT_SESSIONS_VIEW_TITLE, + icon: agentSessionsViewIcon, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [SessionsContainerId, { mergeViewWithContainerWhenSingleView: true, }]), + storageId: SessionsContainerId, + hideIfEmpty: true, + order: 6, + windowVisibility: WindowVisibility.Sessions +}, ViewContainerLocation.Sidebar, { isDefault: true }); + +const agentSessionsViewDescriptor: IViewDescriptor = { + id: SessionsViewId, + containerIcon: agentSessionsViewIcon, + containerTitle: AGENT_SESSIONS_VIEW_TITLE.value, + singleViewPaneContainerTitle: AGENT_SESSIONS_VIEW_TITLE.value, + name: AGENT_SESSIONS_VIEW_TITLE, + canToggleVisibility: false, + canMoveView: false, + ctorDescriptor: new SyncDescriptor(AgenticSessionsViewPane), + windowVisibility: WindowVisibility.Sessions +}; + +Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([agentSessionsViewDescriptor], agentSessionsViewContainer); + +registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); + +registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts new file mode 100644 index 0000000000000..8686c11d381b2 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -0,0 +1,351 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ISessionOpenOptions, openSession as openSessionDefault } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatSessionItem, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; + +export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); + +//#region Active Session Service + +const LAST_SELECTED_SESSION_KEY = 'agentSessions.lastSelectedSession'; +const repositoryOptionId = 'repository'; + +/** + * An active session item extends IChatSessionItem with repository information. + * - For agent session items: repository is the workingDirectory from metadata + * - For new sessions: repository comes from the session option with id 'repository' + */ +export type IActiveSessionItem = (IChatSessionItem | IAgentSession) & { + /** + * The repository URI for this session. + */ + readonly repository: URI | undefined; + + /** + * The worktree URI for this session. + */ + readonly worktree: URI | undefined; +}; + +export interface ISessionsManagementService { + readonly _serviceBrand: undefined; + + /** + * Observable for the currently active session. + */ + readonly activeSession: IObservable; + + /** + * Returns the currently active session, if any. + */ + getActiveSession(): IActiveSessionItem | undefined; + + /** + * Select an existing session as the active session. + * Sets `isNewChatSession` context to false and opens the session. + */ + openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise; + + /** + * Open a new session, apply options, and send the initial request. + * This is the main entry point for the new-chat welcome widget. + */ + openSessionAndSend(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise; + + /** + * Switch to the new-session view. + * No-op if the current session is already a new session. + */ + openNewSession(): void; +} + +export const ISessionsManagementService = createDecorator('sessionsManagementService'); + +export class SessionsManagementService extends Disposable implements ISessionsManagementService { + + declare readonly _serviceBrand: undefined; + + private readonly _activeSession = observableValue(this, undefined); + readonly activeSession: IObservable = this._activeSession; + + private lastSelectedSession: URI | undefined; + private readonly isNewChatSessionContext: IContextKey; + + constructor( + @IStorageService private readonly storageService: IStorageService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatService private readonly chatService: IChatService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILogService private readonly logService: ILogService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + // Bind context key to active session state. + // isNewSession is false when there are any established sessions in the model. + this.isNewChatSessionContext = IsNewChatSessionContext.bindTo(contextKeyService); + + // Load last selected session + this.lastSelectedSession = this.loadLastSelectedSession(); + + // Save on shutdown + this._register(this.storageService.onWillSaveState(() => { + this.saveLastSelectedSession(); + })); + + // Update active session when session options change + this._register(this.chatSessionsService.onDidChangeSessionOptions(sessionResource => { + const currentActive = this._activeSession.get(); + if (currentActive && currentActive.resource.toString() === sessionResource.toString()) { + // Re-fetch the repository from session options and update the active session + const repository = this.getRepositoryFromSessionOption(sessionResource); + if (currentActive.repository?.toString() !== repository?.toString()) { + this._activeSession.set({ ...currentActive, repository }, undefined); + } + } + })); + + // Update active session when the agent sessions model changes (e.g., metadata updates with worktree/repository info) + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this.refreshActiveSessionFromModel(); + })); + } + + private refreshActiveSessionFromModel(): void { + const currentActive = this._activeSession.get(); + if (!currentActive) { + return; + } + + const agentSession = this.agentSessionsService.model.getSession(currentActive.resource); + if (!agentSession) { + // Only switch sessions if the active session was a known agent session + // that got deleted. New session resources that aren't yet in the model + // should not trigger a switch. + if (isAgentSession(currentActive)) { + this.showNextSession(); + } + return; + } + + const [repository, worktree] = this.getRepositoryFromMetadata(agentSession.metadata); + const activeSessionItem: IActiveSessionItem = { + ...agentSession, + repository, + worktree, + }; + this._activeSession.set(activeSessionItem, undefined); + } + + private showNextSession(): void { + const sessions = this.agentSessionsService.model.sessions + .filter(s => !s.isArchived()) + .sort((a, b) => (b.timing.lastRequestEnded ?? b.timing.created) - (a.timing.lastRequestEnded ?? a.timing.created)); + + if (sessions.length > 0) { + this.setActiveSession(sessions[0]); + this.instantiationService.invokeFunction(openSessionDefault, sessions[0]); + } else { + this.openNewSession(); + } + } + + private getRepositoryFromMetadata(metadata: { readonly [key: string]: unknown } | undefined): [URI | undefined, URI | undefined] { + if (!metadata) { + return [undefined, undefined]; + } + + const repositoryPath = metadata?.repositoryPath as string | undefined; + const repositoryPathUri = typeof repositoryPath === 'string' ? URI.file(repositoryPath) : undefined; + + const worktreePath = metadata?.worktreePath as string | undefined; + const worktreePathUri = typeof worktreePath === 'string' ? URI.file(worktreePath) : undefined; + + return [ + URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined, + URI.isUri(worktreePathUri) ? worktreePathUri : undefined]; + } + + private getRepositoryFromSessionOption(sessionResource: URI): URI | undefined { + const optionValue = this.chatSessionsService.getSessionOption(sessionResource, repositoryOptionId); + if (!optionValue) { + return undefined; + } + + // Option value can be a string or IChatSessionProviderOptionItem + const optionId = typeof optionValue === 'string' ? optionValue : (optionValue as IChatSessionProviderOptionItem).id; + if (!optionId) { + return undefined; + } + + try { + return URI.parse(optionId); + } catch { + return undefined; + } + } + + getActiveSession(): IActiveSessionItem | undefined { + return this._activeSession.get(); + } + + async openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise { + const session = this.agentSessionsService.model.getSession(sessionResource); + if (session) { + this.isNewChatSessionContext.set(false); + this.setActiveSession(session); + await this.instantiationService.invokeFunction(openSessionDefault, session, openOptions); + } else { + // For new sessions, load via the chat service first so the model + // is ready before the ChatViewPane renders it. + const modelRef = await this.chatService.loadSessionForResource(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + // Switch view only after the model is loaded so the ChatViewPane + // has content immediately when it becomes visible. + this.isNewChatSessionContext.set(false); + const chatWidget = await this.chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + if (!chatWidget?.viewModel) { + this.logService.warn(`[ActiveSessionService] Failed to open session: ${sessionResource.toString()}`); + modelRef?.dispose(); + return; + } + const repository = this.getRepositoryFromSessionOption(sessionResource); + const activeSessionItem: IActiveSessionItem = { + resource: sessionResource, + label: chatWidget.viewModel.model.title || '', + timing: chatWidget.viewModel.model.timing, + repository, + worktree: undefined + }; + this.logService.info(`[ActiveSessionService] Active session changed (new): ${sessionResource.toString()}, repository: ${repository?.toString() ?? 'none'}`); + this._activeSession.set(activeSessionItem, undefined); + } + } + + async openSessionAndSend(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise { + // 1. Open the session in ChatViewPane - this transitions views, + // loads the model, and connects it to the ChatWidget so + // tool invocations work. + await this.openSession(sessionResource); + + // 2. Apply selected options to the contributed session + if (selectedOptions && selectedOptions.size > 0) { + const modelRef = this.chatService.getActiveSessionReference(sessionResource); + if (modelRef) { + const model = modelRef.object; + const contributedSession = model.contributedChatSession; + if (contributedSession) { + const initialSessionOptions = [...selectedOptions.entries()].map( + ([optionId, value]) => ({ optionId, value }) + ); + model.setContributedChatSession({ + ...contributedSession, + initialSessionOptions, + }); + } + modelRef.dispose(); + } + } + + // 3. Snapshot existing session resources so we can detect the new one + const existingResources = new Set( + this.agentSessionsService.model.sessions.map(s => s.resource.toString()) + ); + + // 4. Send the request through the chat service - the model is now + // connected to the ChatWidget, so tools and rendering work. + const result = await this.chatService.sendRequest(sessionResource, query, sendOptions); + if (result.kind === 'rejected') { + this.logService.error(`[ActiveSessionService] sendRequest rejected: ${result.reason}`); + return; + } + + // 5. After send, the extension creates an agent session. Wait for it + // and set it as the active session so the titlebar and sidebar + // reflect the new session. + let newSession = this.agentSessionsService.model.sessions.find( + s => !existingResources.has(s.resource.toString()) + ); + + if (!newSession) { + let listener: IDisposable | undefined; + newSession = await Promise.race([ + new Promise(resolve => { + listener = this.agentSessionsService.model.onDidChangeSessions(() => { + const session = this.agentSessionsService.model.sessions.find( + s => !existingResources.has(s.resource.toString()) + ); + if (session) { + resolve(session); + } + }); + }), + new Promise(resolve => setTimeout(() => resolve(undefined), 30_000)), + ]); + listener?.dispose(); + } + + if (newSession) { + this.setActiveSession(newSession); + } + } + + openNewSession(): void { + // No-op if the current session is already a new session + if (this.isNewChatSessionContext.get()) { + return; + } + this.isNewChatSessionContext.set(true); + this._activeSession.set(undefined, undefined); + } + + private setActiveSession(session: IAgentSession): void { + this.lastSelectedSession = session.resource; + const [repository, worktree] = this.getRepositoryFromMetadata(session.metadata); + const activeSessionItem: IActiveSessionItem = { + ...session, + repository, + worktree, + }; + this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}, repository: ${repository?.toString() ?? 'none'}`); + this._activeSession.set(activeSessionItem, undefined); + } + + private loadLastSelectedSession(): URI | undefined { + const cached = this.storageService.get(LAST_SELECTED_SESSION_KEY, StorageScope.WORKSPACE); + if (!cached) { + return undefined; + } + + try { + return URI.parse(cached); + } catch { + return undefined; + } + } + + private saveLastSelectedSession(): void { + if (this.lastSelectedSession) { + this.storageService.store(LAST_SELECTED_SESSION_KEY, this.lastSelectedSession.toString(), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + } +} + +//#endregion diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts new file mode 100644 index 0000000000000..faba73697edfb --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -0,0 +1,400 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionsTitleBarWidget.css'; +import { $, addDisposableListener, EventType, reset } from '../../../../base/browser/dom.js'; + +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { Menus } from '../../../browser/menus.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ISessionsManagementService } from './sessionsManagementService.js'; +import { FocusAgentSessionsAction } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsActions.js'; +import { AgentSessionsPicker } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { getAgentChangesSummary, hasValidDiff, IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { getAgentSessionProvider, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { basename } from '../../../../base/common/resources.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ViewAllSessionChangesAction } from '../../../../workbench/contrib/chat/browser/chatEditing/chatEditingActions.js'; + +/** + * Sessions Title Bar Widget - renders the active chat session title + * in the command center of the agent sessions workbench. + * + * Shows the current chat session label as a clickable pill with: + * - Kind icon at the beginning (provider type icon) + * - Session title + * - Repository folder name + * - Changes summary (+insertions -deletions) + * + * On click, opens the sessions picker. + */ +export class SessionsTitleBarWidget extends BaseActionViewItem { + + private _container: HTMLElement | undefined; + private readonly _dynamicDisposables = this._register(new DisposableStore()); + private readonly _modelChangeListener = this._register(new MutableDisposable()); + + /** Cached render state to avoid unnecessary DOM rebuilds */ + private _lastRenderState: string | undefined; + + /** Guard to prevent re-entrant rendering */ + private _isRendering = false; + + constructor( + action: SubmenuItemAction, + options: IBaseActionViewItemOptions | undefined, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IHoverService private readonly hoverService: IHoverService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + @IChatService private readonly chatService: IChatService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(undefined, action, options); + + // Re-render when the active session changes + this._register(autorun(reader => { + const activeSession = this.activeSessionService.activeSession.read(reader); + this._trackModelTitleChanges(activeSession?.resource); + this._lastRenderState = undefined; + this._render(); + })); + + // Re-render when sessions data changes (e.g., changes info updated) + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this._lastRenderState = undefined; + this._render(); + })); + } + + override render(container: HTMLElement): void { + super.render(container); + + this._container = container; + container.classList.add('agent-sessions-titlebar-container'); + + // Initial render + this._render(); + } + + override setFocusable(_focusable: boolean): void { + // Don't set focusable on the container + } + + // Override onClick to prevent the base class from running the underlying + // submenu action when the widget handles clicks itself. + override onClick(): void { + // No-op: click handling is done by the pill handler + } + + private _render(): void { + if (!this._container) { + return; + } + + if (this._isRendering) { + return; + } + this._isRendering = true; + + try { + const label = this._getActiveSessionLabel(); + const icon = this._getActiveSessionIcon(); + const repoLabel = this._getRepositoryLabel(); + const changes = this._getChanges(); + + // Build a render-state key from all displayed data + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${changes?.insertions ?? ''}|${changes?.deletions ?? ''}`; + + // Skip re-render if state hasn't changed + if (this._lastRenderState === renderState) { + return; + } + this._lastRenderState = renderState; + + // Clear existing content + reset(this._container); + this._dynamicDisposables.clear(); + + // Set up container as the button directly + this._container.setAttribute('role', 'button'); + this._container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); + this._container.tabIndex = 0; + + // Center group: icon + label + folder + changes together + const centerGroup = $('span.agent-sessions-titlebar-center'); + + // Kind icon at the beginning + if (icon) { + const iconEl = $('span.agent-sessions-titlebar-icon' + ThemeIcon.asCSSSelector(icon)); + centerGroup.appendChild(iconEl); + } + + // Label + const labelEl = $('span.agent-sessions-titlebar-label'); + labelEl.textContent = label; + centerGroup.appendChild(labelEl); + + // Folder and changes shown next to the title + if (repoLabel || changes) { + if (repoLabel) { + const separator1 = $('span.agent-sessions-titlebar-separator'); + separator1.textContent = '\u00B7'; + centerGroup.appendChild(separator1); + + const repoEl = $('span.agent-sessions-titlebar-repo'); + repoEl.textContent = repoLabel; + centerGroup.appendChild(repoEl); + } + + if (changes) { + const separator2 = $('span.agent-sessions-titlebar-separator'); + separator2.textContent = '\u00B7'; + centerGroup.appendChild(separator2); + + const changesEl = $('span.agent-sessions-titlebar-changes'); + + // Diff icon + const changesIconEl = $('span.agent-sessions-titlebar-changes-icon' + ThemeIcon.asCSSSelector(Codicon.diffMultiple)); + changesEl.appendChild(changesIconEl); + + const addedEl = $('span.agent-sessions-titlebar-added'); + addedEl.textContent = `+${changes.insertions}`; + changesEl.appendChild(addedEl); + + const removedEl = $('span.agent-sessions-titlebar-removed'); + removedEl.textContent = `-${changes.deletions}`; + changesEl.appendChild(removedEl); + + centerGroup.appendChild(changesEl); + + // Separate hover for changes + this._dynamicDisposables.add(this.hoverService.setupManagedHover( + getDefaultHoverDelegate('mouse'), + changesEl, + localize('agentSessions.viewChanges', "View All Changes") + )); + + // Click on changes opens multi-diff editor + this._dynamicDisposables.add(addDisposableListener(changesEl, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._openChanges(); + })); + } + } + + this._container.appendChild(centerGroup); + + // Hover + this._dynamicDisposables.add(this.hoverService.setupManagedHover( + getDefaultHoverDelegate('mouse'), + this._container, + label + )); + + // Click handler - show sessions picker + this._dynamicDisposables.add(addDisposableListener(this._container, EventType.MOUSE_DOWN, (e) => { + e.preventDefault(); + e.stopPropagation(); + })); + this._dynamicDisposables.add(addDisposableListener(this._container, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._showSessionsPicker(); + })); + + // Keyboard handler + this._dynamicDisposables.add(addDisposableListener(this._container, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._showSessionsPicker(); + } + })); + } finally { + this._isRendering = false; + } + } + + /** + * Track title changes on the chat model for the given session resource. + * When the model title changes, re-render the widget. + */ + private _trackModelTitleChanges(sessionResource: URI | undefined): void { + this._modelChangeListener.clear(); + + if (!sessionResource) { + return; + } + + const model = this.chatService.getSession(sessionResource); + if (!model) { + return; + } + + this._modelChangeListener.value = model.onDidChange(e => { + if (e.kind === 'setCustomTitle' || e.kind === 'addRequest') { + this._lastRenderState = undefined; + this._render(); + } + }); + } + + /** + * Get the label of the active chat session. + * Prefers the live model title over the snapshot label from the active session service. + * Falls back to a generic label if no active session is found. + */ + private _getActiveSessionLabel(): string { + const activeSession = this.activeSessionService.getActiveSession(); + if (activeSession?.resource) { + const model = this.chatService.getSession(activeSession.resource); + if (model?.title) { + return model.title; + } + } + + if (activeSession?.label) { + return activeSession.label; + } + + return localize('agentSessions.newSession', "New Session"); + } + + /** + * Get the icon for the active session's kind/provider. + */ + private _getActiveSessionIcon(): ThemeIcon | undefined { + const activeSession = this.activeSessionService.getActiveSession(); + if (!activeSession) { + return undefined; + } + + // Try to get icon from the agent session model (has provider-resolved icon) + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (agentSession) { + return agentSession.icon; + } + + // Fall back to provider icon from the resource + const provider = getAgentSessionProvider(activeSession.resource); + if (provider !== undefined) { + return getAgentSessionProviderIcon(provider); + } + + return undefined; + } + + /** + * Get the repository label for the active session. + */ + private _getRepositoryLabel(): string | undefined { + const activeSession = this.activeSessionService.getActiveSession(); + if (!activeSession) { + return undefined; + } + + const uri = activeSession.repository; + if (!uri) { + return undefined; + } + + return basename(uri); + } + + /** + * Get the changes summary (insertions/deletions) for the active session. + */ + private _getChanges(): { insertions: number; deletions: number } | undefined { + const activeSession = this.activeSessionService.getActiveSession(); + if (!activeSession) { + return undefined; + } + + let changes: IAgentSession['changes'] | undefined; + + if (isAgentSession(activeSession)) { + changes = activeSession.changes; + } else { + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + changes = agentSession?.changes; + } + + if (!changes || !hasValidDiff(changes)) { + return undefined; + } + + return getAgentChangesSummary(changes) ?? undefined; + } + + private _showSessionsPicker(): void { + const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined); + picker.pickAgentSession(); + } + + private _openChanges(): void { + const activeSession = this.activeSessionService.getActiveSession(); + if (!activeSession) { + return; + } + + this.commandService.executeCommand(ViewAllSessionChangesAction.ID, activeSession.resource); + } +} + +/** + * Provides custom rendering for the sessions title bar widget + * in the command center. Uses IActionViewItemService to render a custom widget + * for the TitleBarControlMenu submenu. + */ +export class SessionsTitleBarContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentSessionsTitleBar'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + // Register the submenu item in the Agent Sessions command center + this._register(MenuRegistry.appendMenuItem(Menus.CommandCenter, { + submenu: Menus.TitleBarControlMenu, + title: localize('agentSessionsControl', "Agent Sessions"), + order: 101, + })); + + // Register a placeholder action so the submenu appears + this._register(MenuRegistry.appendMenuItem(Menus.TitleBarControlMenu, { + command: { + id: FocusAgentSessionsAction.id, + title: localize('showSessions', "Show Sessions"), + }, + group: 'a_sessions', + order: 1 + })); + + this._register(actionViewItemService.register(Menus.CommandCenter, Menus.TitleBarControlMenu, (action, options) => { + if (!(action instanceof SubmenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(SessionsTitleBarWidget, action, options); + }, undefined)); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts new file mode 100644 index 0000000000000..8f6af610ad5c2 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -0,0 +1,488 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionsViewPane.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../../workbench/common/views.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; +import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.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'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ACTION_ID_NEW_CHAT } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { AICustomizationManagementEditor } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementSection } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; +import { AICustomizationManagementEditorInput } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.js'; +import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; + +const $ = DOM.$; +export const SessionsViewId = 'agentic.workbench.view.sessionsView'; +const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); + +/** + * Per-source breakdown of item counts. + */ +interface ISourceCounts { + readonly workspace: number; + readonly user: number; + readonly extension: number; +} + +interface IShortcutItem { + readonly label: string; + readonly icon: ThemeIcon; + readonly action: () => Promise; + readonly getSourceCounts?: () => Promise; + /** For items without per-source breakdown (MCP, Models). */ + readonly getCount?: () => Promise; + countContainer?: HTMLElement; +} + +const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; + +export class AgenticSessionsViewPane extends ViewPane { + + private viewPaneContainer: HTMLElement | undefined; + private newSessionButtonContainer: HTMLElement | undefined; + private sessionsControlContainer: HTMLElement | undefined; + sessionsControl: AgentSessionsControl | undefined; + private aiCustomizationContainer: HTMLElement | undefined; + private readonly shortcuts: IShortcutItem[] = []; + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ICommandService commandService: ICommandService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IPromptsService private readonly promptsService: IPromptsService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IMcpService private readonly mcpService: IMcpService, + @IStorageService private readonly storageService: IStorageService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // Initialize shortcuts + this.shortcuts = [ + { label: localize('agents', "Agents"), icon: agentIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Agents), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.agent) }, + { label: localize('skills', "Skills"), icon: skillIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Skills), getSourceCounts: () => this.getSkillSourceCounts() }, + { label: localize('instructions', "Instructions"), icon: instructionsIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Instructions), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.instructions) }, + { label: localize('prompts', "Prompts"), icon: promptIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Prompts), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.prompt) }, + { label: localize('hooks', "Hooks"), icon: hookIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Hooks), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.hook) }, + { label: localize('mcpServers', "MCP Servers"), icon: Codicon.server, action: () => this.openAICustomizationSection(AICustomizationManagementSection.McpServers), getCount: () => Promise.resolve(this.mcpService.servers.get().length) }, + { label: localize('models', "Models"), icon: Codicon.vm, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Models), getCount: () => Promise.resolve(this.languageModelsService.getLanguageModelIds().length) }, + ]; + + // Listen to changes to update counts + this._register(this.promptsService.onDidChangeCustomAgents(() => this.updateCounts())); + this._register(this.promptsService.onDidChangeSlashCommands(() => this.updateCounts())); + this._register(this.languageModelsService.onDidChangeLanguageModels(() => this.updateCounts())); + this._register(autorun(reader => { + this.mcpService.servers.read(reader); + this.updateCounts(); + })); + + // Listen to workspace folder changes to update counts + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.updateCounts())); + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + this.updateCounts(); + })); + + } + + protected override renderBody(parent: HTMLElement): void { + super.renderBody(parent); + + this.viewPaneContainer = parent; + this.viewPaneContainer.classList.add('agent-sessions-viewpane'); + + this.createControls(parent); + } + + private createControls(parent: HTMLElement): void { + const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); + + // Sessions Filter (actions go to view title bar via menu registration) + const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { + filterMenuId: SessionsViewFilterSubMenu, + groupResults: () => AgentSessionsGrouping.Date + })); + + // Sessions section (top, fills available space) + const sessionsSection = DOM.append(sessionsContainer, $('.agent-sessions-section')); + + // Sessions content container + const sessionsContent = DOM.append(sessionsSection, $('.agent-sessions-content')); + + // New Session Button + const newSessionButtonContainer = this.newSessionButtonContainer = DOM.append(sessionsContent, $('.agent-sessions-new-button-container')); + const newSessionButton = this._register(new Button(newSessionButtonContainer, { ...defaultButtonStyles, secondary: true })); + newSessionButton.label = localize('newSession', "New Session"); + this._register(newSessionButton.onDidClick(() => this.activeSessionService.openNewSession())); + + // Keybinding hint inside the button + const keybinding = this.keybindingService.lookupKeybinding(ACTION_ID_NEW_CHAT); + if (keybinding) { + const keybindingHint = DOM.append(newSessionButton.element, $('span.new-session-keybinding-hint')); + keybindingHint.textContent = keybinding.getLabel() ?? ''; + } + + // Sessions Control + this.sessionsControlContainer = DOM.append(sessionsContent, $('.agent-sessions-control-container')); + const sessionsControl = this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsControlContainer, { + source: 'agentSessionsViewPane', + filter: sessionsFilter, + overrideStyles: this.getLocationBasedColors().listOverrideStyles, + getHoverPosition: () => this.getSessionHoverPosition(), + trackActiveEditorSession: () => true, + collapseOlderSections: () => true, + overrideSessionOpen: (resource, openOptions) => this.activeSessionService.openSession(resource, openOptions), + })); + this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible))); + + // Listen to tree updates and restore selection if nothing is selected + this._register(sessionsControl.onDidUpdate(() => { + if (!sessionsControl.hasFocusOrSelection()) { + this.restoreLastSelectedSession(); + } + })); + + // When the active session changes, select it in the tree + this._register(autorun(reader => { + const activeSession = this.activeSessionService.activeSession.read(reader); + if (activeSession) { + if (!sessionsControl.reveal(activeSession.resource)) { + sessionsControl.clearFocus(); + } + } + })); + + // AI Customization shortcuts (bottom, fixed height) + this.aiCustomizationContainer = DOM.append(sessionsContainer, $('.ai-customization-shortcuts')); + this.createAICustomizationShortcuts(this.aiCustomizationContainer); + } + + private restoreLastSelectedSession(): void { + const activeSession = this.activeSessionService.getActiveSession(); + if (activeSession && this.sessionsControl) { + this.sessionsControl.reveal(activeSession.resource); + } + } + + private createAICustomizationShortcuts(container: HTMLElement): void { + // Get initial collapsed state + const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false); + + // Header (clickable to toggle) + const header = DOM.append(container, $('.ai-customization-header')); + header.tabIndex = 0; + header.setAttribute('role', 'button'); + header.setAttribute('aria-expanded', String(!isCollapsed)); + + // Header text + const headerText = DOM.append(header, $('span')); + headerText.textContent = localize('customizations', "CUSTOMIZATIONS"); + + // Chevron icon (right-aligned, shown on hover) + const chevron = DOM.append(header, $('.ai-customization-chevron')); + chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Links container + const linksContainer = DOM.append(container, $('.ai-customization-links')); + if (isCollapsed) { + linksContainer.classList.add('collapsed'); + } + + // Toggle collapse on header click + const toggleCollapse = () => { + const collapsed = linksContainer.classList.toggle('collapsed'); + this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); + header.setAttribute('aria-expanded', String(!collapsed)); + chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Re-layout after the transition so sessions control gets the right height + const onTransitionEnd = () => { + linksContainer.removeEventListener('transitionend', onTransitionEnd); + if (this.viewPaneContainer) { + const { offsetHeight, offsetWidth } = this.viewPaneContainer; + this.layoutBody(offsetHeight, offsetWidth); + } + }; + linksContainer.addEventListener('transitionend', onTransitionEnd); + }; + + this._register(DOM.addDisposableListener(header, 'click', toggleCollapse)); + this._register(DOM.addDisposableListener(header, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleCollapse(); + } + })); + + for (const shortcut of this.shortcuts) { + const link = DOM.append(linksContainer, $('a.ai-customization-link')); + link.tabIndex = 0; + link.setAttribute('role', 'button'); + link.setAttribute('aria-label', shortcut.label); + + // Icon + const iconElement = DOM.append(link, $('.link-icon')); + iconElement.classList.add(...ThemeIcon.asClassNameArray(shortcut.icon)); + + // Label + const labelElement = DOM.append(link, $('.link-label')); + labelElement.textContent = shortcut.label; + + // Count container (right-aligned, shows per-source badges) + const countContainer = DOM.append(link, $('.link-counts')); + shortcut.countContainer = countContainer; + + this._register(DOM.addDisposableListener(link, 'click', (e) => { + DOM.EventHelper.stop(e); + shortcut.action(); + })); + + this._register(DOM.addDisposableListener(link, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + shortcut.action(); + } + })); + } + + // Load initial counts + this.updateCounts(); + } + + private async updateCounts(): Promise { + for (const shortcut of this.shortcuts) { + if (!shortcut.countContainer) { + continue; + } + + if (shortcut.getSourceCounts) { + const counts = await shortcut.getSourceCounts(); + this.renderSourceCounts(shortcut.countContainer, counts); + } else if (shortcut.getCount) { + const count = await shortcut.getCount(); + this.renderSimpleCount(shortcut.countContainer, count); + } + } + } + + private renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { + DOM.clearNode(container); + const total = counts.workspace + counts.user + counts.extension; + container.classList.toggle('hidden', total === 0); + if (total === 0) { + return; + } + + 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) { + if (source.count === 0) { + continue; + } + const badge = DOM.append(container, $('.source-count-badge')); + badge.title = source.title; + const icon = DOM.append(badge, $('.source-count-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(source.icon)); + const num = DOM.append(badge, $('.source-count-num')); + num.textContent = `${source.count}`; + } + } + + private renderSimpleCount(container: HTMLElement, count: number): void { + DOM.clearNode(container); + container.classList.toggle('hidden', count === 0); + if (count > 0) { + const badge = DOM.append(container, $('.source-count-badge')); + const num = DOM.append(badge, $('.source-count-num')); + num.textContent = `${count}`; + } + } + + private async getPromptSourceCounts(promptType: PromptsType): Promise { + const [workspaceItems, userItems, extensionItems] = await Promise.all([ + this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), + this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), + this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), + ]); + + return { + workspace: workspaceItems.length, + user: userItems.length, + extension: extensionItems.length, + }; + } + + private async getSkillSourceCounts(): Promise { + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + if (!skills || skills.length === 0) { + return { workspace: 0, user: 0, extension: 0 }; + } + + const workspaceSkills = skills.filter(s => s.storage === PromptsStorage.local); + + return { + workspace: workspaceSkills.length, + user: skills.filter(s => s.storage === PromptsStorage.user).length, + extension: skills.filter(s => s.storage === PromptsStorage.extension).length, + }; + } + + private async openAICustomizationSection(sectionId: AICustomizationManagementSection): Promise { + const input = AICustomizationManagementEditorInput.getOrCreate(); + const editor = await this.editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + + if (editor instanceof AICustomizationManagementEditor) { + editor.selectSectionById(sectionId); + } + } + + private getSessionHoverPosition(): HoverPosition { + const viewLocation = this.viewDescriptorService.getViewLocationById(this.id); + const sideBarPosition = this.layoutService.getSideBarPosition(); + + return { + [ViewContainerLocation.Sidebar]: sideBarPosition === 0 ? HoverPosition.RIGHT : HoverPosition.LEFT, + [ViewContainerLocation.AuxiliaryBar]: sideBarPosition === 0 ? HoverPosition.LEFT : HoverPosition.RIGHT, + [ViewContainerLocation.ChatBar]: HoverPosition.RIGHT, + [ViewContainerLocation.Panel]: HoverPosition.ABOVE + }[viewLocation ?? ViewContainerLocation.AuxiliaryBar]; + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + + if (!this.sessionsControl || !this.newSessionButtonContainer) { + return; + } + + const buttonHeight = this.newSessionButtonContainer.offsetHeight; + const customizationHeight = this.aiCustomizationContainer?.offsetHeight || 0; + const availableSessionsHeight = height - buttonHeight - customizationHeight; + this.sessionsControl.layout(availableSessionsHeight, width); + } + + override focus(): void { + super.focus(); + + this.sessionsControl?.focus(); + } + + refresh(): void { + this.sessionsControl?.refresh(); + } + + openFind(): void { + this.sessionsControl?.openFind(); + } +} + +// Register Cmd+N / Ctrl+N keybinding for new session in the agent sessions window +KeybindingsRegistry.registerKeybindingRule({ + id: ACTION_ID_NEW_CHAT, + weight: KeybindingWeight.WorkbenchContrib + 1, + primary: KeyMod.CtrlCmd | KeyCode.KeyN, +}); + +MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + submenu: SessionsViewFilterSubMenu, + title: localize2('filterAgentSessions', "Filter Agent Sessions"), + group: 'navigation', + order: 3, + icon: Codicon.filter, + when: ContextKeyExpr.equals('view', SessionsViewId) +} satisfies ISubmenuItem); + +registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.refresh', + title: localize2('refresh', "Refresh Agent Sessions"), + icon: Codicon.refresh, + menu: [{ + id: MenuId.ViewTitle, + group: 'navigation', + order: 1, + when: ContextKeyExpr.equals('view', SessionsViewId), + }], + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + return view?.sessionsControl?.refresh(); + } +}); + +registerAction2(class FindAgentSessionInViewerAction extends Action2 { + + constructor() { + super({ + id: 'sessionsView.find', + title: localize2('find', "Find Agent Session"), + icon: Codicon.search, + menu: [{ + id: MenuId.ViewTitle, + group: 'navigation', + order: 2, + when: ContextKeyExpr.equals('view', SessionsViewId), + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + return view?.sessionsControl?.openFind(); + } +}); diff --git a/src/vs/sessions/electron-browser/sessions-dev.html b/src/vs/sessions/electron-browser/sessions-dev.html new file mode 100644 index 0000000000000..56f1b22575beb --- /dev/null +++ b/src/vs/sessions/electron-browser/sessions-dev.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + diff --git a/src/vs/sessions/electron-browser/sessions.html b/src/vs/sessions/electron-browser/sessions.html new file mode 100644 index 0000000000000..afb0a45e67ec7 --- /dev/null +++ b/src/vs/sessions/electron-browser/sessions.html @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/electron-browser/sessions.main.ts b/src/vs/sessions/electron-browser/sessions.main.ts new file mode 100644 index 0000000000000..87669d73043a2 --- /dev/null +++ b/src/vs/sessions/electron-browser/sessions.main.ts @@ -0,0 +1,422 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../nls.js'; +import product from '../../platform/product/common/product.js'; +import { INativeWindowConfiguration, IWindowsConfiguration, hasNativeMenu } from '../../platform/window/common/window.js'; +import { NativeWindow } from '../../workbench/electron-browser/window.js'; +import { setFullscreen } from '../../base/browser/browser.js'; +import { domContentLoaded } from '../../base/browser/dom.js'; +import { onUnexpectedError } from '../../base/common/errors.js'; +import { URI } from '../../base/common/uri.js'; +import { WorkspaceService } from '../../workbench/services/configuration/browser/configurationService.js'; +import { INativeWorkbenchEnvironmentService, NativeWorkbenchEnvironmentService } from '../../workbench/services/environment/electron-browser/environmentService.js'; +import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; +import { ILoggerService, ILogService, LogLevel } from '../../platform/log/common/log.js'; +import { NativeWorkbenchStorageService } from '../../workbench/services/storage/electron-browser/storageService.js'; +import { IWorkspaceContextService, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IAnyWorkspaceIdentifier, reviveIdentifier, toWorkspaceIdentifier } from '../../platform/workspace/common/workspace.js'; +import { IWorkbenchConfigurationService } from '../../workbench/services/configuration/common/configuration.js'; +import { IStorageService } from '../../platform/storage/common/storage.js'; +import { Disposable } from '../../base/common/lifecycle.js'; +import { ISharedProcessService } from '../../platform/ipc/electron-browser/services.js'; +import { IMainProcessService } from '../../platform/ipc/common/mainProcessService.js'; +import { SharedProcessService } from '../../workbench/services/sharedProcess/electron-browser/sharedProcessService.js'; +import { RemoteAuthorityResolverService } from '../../platform/remote/electron-browser/remoteAuthorityResolverService.js'; +import { IRemoteAuthorityResolverService, RemoteConnectionType } from '../../platform/remote/common/remoteAuthorityResolver.js'; +import { RemoteAgentService } from '../../workbench/services/remote/electron-browser/remoteAgentService.js'; +import { IRemoteAgentService } from '../../workbench/services/remote/common/remoteAgentService.js'; +import { FileService } from '../../platform/files/common/fileService.js'; +import { IFileService } from '../../platform/files/common/files.js'; +import { RemoteFileSystemProviderClient } from '../../workbench/services/remote/common/remoteFileSystemProviderClient.js'; +import { ConfigurationCache } from '../../workbench/services/configuration/common/configurationCache.js'; +import { ISignService } from '../../platform/sign/common/sign.js'; +import { IProductService } from '../../platform/product/common/productService.js'; +import { IUriIdentityService } from '../../platform/uriIdentity/common/uriIdentity.js'; +import { UriIdentityService } from '../../platform/uriIdentity/common/uriIdentityService.js'; +import { INativeKeyboardLayoutService, NativeKeyboardLayoutService } from '../../workbench/services/keybinding/electron-browser/nativeKeyboardLayoutService.js'; +import { ElectronIPCMainProcessService } from '../../platform/ipc/electron-browser/mainProcessService.js'; +import { LoggerChannelClient } from '../../platform/log/common/logIpc.js'; +import { ProxyChannel } from '../../base/parts/ipc/common/ipc.js'; +import { NativeLogService } from '../../workbench/services/log/electron-browser/logService.js'; +import { WorkspaceTrustEnablementService, WorkspaceTrustManagementService } from '../../workbench/services/workspaces/common/workspaceTrust.js'; +import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService } from '../../platform/workspace/common/workspaceTrust.js'; +import { safeStringify } from '../../base/common/objects.js'; +import { IUtilityProcessWorkerWorkbenchService, UtilityProcessWorkerWorkbenchService } from '../../workbench/services/utilityProcess/electron-browser/utilityProcessWorkerWorkbenchService.js'; +import { isCI, isMacintosh, isTahoeOrNewer } from '../../base/common/platform.js'; +import { Schemas } from '../../base/common/network.js'; +import { DiskFileSystemProvider } from '../../workbench/services/files/electron-browser/diskFileSystemProvider.js'; +import { FileUserDataProvider } from '../../platform/userData/common/fileUserDataProvider.js'; +import { IUserDataProfilesService, reviveProfile } from '../../platform/userDataProfile/common/userDataProfile.js'; +import { UserDataProfilesService } from '../../platform/userDataProfile/common/userDataProfileIpc.js'; +import { PolicyChannelClient } from '../../platform/policy/common/policyIpc.js'; +import { IPolicyService } from '../../platform/policy/common/policy.js'; +import { UserDataProfileService } from '../../workbench/services/userDataProfile/common/userDataProfileService.js'; +import { IUserDataProfileService } from '../../workbench/services/userDataProfile/common/userDataProfile.js'; +import { BrowserSocketFactory } from '../../platform/remote/browser/browserSocketFactory.js'; +import { RemoteSocketFactoryService, IRemoteSocketFactoryService } from '../../platform/remote/common/remoteSocketFactoryService.js'; +import { ElectronRemoteResourceLoader } from '../../platform/remote/electron-browser/electronRemoteResourceLoader.js'; +import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; +import { applyZoom } from '../../platform/window/electron-browser/window.js'; +import { mainWindow } from '../../base/browser/window.js'; +import { IDefaultAccountService } from '../../platform/defaultAccount/common/defaultAccount.js'; +import { DefaultAccountService } from '../../workbench/services/accounts/browser/defaultAccount.js'; +import { AccountPolicyService } from '../../workbench/services/policies/common/accountPolicyService.js'; +import { MultiplexPolicyService } from '../../workbench/services/policies/common/multiplexPolicyService.js'; +import { Workbench as AgenticWorkbench } from '../browser/workbench.js'; +import { NativeMenubarControl } from '../../workbench/electron-browser/parts/titlebar/menubarControl.js'; + +export class AgenticMain extends Disposable { + + constructor( + private readonly configuration: INativeWindowConfiguration + ) { + super(); + + this.init(); + } + + private init(): void { + + // Massage configuration file URIs + this.reviveUris(); + + // Apply fullscreen early if configured + setFullscreen(!!this.configuration.fullscreen, mainWindow); + } + + private reviveUris() { + + // Workspace + const workspace = reviveIdentifier(this.configuration.workspace); + if (isWorkspaceIdentifier(workspace) || isSingleFolderWorkspaceIdentifier(workspace)) { + this.configuration.workspace = workspace; + } + + // Files + const filesToWait = this.configuration.filesToWait; + const filesToWaitPaths = filesToWait?.paths; + for (const paths of [filesToWaitPaths, this.configuration.filesToOpenOrCreate, this.configuration.filesToDiff, this.configuration.filesToMerge]) { + if (Array.isArray(paths)) { + for (const path of paths) { + if (path.fileUri) { + path.fileUri = URI.revive(path.fileUri); + } + } + } + } + + if (filesToWait) { + filesToWait.waitMarkerFileUri = URI.revive(filesToWait.waitMarkerFileUri); + } + } + + async open(): Promise { + + // Init services and wait for DOM to be ready in parallel + const [services] = await Promise.all([this.initServices(), domContentLoaded(mainWindow)]); + + // Apply zoom level early + this.applyWindowZoomLevel(services.configurationService); + + // Create Agentic Workbench + const workbench = new AgenticWorkbench(mainWindow.document.body, { + extraClasses: this.getExtraClasses(), + }, services.serviceCollection, services.logService); + + // Listeners + this.registerListeners(workbench, services.storageService); + + // Startup + const instantiationService = workbench.startup(); + + // Window + this._register(instantiationService.createInstance(NativeWindow)); + + // Native menu controller + if (isMacintosh || hasNativeMenu(services.configurationService)) { + this._register(instantiationService.createInstance(NativeMenubarControl)); + } + } + + private applyWindowZoomLevel(configurationService: IConfigurationService) { + let zoomLevel: number | undefined = undefined; + if (this.configuration.isCustomZoomLevel && typeof this.configuration.zoomLevel === 'number') { + zoomLevel = this.configuration.zoomLevel; + } else { + const windowConfig = configurationService.getValue(); + zoomLevel = typeof windowConfig.window?.zoomLevel === 'number' ? windowConfig.window.zoomLevel : 0; + } + + applyZoom(zoomLevel, mainWindow); + } + + private getExtraClasses(): string[] { + if (isMacintosh && isTahoeOrNewer(this.configuration.os.release)) { + return ['macos-tahoe']; + } + + return []; + } + + private registerListeners(workbench: AgenticWorkbench, storageService: NativeWorkbenchStorageService): void { + + // Workbench Lifecycle + this._register(workbench.onWillShutdown(event => event.join(storageService.close(), { id: 'join.closeStorage', label: localize('join.closeStorage', "Saving UI state") }))); + this._register(workbench.onDidShutdown(() => this.dispose())); + } + + private async initServices(): Promise<{ serviceCollection: ServiceCollection; logService: ILogService; storageService: NativeWorkbenchStorageService; configurationService: WorkspaceService }> { + const serviceCollection = new ServiceCollection(); + + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // + // NOTE: Please do NOT register services here. Use `registerSingleton()` + // from `workbench.common.main.ts` if the service is shared between + // desktop and web or `sessions/sessions.desktop.main.ts` if the service + // is sessions desktop only. + // + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + + // Main Process + const mainProcessService = this._register(new ElectronIPCMainProcessService(this.configuration.windowId)); + serviceCollection.set(IMainProcessService, mainProcessService); + + // Product + const productService: IProductService = { _serviceBrand: undefined, ...product }; + serviceCollection.set(IProductService, productService); + + // Environment + const environmentService = new NativeWorkbenchEnvironmentService(this.configuration, productService); + serviceCollection.set(INativeWorkbenchEnvironmentService, environmentService); + + // Logger + const loggers = this.configuration.loggers.map(loggerResource => ({ ...loggerResource, resource: URI.revive(loggerResource.resource) })); + const loggerService = new LoggerChannelClient(this.configuration.windowId, this.configuration.logLevel, environmentService.windowLogsPath, loggers, mainProcessService.getChannel('logger')); + serviceCollection.set(ILoggerService, loggerService); + + // Log + const logService = this._register(new NativeLogService(loggerService, environmentService)); + serviceCollection.set(ILogService, logService); + if (isCI) { + logService.info('workbench#open()'); // marking workbench open helps to diagnose flaky integration/smoke tests + } + if (logService.getLevel() === LogLevel.Trace) { + logService.trace('workbench#open(): with configuration', safeStringify({ ...this.configuration, nls: undefined /* exclude large property */ })); + } + + // Default Account + const defaultAccountService = this._register(new DefaultAccountService(productService)); + serviceCollection.set(IDefaultAccountService, defaultAccountService); + + // Policies + let policyService: IPolicyService; + const accountPolicy = new AccountPolicyService(logService, defaultAccountService); + if (this.configuration.policiesData) { + const policyChannel = new PolicyChannelClient(this.configuration.policiesData, mainProcessService.getChannel('policy')); + policyService = new MultiplexPolicyService([policyChannel, accountPolicy], logService); + } else { + policyService = accountPolicy; + } + serviceCollection.set(IPolicyService, policyService); + + // Shared Process + const sharedProcessService = new SharedProcessService(this.configuration.windowId, logService); + serviceCollection.set(ISharedProcessService, sharedProcessService); + + // Utility Process Worker + const utilityProcessWorkerWorkbenchService = new UtilityProcessWorkerWorkbenchService(this.configuration.windowId, logService, mainProcessService); + serviceCollection.set(IUtilityProcessWorkerWorkbenchService, utilityProcessWorkerWorkbenchService); + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // + // NOTE: Please do NOT register services here. Use `registerSingleton()` + // from `workbench.common.main.ts` if the service is shared between + // desktop and web or `sessions/sessions.desktop.main.ts` if the service + // is sessions desktop only. + // + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + + // Sign + const signService = ProxyChannel.toService(mainProcessService.getChannel('sign')); + serviceCollection.set(ISignService, signService); + + // Files + const fileService = this._register(new FileService(logService)); + serviceCollection.set(IFileService, fileService); + + // Remote + const remoteAuthorityResolverService = new RemoteAuthorityResolverService(productService, new ElectronRemoteResourceLoader(environmentService.window.id, mainProcessService, fileService)); + serviceCollection.set(IRemoteAuthorityResolverService, remoteAuthorityResolverService); + + // Local Files + const diskFileSystemProvider = this._register(new DiskFileSystemProvider(mainProcessService, utilityProcessWorkerWorkbenchService, logService, loggerService)); + fileService.registerProvider(Schemas.file, diskFileSystemProvider); + + // URI Identity + const uriIdentityService = new UriIdentityService(fileService); + serviceCollection.set(IUriIdentityService, uriIdentityService); + + // User Data Profiles + const userDataProfilesService = new UserDataProfilesService(this.configuration.profiles.all, URI.revive(this.configuration.profiles.home).with({ scheme: environmentService.userRoamingDataHome.scheme }), mainProcessService.getChannel('userDataProfiles')); + serviceCollection.set(IUserDataProfilesService, userDataProfilesService); + const userDataProfileService = new UserDataProfileService(reviveProfile(this.configuration.profiles.profile, userDataProfilesService.profilesHome.scheme)); + serviceCollection.set(IUserDataProfileService, userDataProfileService); + + // Use FileUserDataProvider for user data to + // enable atomic read / write operations. + fileService.registerProvider(Schemas.vscodeUserData, this._register(new FileUserDataProvider(Schemas.file, diskFileSystemProvider, Schemas.vscodeUserData, userDataProfilesService, uriIdentityService, logService))); + + // Remote Agent + const remoteSocketFactoryService = new RemoteSocketFactoryService(); + remoteSocketFactoryService.register(RemoteConnectionType.WebSocket, new BrowserSocketFactory(null)); + serviceCollection.set(IRemoteSocketFactoryService, remoteSocketFactoryService); + const remoteAgentService = this._register(new RemoteAgentService(remoteSocketFactoryService, userDataProfileService, environmentService, productService, remoteAuthorityResolverService, signService, logService)); + serviceCollection.set(IRemoteAgentService, remoteAgentService); + + // Remote Files + this._register(RemoteFileSystemProviderClient.register(remoteAgentService, fileService, logService)); + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // + // NOTE: Please do NOT register services here. Use `registerSingleton()` + // from `workbench.common.main.ts` if the service is shared between + // desktop and web or `sessions/sessions.desktop.main.ts` if the service + // is sessions desktop only. + // + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + // Create services that require resolving in parallel + const workspace = this.resolveWorkspaceIdentifier(environmentService); + const [configurationService, storageService] = await Promise.all([ + this.createWorkspaceService(workspace, environmentService, userDataProfileService, userDataProfilesService, fileService, remoteAgentService, uriIdentityService, logService, policyService).then(service => { + + // Workspace + serviceCollection.set(IWorkspaceContextService, service); + + // Configuration + serviceCollection.set(IWorkbenchConfigurationService, service); + + return service; + }), + + this.createStorageService(workspace, environmentService, userDataProfileService, userDataProfilesService, mainProcessService).then(service => { + + // Storage + serviceCollection.set(IStorageService, service); + + return service; + }), + + this.createKeyboardLayoutService(mainProcessService).then(service => { + + // KeyboardLayout + serviceCollection.set(INativeKeyboardLayoutService, service); + + return service; + }) + ]); + + // Workspace Trust Service + const workspaceTrustEnablementService = new WorkspaceTrustEnablementService(configurationService, environmentService); + serviceCollection.set(IWorkspaceTrustEnablementService, workspaceTrustEnablementService); + + const workspaceTrustManagementService = new WorkspaceTrustManagementService(configurationService, remoteAuthorityResolverService, storageService, uriIdentityService, environmentService, configurationService, workspaceTrustEnablementService, fileService); + serviceCollection.set(IWorkspaceTrustManagementService, workspaceTrustManagementService); + + // Update workspace trust so that configuration is updated accordingly + configurationService.updateWorkspaceTrust(workspaceTrustManagementService.isWorkspaceTrusted()); + this._register(workspaceTrustManagementService.onDidChangeTrust(() => configurationService.updateWorkspaceTrust(workspaceTrustManagementService.isWorkspaceTrusted()))); + + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // + // NOTE: Please do NOT register services here. Use `registerSingleton()` + // from `workbench.common.main.ts` if the service is shared between + // desktop and web or `sessions/sessions.desktop.main.ts` if the service + // is sessions desktop only. + // + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + + return { serviceCollection, logService, storageService, configurationService }; + } + + private resolveWorkspaceIdentifier(environmentService: INativeWorkbenchEnvironmentService): IAnyWorkspaceIdentifier { + + // Return early for when a folder or multi-root is opened + if (this.configuration.workspace) { + return this.configuration.workspace; + } + + // Otherwise, workspace is empty, so we derive an identifier + return toWorkspaceIdentifier(this.configuration.backupPath, environmentService.isExtensionDevelopment); + } + + private async createWorkspaceService( + workspace: IAnyWorkspaceIdentifier, + environmentService: INativeWorkbenchEnvironmentService, + userDataProfileService: IUserDataProfileService, + userDataProfilesService: IUserDataProfilesService, + fileService: FileService, + remoteAgentService: IRemoteAgentService, + uriIdentityService: IUriIdentityService, + logService: ILogService, + policyService: IPolicyService + ): Promise { + const configurationCache = new ConfigurationCache([Schemas.file, Schemas.vscodeUserData] /* Cache all non native resources */, environmentService, fileService); + const workspaceService = new WorkspaceService({ remoteAuthority: environmentService.remoteAuthority, configurationCache }, environmentService, userDataProfileService, userDataProfilesService, fileService, remoteAgentService, uriIdentityService, logService, policyService); + + try { + await workspaceService.initialize(workspace); + + return workspaceService; + } catch (error) { + onUnexpectedError(error); + + return workspaceService; + } + } + + private async createStorageService(workspace: IAnyWorkspaceIdentifier, environmentService: INativeWorkbenchEnvironmentService, userDataProfileService: IUserDataProfileService, userDataProfilesService: IUserDataProfilesService, mainProcessService: IMainProcessService): Promise { + const storageService = new NativeWorkbenchStorageService(workspace, userDataProfileService, userDataProfilesService, mainProcessService, environmentService); + + try { + await storageService.initialize(); + + return storageService; + } catch (error) { + onUnexpectedError(error); + + return storageService; + } + } + + private async createKeyboardLayoutService(mainProcessService: IMainProcessService): Promise { + const keyboardLayoutService = new NativeKeyboardLayoutService(mainProcessService); + + try { + await keyboardLayoutService.initialize(); + + return keyboardLayoutService; + } catch (error) { + onUnexpectedError(error); + + return keyboardLayoutService; + } + } +} + +export interface IDesktopMain { + main(configuration: INativeWindowConfiguration): Promise; +} + +export function main(configuration: INativeWindowConfiguration): Promise { + const workbench = new AgenticMain(configuration); + + return workbench.open(); +} diff --git a/src/vs/sessions/electron-browser/sessions.ts b/src/vs/sessions/electron-browser/sessions.ts new file mode 100644 index 0000000000000..b7d33fa75da6a --- /dev/null +++ b/src/vs/sessions/electron-browser/sessions.ts @@ -0,0 +1,323 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable no-restricted-globals */ + +(async function () { + + // Add a perf entry right from the top + performance.mark('code/didStartRenderer'); + + type ISandboxConfiguration = import('../../base/parts/sandbox/common/sandboxTypes.js').ISandboxConfiguration; + type ILoadResult = import('../../platform/window/electron-browser/window.js').ILoadResult; + type ILoadOptions = import('../../platform/window/electron-browser/window.js').ILoadOptions; + type INativeWindowConfiguration = import('../../platform/window/common/window.js').INativeWindowConfiguration; + type IMainWindowSandboxGlobals = import('../../base/parts/sandbox/electron-browser/globals.js').IMainWindowSandboxGlobals; + type IDesktopMain = import('./sessions.main.js').IDesktopMain; + + const preloadGlobals = (window as unknown as { vscode: IMainWindowSandboxGlobals }).vscode; // defined by preload.ts + const safeProcess = preloadGlobals.process; + + //#region Splash Screen + + function showSplash(configuration: INativeWindowConfiguration) { + performance.mark('code/willShowPartsSplash'); + + const baseTheme = 'vs-dark'; + const shellBackground = '#191A1B'; + const shellForeground = '#CCCCCC'; + + // Apply base colors + const style = document.createElement('style'); + style.className = 'initialShellColors'; + window.document.head.appendChild(style); + style.textContent = `body { background-color: ${shellBackground}; color: ${shellForeground}; margin: 0; padding: 0; }`; + + // Set zoom level from splash data if available + if (typeof configuration.partsSplash?.zoomLevel === 'number' && typeof preloadGlobals?.webFrame?.setZoomLevel === 'function') { + preloadGlobals.webFrame.setZoomLevel(configuration.partsSplash.zoomLevel); + } + + const splash = document.createElement('div'); + splash.id = 'monaco-parts-splash'; + splash.className = baseTheme; + + window.document.body.appendChild(splash); + + performance.mark('code/didShowPartsSplash'); + } + + //#endregion + + //#region Window Helpers + + async function load(options: ILoadOptions): Promise> { + + // Window Configuration from Preload Script + const configuration = await resolveWindowConfiguration(); + + // Signal before import() + options?.beforeImport?.(configuration); + + // Developer settings + const { enableDeveloperKeybindings, removeDeveloperKeybindingsAfterLoad, developerDeveloperKeybindingsDisposable, forceDisableShowDevtoolsOnError } = setupDeveloperKeybindings(configuration, options); + + // NLS + setupNLS(configuration); + + // Compute base URL and set as global + const baseUrl = new URL(`${fileUriFromPath(configuration.appRoot, { isWindows: safeProcess.platform === 'win32', scheme: 'vscode-file', fallbackAuthority: 'vscode-app' })}/out/`); + globalThis._VSCODE_FILE_ROOT = baseUrl.toString(); + + // Dev only: CSS import map tricks + setupCSSImportMaps(configuration, baseUrl); + + // ESM Import - load the sessions workbench main module + try { + let workbenchUrl: string; + if (!!safeProcess.env['VSCODE_DEV'] && globalThis._VSCODE_USE_RELATIVE_IMPORTS) { + workbenchUrl = './workbench.desktop.main.js'; // for dev purposes only + } else { + workbenchUrl = new URL(`vs/sessions/sessions.desktop.main.js`, baseUrl).href; + } + + const result = await import(workbenchUrl); + if (developerDeveloperKeybindingsDisposable && removeDeveloperKeybindingsAfterLoad) { + developerDeveloperKeybindingsDisposable(); + } + + return { result, configuration }; + } catch (error) { + onUnexpectedError(error, enableDeveloperKeybindings && !forceDisableShowDevtoolsOnError); + + throw error; + } + } + + async function resolveWindowConfiguration() { + const timeout = setTimeout(() => { console.error(`[resolve window config] Could not resolve window configuration within 10 seconds, but will continue to wait...`); }, 10000); + performance.mark('code/willWaitForWindowConfig'); + + const configuration = await preloadGlobals.context.resolveConfiguration() as T; + performance.mark('code/didWaitForWindowConfig'); + + clearTimeout(timeout); + + return configuration; + } + + function setupDeveloperKeybindings(configuration: T, options: ILoadOptions) { + const { + forceEnableDeveloperKeybindings, + disallowReloadKeybinding, + removeDeveloperKeybindingsAfterLoad, + forceDisableShowDevtoolsOnError + } = typeof options?.configureDeveloperSettings === 'function' ? options.configureDeveloperSettings(configuration) : { + forceEnableDeveloperKeybindings: false, + disallowReloadKeybinding: false, + removeDeveloperKeybindingsAfterLoad: false, + forceDisableShowDevtoolsOnError: false + }; + + const isDev = !!safeProcess.env['VSCODE_DEV']; + const enableDeveloperKeybindings = Boolean(isDev || forceEnableDeveloperKeybindings); + let developerDeveloperKeybindingsDisposable: Function | undefined = undefined; + if (enableDeveloperKeybindings) { + developerDeveloperKeybindingsDisposable = registerDeveloperKeybindings(disallowReloadKeybinding); + } + + return { + enableDeveloperKeybindings, + removeDeveloperKeybindingsAfterLoad, + developerDeveloperKeybindingsDisposable, + forceDisableShowDevtoolsOnError + }; + } + + function registerDeveloperKeybindings(disallowReloadKeybinding: boolean | undefined): Function { + const ipcRenderer = preloadGlobals.ipcRenderer; + + const extractKey = + function (e: KeyboardEvent) { + return [ + e.ctrlKey ? 'ctrl-' : '', + e.metaKey ? 'meta-' : '', + e.altKey ? 'alt-' : '', + e.shiftKey ? 'shift-' : '', + e.keyCode + ].join(''); + }; + + // Devtools & reload support + const TOGGLE_DEV_TOOLS_KB = (safeProcess.platform === 'darwin' ? 'meta-alt-73' : 'ctrl-shift-73'); // mac: Cmd-Alt-I, rest: Ctrl-Shift-I + const TOGGLE_DEV_TOOLS_KB_ALT = '123'; // F12 + const RELOAD_KB = (safeProcess.platform === 'darwin' ? 'meta-82' : 'ctrl-82'); // mac: Cmd-R, rest: Ctrl-R + + let listener: ((e: KeyboardEvent) => void) | undefined = function (e) { + const key = extractKey(e); + if (key === TOGGLE_DEV_TOOLS_KB || key === TOGGLE_DEV_TOOLS_KB_ALT) { + ipcRenderer.send('vscode:toggleDevTools'); + } else if (key === RELOAD_KB && !disallowReloadKeybinding) { + ipcRenderer.send('vscode:reloadWindow'); + } + }; + + window.addEventListener('keydown', listener); + + return function () { + if (listener) { + window.removeEventListener('keydown', listener); + listener = undefined; + } + }; + } + + function setupNLS(configuration: T): void { + globalThis._VSCODE_NLS_MESSAGES = configuration.nls.messages; + globalThis._VSCODE_NLS_LANGUAGE = configuration.nls.language; + + let language = configuration.nls.language || 'en'; + if (language === 'zh-tw') { + language = 'zh-Hant'; + } else if (language === 'zh-cn') { + language = 'zh-Hans'; + } + + window.document.documentElement.setAttribute('lang', language); + } + + function onUnexpectedError(error: string | Error, showDevtoolsOnError: boolean): void { + if (showDevtoolsOnError) { + const ipcRenderer = preloadGlobals.ipcRenderer; + ipcRenderer.send('vscode:openDevTools'); + } + + console.error(`[uncaught exception]: ${error}`); + + if (error && typeof error !== 'string' && error.stack) { + console.error(error.stack); + } + } + + function fileUriFromPath(path: string, config: { isWindows?: boolean; scheme?: string; fallbackAuthority?: string }): string { + + // Since we are building a URI, we normalize any backslash + // to slashes and we ensure that the path begins with a '/'. + let pathName = path.replace(/\\/g, '/'); + if (pathName.length > 0 && pathName.charAt(0) !== '/') { + pathName = `/${pathName}`; + } + + let uri: string; + + // Windows: in order to support UNC paths (which start with '//') + // that have their own authority, we do not use the provided authority + // but rather preserve it. + if (config.isWindows && pathName.startsWith('//')) { + uri = encodeURI(`${config.scheme || 'file'}:${pathName}`); + } + + // Otherwise we optionally add the provided authority if specified + else { + uri = encodeURI(`${config.scheme || 'file'}://${config.fallbackAuthority || ''}${pathName}`); + } + + return uri.replace(/#/g, '%23'); + } + + function setupCSSImportMaps(configuration: T, baseUrl: URL) { + + // DEV --------------------------------------------------------------------------------------- + // DEV: This is for development and enables loading CSS via import-statements via import-maps. + // DEV: For each CSS modules that we have we defined an entry in the import map that maps to + // DEV: a blob URL that loads the CSS via a dynamic @import-rule. + // DEV --------------------------------------------------------------------------------------- + + if (globalThis._VSCODE_DISABLE_CSS_IMPORT_MAP) { + return; // disabled in certain development setups + } + + if (Array.isArray(configuration.cssModules) && configuration.cssModules.length > 0) { + performance.mark('code/willAddCssLoader'); + + globalThis._VSCODE_CSS_LOAD = function (url) { + const link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('type', 'text/css'); + link.setAttribute('href', url); + + window.document.head.appendChild(link); + }; + + const importMap: { imports: Record } = { imports: {} }; + for (const cssModule of configuration.cssModules) { + const cssUrl = new URL(cssModule, baseUrl).href; + const jsSrc = `globalThis._VSCODE_CSS_LOAD('${cssUrl}');\n`; + const blob = new Blob([jsSrc], { type: 'application/javascript' }); + importMap.imports[cssUrl] = URL.createObjectURL(blob); + } + + const ttp = window.trustedTypes?.createPolicy('vscode-bootstrapImportMap', { createScript(value) { return value; }, }); + const importMapSrc = JSON.stringify(importMap, undefined, 2); + const importMapScript = document.createElement('script'); + importMapScript.type = 'importmap'; + importMapScript.setAttribute('nonce', '0c6a828f1297'); + // @ts-expect-error + importMapScript.textContent = ttp?.createScript(importMapSrc) ?? importMapSrc; + window.document.head.appendChild(importMapScript); + + performance.mark('code/didAddCssLoader'); + } + } + + //#endregion + + const { result, configuration } = await load( + { + configureDeveloperSettings: function (windowConfig) { + return { + // disable automated devtools opening on error when running extension tests + // as this can lead to nondeterministic test execution (devtools steals focus) + forceDisableShowDevtoolsOnError: typeof windowConfig.extensionTestsPath === 'string' || windowConfig['enable-smoke-test-driver'] === true, + // enable devtools keybindings in extension development window + forceEnableDeveloperKeybindings: Array.isArray(windowConfig.extensionDevelopmentPath) && windowConfig.extensionDevelopmentPath.length > 0, + removeDeveloperKeybindingsAfterLoad: true + }; + }, + beforeImport: function (windowConfig) { + + // Show our splash as early as possible + showSplash(windowConfig); + + // Code windows have a `vscodeWindowId` property to identify them + Object.defineProperty(window, 'vscodeWindowId', { + get: () => windowConfig.windowId + }); + + // It looks like browsers only lazily enable + // the element when needed. Since we + // leverage canvas elements in our code in many + // locations, we try to help the browser to + // initialize canvas when it is idle, right + // before we wait for the scripts to be loaded. + window.requestIdleCallback(() => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + context?.clearRect(0, 0, canvas.width, canvas.height); + canvas.remove(); + }, { timeout: 50 }); + + // Track import() perf + performance.mark('code/willLoadWorkbenchMain'); + } + } + ); + + // Mark start of workbench + performance.mark('code/didLoadWorkbenchMain'); + + // Load workbench + result.main(configuration); +}()); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts new file mode 100644 index 0000000000000..7e8c8e57a474a --- /dev/null +++ b/src/vs/sessions/sessions.common.main.ts @@ -0,0 +1,439 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//#region --- editor/workbench core + +import '../editor/editor.all.js'; + +import '../workbench/api/browser/extensionHost.contribution.js'; +import '../workbench/browser/workbench.contribution.js'; + +//#endregion + + +//#region --- workbench actions + +import '../workbench/browser/actions/textInputActions.js'; +import '../workbench/browser/actions/developerActions.js'; +import '../workbench/browser/actions/helpActions.js'; +import '../workbench/browser/actions/listCommands.js'; +import '../workbench/browser/actions/navigationActions.js'; +import '../workbench/browser/actions/windowActions.js'; +import '../workbench/browser/actions/workspaceActions.js'; +import '../workbench/browser/actions/workspaceCommands.js'; +import '../workbench/browser/actions/quickAccessActions.js'; +import '../workbench/browser/actions/widgetNavigationCommands.js'; + +//#endregion + + +//#region --- API Extension Points + +import '../workbench/services/actions/common/menusExtensionPoint.js'; +import '../workbench/api/common/configurationExtensionPoint.js'; +import '../workbench/api/browser/viewsExtensionPoint.js'; + +//#endregion + + +//#region --- workbench parts + +import '../workbench/browser/parts/editor/editor.contribution.js'; +import '../workbench/browser/parts/editor/editorParts.js'; +import '../workbench/browser/parts/banner/bannerPart.js'; +import '../workbench/browser/parts/statusbar/statusbarPart.js'; + +//#endregion + + +//#region --- workbench services + +import '../platform/actions/common/actions.contribution.js'; +import '../platform/undoRedo/common/undoRedoService.js'; +import '../platform/mcp/common/mcpResourceScannerService.js'; +import '../workbench/services/workspaces/common/editSessionIdentityService.js'; +import '../workbench/services/workspaces/common/canonicalUriService.js'; +import '../workbench/services/extensions/browser/extensionUrlHandler.js'; +import '../workbench/services/keybinding/common/keybindingEditing.js'; +import '../workbench/services/decorations/browser/decorationsService.js'; +import '../workbench/services/dialogs/common/dialogService.js'; +import '../workbench/services/progress/browser/progressService.js'; +import '../workbench/services/editor/browser/codeEditorService.js'; +import '../workbench/services/preferences/browser/preferencesService.js'; +import '../workbench/services/configuration/common/jsonEditingService.js'; +import '../workbench/services/textmodelResolver/common/textModelResolverService.js'; +import '../workbench/services/editor/browser/editorService.js'; +import '../workbench/services/editor/browser/editorResolverService.js'; +import '../workbench/services/aiEmbeddingVector/common/aiEmbeddingVectorService.js'; +import '../workbench/services/aiRelatedInformation/common/aiRelatedInformationService.js'; +import '../workbench/services/aiSettingsSearch/common/aiSettingsSearchService.js'; +import '../workbench/services/history/browser/historyService.js'; +import '../workbench/services/activity/browser/activityService.js'; +import '../workbench/services/keybinding/browser/keybindingService.js'; +import '../workbench/services/untitled/common/untitledTextEditorService.js'; +import '../workbench/services/textresourceProperties/common/textResourcePropertiesService.js'; +import '../workbench/services/textfile/common/textEditorService.js'; +import '../workbench/services/language/common/languageService.js'; +import '../workbench/services/model/common/modelService.js'; +import '../workbench/services/notebook/common/notebookDocumentService.js'; +import '../workbench/services/commands/common/commandService.js'; +import '../workbench/services/themes/browser/workbenchThemeService.js'; +import '../workbench/services/label/common/labelService.js'; +import '../workbench/services/extensions/common/extensionManifestPropertiesService.js'; +import '../workbench/services/extensionManagement/common/extensionGalleryService.js'; +import '../workbench/services/extensionManagement/browser/extensionEnablementService.js'; +import '../workbench/services/extensionManagement/browser/builtinExtensionsScannerService.js'; +import '../workbench/services/extensionRecommendations/common/extensionIgnoredRecommendationsService.js'; +import '../workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.js'; +import '../workbench/services/extensionManagement/common/extensionFeaturesManagemetService.js'; +import '../workbench/services/notification/common/notificationService.js'; +import '../workbench/services/userDataSync/common/userDataSyncUtil.js'; +import '../workbench/services/userDataProfile/browser/userDataProfileImportExportService.js'; +import '../workbench/services/userDataProfile/browser/userDataProfileManagement.js'; +import '../workbench/services/userDataProfile/common/remoteUserDataProfiles.js'; +import '../workbench/services/remote/common/remoteExplorerService.js'; +import '../workbench/services/remote/common/remoteExtensionsScanner.js'; +import '../workbench/services/terminal/common/embedderTerminalService.js'; +import '../workbench/services/workingCopy/common/workingCopyService.js'; +import '../workbench/services/workingCopy/common/workingCopyFileService.js'; +import '../workbench/services/workingCopy/common/workingCopyEditorService.js'; +import '../workbench/services/filesConfiguration/common/filesConfigurationService.js'; +import '../workbench/services/views/browser/viewDescriptorService.js'; +import '../workbench/services/views/browser/viewsService.js'; +import '../workbench/services/quickinput/browser/quickInputService.js'; +import '../workbench/services/userDataSync/browser/userDataSyncWorkbenchService.js'; +import '../workbench/services/authentication/browser/authenticationService.js'; +import '../workbench/services/authentication/browser/authenticationExtensionsService.js'; +import '../workbench/services/authentication/browser/authenticationUsageService.js'; +import '../workbench/services/authentication/browser/authenticationAccessService.js'; +import '../workbench/services/authentication/browser/authenticationMcpUsageService.js'; +import '../workbench/services/authentication/browser/authenticationMcpAccessService.js'; +import '../workbench/services/authentication/browser/authenticationMcpService.js'; +import '../workbench/services/authentication/browser/dynamicAuthenticationProviderStorageService.js'; +import '../workbench/services/authentication/browser/authenticationQueryService.js'; +import '../platform/hover/browser/hoverService.js'; +import '../platform/userInteraction/browser/userInteractionServiceImpl.js'; +import '../workbench/services/assignment/common/assignmentService.js'; +import '../workbench/services/outline/browser/outlineService.js'; +import '../workbench/services/languageDetection/browser/languageDetectionWorkerServiceImpl.js'; +import '../editor/common/services/languageFeaturesService.js'; +import '../editor/common/services/semanticTokensStylingService.js'; +import '../editor/common/services/treeViewsDndService.js'; +import '../workbench/services/textMate/browser/textMateTokenizationFeature.contribution.js'; +import '../workbench/services/treeSitter/browser/treeSitter.contribution.js'; +import '../workbench/services/userActivity/common/userActivityService.js'; +import '../workbench/services/userActivity/browser/userActivityBrowser.js'; +import '../workbench/services/userAttention/browser/userAttentionBrowser.js'; +import '../workbench/services/editor/browser/editorPaneService.js'; +import '../workbench/services/editor/common/customEditorLabelService.js'; +import '../workbench/services/dataChannel/browser/dataChannelService.js'; +import '../workbench/services/inlineCompletions/common/inlineCompletionsUnification.js'; +import '../workbench/services/chat/common/chatEntitlementService.js'; +import '../workbench/services/log/common/defaultLogLevels.js'; + +import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; +import { GlobalExtensionEnablementService } from '../platform/extensionManagement/common/extensionEnablementService.js'; +import { IAllowedExtensionsService, IGlobalExtensionEnablementService } from '../platform/extensionManagement/common/extensionManagement.js'; +import { ContextViewService } from '../platform/contextview/browser/contextViewService.js'; +import { IContextViewService } from '../platform/contextview/browser/contextView.js'; +import { IListService, ListService } from '../platform/list/browser/listService.js'; +import { MarkerDecorationsService } from '../editor/common/services/markerDecorationsService.js'; +import { IMarkerDecorationsService } from '../editor/common/services/markerDecorations.js'; +import { IMarkerService } from '../platform/markers/common/markers.js'; +import { MarkerService } from '../platform/markers/common/markerService.js'; +import { ContextKeyService } from '../platform/contextkey/browser/contextKeyService.js'; +import { IContextKeyService } from '../platform/contextkey/common/contextkey.js'; +import { ITextResourceConfigurationService } from '../editor/common/services/textResourceConfiguration.js'; +import { TextResourceConfigurationService } from '../editor/common/services/textResourceConfigurationService.js'; +import { IDownloadService } from '../platform/download/common/download.js'; +import { DownloadService } from '../platform/download/common/downloadService.js'; +import { OpenerService } from '../editor/browser/services/openerService.js'; +import { IOpenerService } from '../platform/opener/common/opener.js'; +import { IgnoredExtensionsManagementService, IIgnoredExtensionsManagementService } from '../platform/userDataSync/common/ignoredExtensions.js'; +import { ExtensionStorageService, IExtensionStorageService } from '../platform/extensionManagement/common/extensionStorage.js'; +import { IUserDataSyncLogService } from '../platform/userDataSync/common/userDataSync.js'; +import { UserDataSyncLogService } from '../platform/userDataSync/common/userDataSyncLog.js'; +import { AllowedExtensionsService } from '../platform/extensionManagement/common/allowedExtensionsService.js'; +import { IAllowedMcpServersService, IMcpGalleryService } from '../platform/mcp/common/mcpManagement.js'; +import { McpGalleryService } from '../platform/mcp/common/mcpGalleryService.js'; +import { AllowedMcpServersService } from '../platform/mcp/common/allowedMcpServersService.js'; +import { IWebWorkerService } from '../platform/webWorker/browser/webWorkerService.js'; +import { WebWorkerService } from '../platform/webWorker/browser/webWorkerServiceImpl.js'; + +registerSingleton(IUserDataSyncLogService, UserDataSyncLogService, InstantiationType.Delayed); +registerSingleton(IAllowedExtensionsService, AllowedExtensionsService, InstantiationType.Delayed); +registerSingleton(IIgnoredExtensionsManagementService, IgnoredExtensionsManagementService, InstantiationType.Delayed); +registerSingleton(IGlobalExtensionEnablementService, GlobalExtensionEnablementService, InstantiationType.Delayed); +registerSingleton(IExtensionStorageService, ExtensionStorageService, InstantiationType.Delayed); +registerSingleton(IContextViewService, ContextViewService, InstantiationType.Delayed); +registerSingleton(IListService, ListService, InstantiationType.Delayed); +registerSingleton(IMarkerDecorationsService, MarkerDecorationsService, InstantiationType.Delayed); +registerSingleton(IMarkerService, MarkerService, InstantiationType.Delayed); +registerSingleton(IContextKeyService, ContextKeyService, InstantiationType.Delayed); +registerSingleton(ITextResourceConfigurationService, TextResourceConfigurationService, InstantiationType.Delayed); +registerSingleton(IDownloadService, DownloadService, InstantiationType.Delayed); +registerSingleton(IOpenerService, OpenerService, InstantiationType.Delayed); +registerSingleton(IWebWorkerService, WebWorkerService, InstantiationType.Delayed); +registerSingleton(IMcpGalleryService, McpGalleryService, InstantiationType.Delayed); +registerSingleton(IAllowedMcpServersService, AllowedMcpServersService, InstantiationType.Delayed); + +//#endregion + + +//#region --- workbench contributions + +// Default Account +import '../workbench/services/accounts/browser/defaultAccount.js'; + +// Telemetry +import '../workbench/contrib/telemetry/browser/telemetry.contribution.js'; + +// Preferences +import '../workbench/contrib/preferences/browser/preferences.contribution.js'; +import '../workbench/contrib/preferences/browser/keybindingsEditorContribution.js'; +import '../workbench/contrib/preferences/browser/preferencesSearch.js'; + +// Performance +import '../workbench/contrib/performance/browser/performance.contribution.js'; + +// Notebook +import '../workbench/contrib/notebook/browser/notebook.contribution.js'; + +// Speech +import '../workbench/contrib/speech/browser/speech.contribution.js'; + +// Chat +import '../workbench/contrib/chat/browser/chat.contribution.js'; +import '../workbench/contrib/inlineChat/browser/inlineChat.contribution.js'; +import '../workbench/contrib/mcp/browser/mcp.contribution.js'; +import '../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; +import '../workbench/contrib/chat/browser/contextContrib/chatContext.contribution.js'; + +// Interactive +import '../workbench/contrib/interactive/browser/interactive.contribution.js'; + +// repl +import '../workbench/contrib/replNotebook/browser/repl.contribution.js'; + +// Testing +import '../workbench/contrib/testing/browser/testing.contribution.js'; + +// Logs +import '../workbench/contrib/logs/common/logs.contribution.js'; + +// Quickaccess +import '../workbench/contrib/quickaccess/browser/quickAccess.contribution.js'; + +// Explorer +import '../workbench/contrib/files/browser/explorerViewlet.js'; +import '../workbench/contrib/files/browser/fileActions.contribution.js'; +import '../workbench/contrib/files/browser/files.contribution.js'; + +// Bulk Edit +import '../workbench/contrib/bulkEdit/browser/bulkEditService.js'; +import '../workbench/contrib/bulkEdit/browser/preview/bulkEdit.contribution.js'; + +// Rename Symbol Tracker for Inline completions. +import '../workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.js'; + +// Search +import '../workbench/contrib/search/browser/search.contribution.js'; +import '../workbench/contrib/search/browser/searchView.js'; + +// Search Editor +import '../workbench/contrib/searchEditor/browser/searchEditor.contribution.js'; + +// Sash +import '../workbench/contrib/sash/browser/sash.contribution.js'; + +// SCM +import '../workbench/contrib/scm/browser/scm.contribution.js'; + +// Debug +import '../workbench/contrib/debug/browser/debug.contribution.js'; +import '../workbench/contrib/debug/browser/debugEditorContribution.js'; +import '../workbench/contrib/debug/browser/breakpointEditorContribution.js'; +import '../workbench/contrib/debug/browser/callStackEditorContribution.js'; +import '../workbench/contrib/debug/browser/repl.js'; +import '../workbench/contrib/debug/browser/debugViewlet.js'; + +// Markers +import '../workbench/contrib/markers/browser/markers.contribution.js'; + +// Process Explorer +import '../workbench/contrib/processExplorer/browser/processExplorer.contribution.js'; + +// Merge Editor +import '../workbench/contrib/mergeEditor/browser/mergeEditor.contribution.js'; + +// Multi Diff Editor +import '../workbench/contrib/multiDiffEditor/browser/multiDiffEditor.contribution.js'; + +// Commands +import '../workbench/contrib/commands/common/commands.contribution.js'; + +// Comments +import '../workbench/contrib/comments/browser/comments.contribution.js'; + +// URL Support +import '../workbench/contrib/url/browser/url.contribution.js'; + +// Webview +import '../workbench/contrib/webview/browser/webview.contribution.js'; +import '../workbench/contrib/webviewPanel/browser/webviewPanel.contribution.js'; +import '../workbench/contrib/webviewView/browser/webviewView.contribution.js'; +import '../workbench/contrib/customEditor/browser/customEditor.contribution.js'; + +// External Uri Opener +import '../workbench/contrib/externalUriOpener/common/externalUriOpener.contribution.js'; + +// Extensions Management +import '../workbench/contrib/extensions/browser/extensions.contribution.js'; +import '../workbench/contrib/extensions/browser/extensionsViewlet.js'; + +// Output View +import '../workbench/contrib/output/browser/output.contribution.js'; +import '../workbench/contrib/output/browser/outputView.js'; + +// Terminal +import '../workbench/contrib/terminal/terminal.all.js'; + +// External terminal +import '../workbench/contrib/externalTerminal/browser/externalTerminal.contribution.js'; + +// Relauncher +import '../workbench/contrib/relauncher/browser/relauncher.contribution.js'; + +// Tasks +import '../workbench/contrib/tasks/browser/task.contribution.js'; + +// Remote +import '../workbench/contrib/remote/common/remote.contribution.js'; +import '../workbench/contrib/remote/browser/remote.contribution.js'; + +// Emmet +import '../workbench/contrib/emmet/browser/emmet.contribution.js'; + +// CodeEditor Contributions +import '../workbench/contrib/codeEditor/browser/codeEditor.contribution.js'; + +// Markdown +import '../workbench/contrib/markdown/browser/markdown.contribution.js'; + +// Keybindings Contributions +import '../workbench/contrib/keybindings/browser/keybindings.contribution.js'; + +// Snippets +import '../workbench/contrib/snippets/browser/snippets.contribution.js'; + +// Formatter Help +import '../workbench/contrib/format/browser/format.contribution.js'; + +// Folding +import '../workbench/contrib/folding/browser/folding.contribution.js'; + +// Limit Indicator +import '../workbench/contrib/limitIndicator/browser/limitIndicator.contribution.js'; + +// Inlay Hint Accessibility +import '../workbench/contrib/inlayHints/browser/inlayHintsAccessibilty.js'; + +// Themes +import '../workbench/contrib/themes/browser/themes.contribution.js'; + +// Update +import '../workbench/contrib/update/browser/update.contribution.js'; + +// Surveys +import '../workbench/contrib/surveys/browser/nps.contribution.js'; +import '../workbench/contrib/surveys/browser/languageSurveys.contribution.js'; + +// Welcome +import '../workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.js'; +import '../workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.contribution.js'; +import '../workbench/contrib/welcomeWalkthrough/browser/walkThrough.contribution.js'; +import '../workbench/contrib/welcomeViews/common/viewsWelcome.contribution.js'; +import '../workbench/contrib/welcomeViews/common/newFile.contribution.js'; + +// Call Hierarchy +import '../workbench/contrib/callHierarchy/browser/callHierarchy.contribution.js'; + +// Type Hierarchy +import '../workbench/contrib/typeHierarchy/browser/typeHierarchy.contribution.js'; + +// Outline +import '../workbench/contrib/codeEditor/browser/outline/documentSymbolsOutline.js'; +import '../workbench/contrib/outline/browser/outline.contribution.js'; + +// Language Detection +import '../workbench/contrib/languageDetection/browser/languageDetection.contribution.js'; + +// Language Status +import '../workbench/contrib/languageStatus/browser/languageStatus.contribution.js'; + +// Authentication +import '../workbench/contrib/authentication/browser/authentication.contribution.js'; + +// User Data Sync +import '../workbench/contrib/userDataSync/browser/userDataSync.contribution.js'; + +// User Data Profiles +import '../workbench/contrib/userDataProfile/browser/userDataProfile.contribution.js'; + +// Continue Edit Session +import '../workbench/contrib/editSessions/browser/editSessions.contribution.js'; + +// Remote Coding Agents +import '../workbench/contrib/remoteCodingAgents/browser/remoteCodingAgents.contribution.js'; + +// Code Actions +import '../workbench/contrib/codeActions/browser/codeActions.contribution.js'; + +// Timeline +import '../workbench/contrib/timeline/browser/timeline.contribution.js'; + +// Local History +import '../workbench/contrib/localHistory/browser/localHistory.contribution.js'; + +// Workspace +import '../workbench/contrib/workspace/browser/workspace.contribution.js'; + +// Workspaces +import '../workbench/contrib/workspaces/browser/workspaces.contribution.js'; + +// List +import '../workbench/contrib/list/browser/list.contribution.js'; + +// Accessibility Signals +import '../workbench/contrib/accessibilitySignals/browser/accessibilitySignal.contribution.js'; + +// Bracket Pair Colorizer 2 Telemetry +import '../workbench/contrib/bracketPairColorizer2Telemetry/browser/bracketPairColorizer2Telemetry.contribution.js'; + +// Accessibility +import '../workbench/contrib/accessibility/browser/accessibility.contribution.js'; + +// Metered Connection +import '../workbench/contrib/meteredConnection/browser/meteredConnection.contribution.js'; + +// Share +import '../workbench/contrib/share/browser/share.contribution.js'; + +// Synchronized Scrolling +import '../workbench/contrib/scrollLocking/browser/scrollLocking.contribution.js'; + +// Inline Completions +import '../workbench/contrib/inlineCompletions/browser/inlineCompletions.contribution.js'; + +// Drop or paste into +import '../workbench/contrib/dropOrPasteInto/browser/dropOrPasteInto.contribution.js'; + +// Edit Telemetry +import '../workbench/contrib/editTelemetry/browser/editTelemetry.contribution.js'; + +// Opener +import '../workbench/contrib/opener/browser/opener.contribution.js'; + +//#endregion diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts new file mode 100644 index 0000000000000..f690a68b72e12 --- /dev/null +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -0,0 +1,198 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import './sessions.common.main.js'; + +//#region --- workbench (agentic desktop main) + +import './electron-browser/sessions.main.js'; +import '../workbench/electron-browser/desktop.contribution.js'; + +//#endregion + +//#region --- workbench parts + +import '../workbench/electron-browser/parts/dialogs/dialog.contribution.js'; + +//#endregion + + +//#region --- workbench services + +import '../workbench/services/textfile/electron-browser/nativeTextFileService.js'; +import '../workbench/services/dialogs/electron-browser/fileDialogService.js'; +import '../workbench/services/workspaces/electron-browser/workspacesService.js'; +import '../workbench/services/menubar/electron-browser/menubarService.js'; +import '../workbench/services/update/electron-browser/updateService.js'; +import '../workbench/services/url/electron-browser/urlService.js'; +import '../workbench/services/lifecycle/electron-browser/lifecycleService.js'; +import '../workbench/services/host/electron-browser/nativeHostService.js'; +import '../platform/meteredConnection/electron-browser/meteredConnectionService.js'; +import '../workbench/services/request/electron-browser/requestService.js'; +import '../workbench/services/clipboard/electron-browser/clipboardService.js'; +import '../workbench/services/contextmenu/electron-browser/contextmenuService.js'; +import '../workbench/services/workspaces/electron-browser/workspaceEditingService.js'; +import '../workbench/services/configurationResolver/electron-browser/configurationResolverService.js'; +import '../workbench/services/accessibility/electron-browser/accessibilityService.js'; +import '../workbench/services/keybinding/electron-browser/nativeKeyboardLayout.js'; +import '../workbench/services/path/electron-browser/pathService.js'; +import '../workbench/services/themes/electron-browser/nativeHostColorSchemeService.js'; +import '../workbench/services/extensionManagement/electron-browser/extensionManagementService.js'; +import '../workbench/services/mcp/electron-browser/mcpGalleryManifestService.js'; +import '../workbench/services/mcp/electron-browser/mcpWorkbenchManagementService.js'; +import '../workbench/services/encryption/electron-browser/encryptionService.js'; +import '../workbench/services/imageResize/electron-browser/imageResizeService.js'; +import '../workbench/services/browserElements/electron-browser/browserElementsService.js'; +import '../workbench/services/secrets/electron-browser/secretStorageService.js'; +import '../workbench/services/localization/electron-browser/languagePackService.js'; +import '../workbench/services/telemetry/electron-browser/telemetryService.js'; +import '../workbench/services/extensions/electron-browser/extensionHostStarter.js'; +import '../platform/extensionResourceLoader/common/extensionResourceLoaderService.js'; +import '../workbench/services/localization/electron-browser/localeService.js'; +import '../workbench/services/extensions/electron-browser/extensionsScannerService.js'; +import '../workbench/services/extensionManagement/electron-browser/extensionManagementServerService.js'; +import '../workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.js'; +import '../workbench/services/extensionManagement/electron-browser/extensionTipsService.js'; +import '../workbench/services/userDataSync/electron-browser/userDataSyncService.js'; +import '../workbench/services/userDataSync/electron-browser/userDataAutoSyncService.js'; +import '../workbench/services/timer/electron-browser/timerService.js'; +import '../workbench/services/environment/electron-browser/shellEnvironmentService.js'; +import '../workbench/services/integrity/electron-browser/integrityService.js'; +import '../workbench/services/workingCopy/electron-browser/workingCopyBackupService.js'; +import '../workbench/services/checksum/electron-browser/checksumService.js'; +import '../platform/remote/electron-browser/sharedProcessTunnelService.js'; +import '../workbench/services/tunnel/electron-browser/tunnelService.js'; +import '../platform/diagnostics/electron-browser/diagnosticsService.js'; +import '../platform/profiling/electron-browser/profilingService.js'; +import '../platform/telemetry/electron-browser/customEndpointTelemetryService.js'; +import '../platform/remoteTunnel/electron-browser/remoteTunnelService.js'; +import '../workbench/services/files/electron-browser/elevatedFileService.js'; +import '../workbench/services/search/electron-browser/searchService.js'; +import '../workbench/services/workingCopy/electron-browser/workingCopyHistoryService.js'; +import '../workbench/services/userDataSync/browser/userDataSyncEnablementService.js'; +import '../workbench/services/extensions/electron-browser/nativeExtensionService.js'; +import '../platform/userDataProfile/electron-browser/userDataProfileStorageService.js'; +import '../workbench/services/auxiliaryWindow/electron-browser/auxiliaryWindowService.js'; +import '../platform/extensionManagement/electron-browser/extensionsProfileScannerService.js'; +import '../platform/webContentExtractor/electron-browser/webContentExtractorService.js'; +import '../workbench/services/process/electron-browser/processService.js'; +import '../workbench/services/power/electron-browser/powerService.js'; + +import { registerSingleton } from '../platform/instantiation/common/extensions.js'; +import { IUserDataInitializationService, UserDataInitializationService } from '../workbench/services/userData/browser/userDataInit.js'; +import { SyncDescriptor } from '../platform/instantiation/common/descriptors.js'; + +registerSingleton(IUserDataInitializationService, new SyncDescriptor(UserDataInitializationService, [[]], true)); + + +//#endregion + + +//#region --- workbench contributions + +// Logs +import '../workbench/contrib/logs/electron-browser/logs.contribution.js'; + +// Localizations +import '../workbench/contrib/localization/electron-browser/localization.contribution.js'; + +// Explorer +import '../workbench/contrib/files/electron-browser/fileActions.contribution.js'; + +// CodeEditor Contributions +import '../workbench/contrib/codeEditor/electron-browser/codeEditor.contribution.js'; + +// Debug +import '../workbench/contrib/debug/electron-browser/extensionHostDebugService.js'; + +// Extensions Management +import '../workbench/contrib/extensions/electron-browser/extensions.contribution.js'; + +// Issues +import '../workbench/contrib/issue/electron-browser/issue.contribution.js'; + +// Process Explorer +import '../workbench/contrib/processExplorer/electron-browser/processExplorer.contribution.js'; + +// Remote +import '../workbench/contrib/remote/electron-browser/remote.contribution.js'; + +// Terminal +import '../workbench/contrib/terminal/electron-browser/terminal.contribution.js'; + +// Themes +import '../workbench/contrib/themes/browser/themes.test.contribution.js'; +import '../workbench/services/themes/electron-browser/themes.contribution.js'; +// User Data Sync +import '../workbench/contrib/userDataSync/electron-browser/userDataSync.contribution.js'; + +// Tags +import '../workbench/contrib/tags/electron-browser/workspaceTagsService.js'; +import '../workbench/contrib/tags/electron-browser/tags.contribution.js'; +// Performance +import '../workbench/contrib/performance/electron-browser/performance.contribution.js'; + +// Tasks +import '../workbench/contrib/tasks/electron-browser/taskService.js'; + +// External terminal +import '../workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.js'; + +// Webview +import '../workbench/contrib/webview/electron-browser/webview.contribution.js'; + +// Browser +import '../workbench/contrib/browserView/electron-browser/browserView.contribution.js'; + +// Splash +import '../workbench/contrib/splash/electron-browser/splash.contribution.js'; + +// Local History +import '../workbench/contrib/localHistory/electron-browser/localHistory.contribution.js'; + +// Merge Editor +import '../workbench/contrib/mergeEditor/electron-browser/mergeEditor.contribution.js'; + +// Multi Diff Editor +import '../workbench/contrib/multiDiffEditor/browser/multiDiffEditor.contribution.js'; + +// Remote Tunnel +import '../workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.js'; + +// Chat +import '../workbench/contrib/chat/electron-browser/chat.contribution.js'; +import '../workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.js'; +// Encryption +import '../workbench/contrib/encryption/electron-browser/encryption.contribution.js'; + +// Emergency Alert +import '../workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.js'; + +// MCP +import '../workbench/contrib/mcp/electron-browser/mcp.contribution.js'; + +// Policy Export +import '../workbench/contrib/policyExport/electron-browser/policyExport.contribution.js'; + +//#endregion + + +//#region --- sessions contributions + +import './browser/paneCompositePartService.js'; +import './browser/layoutActions.js'; + +import './contrib/accountMenu/browser/account.contribution.js'; +import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; +import './contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.js'; +import './contrib/chat/browser/chat.contribution.js'; +import './contrib/sessions/browser/sessions.contribution.js'; +import './contrib/changesView/browser/changesView.contribution.js'; +import './contrib/configuration/browser/configuration.contribution.js'; + +//#endregion + +export { main } from './electron-browser/sessions.main.js'; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 06341d03c02be..74800fefdee70 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -23,7 +23,8 @@ import { ExtensionIdentifier } from '../../../platform/extensions/common/extensi import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js'; -import { IChatWidgetService } from '../../contrib/chat/browser/chat.js'; +import { IChatWidget, IChatWidgetService } from '../../contrib/chat/browser/chat.js'; +import { AgentSessionProviders, getAgentSessionProvider } from '../../contrib/chat/browser/agentSessions/agentSessions.js'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/attachments/chatDynamicVariables.js'; import { IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../contrib/chat/common/participants/chatAgents.js'; import { IPromptFileContext, IPromptsService } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; @@ -38,7 +39,7 @@ import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/lang import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, ExtHostContext, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; +import { ExtHostChatAgentsShape2, ExtHostContext, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; interface AgentData { @@ -145,9 +146,18 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._register(this._chatService.onDidReceiveQuestionCarouselAnswer(e => { this._proxy.$handleQuestionCarouselAnswer(e.requestId, e.resolveId, e.answers); })); - this._register(this._chatWidgetService.onDidChangeFocusedWidget(widget => { - this._proxy.$acceptActiveChatSession(widget?.viewModel?.sessionResource); + this._register(this._chatWidgetService.onDidChangeFocusedSession(() => { + this._acceptActiveChatSession(this._chatWidgetService.lastFocusedWidget); })); + + // Push the initial active session if there is already a focused widget + this._acceptActiveChatSession(this._chatWidgetService.lastFocusedWidget); + } + + private _acceptActiveChatSession(widget: IChatWidget | undefined): void { + const sessionResource = widget?.viewModel?.sessionResource; + const isLocal = sessionResource && getAgentSessionProvider(sessionResource) === AgentSessionProviders.Local; + this._proxy.$acceptActiveChatSession(isLocal ? sessionResource : undefined); } $unregisterAgent(handle: number): void { @@ -184,9 +194,21 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA const chatSession = this._chatService.getSession(request.sessionResource); this._pendingProgress.set(request.requestId, { progress, chatSession }); try { + const contributedSession = chatSession?.contributedChatSession; + let chatSessionContext: IChatSessionContextDto | undefined; + if (contributedSession) { + chatSessionContext = { + chatSessionResource: contributedSession.chatSessionResource, + isUntitled: contributedSession.isUntitled, + initialSessionOptions: contributedSession.initialSessionOptions?.map(o => ({ + optionId: o.optionId, + value: typeof o.value === 'string' ? o.value : o.value.id, + })), + }; + } return await this._proxy.$invokeAgent(handle, request, { history, - chatSessionContext: chatSession?.contributedChatSession + chatSessionContext, }, token) ?? {}; } finally { this._pendingProgress.delete(request.requestId); diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index d2d31a6a5da5f..87399cad640c6 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -128,7 +128,8 @@ export class ObservableChatSession extends Disposable implements IChatSession { participant: turn.participant, command: turn.command, variableData: variables ? { variables } : undefined, - id: turn.id + id: turn.id, + modelId: turn.modelId, }; } diff --git a/src/vs/workbench/api/browser/mainThreadComments.ts b/src/vs/workbench/api/browser/mainThreadComments.ts index 5a7b3ad78cd79..193ad8729928b 100644 --- a/src/vs/workbench/api/browser/mainThreadComments.ts +++ b/src/vs/workbench/api/browser/mainThreadComments.ts @@ -216,10 +216,13 @@ export class MainThreadCommentThread implements languages.CommentThread { dispose() { this._isDisposed = true; this._onDidChangeCollapsibleState.dispose(); + this._onDidChangeInitialCollapsibleState.dispose(); this._onDidChangeComments.dispose(); this._onDidChangeInput.dispose(); this._onDidChangeLabel.dispose(); + this._onDidChangeCanReply.dispose(); this._onDidChangeState.dispose(); + this._onDidChangeApplicability.dispose(); } toJSON(): MarshalledCommentThread { diff --git a/src/vs/workbench/api/browser/mainThreadEditor.ts b/src/vs/workbench/api/browser/mainThreadEditor.ts index 257e99854526a..03dfacf03b65b 100644 --- a/src/vs/workbench/api/browser/mainThreadEditor.ts +++ b/src/vs/workbench/api/browser/mainThreadEditor.ts @@ -205,6 +205,7 @@ export class MainThreadTextEditor { public dispose(): void { this._modelListeners.dispose(); + this._onPropertiesChanged.dispose(); this._codeEditor = null; this._codeEditorListeners.dispose(); } diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 18430fbfffa87..42881dbe8b131 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -1290,10 +1290,10 @@ export class MainThreadDocumentRangeSemanticTokensProvider implements languages. class ExtensionBackedInlineCompletionsProvider extends Disposable implements languages.InlineCompletionsProvider { public readonly setModelId: ((modelId: string) => Promise) | undefined; - public readonly _onDidChangeEmitter = new Emitter(); + public readonly _onDidChangeEmitter = this._register(new Emitter()); public readonly onDidChangeInlineCompletions: Event | undefined; - public readonly _onDidChangeModelInfoEmitter = new Emitter(); + public readonly _onDidChangeModelInfoEmitter = this._register(new Emitter()); public readonly onDidChangeModelInfo: Event | undefined; constructor( diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts index d86f50442d3a5..a15d68e0215f6 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts @@ -194,9 +194,11 @@ export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { const accountLabel = auth.accountLabel ?? localize('languageModelsAccountId', 'Language Models'); const disposables = new DisposableStore(); - this._authenticationService.registerAuthenticationProvider(authProviderId, new LanguageModelAccessAuthProvider(authProviderId, auth.providerLabel, accountLabel)); + const provider = new LanguageModelAccessAuthProvider(authProviderId, auth.providerLabel, accountLabel); + this._authenticationService.registerAuthenticationProvider(authProviderId, provider); disposables.add(toDisposable(() => { this._authenticationService.unregisterAuthenticationProvider(authProviderId); + provider.dispose(); })); disposables.add(this._authenticationAccessService.onDidChangeExtensionSessionAccess(async (e) => { const allowedExtensions = this._authenticationAccessService.readAllowedExtensions(authProviderId, accountLabel); @@ -282,4 +284,8 @@ class LanguageModelAccessAuthProvider implements IAuthenticationProvider { scopes, }; } + + dispose(): void { + this._onDidChangeSessions.dispose(); + } } diff --git a/src/vs/workbench/api/browser/mainThreadPower.ts b/src/vs/workbench/api/browser/mainThreadPower.ts index dbd8078aa19c1..9f7ea9d7d5993 100644 --- a/src/vs/workbench/api/browser/mainThreadPower.ts +++ b/src/vs/workbench/api/browser/mainThreadPower.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostContext, ExtHostPowerShape, MainContext, MainThreadPowerShape, PowerSaveBlockerType, PowerSystemIdleState, PowerThermalState } from '../common/extHost.protocol.js'; import { IPowerService } from '../../services/power/common/powerService.js'; @@ -12,7 +12,6 @@ import { IPowerService } from '../../services/power/common/powerService.js'; export class MainThreadPower extends Disposable implements MainThreadPowerShape { private readonly proxy: ExtHostPowerShape; - private readonly disposables = this._register(new DisposableStore()); constructor( extHostContext: IExtHostContext, @@ -22,14 +21,14 @@ export class MainThreadPower extends Disposable implements MainThreadPowerShape this.proxy = extHostContext.getProxy(ExtHostContext.ExtHostPower); // Forward power events to extension host - this.powerService.onDidSuspend(this.proxy.$onDidSuspend, this.proxy, this.disposables); - this.powerService.onDidResume(this.proxy.$onDidResume, this.proxy, this.disposables); - this.powerService.onDidChangeOnBatteryPower(this.proxy.$onDidChangeOnBatteryPower, this.proxy, this.disposables); - this.powerService.onDidChangeThermalState((state: PowerThermalState) => this.proxy.$onDidChangeThermalState(state), this, this.disposables); - this.powerService.onDidChangeSpeedLimit(this.proxy.$onDidChangeSpeedLimit, this.proxy, this.disposables); - this.powerService.onWillShutdown(this.proxy.$onWillShutdown, this.proxy, this.disposables); - this.powerService.onDidLockScreen(this.proxy.$onDidLockScreen, this.proxy, this.disposables); - this.powerService.onDidUnlockScreen(this.proxy.$onDidUnlockScreen, this.proxy, this.disposables); + this._register(this.powerService.onDidSuspend(this.proxy.$onDidSuspend, this.proxy)); + this._register(this.powerService.onDidResume(this.proxy.$onDidResume, this.proxy)); + this._register(this.powerService.onDidChangeOnBatteryPower(this.proxy.$onDidChangeOnBatteryPower, this.proxy)); + this._register(this.powerService.onDidChangeThermalState((state: PowerThermalState) => this.proxy.$onDidChangeThermalState(state), this)); + this._register(this.powerService.onDidChangeSpeedLimit(this.proxy.$onDidChangeSpeedLimit, this.proxy)); + this._register(this.powerService.onWillShutdown(this.proxy.$onWillShutdown, this.proxy)); + this._register(this.powerService.onDidLockScreen(this.proxy.$onDidLockScreen, this.proxy)); + this._register(this.powerService.onDidUnlockScreen(this.proxy.$onDidUnlockScreen, this.proxy)); } async $getSystemIdleState(idleThreshold: number): Promise { diff --git a/src/vs/workbench/api/browser/mainThreadSCM.ts b/src/vs/workbench/api/browser/mainThreadSCM.ts index 97eb0456cda7e..b60febba917c8 100644 --- a/src/vs/workbench/api/browser/mainThreadSCM.ts +++ b/src/vs/workbench/api/browser/mainThreadSCM.ts @@ -437,6 +437,7 @@ class MainThreadSCMProvider implements ISCMProvider { const artifactProvider = new MainThreadSCMArtifactProvider(this.proxy, this.handle); this._artifactProvider.set(artifactProvider, undefined); } else if (features.hasArtifactProvider === false && this.artifactProvider.get()) { + this._artifactProvider.get()?.dispose(); this._artifactProvider.set(undefined, undefined); } @@ -595,6 +596,9 @@ class MainThreadSCMProvider implements ISCMProvider { } dispose(): void { + this._onDidChangeResourceGroups.dispose(); + this._onDidChangeResources.dispose(); + this._artifactProvider.get()?.dispose(); this._stagedQuickDiff?.dispose(); this._quickDiff?.dispose(); } diff --git a/src/vs/workbench/api/browser/mainThreadWorkspace.ts b/src/vs/workbench/api/browser/mainThreadWorkspace.ts index 30ebb3092893f..02a2e749a1cf0 100644 --- a/src/vs/workbench/api/browser/mainThreadWorkspace.ts +++ b/src/vs/workbench/api/browser/mainThreadWorkspace.ts @@ -140,7 +140,6 @@ export class MainThreadWorkspace implements MainThreadWorkspaceShape { id: workspace.id, name: this._labelService.getWorkspaceLabel(workspace), transient: workspace.transient, - isAgentSessionsWorkspace: workspace.isAgentSessionsWorkspace }; } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index b8e9775191ade..8cdf04941f63c 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1086,7 +1086,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, get isAgentSessionsWorkspace() { checkProposedApiEnabled(extension, 'agentSessionsWorkspace'); - return extHostWorkspace.isAgentSessionsWorkspace; + return !!initData.environment.isSessionsWindow; }, updateWorkspaceFolders: (index, deleteCount, ...workspaceFoldersToAdd) => { return extHostWorkspace.updateWorkspaceFolders(extension, index, deleteCount || 0, ...workspaceFoldersToAdd); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index c596dbbf2a61f..2025e31c43636 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1460,6 +1460,7 @@ export type IChatAgentHistoryEntryDto = { export interface IChatSessionContextDto { readonly chatSessionResource: UriComponents; readonly isUntitled: boolean; + readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }>; } export interface ExtHostChatAgentsShape2 { @@ -3385,6 +3386,7 @@ export type IChatSessionHistoryItemDto = { participant: string; command?: string; variableData?: Dto; + modelId?: string; } | { type: 'response'; parts: IChatProgressDto[]; diff --git a/src/vs/workbench/api/common/extHostAuthentication.ts b/src/vs/workbench/api/common/extHostAuthentication.ts index 3ffef80d86295..5bd4fbf896920 100644 --- a/src/vs/workbench/api/common/extHostAuthentication.ts +++ b/src/vs/workbench/api/common/extHostAuthentication.ts @@ -407,6 +407,7 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { this._logger = loggerService.createLogger(this.id, { name: `Auth: ${this.label}` }); this._disposable = new DisposableStore(); this._disposable.add(this._onDidChangeSessions); + this._disposable.add(this._onDidChangeClientId); const scopedEvent = Event.chain(onDidDynamicAuthProviderTokensChange.event, $ => $ .filter(e => e.authProviderId === this.id && e.clientId === _clientId) .map(e => e.tokens) diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 4cc0bc3cf550d..888128faa32fd 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -755,6 +755,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS label: context.chatSessionContext.isUntitled ? 'Untitled Session' : 'Session', }, isUntitled: context.chatSessionContext.isUntitled, + initialSessionOptions: context.chatSessionContext.initialSessionOptions, }; } @@ -1244,6 +1245,8 @@ class ExtHostChatAgent { disposed = true; that._followupProvider = undefined; that._onDidReceiveFeedback.dispose(); + that._onDidPerformAction.dispose(); + that._pauseStateEmitter.dispose(); that._proxy.$unregisterAgent(that._handle); }, } satisfies vscode.ChatParticipant; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index d867f092b9dcd..e62fe6439249e 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -2,7 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ import type * as vscode from 'vscode'; import { coalesce } from '../../../base/common/arrays.js'; @@ -663,7 +662,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio prompt: turn.prompt, participant: turn.participant, command: turn.command, - variableData: variables.length > 0 ? { variables } : undefined + variableData: variables.length > 0 ? { variables } : undefined, + modelId: turn.modelId, }; } diff --git a/src/vs/workbench/api/common/extHostCommands.ts b/src/vs/workbench/api/common/extHostCommands.ts index 161e19baa3a5e..15c769373b8e5 100644 --- a/src/vs/workbench/api/common/extHostCommands.ts +++ b/src/vs/workbench/api/common/extHostCommands.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import { validateConstraint } from '../../../base/common/types.js'; import { ICommandMetadata } from '../../../platform/commands/common/commands.js'; import * as extHostTypes from './extHostTypes.js'; diff --git a/src/vs/workbench/api/common/extHostComments.ts b/src/vs/workbench/api/common/extHostComments.ts index 826b79501beb0..3f1378c31dd1f 100644 --- a/src/vs/workbench/api/common/extHostComments.ts +++ b/src/vs/workbench/api/common/extHostComments.ts @@ -585,6 +585,7 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo dispose() { this._isDiposed = true; this._acceptInputDisposables.dispose(); + this._onDidUpdateCommentThread.dispose(); this._localDisposables.forEach(disposable => disposable.dispose()); } } diff --git a/src/vs/workbench/api/common/extHostDiagnostics.ts b/src/vs/workbench/api/common/extHostDiagnostics.ts index d3e84c8d05f0d..565959c5ddf54 100644 --- a/src/vs/workbench/api/common/extHostDiagnostics.ts +++ b/src/vs/workbench/api/common/extHostDiagnostics.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import { localize } from '../../../nls.js'; import { IMarkerData, MarkerSeverity } from '../../../platform/markers/common/markers.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; diff --git a/src/vs/workbench/api/common/extHostDocuments.ts b/src/vs/workbench/api/common/extHostDocuments.ts index ecbef1f099b61..517568ceb6daf 100644 --- a/src/vs/workbench/api/common/extHostDocuments.ts +++ b/src/vs/workbench/api/common/extHostDocuments.ts @@ -18,19 +18,19 @@ import { ISerializedModelContentChangedEvent } from '../../../editor/common/text export class ExtHostDocuments implements ExtHostDocumentsShape { - private readonly _onDidAddDocument = new Emitter(); - private readonly _onDidRemoveDocument = new Emitter(); - private readonly _onDidChangeDocument = new Emitter>(); - private readonly _onDidChangeDocumentWithReason = new Emitter(); - private readonly _onDidSaveDocument = new Emitter(); + private readonly _toDispose = new DisposableStore(); + + private readonly _onDidAddDocument = this._toDispose.add(new Emitter()); + private readonly _onDidRemoveDocument = this._toDispose.add(new Emitter()); + private readonly _onDidChangeDocument = this._toDispose.add(new Emitter>()); + private readonly _onDidChangeDocumentWithReason = this._toDispose.add(new Emitter()); + private readonly _onDidSaveDocument = this._toDispose.add(new Emitter()); readonly onDidAddDocument: Event = this._onDidAddDocument.event; readonly onDidRemoveDocument: Event = this._onDidRemoveDocument.event; readonly onDidChangeDocument: Event = this._onDidChangeDocument.event as Event; readonly onDidChangeDocumentWithReason: Event = this._onDidChangeDocumentWithReason.event; readonly onDidSaveDocument: Event = this._onDidSaveDocument.event; - - private readonly _toDispose = new DisposableStore(); private _proxy: MainThreadDocumentsShape; private _documentsAndEditors: ExtHostDocumentsAndEditors; private _documentLoader = new Map>(); diff --git a/src/vs/workbench/api/common/extHostExtensionService.ts b/src/vs/workbench/api/common/extHostExtensionService.ts index 215f56cc66fe7..a0178f266d4a8 100644 --- a/src/vs/workbench/api/common/extHostExtensionService.ts +++ b/src/vs/workbench/api/common/extHostExtensionService.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import * as nls from '../../../nls.js'; import * as path from '../../../base/common/path.js'; import * as performance from '../../../base/common/performance.js'; diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 6095aca4be16a..f3d03c9d08a01 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -186,7 +186,6 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape if (isProposedApiEnabled(item.extension, 'chatParticipantPrivate')) { options.chatRequestId = dto.chatRequestId; options.chatInteractionId = dto.chatInteractionId; - options.chatSessionId = dto.context?.sessionId; options.chatSessionResource = URI.revive(dto.context?.sessionResource); options.subAgentInvocationId = dto.subAgentInvocationId; } @@ -289,7 +288,6 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape const options: vscode.LanguageModelToolInvocationPrepareOptions = { input: context.parameters, chatRequestId: context.chatRequestId, - chatSessionId: context.chatSessionId, chatSessionResource: context.chatSessionResource, chatInteractionId: context.chatInteractionId, forceConfirmationReason: context.forceConfirmationReason diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index f4383ce38d2d5..18c2045825c0d 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -228,6 +228,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { isDefaultForLocation, isUserSelectable: m.isUserSelectable, statusIcon: m.statusIcon, + targetChatSessionType: m.targetChatSessionType, modelPickerCategory: m.category ?? DEFAULT_MODEL_PICKER_CATEGORY, capabilities: m.capabilities ? { vision: m.capabilities.imageInput, diff --git a/src/vs/workbench/api/common/extHostNotebookKernels.ts b/src/vs/workbench/api/common/extHostNotebookKernels.ts index eecdf625b0088..26ba550555f25 100644 --- a/src/vs/workbench/api/common/extHostNotebookKernels.ts +++ b/src/vs/workbench/api/common/extHostNotebookKernels.ts @@ -576,7 +576,7 @@ class NotebookCellExecutionTask extends Disposable { private static HANDLE = 0; private _handle = NotebookCellExecutionTask.HANDLE++; - private _onDidChangeState = new Emitter(); + private _onDidChangeState = this._register(new Emitter()); readonly onDidChangeState = this._onDidChangeState.event; private _state = NotebookCellExecutionTaskState.Init; @@ -786,7 +786,7 @@ class NotebookExecutionTask extends Disposable { private static HANDLE = 0; private _handle = NotebookExecutionTask.HANDLE++; - private _onDidChangeState = new Emitter(); + private _onDidChangeState = this._register(new Emitter()); readonly onDidChangeState = this._onDidChangeState.event; private _state = NotebookExecutionTaskState.Init; diff --git a/src/vs/workbench/api/common/extHostSCM.ts b/src/vs/workbench/api/common/extHostSCM.ts index a7c37795384bb..bf479e9ebb196 100644 --- a/src/vs/workbench/api/common/extHostSCM.ts +++ b/src/vs/workbench/api/common/extHostSCM.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import { URI, UriComponents } from '../../../base/common/uri.js'; import { Event, Emitter } from '../../../base/common/event.js'; import { debounce } from '../../../base/common/decorators.js'; @@ -539,6 +537,8 @@ class ExtHostSourceControlResourceGroup implements vscode.SourceControlResourceG dispose(): void { this._disposed = true; this._onDidDispose.fire(); + this._onDidUpdateResourceStates.dispose(); + this._onDidDispose.dispose(); } } @@ -939,6 +939,7 @@ class ExtHostSourceControl implements vscode.SourceControl { this._groups.forEach(group => group.dispose()); this.#proxy.$unregisterSourceControl(this.handle); + this._onDidChangeSelection.dispose(); this._onDidDispose.fire(); this._onDidDispose.dispose(); } diff --git a/src/vs/workbench/api/common/extHostSecrets.ts b/src/vs/workbench/api/common/extHostSecrets.ts index eeada39be07a3..1015639d9db19 100644 --- a/src/vs/workbench/api/common/extHostSecrets.ts +++ b/src/vs/workbench/api/common/extHostSecrets.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import type * as vscode from 'vscode'; import { ExtHostSecretState } from './extHostSecretState.js'; diff --git a/src/vs/workbench/api/common/extHostStatusBar.ts b/src/vs/workbench/api/common/extHostStatusBar.ts index 4cb635b3d5381..3c33dd1babeeb 100644 --- a/src/vs/workbench/api/common/extHostStatusBar.ts +++ b/src/vs/workbench/api/common/extHostStatusBar.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import { StatusBarAlignment as ExtHostStatusBarAlignment, Disposable, ThemeColor, asStatusBarItemIdentifier } from './extHostTypes.js'; import type * as vscode from 'vscode'; import { MainContext, MainThreadStatusBarShape, IMainContext, ICommandDto, ExtHostStatusBarShape, StatusBarItemDto } from './extHost.protocol.js'; diff --git a/src/vs/workbench/api/common/extHostTask.ts b/src/vs/workbench/api/common/extHostTask.ts index 6ac41d12fbd7e..5bf476c980a67 100644 --- a/src/vs/workbench/api/common/extHostTask.ts +++ b/src/vs/workbench/api/common/extHostTask.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import { URI, UriComponents } from '../../../base/common/uri.js'; import { asPromise } from '../../../base/common/async.js'; import { Event, Emitter } from '../../../base/common/event.js'; diff --git a/src/vs/workbench/api/common/extHostTelemetry.ts b/src/vs/workbench/api/common/extHostTelemetry.ts index 716fc15ef27a1..93db4e49ab331 100644 --- a/src/vs/workbench/api/common/extHostTelemetry.ts +++ b/src/vs/workbench/api/common/extHostTelemetry.ts @@ -325,6 +325,7 @@ export class ExtHostTelemetryLogger { } else { this._sender = undefined; } + this._onDidChangeEnableStates.dispose(); } } diff --git a/src/vs/workbench/api/common/extHostTerminalService.ts b/src/vs/workbench/api/common/extHostTerminalService.ts index 7a44abb0eb1d3..225ed9c223111 100644 --- a/src/vs/workbench/api/common/extHostTerminalService.ts +++ b/src/vs/workbench/api/common/extHostTerminalService.ts @@ -1040,7 +1040,7 @@ class UnifiedEnvironmentVariableCollection extends Disposable { this._onDidChangeCollection.fire(); } - protected readonly _onDidChangeCollection: Emitter = new Emitter(); + protected readonly _onDidChangeCollection: Emitter = this._register(new Emitter()); get onDidChangeCollection(): Event { return this._onDidChangeCollection && this._onDidChangeCollection.event; } constructor( diff --git a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts index 2367f76ded64b..37a03148780d4 100644 --- a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts +++ b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts @@ -31,11 +31,11 @@ export class ExtHostTerminalShellIntegration extends Disposable implements IExtH private _activeShellIntegrations: Map = new Map(); - protected readonly _onDidChangeTerminalShellIntegration = new Emitter(); + protected readonly _onDidChangeTerminalShellIntegration = this._register(new Emitter()); readonly onDidChangeTerminalShellIntegration = this._onDidChangeTerminalShellIntegration.event; - protected readonly _onDidStartTerminalShellExecution = new Emitter(); + protected readonly _onDidStartTerminalShellExecution = this._register(new Emitter()); readonly onDidStartTerminalShellExecution = this._onDidStartTerminalShellExecution.event; - protected readonly _onDidEndTerminalShellExecution = new Emitter(); + protected readonly _onDidEndTerminalShellExecution = this._register(new Emitter()); readonly onDidEndTerminalShellExecution = this._onDidEndTerminalShellExecution.event; constructor( diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index 423b6d3b33a28..e1fa001aeee9c 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import type * as vscode from 'vscode'; import { RunOnceScheduler } from '../../../base/common/async.js'; import { VSBuffer } from '../../../base/common/buffer.js'; diff --git a/src/vs/workbench/api/common/extHostTextEditors.ts b/src/vs/workbench/api/common/extHostTextEditors.ts index 7655c14f8d7d1..74bd16808a440 100644 --- a/src/vs/workbench/api/common/extHostTextEditors.ts +++ b/src/vs/workbench/api/common/extHostTextEditors.ts @@ -17,13 +17,13 @@ import * as vscode from 'vscode'; export class ExtHostEditors extends Disposable implements ExtHostEditorsShape { - private readonly _onDidChangeTextEditorSelection = new Emitter(); - private readonly _onDidChangeTextEditorOptions = new Emitter(); - private readonly _onDidChangeTextEditorVisibleRanges = new Emitter(); - private readonly _onDidChangeTextEditorViewColumn = new Emitter(); - private readonly _onDidChangeTextEditorDiffInformation = new Emitter(); - private readonly _onDidChangeActiveTextEditor = new Emitter(); - private readonly _onDidChangeVisibleTextEditors = new Emitter(); + private readonly _onDidChangeTextEditorSelection = this._register(new Emitter()); + private readonly _onDidChangeTextEditorOptions = this._register(new Emitter()); + private readonly _onDidChangeTextEditorVisibleRanges = this._register(new Emitter()); + private readonly _onDidChangeTextEditorViewColumn = this._register(new Emitter()); + private readonly _onDidChangeTextEditorDiffInformation = this._register(new Emitter()); + private readonly _onDidChangeActiveTextEditor = this._register(new Emitter()); + private readonly _onDidChangeVisibleTextEditors = this._register(new Emitter()); readonly onDidChangeTextEditorSelection: Event = this._onDidChangeTextEditorSelection.event; readonly onDidChangeTextEditorOptions: Event = this._onDidChangeTextEditorOptions.event; diff --git a/src/vs/workbench/api/common/extHostTunnelService.ts b/src/vs/workbench/api/common/extHostTunnelService.ts index de79ab6d9599a..a85ca72b95a8d 100644 --- a/src/vs/workbench/api/common/extHostTunnelService.ts +++ b/src/vs/workbench/api/common/extHostTunnelService.ts @@ -68,7 +68,7 @@ export class ExtHostTunnelService extends Disposable implements IExtHostTunnelSe private _forwardPortProvider: ((tunnelOptions: TunnelOptions, tunnelCreationOptions: TunnelCreationOptions, token?: vscode.CancellationToken) => Thenable | undefined) | undefined; private _showCandidatePort: (host: string, port: number, detail: string) => Thenable = () => { return Promise.resolve(true); }; private _extensionTunnels: Map> = new Map(); - private _onDidChangeTunnels: Emitter = new Emitter(); + private _onDidChangeTunnels: Emitter = this._register(new Emitter()); onDidChangeTunnels: vscode.Event = this._onDidChangeTunnels.event; private _providerHandleCounter: number = 0; diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index cf4575655c432..33aeea43820a5 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3429,7 +3429,7 @@ export namespace ChatAgentRequest { acceptedConfirmationData: request.acceptedConfirmationData, rejectedConfirmationData: request.rejectedConfirmationData, location2, - toolInvocationToken: Object.freeze({ sessionId, sessionResource: request.sessionResource }) as never, + toolInvocationToken: Object.freeze({ sessionResource: request.sessionResource }) as never, tools, model, editedFileEvents: request.editedFileEvents, diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index d7d1c805b8793..0b762be0bdc58 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import type * as vscode from 'vscode'; import { asArray } from '../../../base/common/arrays.js'; import { VSBuffer } from '../../../base/common/buffer.js'; @@ -3518,7 +3516,8 @@ export class ChatRequestTurn implements vscode.ChatRequestTurn2 { readonly participant: string, readonly toolReferences: vscode.ChatLanguageModelToolReference[], readonly editedFileEvents?: vscode.ChatRequestEditedFileEvent[], - readonly id?: string + readonly id?: string, + readonly modelId?: string, ) { } } diff --git a/src/vs/workbench/api/common/extHostTypes/markdownString.ts b/src/vs/workbench/api/common/extHostTypes/markdownString.ts index c7840b9f74ab3..98b43ba3e82ec 100644 --- a/src/vs/workbench/api/common/extHostTypes/markdownString.ts +++ b/src/vs/workbench/api/common/extHostTypes/markdownString.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import type * as vscode from 'vscode'; import { MarkdownString as BaseMarkdownString, MarkdownStringTrustedOptions } from '../../../../base/common/htmlContent.js'; import { es5ClassCompat } from './es5ClassCompat.js'; diff --git a/src/vs/workbench/api/common/extHostTypes/notebooks.ts b/src/vs/workbench/api/common/extHostTypes/notebooks.ts index 3c8e906022395..9eee022cdf1cd 100644 --- a/src/vs/workbench/api/common/extHostTypes/notebooks.ts +++ b/src/vs/workbench/api/common/extHostTypes/notebooks.ts @@ -9,8 +9,6 @@ import { illegalArgument } from '../../../../base/common/errors.js'; import { Mimes, normalizeMimeType, isTextStreamMime } from '../../../../base/common/mime.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -/* eslint-disable local/code-no-native-private */ - export enum NotebookCellKind { Markup = 1, Code = 2 diff --git a/src/vs/workbench/api/common/extHostWebview.ts b/src/vs/workbench/api/common/extHostWebview.ts index 435df4b03fc93..3c66bc3eda68b 100644 --- a/src/vs/workbench/api/common/extHostWebview.ts +++ b/src/vs/workbench/api/common/extHostWebview.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import { VSBuffer } from '../../../base/common/buffer.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; diff --git a/src/vs/workbench/api/common/extHostWebviewPanels.ts b/src/vs/workbench/api/common/extHostWebviewPanels.ts index d9059d6896b54..4f9bac1451ac9 100644 --- a/src/vs/workbench/api/common/extHostWebviewPanels.ts +++ b/src/vs/workbench/api/common/extHostWebviewPanels.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ - import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; diff --git a/src/vs/workbench/api/common/extHostWebviewView.ts b/src/vs/workbench/api/common/extHostWebviewView.ts index 4696f33c5fae8..322f9c1ca3f7a 100644 --- a/src/vs/workbench/api/common/extHostWebviewView.ts +++ b/src/vs/workbench/api/common/extHostWebviewView.ts @@ -13,8 +13,6 @@ import type * as vscode from 'vscode'; import * as extHostProtocol from './extHost.protocol.js'; import * as extHostTypes from './extHostTypes.js'; -/* eslint-disable local/code-no-native-private */ - class ExtHostWebviewView extends Disposable implements vscode.WebviewView { readonly #handle: extHostProtocol.WebviewHandle; diff --git a/src/vs/workbench/api/common/extHostWorkspace.ts b/src/vs/workbench/api/common/extHostWorkspace.ts index 55ca51e9a6445..3b01c6c8ac630 100644 --- a/src/vs/workbench/api/common/extHostWorkspace.ts +++ b/src/vs/workbench/api/common/extHostWorkspace.ts @@ -95,7 +95,7 @@ class ExtHostWorkspaceImpl extends Workspace { return { workspace: null, added: [], removed: [] }; } - const { id, name, folders, configuration, transient, isUntitled, isAgentSessionsWorkspace } = data; + const { id, name, folders, configuration, transient, isUntitled } = data; const newWorkspaceFolders: vscode.WorkspaceFolder[] = []; // If we have an existing workspace, we try to find the folders that match our @@ -123,7 +123,7 @@ class ExtHostWorkspaceImpl extends Workspace { // make sure to restore sort order based on index newWorkspaceFolders.sort((f1, f2) => f1.index < f2.index ? -1 : 1); - const workspace = new ExtHostWorkspaceImpl(id, name, newWorkspaceFolders, !!transient, configuration ? URI.revive(configuration) : null, !!isUntitled, !!isAgentSessionsWorkspace, uri => ignorePathCasing(uri, extHostFileSystemInfo)); + const workspace = new ExtHostWorkspaceImpl(id, name, newWorkspaceFolders, !!transient, configuration ? URI.revive(configuration) : null, !!isUntitled, uri => ignorePathCasing(uri, extHostFileSystemInfo)); const { added, removed } = delta(oldWorkspace ? oldWorkspace.workspaceFolders : [], workspace.workspaceFolders, compareWorkspaceFolderByUri, extHostFileSystemInfo); return { workspace, added, removed }; @@ -143,8 +143,8 @@ class ExtHostWorkspaceImpl extends Workspace { private readonly _workspaceFolders: vscode.WorkspaceFolder[] = []; private readonly _structure: TernarySearchTree; - constructor(id: string, private _name: string, folders: vscode.WorkspaceFolder[], transient: boolean, configuration: URI | null, private _isUntitled: boolean, isAgentSessionsWorkspace: boolean, ignorePathCasing: (key: URI) => boolean) { - super(id, folders.map(f => new WorkspaceFolder(f)), transient, configuration, ignorePathCasing, isAgentSessionsWorkspace); + constructor(id: string, private _name: string, folders: vscode.WorkspaceFolder[], transient: boolean, configuration: URI | null, private _isUntitled: boolean, ignorePathCasing: (key: URI) => boolean) { + super(id, folders.map(f => new WorkspaceFolder(f)), transient, configuration, ignorePathCasing); this._structure = TernarySearchTree.forUris(ignorePathCasing, () => true); // setup the workspace folder data structure @@ -226,7 +226,7 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac this._proxy = extHostRpc.getProxy(MainContext.MainThreadWorkspace); this._messageService = extHostRpc.getProxy(MainContext.MainThreadMessageService); const data = initData.workspace; - this._confirmedWorkspace = data ? new ExtHostWorkspaceImpl(data.id, data.name, [], !!data.transient, data.configuration ? URI.revive(data.configuration) : null, !!data.isUntitled, !!data.isAgentSessionsWorkspace, uri => ignorePathCasing(uri, extHostFileSystemInfo)) : undefined; + this._confirmedWorkspace = data ? new ExtHostWorkspaceImpl(data.id, data.name, [], !!data.transient, data.configuration ? URI.revive(data.configuration) : null, !!data.isUntitled, uri => ignorePathCasing(uri, extHostFileSystemInfo)) : undefined; } $initializeWorkspace(data: IWorkspaceData | null, trusted: boolean): void { @@ -249,10 +249,6 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac return this._actualWorkspace ? this._actualWorkspace.name : undefined; } - get isAgentSessionsWorkspace(): boolean { - return this._actualWorkspace?.isAgentSessionsWorkspace ?? false; - } - get workspaceFile(): vscode.Uri | undefined { if (this._actualWorkspace) { if (this._actualWorkspace.configuration) { diff --git a/src/vs/workbench/browser/contextkeys.ts b/src/vs/workbench/browser/contextkeys.ts index 366e575955898..16c7e5e226710 100644 --- a/src/vs/workbench/browser/contextkeys.ts +++ b/src/vs/workbench/browser/contextkeys.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../base/common/lifecycle.js'; import { IContextKeyService, IContextKey, setConstant as setConstantContextKey } from '../../platform/contextkey/common/contextkey.js'; import { IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext, IsIOSContext, ProductQualityContext, IsMobileContext } from '../../platform/contextkey/common/contextkeys.js'; -import { SplitEditorsVertically, InEditorZenModeContext, AuxiliaryBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, TitleBarVisibleContext, TitleBarStyleContext, IsAuxiliaryWindowFocusedContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorGroupLockedContext, MultipleEditorGroupsContext, EditorsVisibleContext, AuxiliaryBarMaximizedContext, InAutomationContext, IsAgentSessionsWorkspaceContext } from '../common/contextkeys.js'; +import { SplitEditorsVertically, InEditorZenModeContext, AuxiliaryBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, TitleBarVisibleContext, TitleBarStyleContext, IsAuxiliaryWindowFocusedContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorGroupLockedContext, MultipleEditorGroupsContext, EditorsVisibleContext, AuxiliaryBarMaximizedContext, InAutomationContext, IsSessionsWindowContext } from '../common/contextkeys.js'; import { preferredSideBySideGroupDirection, GroupDirection, IEditorGroupsService } from '../services/editor/common/editorGroupsService.js'; import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; import { IWorkbenchEnvironmentService } from '../services/environment/common/environmentService.js'; @@ -47,7 +47,7 @@ export class WorkbenchContextKeysHandler extends Disposable { private virtualWorkspaceContext: IContextKey; private temporaryWorkspaceContext: IContextKey; - private isAgentSessionsWorkspaceContext: IContextKey; + private isSessionsWindowContext: IContextKey; private inAutomationContext: IContextKey; private inZenModeContext: IContextKey; @@ -94,8 +94,8 @@ export class WorkbenchContextKeysHandler extends Disposable { this.virtualWorkspaceContext = VirtualWorkspaceContext.bindTo(this.contextKeyService); this.temporaryWorkspaceContext = TemporaryWorkspaceContext.bindTo(this.contextKeyService); - this.isAgentSessionsWorkspaceContext = IsAgentSessionsWorkspaceContext.bindTo(this.contextKeyService); - this.isAgentSessionsWorkspaceContext.set(!!this.contextService.getWorkspace().isAgentSessionsWorkspace); + this.isSessionsWindowContext = IsSessionsWindowContext.bindTo(this.contextKeyService); + this.isSessionsWindowContext.set(this.environmentService.isSessionsWindow); this.updateWorkspaceContextKeys(); // Capabilities diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index c7679ab51cb73..89f0b6d3b555a 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -23,7 +23,7 @@ import { IHostService } from '../services/host/browser/host.js'; import { IBrowserWorkbenchEnvironmentService } from '../services/environment/browser/environmentService.js'; import { IEditorService } from '../services/editor/common/editorService.js'; import { EditorGroupLayout, GroupActivationReason, GroupOrientation, GroupsOrder, IEditorGroupsService } from '../services/editor/common/editorGroupsService.js'; -import { SerializableGrid, ISerializableView, ISerializedGrid, Orientation, ISerializedNode, ISerializedLeafNode, Direction, IViewSize, Sizing, IViewVisibilityAnimationOptions } from '../../base/browser/ui/grid/grid.js'; +import { SerializableGrid, ISerializableView, ISerializedGrid, Orientation, ISerializedNode, ISerializedLeafNode, Direction, IViewSize, Sizing } from '../../base/browser/ui/grid/grid.js'; import { Part } from './part.js'; import { IStatusbarService } from '../services/statusbar/browser/statusbar.js'; import { IFileService } from '../../platform/files/common/files.js'; @@ -47,8 +47,6 @@ import { AuxiliaryBarPart } from './parts/auxiliarybar/auxiliaryBarPart.js'; import { ITelemetryService } from '../../platform/telemetry/common/telemetry.js'; import { IAuxiliaryWindowService } from '../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import { CodeWindow, mainWindow } from '../../base/browser/window.js'; -import { EASE_OUT, EASE_IN } from '../../base/browser/ui/motion/motion.js'; -import { CancellationToken } from '../../base/common/cancellation.js'; //#region Layout Implementation @@ -1599,7 +1597,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.auxiliaryBarPartView = auxiliaryBarPart; this.statusBarPartView = statusBar; - const viewMap = { + const viewMap: Record = { [Parts.ACTIVITYBAR_PART]: this.activityBarPartView, [Parts.BANNER_PART]: this.bannerPartView, [Parts.TITLEBAR_PART]: this.titleBarPartView, @@ -1870,32 +1868,27 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.stateModel.setRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN, hidden); - // Adjust CSS - for hiding, defer adding the class until animation - // completes so the part stays visible during the exit animation. - if (!hidden) { + // Adjust CSS + if (hidden) { + this.mainContainer.classList.add(LayoutClasses.SIDEBAR_HIDDEN); + } else { this.mainContainer.classList.remove(LayoutClasses.SIDEBAR_HIDDEN); } // Propagate to grid - this.workbenchGrid.setViewVisible( - this.sideBarPartView, - !hidden, - createViewVisibilityAnimation(hidden, () => { - if (!hidden) { return; } - // Deferred to after close animation - this.mainContainer.classList.add(LayoutClasses.SIDEBAR_HIDDEN); - if (this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); - - if (!this.isAuxiliaryBarMaximized()) { - this.focusPanelOrEditor(); // do not auto focus when auxiliary bar is maximized - } - } - }) - ); + this.workbenchGrid.setViewVisible(this.sideBarPartView, !hidden); + + // If sidebar becomes hidden, also hide the current active Viewlet if any + if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); + + if (!this.isAuxiliaryBarMaximized()) { + this.focusPanelOrEditor(); // do not auto focus when auxiliary bar is maximized + } + } // If sidebar becomes visible, show last active Viewlet or default viewlet - if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { + else if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { const viewletToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Sidebar); if (viewletToOpen) { this.openViewContainer(ViewContainerLocation.Sidebar, viewletToOpen); @@ -2019,6 +2012,13 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi const panelOpensMaximized = this.panelOpensMaximized(); + // Adjust CSS + if (hidden) { + this.mainContainer.classList.add(LayoutClasses.PANEL_HIDDEN); + } else { + this.mainContainer.classList.remove(LayoutClasses.PANEL_HIDDEN); + } + // If maximized and in process of hiding, unmaximize FIRST before // changing visibility to prevent conflict with setEditorHidden // which would force panel visible again (fixes #281772) @@ -2026,30 +2026,13 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.toggleMaximizedPanel(); } - // Adjust CSS - for hiding, defer adding the class until animation - // completes because `.nopanel .part.panel { display: none !important }` - // would instantly hide the panel content mid-animation. - if (!hidden) { - this.mainContainer.classList.remove(LayoutClasses.PANEL_HIDDEN); - } - // Propagate layout changes to grid - this.workbenchGrid.setViewVisible( - this.panelPartView, - !hidden, - createViewVisibilityAnimation(hidden, () => { - if (!hidden) { return; } - // Deferred to after close animation - this.mainContainer.classList.add(LayoutClasses.PANEL_HIDDEN); - if (this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Panel); - } - }) - ); + this.workbenchGrid.setViewVisible(this.panelPartView, !hidden); - // If panel part becomes hidden, focus the editor after animation starts + // If panel part becomes hidden, also hide the current active panel if any let focusEditor = false; if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Panel); if ( !isIOS && // do not auto focus on iOS (https://github.com/microsoft/vscode/issues/127832) !this.isAuxiliaryBarMaximized() // do not auto focus when auxiliary bar is maximized @@ -2223,30 +2206,24 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN, hidden); - // Adjust CSS - for hiding, defer adding the class until animation - // completes because `.noauxiliarybar .part.auxiliarybar { display: none !important }` - // would instantly hide the content mid-animation. - if (!hidden) { + // Adjust CSS + if (hidden) { + this.mainContainer.classList.add(LayoutClasses.AUXILIARYBAR_HIDDEN); + } else { this.mainContainer.classList.remove(LayoutClasses.AUXILIARYBAR_HIDDEN); } // Propagate to grid - this.workbenchGrid.setViewVisible( - this.auxiliaryBarPartView, - !hidden, - createViewVisibilityAnimation(hidden, () => { - if (!hidden) { return; } - // Deferred to after close animation - this.mainContainer.classList.add(LayoutClasses.AUXILIARYBAR_HIDDEN); - if (this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar); - this.focusPanelOrEditor(); - } - }) - ); + this.workbenchGrid.setViewVisible(this.auxiliaryBarPartView, !hidden); + + // If auxiliary bar becomes hidden, also hide the current active pane composite if any + if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar); + this.focusPanelOrEditor(); + } // If auxiliary bar becomes visible, show last active pane composite or default pane composite - if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { + else if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { let viewletToOpen: string | undefined = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.AuxiliaryBar); // verify that the viewlet we try to open has views before we default to it @@ -2739,21 +2716,6 @@ function getZenModeConfiguration(configurationService: IConfigurationService): Z return configurationService.getValue(WorkbenchLayoutSettings.ZEN_MODE_CONFIG); } -/** Duration (ms) for panel/sidebar open (entrance) animations. */ -const PANEL_OPEN_DURATION = 135; - -/** Duration (ms) for panel/sidebar close (exit) animations. */ -const PANEL_CLOSE_DURATION = 35; - -function createViewVisibilityAnimation(hidden: boolean, onComplete?: () => void, token: CancellationToken = CancellationToken.None): IViewVisibilityAnimationOptions { - return { - duration: hidden ? PANEL_CLOSE_DURATION : PANEL_OPEN_DURATION, - easing: hidden ? EASE_IN : EASE_OUT, - token, - onComplete, - }; -} - //#endregion //#region Layout State Model diff --git a/src/vs/workbench/browser/media/motion.css b/src/vs/workbench/browser/media/motion.css deleted file mode 100644 index fbd8215265a84..0000000000000 --- a/src/vs/workbench/browser/media/motion.css +++ /dev/null @@ -1,22 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Motion custom properties -- only active when motion is enabled */ -.monaco-workbench.monaco-enable-motion { - --vscode-motion-panel-open-duration: 175ms; - --vscode-motion-panel-close-duration: 75ms; - --vscode-motion-quick-input-open-duration: 175ms; - --vscode-motion-quick-input-close-duration: 75ms; - --vscode-motion-ease-out: cubic-bezier(0.1, 0.9, 0.2, 1); - --vscode-motion-ease-in: cubic-bezier(0.9, 0.1, 1, 0.2); -} - -/* Disable all motion durations when reduced motion is active */ -.monaco-workbench.monaco-reduce-motion { - --vscode-motion-panel-open-duration: 0ms; - --vscode-motion-panel-close-duration: 0ms; - --vscode-motion-quick-input-open-duration: 0ms; - --vscode-motion-quick-input-close-duration: 0ms; -} diff --git a/src/vs/workbench/browser/panecomposite.ts b/src/vs/workbench/browser/panecomposite.ts index fe21eedbcb718..12407ea94a0bc 100644 --- a/src/vs/workbench/browser/panecomposite.ts +++ b/src/vs/workbench/browser/panecomposite.ts @@ -193,6 +193,7 @@ export const Extensions = { Viewlets: 'workbench.contributions.viewlets', Panels: 'workbench.contributions.panels', Auxiliary: 'workbench.contributions.auxiliary', + ChatBar: 'workbench.contributions.chatbar', }; export class PaneCompositeRegistry extends CompositeRegistry { @@ -229,3 +230,4 @@ export class PaneCompositeRegistry extends CompositeRegistry { Registry.add(Extensions.Viewlets, new PaneCompositeRegistry()); Registry.add(Extensions.Panels, new PaneCompositeRegistry()); Registry.add(Extensions.Auxiliary, new PaneCompositeRegistry()); +Registry.add(Extensions.ChatBar, new PaneCompositeRegistry()); diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index 6e847c65e2f35..0307cab0e03bd 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -421,11 +421,23 @@ export class ActivityBarCompositeBar extends PaneCompositeBar { getActivityBarContextMenuActions(): IAction[] { const activityBarPositionMenu = this.menuService.getMenuActions(MenuId.ActivityBarPositionMenu, this.contextKeyService, { shouldForwardArgs: true, renderShortTitle: true }); const positionActions = getContextMenuActions(activityBarPositionMenu).secondary; - const actions = [ - new SubmenuAction('workbench.action.panel.position', localize('activity bar position', "Activity Bar Position"), positionActions), - toAction({ id: ToggleSidebarPositionAction.ID, label: ToggleSidebarPositionAction.getLabel(this.layoutService), run: () => this.instantiationService.invokeFunction(accessor => new ToggleSidebarPositionAction().run(accessor)) }), + const actions: IAction[] = [ + new SubmenuAction('workbench.action.activityBar.position', localize('activity bar position', "Activity Bar Position"), positionActions), ]; + // Show size submenu only when activity bar is in default position + const activityBarPosition = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION); + if (activityBarPosition === ActivityBarPosition.DEFAULT) { + const isCompact = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_COMPACT) ?? false; + const sizeActions = [ + toAction({ id: 'workbench.action.activityBar.size.default', label: localize('activityBarSizeDefault', "Default"), checked: !isCompact, run: () => this.configurationService.updateValue(LayoutSettings.ACTIVITY_BAR_COMPACT, false) }), + toAction({ id: 'workbench.action.activityBar.size.compact', label: localize('activityBarSizeCompact', "Compact"), checked: isCompact, run: () => this.configurationService.updateValue(LayoutSettings.ACTIVITY_BAR_COMPACT, true) }), + ]; + actions.push(new SubmenuAction('workbench.action.activityBar.size', localize('activity bar size', "Activity Bar Size"), sizeActions)); + } + + actions.push(toAction({ id: ToggleSidebarPositionAction.ID, label: ToggleSidebarPositionAction.getLabel(this.layoutService), run: () => this.instantiationService.invokeFunction(accessor => new ToggleSidebarPositionAction().run(accessor)) })); + if (this.part === Parts.SIDEBAR_PART) { actions.push(toAction({ id: ToggleSidebarVisibilityAction.ID, label: ToggleSidebarVisibilityAction.LABEL, run: () => this.instantiationService.invokeFunction(accessor => new ToggleSidebarVisibilityAction().run(accessor)) })); } diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts index 5abfb88dd9cbc..4eefda8085bb1 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts @@ -20,7 +20,6 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { SwitchCompositeViewAction } from '../compositeBarActions.js'; const maximizeIcon = registerIcon('auxiliarybar-maximize', Codicon.screenFull, localize('maximizeIcon', 'Icon to maximize the secondary side bar.')); -const restoreIcon = registerIcon('auxiliarybar-restore', Codicon.screenNormal, localize('restoreIcon', 'Icon to restore the secondary side bar.')); const closeIcon = registerIcon('auxiliarybar-close', Codicon.close, localize('closeIcon', 'Icon to close the secondary side bar.')); const auxiliaryBarRightIcon = registerIcon('auxiliarybar-right-layout-icon', Codicon.layoutSidebarRight, localize('toggleAuxiliaryIconRight', 'Icon to toggle the secondary side bar off in its right position.')); @@ -224,17 +223,10 @@ class MaximizeAuxiliaryBar extends Action2 { super({ id: MaximizeAuxiliaryBar.ID, title: localize2('maximizeAuxiliaryBar', 'Maximize Secondary Side Bar'), - tooltip: localize('maximizeAuxiliaryBarTooltip', "Maximize Secondary Side Bar Size"), + tooltip: localize('maximizeAuxiliaryBarTooltip', "Maximize Secondary Side Bar"), category: Categories.View, f1: true, precondition: AuxiliaryBarMaximizedContext.negate(), - icon: maximizeIcon, - menu: { - id: MenuId.AuxiliaryBarTitle, - group: 'navigation', - order: 1, - when: AuxiliaryBarMaximizedContext.negate() - } }); } @@ -254,16 +246,14 @@ class RestoreAuxiliaryBar extends Action2 { super({ id: RestoreAuxiliaryBar.ID, title: localize2('restoreAuxiliaryBar', 'Restore Secondary Side Bar'), - tooltip: localize('restoreAuxiliaryBarTooltip', "Restore Secondary Side Bar Size"), + tooltip: localize('restoreAuxiliaryBar', 'Restore Secondary Side Bar'), category: Categories.View, f1: true, precondition: AuxiliaryBarMaximizedContext, - icon: restoreIcon, - menu: { - id: MenuId.AuxiliaryBarTitle, - group: 'navigation', - order: 1, - when: AuxiliaryBarMaximizedContext + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyW, + win: { primary: KeyMod.CtrlCmd | KeyCode.F4, secondary: [KeyMod.CtrlCmd | KeyCode.KeyW] }, } }); } @@ -284,8 +274,19 @@ class ToggleMaximizedAuxiliaryBar extends Action2 { super({ id: ToggleMaximizedAuxiliaryBar.ID, title: localize2('toggleMaximizedAuxiliaryBar', 'Toggle Maximized Secondary Side Bar'), + tooltip: localize('maximizeAuxiliaryBarTooltip2', "Maximize Secondary Side Bar"), f1: true, - category: Categories.View + category: Categories.View, + icon: maximizeIcon, + toggled: { + condition: AuxiliaryBarMaximizedContext, + tooltip: localize('restoreAuxiliaryBar', 'Restore Secondary Side Bar'), + }, + menu: { + id: MenuId.AuxiliaryBarTitle, + group: 'navigation', + order: 1, + } }); } diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts index bb02c39dc9600..d32082b4e1272 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts @@ -114,6 +114,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { ViewContainerLocation.AuxiliaryBar, Extensions.Auxiliary, MenuId.AuxiliaryBarTitle, + undefined, notificationService, storageService, contextMenuService, @@ -150,7 +151,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { private onDidChangeAutoHideViewContainers(e: { before: number; after: number }): void { // Only update if auto-hide is enabled and composite bar would show const autoHide = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_AUTO_HIDE); - if (autoHide && (this.configuration.position !== ActivityBarPosition.HIDDEN)) { + if (autoHide && (this.configuration.position === ActivityBarPosition.TOP || this.configuration.position === ActivityBarPosition.BOTTOM)) { const visibleBefore = e.before > 1; const visibleAfter = e.after > 1; if (visibleBefore !== visibleAfter) { @@ -265,14 +266,17 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { } // Check if auto-hide is enabled and there's only one visible view container - const autoHide = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_AUTO_HIDE); - if (autoHide) { - // Use visible composite count from the composite bar if available (considers pinned state), - // otherwise fall back to the tracker's count (based on active view descriptors). - // Note: We access paneCompositeBar directly to avoid circular calls with getVisiblePaneCompositeIds() - const visibleCount = this.visibleViewContainersTracker.visibleCount; - if (visibleCount <= 1) { - return false; + // while the activity bar is configured to be top or bottom. + if (this.configuration.position === ActivityBarPosition.TOP || this.configuration.position === ActivityBarPosition.BOTTOM) { + const autoHide = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_AUTO_HIDE); + if (autoHide) { + // Use visible composite count from the composite bar if available (considers pinned state), + // otherwise fall back to the tracker's count (based on active view descriptors). + // Note: We access paneCompositeBar directly to avoid circular calls with getVisiblePaneCompositeIds() + const visibleCount = this.visibleViewContainersTracker.visibleCount; + if (visibleCount <= 1) { + return false; + } } } diff --git a/src/vs/platform/dialogs/browser/dialog.ts b/src/vs/workbench/browser/parts/dialogs/dialog.ts similarity index 65% rename from src/vs/platform/dialogs/browser/dialog.ts rename to src/vs/workbench/browser/parts/dialogs/dialog.ts index 01d12f126da19..d2f18acdb50b0 100644 --- a/src/vs/platform/dialogs/browser/dialog.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialog.ts @@ -3,16 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { EventHelper } from '../../../base/browser/dom.js'; -import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; -import { IDialogOptions } from '../../../base/browser/ui/dialog/dialog.js'; -import { fromNow } from '../../../base/common/date.js'; -import { localize } from '../../../nls.js'; -import { IKeybindingService } from '../../keybinding/common/keybinding.js'; -import { ResultKind } from '../../keybinding/common/keybindingResolver.js'; -import { ILayoutService } from '../../layout/browser/layoutService.js'; -import { IProductService } from '../../product/common/productService.js'; -import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, defaultDialogStyles } from '../../theme/browser/defaultStyles.js'; +import { EventHelper } from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { IDialogOptions } from '../../../../base/browser/ui/dialog/dialog.js'; +import { fromNow } from '../../../../base/common/date.js'; +import { localize } from '../../../../nls.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ResultKind } from '../../../../platform/keybinding/common/keybindingResolver.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, defaultDialogStyles } from '../../../../platform/theme/browser/defaultStyles.js'; const defaultDialogAllowableCommands = new Set([ 'workbench.action.quit', @@ -25,7 +26,7 @@ const defaultDialogAllowableCommands = new Set([ 'editor.action.clipboardPasteAction' ]); -export function createWorkbenchDialogOptions(options: Partial, keybindingService: IKeybindingService, layoutService: ILayoutService, allowableCommands = defaultDialogAllowableCommands): IDialogOptions { +export function createWorkbenchDialogOptions(options: Partial, keybindingService: IKeybindingService, layoutService: ILayoutService, hostService: IHostService, allowableCommands = defaultDialogAllowableCommands): IDialogOptions { return { keyEventProcessor: (event: StandardKeyboardEvent) => { const resolved = keybindingService.softDispatch(event, layoutService.activeContainer); @@ -39,6 +40,7 @@ export function createWorkbenchDialogOptions(options: Partial, k checkboxStyles: defaultCheckboxStyles, inputBoxStyles: defaultInputBoxStyles, dialogStyles: defaultDialogStyles, + onVisibilityChange: (window, visible) => hostService.setWindowDimmed(window, visible), ...options }; } diff --git a/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts b/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts index bba15b35fbba8..7e0e5ce8e9d2f 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts @@ -3,11 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IDialogHandler, IDialogResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { IDialogsModel, IDialogViewItem } from '../../../common/dialogs.js'; @@ -16,9 +12,7 @@ import { DialogService } from '../../../services/dialogs/common/dialogService.js import { Disposable } from '../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { createBrowserAboutDialogDetails } from '../../../../platform/dialogs/browser/dialog.js'; -import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { createBrowserAboutDialogDetails } from './dialog.js'; export class DialogHandlerContribution extends Disposable implements IWorkbenchContribution { @@ -31,18 +25,12 @@ export class DialogHandlerContribution extends Disposable implements IWorkbenchC constructor( @IDialogService private dialogService: IDialogService, - @ILogService logService: ILogService, - @ILayoutService layoutService: ILayoutService, - @IKeybindingService keybindingService: IKeybindingService, @IInstantiationService instantiationService: IInstantiationService, @IProductService private productService: IProductService, - @IClipboardService clipboardService: IClipboardService, - @IOpenerService openerService: IOpenerService, - @IMarkdownRendererService markdownRendererService: IMarkdownRendererService, ) { super(); - this.impl = new Lazy(() => new BrowserDialogHandler(logService, layoutService, keybindingService, instantiationService, clipboardService, openerService, markdownRendererService)); + this.impl = new Lazy(() => instantiationService.createInstance(BrowserDialogHandler)); this.model = (this.dialogService as DialogService).model; this._register(this.model.onWillShowDialog(() => { diff --git a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts index 7664074eced18..16e11df36305b 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts @@ -15,7 +15,8 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboa import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { createWorkbenchDialogOptions } from '../../../../platform/dialogs/browser/dialog.js'; +import { createWorkbenchDialogOptions } from './dialog.js'; +import { IHostService } from '../../../services/host/browser/host.js'; export class BrowserDialogHandler extends AbstractDialogHandler { @@ -36,6 +37,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { @IClipboardService private readonly clipboardService: IClipboardService, @IOpenerService private readonly openerService: IOpenerService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IHostService private readonly hostService: IHostService, ) { super(); } @@ -119,7 +121,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { checkboxLabel: checkbox?.label, checkboxChecked: checkbox?.checked, inputs - }, this.keybindingService, this.layoutService, BrowserDialogHandler.ALLOWABLE_COMMANDS) + }, this.keybindingService, this.layoutService, this.hostService, BrowserDialogHandler.ALLOWABLE_COMMANDS) ); dialogDisposables.add(dialog); diff --git a/src/vs/workbench/browser/parts/editor/editor.ts b/src/vs/workbench/browser/parts/editor/editor.ts index 9a1b9ab092fd1..28d0480ab86a0 100644 --- a/src/vs/workbench/browser/parts/editor/editor.ts +++ b/src/vs/workbench/browser/parts/editor/editor.ts @@ -55,6 +55,7 @@ export const DEFAULT_EDITOR_PART_OPTIONS: IEditorPartOptions = { labelFormat: 'default', splitSizing: 'auto', splitOnDragAndDrop: true, + allowDropIntoGroup: true, dragToOpenWindow: true, centeredLayoutFixedWidth: false, doubleClickTabToToggleEditorGroupSizes: 'expand', @@ -142,6 +143,7 @@ function validateEditorPartOptions(options: IEditorPartOptions): IEditorPartOpti 'mouseBackForwardToNavigate': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['mouseBackForwardToNavigate']), 'restoreViewState': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['restoreViewState']), 'splitOnDragAndDrop': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['splitOnDragAndDrop']), + 'allowDropIntoGroup': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['allowDropIntoGroup']), 'dragToOpenWindow': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['dragToOpenWindow']), 'centeredLayoutFixedWidth': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['centeredLayoutFixedWidth']), 'hasIcons': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['hasIcons']), diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 693918dc2cacf..5fd906f1d8585 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -27,7 +27,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { ActiveGroupEditorsByMostRecentlyUsedQuickAccess } from './editorQuickAccess.js'; import { SideBySideEditor } from './sideBySideEditor.js'; import { TextDiffEditor } from './textDiffEditor.js'; -import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, EditorPartModalMaximizedContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; +import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, EditorPartModalMaximizedContext, EditorPartModalNavigationContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; import { CloseDirection, EditorInputCapabilities, EditorsOrder, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, isEditorInputWithOptionsAndGroup } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; @@ -108,6 +108,8 @@ export const NEW_EMPTY_EDITOR_WINDOW_COMMAND_ID = 'workbench.action.newEmptyEdit export const CLOSE_MODAL_EDITOR_COMMAND_ID = 'workbench.action.closeModalEditor'; export const MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID = 'workbench.action.moveModalEditorToMain'; export const TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID = 'workbench.action.toggleModalEditorMaximized'; +export const NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID = 'workbench.action.navigateModalEditorPrevious'; +export const NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID = 'workbench.action.navigateModalEditorNext'; export const API_OPEN_EDITOR_COMMAND_ID = '_workbench.open'; export const API_OPEN_DIFF_EDITOR_COMMAND_ID = '_workbench.diff'; @@ -1447,7 +1449,6 @@ function registerModalEditorCommands(): void { icon: Codicon.screenFull, toggled: { condition: EditorPartModalMaximizedContext, - icon: Codicon.screenNormal, title: localize('restoreModalEditorSize', "Restore Modal Editor") }, menu: { @@ -1501,6 +1502,64 @@ function registerModalEditorCommands(): void { } } }); + + registerAction2(class extends Action2 { + constructor() { + super({ + id: NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID, + title: localize2('navigateModalEditorPrevious', 'Navigate to Previous Item in Modal Editor'), + category: Categories.View, + precondition: ContextKeyExpr.and(EditorPartModalContext, EditorPartModalNavigationContext), + keybinding: { + primary: KeyMod.Alt | KeyCode.UpArrow, + weight: KeybindingWeight.WorkbenchContrib + 10, + when: ContextKeyExpr.and(EditorPartModalContext, EditorPartModalNavigationContext) + } + }); + } + run(accessor: ServicesAccessor): void { + const editorGroupsService = accessor.get(IEditorGroupsService); + + for (const part of editorGroupsService.parts) { + if (isModalEditorPart(part)) { + const nav = part.navigation; + if (nav && nav.current > 0) { + nav.navigate(nav.current - 1); + } + break; + } + } + } + }); + + registerAction2(class extends Action2 { + constructor() { + super({ + id: NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID, + title: localize2('navigateModalEditorNext', 'Navigate to Next Item in Modal Editor'), + category: Categories.View, + precondition: ContextKeyExpr.and(EditorPartModalContext, EditorPartModalNavigationContext), + keybinding: { + primary: KeyMod.Alt | KeyCode.DownArrow, + weight: KeybindingWeight.WorkbenchContrib + 10, + when: ContextKeyExpr.and(EditorPartModalContext, EditorPartModalNavigationContext) + } + }); + } + run(accessor: ServicesAccessor): void { + const editorGroupsService = accessor.get(IEditorGroupsService); + + for (const part of editorGroupsService.parts) { + if (isModalEditorPart(part)) { + const nav = part.navigation; + if (nav && nav.current < nav.total - 1) { + nav.navigate(nav.current + 1); + } + break; + } + } + } + }); } function isModalEditorPart(obj: unknown): obj is IModalEditorPart { @@ -1511,6 +1570,8 @@ function isModalEditorPart(obj: unknown): obj is IModalEditorPart { && typeof part.onWillClose === 'function' && typeof part.toggleMaximized === 'function' && typeof part.maximized === 'boolean' + && typeof part.updateOptions === 'function' + && !!part.modalElement && part.windowId === mainWindow.vscodeWindowId; } diff --git a/src/vs/workbench/browser/parts/editor/editorDropTarget.ts b/src/vs/workbench/browser/parts/editor/editorDropTarget.ts index f604df1d0fe58..f9c80ed1dd266 100644 --- a/src/vs/workbench/browser/parts/editor/editorDropTarget.ts +++ b/src/vs/workbench/browser/parts/editor/editorDropTarget.ts @@ -20,7 +20,7 @@ import { IThemeService, Themable } from '../../../../platform/theme/common/theme import { isTemporaryWorkspace, IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { CodeDataTransfers, containsDragType, Extensions as DragAndDropExtensions, IDragAndDropContributionRegistry, LocalSelectionTransfer } from '../../../../platform/dnd/browser/dnd.js'; import { DraggedEditorGroupIdentifier, DraggedEditorIdentifier, extractTreeDropData, ResourcesDropHandler } from '../../dnd.js'; -import { IEditorGroupView, prepareMoveCopyEditors } from './editor.js'; +import { IEditorGroupsView, IEditorGroupView, prepareMoveCopyEditors } from './editor.js'; import { EditorInputCapabilities, IEditorIdentifier, IUntypedEditorInput } from '../../../common/editor.js'; import { EDITOR_DRAG_AND_DROP_BACKGROUND, EDITOR_DROP_INTO_PROMPT_BACKGROUND, EDITOR_DROP_INTO_PROMPT_BORDER, EDITOR_DROP_INTO_PROMPT_FOREGROUND } from '../../../common/theme.js'; import { GroupDirection, IEditorDropTargetDelegate, IEditorGroup, IEditorGroupsService, IMergeGroupOptions, MergeGroupMode } from '../../../services/editor/common/editorGroupsService.js'; @@ -181,7 +181,7 @@ class DropOverlay extends Themable { // Position overlay and conditionally enable or disable // editor group splitting support based on setting and // keymodifiers used. - let splitOnDragAndDrop = !!this.editorGroupService.partOptions.splitOnDragAndDrop; + let splitOnDragAndDrop = !!this.groupView.groupsView.partOptions.splitOnDragAndDrop; if (this.isToggleSplitOperation(e)) { splitOnDragAndDrop = !splitOnDragAndDrop; } @@ -316,7 +316,7 @@ class DropOverlay extends Themable { // Optimization: if we move the last editor of an editor group // and we are configured to close empty editor groups, we can // rather move the entire editor group according to the direction - if (this.editorGroupService.partOptions.closeEmptyGroups && sourceGroup.count === 1 && typeof splitDirection === 'number' && !copyEditor) { + if (this.groupView.groupsView.partOptions.closeEmptyGroups && sourceGroup.count === 1 && typeof splitDirection === 'number' && !copyEditor) { targetGroup = this.editorGroupService.moveGroup(sourceGroup, this.groupView, splitDirection); } @@ -383,7 +383,7 @@ class DropOverlay extends Themable { } private positionOverlay(mousePosX: number, mousePosY: number, isDraggingGroup: boolean, enableSplitting: boolean): void { - const preferSplitVertically = this.editorGroupService.partOptions.openSideBySideDirection === 'right'; + const preferSplitVertically = this.groupView.groupsView.partOptions.openSideBySideDirection === 'right'; const editorControlWidth = this.groupView.element.clientWidth; const editorControlHeight = this.groupView.element.clientHeight - this.getOverlayOffsetHeight(); @@ -523,7 +523,7 @@ class DropOverlay extends Themable { private getOverlayOffsetHeight(): number { // With tabs and opened editors: use the area below tabs as drop target - if (!this.groupView.isEmpty && this.editorGroupService.partOptions.showTabs === 'multiple') { + if (!this.groupView.isEmpty && this.groupView.groupsView.partOptions.showTabs === 'multiple') { return this.groupView.titleHeight.offset; } @@ -571,6 +571,7 @@ export class EditorDropTarget extends Themable { private readonly groupTransfer = LocalSelectionTransfer.getInstance(); constructor( + private readonly groupsView: IEditorGroupsView, private readonly container: HTMLElement, private readonly delegate: IEditorDropTargetDelegate, @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @@ -620,6 +621,14 @@ export class EditorDropTarget extends Themable { } } + // Check if dropping into group is allowed + if (!this.groupsView.partOptions.allowDropIntoGroup) { + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'none'; + } + return; + } + // Signal DND start this.updateContainer(true); diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index 4326662a9abaa..ca2d962ea0dd6 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -973,7 +973,7 @@ export class EditorPart extends Part implements IEditorPart, createEditorDropTarget(container: unknown, delegate: IEditorDropTargetDelegate): IDisposable { assertType(isHTMLElement(container)); - return this.scopedInstantiationService.createInstance(EditorDropTarget, container, delegate); + return this.scopedInstantiationService.createInstance(EditorDropTarget, this, container, delegate); } //#region Part diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index 8565198960330..c2ccf4a7316b0 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -28,6 +28,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { DeepPartial } from '../../../../base/common/types.js'; import { IStatusbarService } from '../../../services/statusbar/browser/statusbar.js'; import { mainWindow } from '../../../../base/browser/window.js'; +import { IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js'; interface IEditorPartsUIState { readonly auxiliary: IAuxiliaryEditorPartState[]; @@ -157,14 +158,16 @@ export class EditorParts extends MultiWindowParts { + async createModalEditorPart(options?: IModalEditorPartOptions): Promise { // Reuse existing modal editor part if it exists if (this.modalEditorPart) { + this.modalEditorPart.updateOptions(options); + return this.modalEditorPart; } - const { part, instantiationService, disposables } = await this.instantiationService.createInstance(ModalEditorPart, this).create(); + const { part, instantiationService, disposables } = await this.instantiationService.createInstance(ModalEditorPart, this).create(options); // Keep instantiation service and reference to reuse this.modalEditorPart = part; diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index 19b64fc8c8522..44c003884676a 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -3,32 +3,30 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/** Modal Editor Part: Modal Block */ +/** Modal Background Block */ .monaco-modal-editor-block { position: fixed; width: 100%; + height: 100%; + top: 0; left: 0; - /* z-index for modal editors: below dialogs, quick input, context views, hovers but above other things */ - z-index: 2000; + /* z-index for modal editors: above titlebar (2500) but below quick input (2550) and dialogs (2575) */ + z-index: 2540; display: flex; justify-content: center; align-items: center; /* Never allow content to escape above the title bar */ overflow: hidden; -} - -.monaco-modal-editor-block.dimmed { background: rgba(0, 0, 0, 0.3); -} -/** Modal Editor Part: Shadow Container */ -.monaco-modal-editor-block .modal-editor-shadow { - box-shadow: 0 4px 32px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.2)); - border-radius: 8px; - overflow: hidden; + .modal-editor-shadow { + box-shadow: 0 4px 32px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.2)); + border-radius: 8px; + overflow: hidden; + } } -/** Modal Editor Part: Editor Container */ +/** Modal Editor Container */ .monaco-modal-editor-block .modal-editor-part { display: flex; flex-direction: column; @@ -38,13 +36,19 @@ border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); border-radius: 8px; overflow: hidden; -} -.monaco-modal-editor-block .modal-editor-part:focus { - outline: none; + &:focus { + outline: none; + } + + .content { + flex: 1; + position: relative; + overflow: hidden; + } } -/** Modal Editor Part: Header with title and close button */ +/** Modal Editor Header */ .monaco-modal-editor-block .modal-editor-header { display: grid; grid-template-columns: 1fr auto 1fr; @@ -55,36 +59,75 @@ color: var(--vscode-titleBar-activeForeground); background-color: var(--vscode-titleBar-activeBackground); border-bottom: 1px solid var(--vscode-titleBar-border, transparent); -} -.monaco-modal-editor-block .modal-editor-title { - grid-column: 1; - font-size: 12px; - font-weight: 500; - color: var(--vscode-titleBar-activeForeground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} + /* Modal Editor Title */ + .modal-editor-title { + grid-column: 1; + font-size: 12px; + font-weight: 500; + color: var(--vscode-titleBar-activeForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } -.monaco-modal-editor-block .modal-editor-action-container { - grid-column: 3; - display: flex; - align-items: center; - justify-content: flex-end; -} + /* Modal Editor Navigation */ + .modal-editor-navigation { + grid-column: 2; + display: flex; + align-items: center; + height: 22px; + border-radius: 4px; + border: 1px solid var(--vscode-commandCenter-border, transparent); + overflow: hidden; + user-select: none; + -webkit-user-select: none; -.monaco-modal-editor-block .modal-editor-action-container .actions-container { - gap: 4px; -} + .modal-editor-nav-button.monaco-button { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 100%; + padding: 0; + border: none; + border-radius: 0; + background: none; + color: inherit; + opacity: 0.7; + } -.monaco-modal-editor-block .modal-editor-action-container .actions-container .codicon { - color: inherit; -} + .modal-editor-nav-button.monaco-button:hover:not(.disabled) { + opacity: 1; + background-color: var(--vscode-commandCenter-activeBackground); + } -/** Modal Editor Part: Ensure proper sizing */ -.monaco-modal-editor-block .modal-editor-part .content { - flex: 1; - position: relative; - overflow: hidden; + .modal-editor-nav-button.monaco-button.disabled { + opacity: 0.3; + } + + .modal-editor-nav-label { + font-size: 11px; + font-variant-numeric: tabular-nums; + opacity: 0.7; + white-space: nowrap; + padding: 0 6px; + } + } + + /* Modal Editor Actions */ + .modal-editor-action-container { + grid-column: 3; + display: flex; + align-items: center; + justify-content: flex-end; + + .actions-container { + gap: 4px; + + .codicon { + color: inherit; + } + } + } } diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 8d2c6acb5920e..b58cb981051f4 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import './media/modalEditorPart.css'; -import { $, addDisposableListener, append, EventHelper, EventType, isHTMLElement } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventHelper, EventType, hide, isHTMLElement, show } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; @@ -22,12 +24,15 @@ import { IEditorGroupView, IEditorPartsView } from './editor.js'; import { EditorPart } from './editorPart.js'; import { GroupDirection, GroupsOrder, IModalEditorPart, GroupActivationReason } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { EditorPartModalContext, EditorPartModalMaximizedContext } from '../../../common/contextkeys.js'; +import { EditorPartModalContext, EditorPartModalMaximizedContext, EditorPartModalNavigationContext } from '../../../common/contextkeys.js'; import { Verbosity } from '../../../common/editor.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { localize } from '../../../../nls.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { CLOSE_MODAL_EDITOR_COMMAND_ID, MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID, NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID, TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID } from './editorCommands.js'; +import { IModalEditorNavigation, IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js'; const defaultModalEditorAllowableCommands = new Set([ 'workbench.action.quit', @@ -36,6 +41,11 @@ const defaultModalEditorAllowableCommands = new Set([ 'workbench.action.closeAllEditors', 'workbench.action.files.save', 'workbench.action.files.saveAll', + CLOSE_MODAL_EDITOR_COMMAND_ID, + MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, + TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID, + NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID, + NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID, ]); export interface ICreateModalEditorPartResult { @@ -52,38 +62,100 @@ export class ModalEditorPart { @IEditorService private readonly editorService: IEditorService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IKeybindingService private readonly keybindingService: IKeybindingService, + @IHostService private readonly hostService: IHostService, ) { } - async create(): Promise { + async create(options?: IModalEditorPartOptions): Promise { const disposables = new DisposableStore(); - // Create modal container - const modalElement = $('.monaco-modal-editor-block.dimmed'); - modalElement.tabIndex = -1; + // Modal container + const modalElement = $('.monaco-modal-editor-block'); this.layoutService.mainContainer.appendChild(modalElement); disposables.add(toDisposable(() => modalElement.remove())); + disposables.add(addDisposableListener(modalElement, EventType.MOUSE_DOWN, e => { + if (e.target === modalElement) { + EventHelper.stop(e, true); + + // Guide focus back into the modal when clicking outside modal + editorPartContainer.focus(); + } + })); + + disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + + // Close on Escape + if (event.equals(KeyCode.Escape)) { + EventHelper.stop(event, true); + + editorPart.close(); + } + + // Prevent unsupported commands + else { + const resolved = this.keybindingService.softDispatch(event, this.layoutService.mainContainer); + if (resolved.kind === ResultKind.KbFound && resolved.commandId) { + if ( + resolved.commandId.startsWith('workbench.') && + !defaultModalEditorAllowableCommands.has(resolved.commandId) + ) { + EventHelper.stop(event, true); + } + } + } + })); + const shadowElement = modalElement.appendChild($('.modal-editor-shadow')); - // Create editor part container + // Editor part container const titleId = 'modal-editor-title'; const editorPartContainer = $('.part.editor.modal-editor-part', { role: 'dialog', 'aria-modal': 'true', - 'aria-labelledby': titleId + 'aria-labelledby': titleId, + tabIndex: -1 }); shadowElement.appendChild(editorPartContainer); - // Create header with title and close button + // Header const headerElement = editorPartContainer.appendChild($('.modal-editor-header')); - // Title element (centered) + // Title element const titleElement = append(headerElement, $('div.modal-editor-title')); titleElement.id = titleId; titleElement.textContent = ''; - // Action buttons + // Navigation widget + const navigationContainer = append(headerElement, $('div.modal-editor-navigation')); + hide(navigationContainer); + disposables.add(addDisposableListener(navigationContainer, EventType.DBLCLICK, e => EventHelper.stop(e, true))); + + const previousButton = disposables.add(new Button(navigationContainer, { title: localize('previousItem', "Previous") })); + previousButton.icon = Codicon.chevronLeft; + previousButton.element.classList.add('modal-editor-nav-button'); + disposables.add(previousButton.onDidClick(() => { + const navigation = editorPart.navigation; + if (navigation && navigation.current > 0) { + navigation.navigate(navigation.current - 1); + } + })); + + const navigationLabel = append(navigationContainer, $('span.modal-editor-nav-label')); + navigationLabel.setAttribute('aria-live', 'polite'); + + const nextButton = disposables.add(new Button(navigationContainer, { title: localize('nextItem', "Next") })); + nextButton.icon = Codicon.chevronRight; + nextButton.element.classList.add('modal-editor-nav-button'); + disposables.add(nextButton.onDidClick(() => { + const navigation = editorPart.navigation; + if (navigation && navigation.current < navigation.total - 1) { + navigation.navigate(navigation.current + 1); + } + })); + + // Toolbar const actionBarContainer = append(headerElement, $('div.modal-editor-action-container')); // Create the editor part @@ -91,20 +163,34 @@ export class ModalEditorPart { ModalEditorPartImpl, mainWindow.vscodeWindowId, this.editorPartsView, - localize('modalEditorPart', "Modal Editor Area") + modalElement, + options, )); disposables.add(this.editorPartsView.registerPart(editorPart)); editorPart.create(editorPartContainer); + disposables.add(Event.once(editorPart.onWillClose)(() => disposables.dispose())); + disposables.add(Event.runAndSubscribe(editorPart.onDidChangeNavigation, ((navigation: IModalEditorNavigation | undefined) => { + if (navigation && navigation.total > 1) { + show(navigationContainer); + navigationLabel.textContent = localize('navigationCounter', "{0} of {1}", navigation.current + 1, navigation.total); + previousButton.enabled = navigation.current > 0; + nextButton.enabled = navigation.current < navigation.total - 1; + } else { + hide(navigationContainer); + } + }), editorPart.navigation)); + // Create scoped instantiation service const modalEditorService = this.editorService.createScoped(editorPart, disposables); const scopedInstantiationService = disposables.add(editorPart.scopedInstantiationService.createChild(new ServiceCollection( [IEditorService, modalEditorService] ))); - // Create toolbar driven by MenuId.ModalEditorTitle + // Create toolbar disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.ModalEditorTitle, { hiddenItemStrategy: HiddenItemStrategy.NoHide, + highlightToggledItems: true, menuOptions: { shouldForwardArgs: true } })); @@ -118,31 +204,13 @@ export class ModalEditorPart { editorPart.notifyActiveEditorChanged(); }))); - // Handle close on click outside (on the dimmed background) - disposables.add(addDisposableListener(modalElement, EventType.MOUSE_DOWN, e => { - if (e.target === modalElement) { - editorPart.close(); - } - })); + // Handle double-click on header to toggle maximize + disposables.add(addDisposableListener(headerElement, EventType.DBLCLICK, e => { + EventHelper.stop(e); - // Block certain workbench commands from being dispatched while the modal is open - disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => { - const event = new StandardKeyboardEvent(e); - const resolved = this.keybindingService.softDispatch(event, this.layoutService.mainContainer); - if (resolved.kind === ResultKind.KbFound && resolved.commandId) { - if ( - resolved.commandId.startsWith('workbench.') && - !defaultModalEditorAllowableCommands.has(resolved.commandId) - ) { - EventHelper.stop(event, true); - } - } + editorPart.toggleMaximized(); })); - // Handle close event from editor part - disposables.add(Event.once(editorPart.onWillClose)(() => { - disposables.dispose(); - })); // Layout the modal editor part const layoutModal = () => { @@ -154,9 +222,10 @@ export class ModalEditorPart { let height: number; if (editorPart.maximized) { - const padding = 16; // Keep a small margin around all edges - width = Math.max(containerDimension.width - padding, 0); - height = Math.max(availableHeight - padding, 0); + const horizontalPadding = 16; + const verticalPadding = Math.max(titleBarOffset /* keep away from title bar to prevent clipping issues with WCO */, 16); + width = Math.max(containerDimension.width - horizontalPadding, 0); + height = Math.max(availableHeight - verticalPadding, 0); } else { const maxWidth = 1200; const maxHeight = 800; @@ -168,10 +237,6 @@ export class ModalEditorPart { height = Math.min(height, availableHeight); // Ensure the modal never exceeds available height (below the title bar) - // Shift the modal block below the title bar - modalElement.style.top = `${titleBarOffset}px`; - modalElement.style.height = `calc(100% - ${titleBarOffset}px)`; - editorPartContainer.style.width = `${width}px`; editorPartContainer.style.height = `${height}px`; @@ -182,6 +247,10 @@ export class ModalEditorPart { disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, layoutModal)); disposables.add(editorPart.onDidChangeMaximized(() => layoutModal())); + // Dim window controls to match the modal overlay + this.hostService.setWindowDimmed(mainWindow, true); + disposables.add(toDisposable(() => this.hostService.setWindowDimmed(mainWindow, false))); + // Focus the modal editorPartContainer.focus(); @@ -203,9 +272,15 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { private readonly _onDidChangeMaximized = this._register(new Emitter()); readonly onDidChangeMaximized = this._onDidChangeMaximized.event; + private readonly _onDidChangeNavigation = this._register(new Emitter()); + readonly onDidChangeNavigation = this._onDidChangeNavigation.event; + private _maximized = false; get maximized(): boolean { return this._maximized; } + private _navigation: IModalEditorNavigation | undefined; + get navigation(): IModalEditorNavigation | undefined { return this._navigation; } + private readonly optionsDisposable = this._register(new MutableDisposable()); private previousMainWindowActiveElement: Element | null = null; @@ -213,7 +288,8 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { constructor( windowId: number, editorPartsView: IEditorPartsView, - groupsLabel: string, + public readonly modalElement: HTMLElement, + options: IModalEditorPartOptions | undefined, @IInstantiationService instantiationService: IInstantiationService, @IThemeService themeService: IThemeService, @IConfigurationService configurationService: IConfigurationService, @@ -223,7 +299,9 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { @IContextKeyService contextKeyService: IContextKeyService, ) { const id = ModalEditorPartImpl.COUNTER++; - super(editorPartsView, `workbench.parts.modalEditor.${id}`, groupsLabel, windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); + super(editorPartsView, `workbench.parts.modalEditor.${id}`, localize('modalEditorPart', "Modal Editor Area"), windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); + + this._navigation = options?.navigation; this.enforceModalPartOptions(); } @@ -238,11 +316,13 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { const editorCount = this.groups.reduce((count, group) => count + group.count, 0); this.optionsDisposable.value = this.enforcePartOptions({ showTabs: editorCount > 1 ? 'multiple' : 'none', + enablePreview: true, closeEmptyGroups: true, tabActionCloseVisibility: editorCount > 1, editorActionsLocation: 'default', tabHeight: 'default', - wrapTabs: false + wrapTabs: false, + allowDropIntoGroup: false }); } @@ -250,6 +330,12 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { this.enforceModalPartOptions(); } + updateOptions(options?: IModalEditorPartOptions): void { + this._navigation = options?.navigation; + + this._onDidChangeNavigation.fire(options?.navigation); + } + toggleMaximized(): void { this._maximized = !this._maximized; @@ -264,6 +350,10 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { isMaximizedContext.set(this._maximized); this._register(this.onDidChangeMaximized(maximized => isMaximizedContext.set(maximized))); + const hasNavigationContext = EditorPartModalNavigationContext.bindTo(this.scopedContextKeyService); + hasNavigationContext.set(!!this._navigation && this._navigation.total > 1); + this._register(this.onDidChangeNavigation(navigation => hasNavigationContext.set(!!navigation && navigation.total > 1))); + super.handleContextKeys(); } @@ -365,4 +455,10 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { return result; } + + override dispose(): void { + this._navigation = undefined; // ensure to free the reference to the navigation closure + + super.dispose(); + } } diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index e46ba514858cb..1f1187c362507 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -145,6 +145,7 @@ export abstract class AbstractPaneCompositePart extends CompositePart this.actionViewItemProvider(action, options), orientation: ActionsOrientation.HORIZONTAL, @@ -708,14 +707,6 @@ export abstract class AbstractPaneCompositePart extends CompositePart .custom-view-tree-node-item-resourceLabel > .custom-view-tree-node-item-icon.disabled { opacity: 0.6; } + /* makes spinning icons square */ .customview-tree .monaco-list .monaco-list-row .custom-view-tree-node-item > .custom-view-tree-node-item-resourceLabel > .custom-view-tree-node-item-icon.codicon.codicon-modifier-spin { padding-left: 6px; @@ -251,6 +254,35 @@ display: block; } +/* When horizontal scrolling is enabled, shift actions back to the viewport's right edge */ +.customview-tree .monaco-list.horizontal-scrolling .monaco-list-row:has(.actions .action-item):hover .custom-view-tree-node-item .actions, + +.customview-tree .monaco-list.horizontal-scrolling .monaco-list-row:has(.actions .action-item).selected .custom-view-tree-node-item .actions, + +.customview-tree .monaco-list.horizontal-scrolling .monaco-list-row:has(.actions .action-item).focused .custom-view-tree-node-item .actions { + position: relative; + right: var(--list-scroll-right-offset, 0px); + background-color: var(--vscode-list-hoverBackground); + padding-left: 4px; +} + +.customview-tree .monaco-list.horizontal-scrolling .monaco-list-row:has(.monaco-icon-label[class*="monaco-decoration-"]) .actions { + right: max(calc(var(--list-scroll-right-offset, 0px) - 17px), + 0px) !important; +} + +.customview-tree .monaco-list.horizontal-scrolling:focus .monaco-list-row.selected .custom-view-tree-node-item .actions { + background-color: var(--vscode-list-activeSelectionBackground); +} + +.customview-tree .monaco-list.horizontal-scrolling:not(:focus) .monaco-list-row.selected .custom-view-tree-node-item .actions { + background-color: var(--vscode-list-inactiveSelectionBackground); +} + +.customview-tree .monaco-list.horizontal-scrolling .monaco-list-row.focused:not(.selected) .custom-view-tree-node-item .actions { + background-color: var(--vscode-list-focusBackground); +} + /* filter view pane */ .monaco-workbench .auxiliarybar.pane-composite-part > .title.has-composite-bar > .title-actions .monaco-action-bar .action-item.viewpane-filter-container { diff --git a/src/vs/workbench/browser/style.ts b/src/vs/workbench/browser/style.ts index de344e7e46b57..9250ef3f28006 100644 --- a/src/vs/workbench/browser/style.ts +++ b/src/vs/workbench/browser/style.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import './media/style.css'; -import './media/motion.css'; import { registerThemingParticipant } from '../../platform/theme/common/themeService.js'; import { WORKBENCH_BACKGROUND, TITLE_BAR_ACTIVE_BACKGROUND } from '../common/theme.js'; import { isWeb, isIOS } from '../../base/common/platform.js'; diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index a54437a263347..b1c5637a750c3 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -715,6 +715,18 @@ const registry = Registry.as(ConfigurationExtensions.Con tags: ['accessibility'], enum: ['on', 'off', 'auto'] }, + 'workbench.reduceTransparency': { + type: 'string', + description: localize('workbench.reduceTransparency', "Controls whether the workbench should render with fewer transparency and blur effects for improved performance."), + 'enumDescriptions': [ + localize('workbench.reduceTransparency.on', "Always render without transparency and blur effects."), + localize('workbench.reduceTransparency.off', "Do not reduce transparency and blur effects."), + localize('workbench.reduceTransparency.auto', "Reduce transparency and blur effects based on OS configuration."), + ], + default: 'off', + tags: ['accessibility'], + enum: ['on', 'off', 'auto'] + }, 'workbench.navigationControl.enabled': { 'type': 'boolean', 'default': true, diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index fad039e7689b1..c0348746b6ee1 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -33,7 +33,7 @@ export const RemoteNameContext = new RawContextKey('remoteName', '', loc export const VirtualWorkspaceContext = new RawContextKey('virtualWorkspace', '', localize('virtualWorkspace', "The scheme of the current workspace is from a virtual file system or an empty string.")); export const TemporaryWorkspaceContext = new RawContextKey('temporaryWorkspace', false, localize('temporaryWorkspace', "The scheme of the current workspace is from a temporary file system.")); -export const IsAgentSessionsWorkspaceContext = new RawContextKey('isAgentSessionsWorkspace', false, localize('isAgentSessionsWorkspace', "Whether the current workspace is the agent sessions workspace.")); +export const IsSessionsWindowContext = new RawContextKey('isSessionsWindow', false, localize('isSessionsWindow', "Whether the current window is a sessions window.")); export const HasWebFileSystemAccess = new RawContextKey('hasWebFileSystemAccess', false, true); // Support for FileSystemAccess web APIs (https://wicg.github.io/file-system-access) @@ -96,6 +96,7 @@ export const EditorPartMaximizedEditorGroupContext = new RawContextKey( export const EditorPartModalContext = new RawContextKey('editorPartModal', false, localize('editorPartModal', "Whether focus is in a modal editor part")); export const EditorPartModalMaximizedContext = new RawContextKey('editorPartModalMaximized', false, localize('editorPartModalMaximized', "Whether the modal editor part is maximized")); +export const EditorPartModalNavigationContext = new RawContextKey('editorPartModalNavigation', false, localize('editorPartModalNavigation', "Whether the modal editor part has navigation context")); // Editor Layout Context Keys export const EditorsVisibleContext = new RawContextKey('editorIsOpen', false, localize('editorIsOpen', "Whether an editor is open")); diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index eded82c117156..2e535074002fa 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -1261,6 +1261,7 @@ interface IEditorPartConfiguration { splitInGroupLayout?: 'vertical' | 'horizontal'; splitSizing?: 'auto' | 'split' | 'distribute'; splitOnDragAndDrop?: boolean; + allowDropIntoGroup?: boolean; dragToOpenWindow?: boolean; centeredLayoutFixedWidth?: boolean; doubleClickTabToToggleEditorGroupSizes?: 'maximize' | 'expand' | 'off'; diff --git a/src/vs/workbench/common/views.ts b/src/vs/workbench/common/views.ts index 71d443000734e..b439e8700356e 100644 --- a/src/vs/workbench/common/views.ts +++ b/src/vs/workbench/common/views.ts @@ -39,7 +39,8 @@ export namespace Extensions { export const enum ViewContainerLocation { Sidebar, Panel, - AuxiliaryBar + AuxiliaryBar, + ChatBar, } export function ViewContainerLocationToString(viewContainerLocation: ViewContainerLocation) { @@ -47,6 +48,7 @@ export function ViewContainerLocationToString(viewContainerLocation: ViewContain case ViewContainerLocation.Sidebar: return 'sidebar'; case ViewContainerLocation.Panel: return 'panel'; case ViewContainerLocation.AuxiliaryBar: return 'auxiliarybar'; + case ViewContainerLocation.ChatBar: return 'chatbar'; } } @@ -58,6 +60,24 @@ type OpenCommandActionDescriptor = { readonly keybindings?: IKeybindings & { when?: ContextKeyExpression }; }; +/** + * Specifies in which window a view or view container should be visible. + */ +export const enum WindowVisibility { + /** + * Visible only in the editor window + */ + Editor = 1, + /** + * Visible only in sessions window + */ + Sessions = 2, + /** + * Visible in both editor and sessions windows + */ + Both = 3, +} + /** * View Container Contexts */ @@ -119,6 +139,12 @@ export interface IViewContainerDescriptor { readonly rejectAddedViews?: boolean; + /** + * Specifies in which window this view container should be visible. + * Defaults to WindowVisibility.Editor + */ + readonly windowVisibility?: WindowVisibility; + requestedIndex?: number; } @@ -299,6 +325,12 @@ export interface IViewDescriptor { readonly openCommandActionDescriptor?: OpenCommandActionDescriptor; readonly accessibilityHelpContent?: MarkdownString; + + /** + * Specifies in which window this view should be visible. + * Defaults to WindowVisibility.Workbench (main workbench only). + */ + readonly windowVisibility?: WindowVisibility; } export interface ICustomViewDescriptor extends IViewDescriptor { diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts index bebcf70f3b357..6870246190f5b 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts @@ -864,7 +864,7 @@ export class AccessibleView extends Disposable { } private _navigationHint(): string { - return localize('accessibleViewNextPreviousHint', "Show the next item{0} or previous item{1}.", ``); + return localize('accessibleViewNextPreviousHint', "Show the next item{0} or previous item{1}.", ``, ``); } private _disableVerbosityHint(provider: AccesibleViewContentProvider): string { diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 732fa1974e481..d717ada29b959 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -68,6 +68,11 @@ export interface IBrowserViewWorkbenchService { * Clear all storage data for the current workspace browser session */ clearWorkspaceStorage(): Promise; + + /** + * Get the endpoint for connecting to a browser view's CDP proxy server + */ + getDebugWebSocketEndpoint(): Promise; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 43ce32c896b38..d1bd6f3d4c8fb 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -55,7 +55,12 @@ class OpenIntegratedBrowserAction extends Action2 { logBrowserOpen(telemetryService, options.url ? 'commandWithUrl' : 'commandWithoutUrl'); - await editorService.openEditor({ resource }, group); + const editorPane = await editorService.openEditor({ resource }, group); + + // Lock the group when opening to the side + if (options.openToSide && editorPane?.group) { + editorPane.group.lock(true); + } } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index 68d2376c587d1..30199e27cbf40 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -54,4 +54,8 @@ export class BrowserViewWorkbenchService implements IBrowserViewWorkbenchService const workspaceId = this.workspaceContextService.getWorkspace().id; return this._browserViewService.clearWorkspaceStorage(workspaceId); } + + async getDebugWebSocketEndpoint() { + return this._browserViewService.getDebugWebSocketEndpoint(); + } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts index 33329d25ca5ab..a882c91452e88 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts @@ -25,6 +25,7 @@ const OVERLAY_DEFINITIONS: ReadonlyArray<{ className: string; type: BrowserOverl { className: 'editor-widget', type: BrowserOverlayType.Hover }, { className: 'suggest-details-container', type: BrowserOverlayType.Hover }, { className: 'monaco-dialog-modal-block', type: BrowserOverlayType.Dialog }, + { className: 'monaco-modal-editor-block', type: BrowserOverlayType.Dialog }, { className: 'notifications-center', type: BrowserOverlayType.Notification }, { className: 'notification-toast-container', type: BrowserOverlayType.Notification }, // Context view is very generic, so treat the content as unknown diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts index f1ec099751c11..7be04e1760c8d 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts @@ -9,6 +9,7 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IChatWidgetService } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { isResponseVM } from '../../common/model/chatViewModel.js'; @@ -27,7 +28,7 @@ class AnnounceChatConfirmationAction extends Action2 { keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyA | KeyMod.Shift, - when: CONTEXT_ACCESSIBILITY_MODE_ENABLED + when: ContextKeyExpr.and(CONTEXT_ACCESSIBILITY_MODE_ENABLED, ChatContextKeys.Editing.hasQuestionCarousel.negate()) } }); } @@ -60,7 +61,12 @@ class AnnounceChatConfirmationAction extends Action2 { } if (firstConfirmationElement) { - firstConfirmationElement.focus(); + // Toggle: if the confirmation is already focused, move focus back to input + if (firstConfirmationElement.contains(pendingWidget.domNode.ownerDocument.activeElement)) { + pendingWidget.focusInput(); + } else { + firstConfirmationElement.focus(); + } } else { alert(localize('noConfirmationRequired', 'No chat confirmation required')); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 158ece0a481d0..7ac9212392892 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -38,7 +38,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; -import { ACTIVE_GROUP, AUX_WINDOW_GROUP } from '../../../../services/editor/common/editorService.js'; +import { ACTIVE_GROUP, AUX_WINDOW_GROUP, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; import { IHostService } from '../../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; @@ -60,11 +60,14 @@ import { ILanguageModelChatSelector, ILanguageModelsService } from '../../common import { CopilotUsageExtensionFeatureId } from '../../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService, IToolData, IToolSet, isToolSet } from '../../common/tools/languageModelToolsService.js'; -import { ChatViewId, IChatWidget, IChatWidgetService } from '../chat.js'; +import { ChatViewId, IChatWidget, IChatWidgetService, isIChatViewViewContext } from '../chat.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatEditorInput, showClearEditingSessionConfirmation } from '../widgetHosts/editor/chatEditorInput.js'; import { convertBufferToScreenshotVariable } from '../attachments/chatScreenshotContext.js'; -import { LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { getChatSessionType, LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { localChatSessionType } from '../../common/chatSessionsService.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; export const CHAT_CATEGORY = localize2('chat.category', 'Chat'); @@ -76,6 +79,12 @@ export const CHAT_SETUP_ACTION_ID = 'workbench.action.chat.triggerSetup'; export const CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID = 'workbench.action.chat.triggerSetupSupportAnonymousAction'; const TOGGLE_CHAT_ACTION_ID = 'workbench.action.chat.toggle'; +export const GENERATE_INSTRUCTIONS_COMMAND_ID = 'workbench.action.chat.generateInstructions'; +export const GENERATE_INSTRUCTION_COMMAND_ID = 'workbench.action.chat.generateInstruction'; +export const GENERATE_PROMPT_COMMAND_ID = 'workbench.action.chat.generatePrompt'; +export const GENERATE_SKILL_COMMAND_ID = 'workbench.action.chat.generateSkill'; +export const GENERATE_AGENT_COMMAND_ID = 'workbench.action.chat.generateAgent'; + const defaultChat = { manageSettingsUrl: product.defaultChatAgent?.manageSettingsUrl ?? '', provider: product.defaultChatAgent?.provider ?? { enterprise: { id: '' } }, @@ -689,6 +698,23 @@ export function registerChatActions() { } }); + registerAction2(class NewChatEditorToSideAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.openChatToSide', + title: localize2('interactiveSession.openToSide', "New Chat Editor to the Side"), + f1: true, + category: CHAT_CATEGORY, + precondition: ChatContextKeys.enabled, + }); + } + + async run(accessor: ServicesAccessor) { + const widgetService = accessor.get(IChatWidgetService); + await widgetService.openSession(LocalChatSessionUri.getNewSessionUri(), SIDE_GROUP, { pinned: true } satisfies IChatEditorOptions); + } + }); + registerAction2(class NewChatWindowAction extends Action2 { constructor() { super({ @@ -839,16 +865,16 @@ export function registerChatActions() { constructor() { super({ id: FocusTodosViewAction.ID, - title: localize2('interactiveSession.focusTodosView.label', "Agent TODOs: Toggle Focus Between TODOs and Input"), + title: localize2('interactiveSession.focusTodosView.label', "Toggle Focus Between TODOs and Input"), category: CHAT_CATEGORY, f1: true, - precondition: ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent)), + precondition: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), keybinding: [{ - weight: KeybindingWeight.WorkbenchContrib, + weight: KeybindingWeight.WorkbenchContrib + 1, primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyT, when: ContextKeyExpr.or( - ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent)), - ChatContextKeys.inChatTodoList + ContextKeyExpr.and(ChatContextKeys.inChatInput, ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent)), + ContextKeyExpr.and(ChatContextKeys.inChatTodoList, ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent)) ), }] }); @@ -876,8 +902,8 @@ export function registerChatActions() { precondition: ChatContextKeys.inChatSession, keybinding: [{ weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyH, - when: ChatContextKeys.inChatSession, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, + when: ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.Editing.hasQuestionCarousel), }] }); } @@ -941,6 +967,29 @@ export function registerChatActions() { } }); + registerAction2(class ToggleShowContextUsageAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.toggleShowContextUsage', + title: localize2('chat.showContextUsage', "Show Context Usage"), + category: CHAT_CATEGORY, + toggled: ContextKeyExpr.equals(`config.${ChatConfiguration.ChatContextUsageEnabled}`, true), + menu: { + id: MenuId.ChatWelcomeContext, + group: '1_display', + order: 1, + when: ChatContextKeys.inChatEditor.negate() + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const configurationService = accessor.get(IConfigurationService); + const currentValue = configurationService.getValue(ChatConfiguration.ChatContextUsageEnabled); + await configurationService.updateValue(ChatConfiguration.ChatContextUsageEnabled, !currentValue); + } + }); + const nonEnterpriseCopilotUsers = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.notEquals(`config.${defaultChat.completionsAdvancedSetting}.authProvider`, defaultChat.provider.enterprise.id)); registerAction2(class extends Action2 { constructor() { @@ -1094,12 +1143,35 @@ export function registerChatActions() { } }); - registerAction2(class UpdateInstructionsAction extends Action2 { + registerAction2(class GenerateInstructionsAction extends Action2 { + constructor() { + super({ + id: GENERATE_INSTRUCTIONS_COMMAND_ID, + title: localize2('generateInstructions', "Generate Workspace Instructions with Agent"), + shortTitle: localize2('generateInstructions.short', "Generate Instructions with Agent"), + category: CHAT_CATEGORY, + icon: Codicon.sparkle, + f1: true, + precondition: ChatContextKeys.enabled + }); + } + + async run(accessor: ServicesAccessor): Promise { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand('workbench.action.chat.open', { + mode: 'agent', + query: '/init', + isPartialQuery: false, + }); + } + }); + + registerAction2(class GenerateInstructionAction extends Action2 { constructor() { super({ - id: 'workbench.action.chat.generateInstructions', - title: localize2('generateInstructions', "Generate Workspace Instructions File"), - shortTitle: localize2('generateInstructions.short', "Generate Chat Instructions"), + id: GENERATE_INSTRUCTION_COMMAND_ID, + title: localize2('generateInstruction', "Generate On-demand Instruction with Agent"), + shortTitle: localize2('generateInstruction.short', "Generate Instruction with Agent"), category: CHAT_CATEGORY, icon: Codicon.sparkle, f1: true, @@ -1109,31 +1181,79 @@ export function registerChatActions() { async run(accessor: ServicesAccessor): Promise { const commandService = accessor.get(ICommandService); + await commandService.executeCommand('workbench.action.chat.open', { + mode: 'agent', + query: '/create-instruction ', + isPartialQuery: true, + }); + } + }); - // Use chat command to open and send the query - const query = `Analyze this codebase to generate or update \`.github/copilot-instructions.md\` for guiding AI coding agents. + registerAction2(class GeneratePromptAction extends Action2 { + constructor() { + super({ + id: GENERATE_PROMPT_COMMAND_ID, + title: localize2('generatePrompt', "Generate Prompt File with Agent"), + shortTitle: localize2('generatePrompt.short', "Generate Prompt with Agent"), + category: CHAT_CATEGORY, + icon: Codicon.sparkle, + f1: true, + precondition: ChatContextKeys.enabled + }); + } -Focus on discovering the essential knowledge that would help an AI agents be immediately productive in this codebase. Consider aspects like: -- The "big picture" architecture that requires reading multiple files to understand - major components, service boundaries, data flows, and the "why" behind structural decisions -- Critical developer workflows (builds, tests, debugging) especially commands that aren't obvious from file inspection alone -- Project-specific conventions and patterns that differ from common practices -- Integration points, external dependencies, and cross-component communication patterns + async run(accessor: ServicesAccessor): Promise { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand('workbench.action.chat.open', { + mode: 'agent', + query: '/create-prompt ', + isPartialQuery: true, + }); + } + }); -Source existing AI conventions from \`**/{.github/copilot-instructions.md,AGENT.md,AGENTS.md,CLAUDE.md,.cursorrules,.windsurfrules,.clinerules,.cursor/rules/**,.windsurf/rules/**,.clinerules/**,README.md}\` (do one glob search). + registerAction2(class GenerateSkillAction extends Action2 { + constructor() { + super({ + id: GENERATE_SKILL_COMMAND_ID, + title: localize2('generateSkill', "Generate Skill with Agent"), + shortTitle: localize2('generateSkill.short', "Generate Skill with Agent"), + category: CHAT_CATEGORY, + icon: Codicon.sparkle, + f1: true, + precondition: ChatContextKeys.enabled + }); + } -Guidelines (read more at https://aka.ms/vscode-instructions-docs): -- If \`.github/copilot-instructions.md\` exists, merge intelligently - preserve valuable content while updating outdated sections -- Write concise, actionable instructions (~20-50 lines) using markdown structure -- Include specific examples from the codebase when describing patterns -- Avoid generic advice ("write tests", "handle errors") - focus on THIS project's specific approaches -- Document only discoverable patterns, not aspirational practices -- Reference key files/directories that exemplify important patterns + async run(accessor: ServicesAccessor): Promise { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand('workbench.action.chat.open', { + mode: 'agent', + query: '/create-skill ', + isPartialQuery: true, + }); + } + }); -Update \`.github/copilot-instructions.md\` for the user, then ask for feedback on any unclear or incomplete sections to iterate.`; + registerAction2(class GenerateAgentAction extends Action2 { + constructor() { + super({ + id: GENERATE_AGENT_COMMAND_ID, + title: localize2('generateAgent', "Generate Custom Agent with Agent"), + shortTitle: localize2('generateAgent.short', "Generate Agent with Agent"), + category: CHAT_CATEGORY, + icon: Codicon.sparkle, + f1: true, + precondition: ChatContextKeys.enabled + }); + } + async run(accessor: ServicesAccessor): Promise { + const commandService = accessor.get(ICommandService); await commandService.executeCommand('workbench.action.chat.open', { mode: 'agent', - query: query, + query: '/create-agent ', + isPartialQuery: true, }); } }); @@ -1407,6 +1527,25 @@ export interface IClearEditingSessionConfirmationOptions { isArchiveAction?: boolean; } +/** + * Clears the current chat session and starts a new one, preserving + * the session type (e.g. Claude, Cloud, Background) for non-local sessions + * in the sidebar. + */ +export async function clearChatSessionPreservingType(widget: IChatWidget, viewsService: IViewsService, sessionType?: string): Promise { + const currentResource = widget.viewModel?.model.sessionResource; + const newSessionType = sessionType ?? (currentResource ? getChatSessionType(currentResource) : localChatSessionType); + if (isIChatViewViewContext(widget.viewContext) && newSessionType !== localChatSessionType) { + // For the sidebar, we need to explicitly load a session with the same type + const newResource = URI.from({ scheme: newSessionType, path: `/untitled-${generateUuid()}` }); + const view = await viewsService.openView(ChatViewId) as ChatViewPane; + await view.loadSession(newResource); + } else { + // For the editor, widget.clear() already preserves the session type via clearChatEditor + await widget.clear(); + } +} + // --- Chat Submenus in various Components diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts index bb72913ae98e3..225803f5ae065 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts @@ -87,8 +87,8 @@ export interface IFileStatusInfo { overwrittenBy?: string; /** Extension ID if this file comes from an extension */ extensionId?: string; - /** If true, hidden from / menu (user-invokable: false) */ - userInvokable?: boolean; + /** If false, hidden from / menu (user-invocable: false) */ + userInvocable?: boolean; /** If true, won't be auto-loaded by agent (disable-model-invocation: true) */ disableModelInvocation?: boolean; } @@ -450,6 +450,8 @@ function getSkipReasonMessage(skipReason: PromptFileSkipReason | undefined, erro return nls.localize('status.typeDisabled', 'Disabled'); case 'all-hooks-disabled': return nls.localize('status.allHooksDisabled', 'All hooks disabled via disableAllHooks'); + case 'claude-hooks-disabled': + return nls.localize('status.claudeHooksDisabled', 'Claude hooks disabled via chat.useClaudeHooks setting'); default: return errorMessage ?? nls.localize('status.unknownError', 'Unknown error'); } @@ -466,7 +468,7 @@ function convertDiscoveryResultToFileStatus(result: IPromptFileDiscoveryResult): name: result.name, storage: result.storage, extensionId: result.extensionId, - userInvokable: result.userInvokable, + userInvocable: result.userInvocable, disableModelInvocation: result.disableModelInvocation }; } @@ -789,8 +791,8 @@ function getSkillFlags(file: IFileStatusInfo, type: PromptsType): string { flags.push(`${ICON_MANUAL} *${nls.localize('status.skill.manualOnly', 'manual only')}*`); } - // userInvokable: false means hidden from / menu - if (file.userInvokable === false) { + // userInvocable: false means hidden from / menu + if (file.userInvocable === false) { flags.push(`${ICON_HIDDEN} *${nls.localize('status.skill.hiddenFromMenu', 'hidden from menu')}*`); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 2192b3827277b..9ec8cb095ebb3 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -21,6 +21,7 @@ import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.j import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatMode, IChatModeService } from '../../common/chatModes.js'; import { chatVariableLeader } from '../../common/requestParser/chatParserTypes.js'; @@ -35,7 +36,7 @@ import { IChatWidget, IChatWidgetService } from '../chat.js'; import { getAgentSessionProvider, AgentSessionProviders } from '../agentSessions/agentSessions.js'; import { getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; import { ctxHasEditorModification, ctxHasRequestInProgress, ctxIsGlobalEditingSession } from '../chatEditing/chatEditingEditorContextKeys.js'; -import { ACTION_ID_NEW_CHAT, CHAT_CATEGORY, handleCurrentEditingSession, handleModeSwitch } from './chatActions.js'; +import { ACTION_ID_NEW_CHAT, CHAT_CATEGORY, clearChatSessionPreservingType, handleCurrentEditingSession, handleModeSwitch } from './chatActions.js'; import { CreateRemoteAgentJobAction } from './chatContinueInAction.js'; export interface IVoiceChatExecuteActionContext { @@ -180,7 +181,7 @@ abstract class SubmitAction extends Action2 { } const requestInProgressOrPendingToolCall = ContextKeyExpr.or(ChatContextKeys.requestInProgress, ChatContextKeys.Editing.hasToolConfirmation); -const whenNotInProgress = ContextKeyExpr.and(ChatContextKeys.requestInProgress.negate(), ChatContextKeys.Editing.hasToolConfirmation.negate()); +const whenNotInProgress = ChatContextKeys.requestInProgress.negate(); export class ChatSubmitAction extends SubmitAction { static readonly ID = 'workbench.action.chat.submit'; @@ -198,11 +199,11 @@ export class ChatSubmitAction extends SubmitAction { title: localize2('interactive.submit.label', "Send"), f1: false, category: CHAT_CATEGORY, - icon: Codicon.send, + icon: Codicon.arrowUp, precondition, toggled: { condition: ChatContextKeys.lockedToCodingAgent, - icon: Codicon.send, + icon: Codicon.arrowUp, tooltip: localize('sendToAgent', "Send to Agent"), }, keybinding: { @@ -417,7 +418,9 @@ export class OpenModelPickerAction extends Action2 { group: 'navigation', when: ContextKeyExpr.and( - ChatContextKeys.lockedToCodingAgent.negate(), + ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.chatSessionHasTargetedModels), ContextKeyExpr.or( ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.Chat), ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.EditorInline), @@ -426,6 +429,7 @@ export class OpenModelPickerAction extends Action2 { // Hide in welcome view when session type is not local ContextKeyExpr.or( ChatContextKeys.inAgentSessionsWelcome.negate(), + ChatContextKeys.chatSessionHasTargetedModels, ChatContextKeys.agentSessionType.isEqualTo(AgentSessionProviders.Local)) ) } @@ -673,7 +677,7 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { title: localize2('edits.submit.label', "Send"), f1: false, category: CHAT_CATEGORY, - icon: Codicon.send, + icon: Codicon.arrowUp, precondition, menu: [ { @@ -796,6 +800,7 @@ class SendToNewChatAction extends Action2 { const context = args[0] as IChatExecuteActionContext | undefined; const widgetService = accessor.get(IChatWidgetService); + const viewsService = accessor.get(IViewsService); const dialogService = accessor.get(IDialogService); const chatService = accessor.get(IChatService); const widget = context?.widget ?? widgetService.lastFocusedWidget; @@ -819,7 +824,8 @@ class SendToNewChatAction extends Action2 { // Clear the input from the current session before creating a new one widget.setInput(''); - await widget.clear(); + await clearChatSessionPreservingType(widget, viewsService); + widget.acceptInput(inputBeforeClear, { storeToHistory: true }); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index 1bed8d9733a48..febdbc539f7fb 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -5,8 +5,6 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { generateUuid } from '../../../../../base/common/uuid.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; @@ -15,19 +13,14 @@ import { CommandsRegistry } from '../../../../../platform/commands/common/comman import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatEditingSession } from '../../common/editing/chatEditingService.js'; import { IChatService } from '../../common/chatService/chatService.js'; -import { localChatSessionType } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; -import { getChatSessionType, LocalChatSessionUri } from '../../common/model/chatUri.js'; -import { ChatViewId, IChatWidgetService, isIChatViewViewContext } from '../chat.js'; +import { ChatViewId, IChatWidgetService } from '../chat.js'; import { EditingSessionAction, EditingSessionActionContext, getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; -import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; -import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; -import { ACTION_ID_NEW_CHAT, ACTION_ID_NEW_EDIT_SESSION, CHAT_CATEGORY, handleCurrentEditingSession } from './chatActions.js'; +import { ACTION_ID_NEW_CHAT, ACTION_ID_NEW_EDIT_SESSION, CHAT_CATEGORY, clearChatSessionPreservingType, handleCurrentEditingSession } from './chatActions.js'; import { clearChatEditor } from './chatClear.js'; import { AgentSessionProviders, AgentSessionsViewerOrientation } from '../agentSessions/agentSessions.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -115,12 +108,6 @@ export function registerNewChatActions() { id: MenuId.ChatNewMenu, group: '1_open', order: 1, - }, - { - id: MenuId.CompactWindowEditorTitle, - group: 'navigation', - when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), - order: 1 } ], keybinding: { @@ -270,23 +257,6 @@ export function registerNewChatActions() { }); } -/** - * Creates a new session resource URI with the specified session type. - * For remote sessions, creates a URI with the session type as the scheme. - * For local sessions, creates a LocalChatSessionUri. - */ -function getResourceForNewChatSession(sessionType: string): URI { - const isRemoteSession = sessionType !== localChatSessionType; - if (isRemoteSession) { - return URI.from({ - scheme: sessionType, - path: `/untitled-${generateUuid()}`, - }); - } - - return LocalChatSessionUri.forSession(generateUuid()); -} - async function runNewChatAction( accessor: ServicesAccessor, context: EditingSessionActionContext | undefined, @@ -311,18 +281,8 @@ async function runNewChatAction( await editingSession?.stop(); - // Create a new session with the same type as the current session - const currentResource = widget.viewModel?.model.sessionResource; - const newSessionType = sessionType ?? (currentResource ? getChatSessionType(currentResource) : localChatSessionType); - if (isIChatViewViewContext(widget.viewContext) && newSessionType !== localChatSessionType) { - // For the sidebar, we need to explicitly load a session with the same type - const newResource = getResourceForNewChatSession(newSessionType); - const view = await viewsService.openView(ChatViewId) as ChatViewPane; - await view.loadSession(newResource); - } else { - // For the editor, widget.clear() already preserves the session type via clearChatEditor - await widget.clear(); - } + // Create a new session, preserving the session type (or using the specified one) + await clearChatSessionPreservingType(widget, viewsService, sessionType); widget.attachmentModel.clear(true); widget.focusInput(); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts index c3200e37a0048..a8d166e68f7d2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -13,17 +13,12 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatRequestQueueKind, IChatService } from '../../common/chatService/chatService.js'; -import { ChatConfiguration } from '../../common/constants.js'; import { isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY } from './chatActions.js'; -const queueingEnabledCondition = ContextKeyExpr.equals(`config.${ChatConfiguration.RequestQueueingEnabled}`, true); -const requestInProgressOrPendingToolCall = ContextKeyExpr.or(ChatContextKeys.requestInProgress, ChatContextKeys.Editing.hasToolConfirmation); - const queuingActionsPresent = ContextKeyExpr.and( - queueingEnabledCondition, - ContextKeyExpr.or(requestInProgressOrPendingToolCall, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.QueueOrSteer)), + ContextKeyExpr.or(ChatContextKeys.requestInProgress, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.QueueOrSteer)), ChatContextKeys.editingRequestType.notEqualsTo(ChatContextKeys.EditingRequestType.Sent), ); @@ -141,7 +136,6 @@ export class ChatRemovePendingRequestAction extends Action2 { group: 'navigation', order: 4, when: ContextKeyExpr.and( - queueingEnabledCondition, ChatContextKeys.isRequest, ChatContextKeys.isPendingRequest ) @@ -181,7 +175,6 @@ export class ChatSendPendingImmediatelyAction extends Action2 { group: 'navigation', order: 3, when: ContextKeyExpr.and( - queueingEnabledCondition, ChatContextKeys.isRequest, ChatContextKeys.isPendingRequest ) @@ -239,11 +232,8 @@ export class ChatRemoveAllPendingRequestsAction extends Action2 { id: MenuId.ChatContext, group: 'navigation', order: 3, - when: ContextKeyExpr.and( - queueingEnabledCondition, - ChatContextKeys.hasPendingRequests - ) - }] + when: ChatContextKeys.hasPendingRequests, + }], }); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts index 7f0aa2b785212..af3b2c394e33f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts @@ -114,7 +114,7 @@ class SkipToolConfirmation extends ToolConfirmationAction { } } -class ConfigureToolsAction extends Action2 { +export class ConfigureToolsAction extends Action2 { public static ID = 'workbench.action.chat.configureTools'; constructor() { diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachment.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachment.ts new file mode 100644 index 0000000000000..b942e505b6c92 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachment.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { basename } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { localize } from '../../../../../nls.js'; +import { IAgentFeedbackService, IAgentFeedback } from './agentFeedbackService.js'; +import { IChatWidgetService } from '../chat.js'; +import { IAgentFeedbackVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +const ATTACHMENT_ID_PREFIX = 'agentFeedback:'; + +/** + * Keeps the "N feedback items" attachment in the chat input in sync with the + * AgentFeedbackService. One attachment per session resource, updated reactively. + * Clears feedback after the chat prompt is sent. + */ +export class AgentFeedbackAttachmentContribution extends Disposable { + + static readonly ID = 'workbench.contrib.agentFeedbackAttachment'; + + /** Track onDidAcceptInput subscriptions per widget session */ + private readonly _widgetListeners = this._store.add(new DisposableMap()); + + /** Cache of resolved code snippets keyed by feedback ID */ + private readonly _snippetCache = new Map(); + + constructor( + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @ITextModelService private readonly _textModelService: ITextModelService, + ) { + super(); + + this._store.add(this._agentFeedbackService.onDidChangeFeedback(e => { + this._updateAttachment(e.sessionResource); + this._ensureAcceptListener(e.sessionResource); + })); + } + + private async _updateAttachment(sessionResource: URI): Promise { + const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + return; + } + + const feedbackItems = this._agentFeedbackService.getFeedback(sessionResource); + const attachmentId = ATTACHMENT_ID_PREFIX + sessionResource.toString(); + + if (feedbackItems.length === 0) { + widget.attachmentModel.delete(attachmentId); + this._snippetCache.clear(); + return; + } + + const value = await this._buildFeedbackValue(feedbackItems); + + const entry: IAgentFeedbackVariableEntry = { + kind: 'agentFeedback', + id: attachmentId, + name: feedbackItems.length === 1 + ? localize('agentFeedback.one', "1 comment") + : localize('agentFeedback.many', "{0} comments", feedbackItems.length), + icon: Codicon.comment, + sessionResource, + feedbackItems: feedbackItems.map(f => ({ + id: f.id, + text: f.text, + resourceUri: f.resourceUri, + range: f.range, + })), + value, + }; + + // Upsert + widget.attachmentModel.delete(attachmentId); + widget.attachmentModel.addContext(entry); + } + + /** + * Builds a rich string value for the agent feedback attachment that includes + * the code snippet at each feedback item's location alongside the feedback text. + * Uses a cache keyed by feedback ID to avoid re-resolving snippets for + * items that haven't changed. + */ + private async _buildFeedbackValue(feedbackItems: readonly IAgentFeedback[]): Promise { + // Prune stale cache entries for items that no longer exist + const currentIds = new Set(feedbackItems.map(f => f.id)); + for (const cachedId of this._snippetCache.keys()) { + if (!currentIds.has(cachedId)) { + this._snippetCache.delete(cachedId); + } + } + + // Resolve only new (uncached) snippets + const uncachedItems = feedbackItems.filter(f => !this._snippetCache.has(f.id)); + if (uncachedItems.length > 0) { + await Promise.all(uncachedItems.map(async f => { + const snippet = await this._getCodeSnippet(f.resourceUri, f.range); + this._snippetCache.set(f.id, snippet); + })); + } + + // Build the final string from cache + const parts: string[] = ['The following comments were made on the code changes:']; + for (const item of feedbackItems) { + const codeSnippet = this._snippetCache.get(item.id); + const fileName = basename(item.resourceUri); + const lineRef = item.range.startLineNumber === item.range.endLineNumber + ? `${item.range.startLineNumber}` + : `${item.range.startLineNumber}-${item.range.endLineNumber}`; + + let part = `[${fileName}:${lineRef}]`; + if (codeSnippet) { + part += `\n\`\`\`\n${codeSnippet}\n\`\`\``; + } + part += `\nComment: ${item.text}`; + parts.push(part); + } + + return parts.join('\n\n'); + } + + /** + * Resolves the text model for a resource and extracts the code in the given range. + * Returns undefined if the model cannot be resolved. + */ + private async _getCodeSnippet(resourceUri: URI, range: IRange): Promise { + try { + const ref = await this._textModelService.createModelReference(resourceUri); + try { + return ref.object.textEditorModel.getValueInRange(range); + } finally { + ref.dispose(); + } + } catch { + return undefined; + } + } + + /** + * Ensure we listen for the chat widget's submit event so we can clear feedback after send. + */ + private _ensureAcceptListener(sessionResource: URI): void { + const key = sessionResource.toString(); + if (this._widgetListeners.has(key)) { + return; + } + + const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + return; + } + + this._widgetListeners.set(key, widget.onDidSubmitAgent(() => { + this._agentFeedbackService.clearFeedback(sessionResource); + this._widgetListeners.deleteAndDispose(key); + })); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachmentWidget.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachmentWidget.ts new file mode 100644 index 0000000000000..290e3f2637b11 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachmentWidget.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/agentFeedbackAttachment.css'; +import * as dom from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import * as event from '../../../../../base/common/event.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IAgentFeedbackVariableEntry } from '../../common/attachments/chatVariableEntries.js'; +import { AgentFeedbackHover } from './agentFeedbackHover.js'; + +/** + * Attachment widget that renders "N comments" with a comment icon + * and a custom hover showing all feedback items with actions. + */ +export class AgentFeedbackAttachmentWidget extends Disposable { + + readonly element: HTMLElement; + + private readonly _onDidDelete = this._store.add(new event.Emitter()); + readonly onDidDelete = this._onDidDelete.event; + + private readonly _onDidOpen = this._store.add(new event.Emitter()); + readonly onDidOpen = this._onDidOpen.event; + + constructor( + private readonly _attachment: IAgentFeedbackVariableEntry, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, + container: HTMLElement, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + this.element = dom.append(container, dom.$('.chat-attached-context-attachment.agent-feedback-attachment')); + this.element.tabIndex = 0; + this.element.role = 'button'; + + // Icon + const iconSpan = dom.$('span'); + iconSpan.classList.add(...ThemeIcon.asClassNameArray(Codicon.comment)); + const pillIcon = dom.$('div.chat-attached-context-pill', {}, iconSpan); + this.element.appendChild(pillIcon); + + // Label + const label = dom.$('span.chat-attached-context-custom-text', {}, this._attachment.name); + this.element.appendChild(label); + + // Clear button + if (options.supportsDeletion) { + const clearBtn = dom.append(this.element, dom.$('.chat-attached-context-clear-button')); + const clearIcon = dom.$('span'); + clearIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.close)); + clearBtn.appendChild(clearIcon); + clearBtn.title = localize('removeAttachment', "Remove"); + this._store.add(dom.addDisposableListener(clearBtn, dom.EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._onDidDelete.fire(e); + })); + if (options.shouldFocusClearButton) { + clearBtn.focus(); + } + } + + // Aria label + this.element.ariaLabel = localize('chat.agentFeedback', "Attached agent feedback, {0}", this._attachment.name); + + // Custom interactive hover + this._store.add(this._instantiationService.createInstance(AgentFeedbackHover, this.element, this._attachment)); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorActions.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorActions.ts new file mode 100644 index 0000000000000..e64ffc5658e66 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorActions.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { EditorsOrder, IEditorIdentifier } from '../../../../common/editor.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IChatWidgetService } from '../chat.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { CHAT_CATEGORY } from '../actions/chatActions.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; +import { getActiveResourceCandidates } from './agentFeedbackEditorUtils.js'; + +export const submitFeedbackActionId = 'agentFeedbackEditor.action.submit'; +export const navigatePreviousFeedbackActionId = 'agentFeedbackEditor.action.navigatePrevious'; +export const navigateNextFeedbackActionId = 'agentFeedbackEditor.action.navigateNext'; +export const clearAllFeedbackActionId = 'agentFeedbackEditor.action.clearAll'; +export const navigationBearingFakeActionId = 'agentFeedbackEditor.navigation.bearings'; + +abstract class AgentFeedbackEditorAction extends Action2 { + + constructor(desc: ConstructorParameters[0]) { + super({ + category: CHAT_CATEGORY, + ...desc, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + + const candidates = getActiveResourceCandidates(editorService.activeEditorPane?.input); + const sessionResource = candidates + .map(candidate => agentFeedbackService.getMostRecentSessionForResource(candidate)) + .find((value): value is URI => !!value); + if (!sessionResource) { + return; + } + + return this.runWithSession(accessor, sessionResource); + } + + abstract runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise | void; +} + +class SubmitFeedbackAction extends AgentFeedbackEditorAction { + + constructor() { + super({ + id: submitFeedbackActionId, + title: localize2('agentFeedback.submit', 'Submit Feedback'), + shortTitle: localize2('agentFeedback.submitShort', 'Submit'), + icon: Codicon.send, + precondition: ChatContextKeys.enabled, + menu: { + id: MenuId.AgentFeedbackEditorContent, + group: 'a_submit', + order: 0, + when: ChatContextKeys.enabled, + }, + }); + } + + override async runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise { + const chatWidgetService = accessor.get(IChatWidgetService); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + const editorService = accessor.get(IEditorService); + + const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + return; + } + + // Close all editors belonging to the session resource + const editorsToClose: IEditorIdentifier[] = []; + for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL)) { + const candidates = getActiveResourceCandidates(editor); + const belongsToSession = candidates.some(uri => + isEqual(agentFeedbackService.getMostRecentSessionForResource(uri), sessionResource) + ); + if (belongsToSession) { + editorsToClose.push({ editor, groupId }); + } + } + if (editorsToClose.length) { + await editorService.closeEditors(editorsToClose); + } + + await widget.acceptInput('Act on the provided feedback'); + } +} + +class NavigateFeedbackAction extends AgentFeedbackEditorAction { + + constructor(private readonly _next: boolean) { + super({ + id: _next ? navigateNextFeedbackActionId : navigatePreviousFeedbackActionId, + title: _next + ? localize2('agentFeedback.next', 'Go to Next Feedback Comment') + : localize2('agentFeedback.previous', 'Go to Previous Feedback Comment'), + icon: _next ? Codicon.arrowDown : Codicon.arrowUp, + f1: true, + precondition: ChatContextKeys.enabled, + menu: { + id: MenuId.AgentFeedbackEditorContent, + group: 'navigate', + order: _next ? 2 : 1, + when: ChatContextKeys.enabled, + }, + }); + } + + override runWithSession(accessor: ServicesAccessor, sessionResource: URI): void { + const agentFeedbackService = accessor.get(IAgentFeedbackService); + const editorService = accessor.get(IEditorService); + + const feedback = agentFeedbackService.getNextFeedback(sessionResource, this._next); + if (!feedback) { + return; + } + + editorService.openEditor({ + resource: feedback.resourceUri, + options: { + selection: feedback.range, + preserveFocus: false, + revealIfVisible: true, + } + }); + } +} + +class ClearAllFeedbackAction extends AgentFeedbackEditorAction { + + constructor() { + super({ + id: clearAllFeedbackActionId, + title: localize2('agentFeedback.clear', 'Clear'), + tooltip: localize2('agentFeedback.clearAllTooltip', 'Clear All Feedback'), + icon: Codicon.clearAll, + f1: true, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled), + menu: { + id: MenuId.AgentFeedbackEditorContent, + group: 'a_submit', + order: 1, + when: ChatContextKeys.enabled, + }, + }); + } + + override runWithSession(accessor: ServicesAccessor, sessionResource: URI): void { + const agentFeedbackService = accessor.get(IAgentFeedbackService); + agentFeedbackService.clearFeedback(sessionResource); + } +} + +export function registerAgentFeedbackEditorActions(): void { + registerAction2(SubmitFeedbackAction); + registerAction2(class extends NavigateFeedbackAction { constructor() { super(false); } }); + registerAction2(class extends NavigateFeedbackAction { constructor() { super(true); } }); + registerAction2(ClearAllFeedbackAction); + + MenuRegistry.appendMenuItem(MenuId.AgentFeedbackEditorContent, { + command: { + id: navigationBearingFakeActionId, + title: localize('label', 'Navigation Status'), + precondition: ContextKeyExpr.false(), + }, + group: 'navigate', + order: -1, + when: ChatContextKeys.enabled, + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorOverlay.ts new file mode 100644 index 0000000000000..e0ba9f6c0a550 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorOverlay.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/agentFeedbackEditorOverlay.css'; +import { Disposable, DisposableMap, DisposableStore, combinedDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, observableFromEvent, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../../base/common/actions.js'; +import { Event } from '../../../../../base/common/event.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { EditorGroupView } from '../../../../browser/parts/editor/editorGroupView.js'; +import { IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; +import { navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from './agentFeedbackEditorActions.js'; +import { assertType } from '../../../../../base/common/types.js'; +import { localize } from '../../../../../nls.js'; +import { getActiveResourceCandidates } from './agentFeedbackEditorUtils.js'; + +class AgentFeedbackActionViewItem extends ActionViewItem { + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + private readonly _keybindingService: IKeybindingService, + private readonly _primaryActionIds: readonly string[] = [submitFeedbackActionId], + ) { + const isIconOnly = action.id === navigatePreviousFeedbackActionId || action.id === navigateNextFeedbackActionId; + super(undefined, action, { ...options, icon: isIconOnly, label: !isIconOnly, keybindingNotRenderedWithLabel: true }); + } + + override render(container: HTMLElement): void { + super.render(container); + if (this._primaryActionIds.includes(this._action.id)) { + this.element?.classList.add('primary'); + } + } + + protected override getTooltip(): string | undefined { + const value = super.getTooltip(); + if (!value || this.options.keybinding) { + return value; + } + return this._keybindingService.appendKeybinding(value, this._action.id); + } +} + +class AgentFeedbackOverlayWidget extends Disposable { + + private readonly _domNode: HTMLElement; + private readonly _toolbarNode: HTMLElement; + private readonly _showStore = this._store.add(new DisposableStore()); + private readonly _navigationBearings = observableValue<{ activeIdx: number; totalCount: number }>(this, { activeIdx: -1, totalCount: 0 }); + + constructor( + @IInstantiationService private readonly _instaService: IInstantiationService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + ) { + super(); + + this._domNode = document.createElement('div'); + this._domNode.classList.add('agent-feedback-editor-overlay-widget'); + + this._toolbarNode = document.createElement('div'); + this._toolbarNode.classList.add('agent-feedback-editor-overlay-toolbar'); + } + + getDomNode(): HTMLElement { + return this._domNode; + } + + show(navigationBearings: { activeIdx: number; totalCount: number }): void { + this._showStore.clear(); + this._navigationBearings.set(navigationBearings, undefined); + + if (!this._domNode.contains(this._toolbarNode)) { + this._domNode.appendChild(this._toolbarNode); + } + + this._showStore.add(this._instaService.createInstance(MenuWorkbenchToolBar, this._toolbarNode, MenuId.AgentFeedbackEditorContent, { + telemetrySource: 'agentFeedback.overlayToolbar', + hiddenItemStrategy: HiddenItemStrategy.Ignore, + toolbarOptions: { + primaryGroup: () => true, + useSeparatorsInPrimaryActions: true + }, + menuOptions: { renderShortTitle: true }, + actionViewItemProvider: (action, options) => { + if (action.id === navigationBearingFakeActionId) { + const that = this; + return new class extends ActionViewItem { + constructor() { + super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true }); + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('label-item'); + + this._store.add(autorun(r => { + assertType(this.label); + const { activeIdx, totalCount } = that._navigationBearings.read(r); + if (totalCount > 0) { + const current = activeIdx === -1 ? 1 : activeIdx + 1; + this.label.innerText = localize('nOfM', '{0}/{1}', current, totalCount); + } else { + this.label.innerText = localize('zero', '0/0'); + } + })); + } + }; + } + + return new AgentFeedbackActionViewItem(action, options, this._keybindingService); + }, + })); + this._showStore.add(toDisposable(() => this._toolbarNode.remove())); + } + + hide(): void { + this._showStore.clear(); + this._navigationBearings.set({ activeIdx: -1, totalCount: 0 }, undefined); + this._toolbarNode.remove(); + } +} + +class AgentFeedbackOverlayController { + + private readonly _store = new DisposableStore(); + private readonly _domNode = document.createElement('div'); + + constructor( + container: HTMLElement, + group: IEditorGroup, + @IAgentFeedbackService agentFeedbackService: IAgentFeedbackService, + @IInstantiationService instaService: IInstantiationService, + ) { + this._domNode.classList.add('agent-feedback-editor-overlay'); + this._domNode.style.position = 'absolute'; + this._domNode.style.bottom = '24px'; + this._domNode.style.right = '24px'; + this._domNode.style.zIndex = '100'; + + const widget = this._store.add(instaService.createInstance(AgentFeedbackOverlayWidget)); + this._domNode.appendChild(widget.getDomNode()); + this._store.add(toDisposable(() => this._domNode.remove())); + + const show = () => { + if (!container.contains(this._domNode)) { + container.appendChild(this._domNode); + } + }; + + const hide = () => { + if (container.contains(this._domNode)) { + widget.hide(); + this._domNode.remove(); + } + }; + + const activeSignal = observableSignalFromEvent(this, Event.any( + group.onDidActiveEditorChange, + group.onDidModelChange, + agentFeedbackService.onDidChangeFeedback, + agentFeedbackService.onDidChangeNavigation, + )); + + this._store.add(autorun(r => { + activeSignal.read(r); + + const candidates = getActiveResourceCandidates(group.activeEditorPane?.input); + let shouldShow = false; + let navigationBearings = { activeIdx: -1, totalCount: 0 }; + for (const candidate of candidates) { + const sessionResource = agentFeedbackService.getMostRecentSessionForResource(candidate); + if (sessionResource && agentFeedbackService.getFeedback(sessionResource).length > 0) { + shouldShow = true; + navigationBearings = agentFeedbackService.getNavigationBearing(sessionResource); + break; + } + } + + if (!shouldShow) { + hide(); + return; + } + + widget.show(navigationBearings); + show(); + })); + } + + dispose(): void { + this._store.dispose(); + } +} + +export class AgentFeedbackEditorOverlay implements IWorkbenchContribution { + + static readonly ID = 'chat.agentFeedback.editorOverlay'; + + private readonly _store = new DisposableStore(); + + constructor( + @IEditorGroupsService editorGroupsService: IEditorGroupsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + const editorGroups = observableFromEvent( + this, + Event.any(editorGroupsService.onDidAddGroup, editorGroupsService.onDidRemoveGroup), + () => editorGroupsService.groups + ); + + const overlayWidgets = this._store.add(new DisposableMap()); + + this._store.add(autorun(r => { + const groups = editorGroups.read(r); + const toDelete = new Set(overlayWidgets.keys()); + + for (const group of groups) { + if (!(group instanceof EditorGroupView)) { + continue; + } + + toDelete.delete(group); + + if (!overlayWidgets.has(group)) { + const scopedInstaService = instantiationService.createChild( + new ServiceCollection([IContextKeyService, group.scopedContextKeyService]) + ); + + const ctrl = scopedInstaService.createInstance(AgentFeedbackOverlayController, group.element, group); + overlayWidgets.set(group, combinedDisposable(ctrl, scopedInstaService)); + } + } + + for (const group of toDelete) { + overlayWidgets.deleteAndDispose(group); + } + })); + } + + dispose(): void { + this._store.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorUtils.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorUtils.ts new file mode 100644 index 0000000000000..64b648b59ba19 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorUtils.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; + +export function getActiveResourceCandidates(input: Parameters[0]): URI[] { + const result: URI[] = []; + const resources = EditorResourceAccessor.getOriginalUri(input, { supportSideBySide: SideBySideEditor.BOTH }); + if (!resources) { + return result; + } + + if (URI.isUri(resources)) { + result.push(resources); + return result; + } + + if (resources.secondary) { + result.push(resources.secondary); + } + if (resources.primary) { + result.push(resources.primary); + } + + return result; +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackHover.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackHover.ts new file mode 100644 index 0000000000000..c730e9460cf00 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackHover.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js'; +import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../browser/labels.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; +import { IAgentFeedbackVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +/** + * Creates the custom hover content for the "N comments" attachment. + * Shows each feedback item with its file, range, text, and actions (remove / go to). + */ +export class AgentFeedbackHover extends Disposable { + + constructor( + private readonly _element: HTMLElement, + private readonly _attachment: IAgentFeedbackVariableEntry, + @IHoverService private readonly _hoverService: IHoverService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IEditorService private readonly _editorService: IEditorService, + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + ) { + super(); + + // Show on hover (delayed) + this._store.add(this._hoverService.setupDelayedHover( + this._element, + () => this._buildHoverContent(), + { groupId: 'chat-attachments' } + )); + + // Show immediately on click + this._store.add(dom.addDisposableListener(this._element, dom.EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._showHoverNow(); + })); + } + + private _showHoverNow(): void { + const opts = this._buildHoverContent(); + this._hoverService.showInstantHover({ + content: opts.content, + target: this._element, + style: opts.style, + position: opts.position, + trapFocus: opts.trapFocus, + }); + } + + private _buildHoverContent(): { content: HTMLElement; style: HoverStyle; position: { hoverPosition: HoverPosition }; trapFocus: boolean; dispose: () => void } { + const disposables = new DisposableStore(); + const hoverElement = dom.$('div.agent-feedback-hover'); + + const title = dom.$('div.agent-feedback-hover-title'); + title.textContent = this._attachment.feedbackItems.length === 1 + ? localize('agentFeedbackHover.titleOne', "1 feedback comment") + : localize('agentFeedbackHover.titleMany', "{0} feedback comments", this._attachment.feedbackItems.length); + hoverElement.appendChild(title); + + const list = dom.$('div.agent-feedback-hover-list'); + hoverElement.appendChild(list); + + // Create ResourceLabels for file icons + const resourceLabels = disposables.add(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + + // Group feedback items by file + const byFile = new Map(); + for (const item of this._attachment.feedbackItems) { + const key = item.resourceUri.toString(); + let group = byFile.get(key); + if (!group) { + group = []; + byFile.set(key, group); + } + group.push(item); + } + + for (const [, items] of byFile) { + // File header with icon via ResourceLabels + const fileHeader = dom.$('div.agent-feedback-hover-file-header'); + list.appendChild(fileHeader); + const label = resourceLabels.create(fileHeader); + label.setFile(items[0].resourceUri, { hidePath: false }); + + for (const item of items) { + const row = dom.$('div.agent-feedback-hover-row'); + list.appendChild(row); + + // Feedback text - clicking goes to location + const text = dom.$('div.agent-feedback-hover-text'); + text.textContent = item.text; + row.appendChild(text); + + row.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._goToFeedback(item.resourceUri, item.range); + }); + + // Remove button + const removeBtn = dom.$('a.agent-feedback-hover-remove'); + removeBtn.title = localize('agentFeedbackHover.remove', "Remove feedback"); + const removeIcon = dom.$('span'); + removeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.close)); + removeBtn.appendChild(removeIcon); + row.appendChild(removeBtn); + + removeBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._agentFeedbackService.removeFeedback(this._attachment.sessionResource, item.id); + }); + } + } + + return { + content: hoverElement, + style: HoverStyle.Pointer, + position: { hoverPosition: HoverPosition.BELOW }, + trapFocus: true, + dispose: () => disposables.dispose(), + }; + } + + private _goToFeedback(resourceUri: URI, range: IRange): void { + this._editorService.openEditor({ + resource: resourceUri, + options: { + selection: range, + preserveFocus: false, + revealIfVisible: true, + } + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackService.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackService.ts new file mode 100644 index 0000000000000..f1352d999bd4d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackService.ts @@ -0,0 +1,570 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { Comment, CommentThread, CommentThreadCollapsibleState, CommentThreadState, CommentInput } from '../../../../../editor/common/languages.js'; +import { createDecorator, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ICommentController, ICommentInfo, ICommentService, INotebookCommentInfo } from '../../../comments/browser/commentService.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { registerAction2, Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { IChatEditingService } from '../../common/editing/chatEditingService.js'; +import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; +import { agentSessionContainsResource, editingEntriesContainResource } from '../sessionResourceMatching.js'; +import { IChatWidget, IChatWidgetService } from '../chat.js'; + +// --- Types -------------------------------------------------------------------- + +export interface IAgentFeedback { + readonly id: string; + readonly text: string; + readonly resourceUri: URI; + readonly range: IRange; + readonly sessionResource: URI; +} + +export interface IAgentFeedbackChangeEvent { + readonly sessionResource: URI; + readonly feedbackItems: readonly IAgentFeedback[]; +} + +export interface IAgentFeedbackNavigationBearing { + readonly activeIdx: number; + readonly totalCount: number; +} + +// --- Service Interface -------------------------------------------------------- + +export const IAgentFeedbackService = createDecorator('agentFeedbackService'); + +export interface IAgentFeedbackService { + readonly _serviceBrand: undefined; + + readonly onDidChangeFeedback: Event; + readonly onDidChangeNavigation: Event; + + /** + * Add a feedback item for the given session. + */ + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback; + + /** + * Remove a single feedback item. + */ + removeFeedback(sessionResource: URI, feedbackId: string): void; + + /** + * Get all feedback items for a session. + */ + getFeedback(sessionResource: URI): readonly IAgentFeedback[]; + + /** + * Resolve the most recently updated session that has feedback for a given resource. + */ + getMostRecentSessionForResource(resourceUri: URI): URI | undefined; + + /** + * Navigate to next/previous feedback item in a session. + */ + getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined; + + /** + * Get the current navigation bearings for a session. + */ + getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing; + + /** + * Clear all feedback items for a session (e.g., after sending). + */ + clearFeedback(sessionResource: URI): void; +} + +// --- Implementation ----------------------------------------------------------- + +const AGENT_FEEDBACK_OWNER = 'agentFeedbackController'; +const AGENT_FEEDBACK_CONTEXT_VALUE = 'agentFeedback'; +const AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX = 'agentFeedback:'; + +export class AgentFeedbackService extends Disposable implements IAgentFeedbackService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeFeedback = this._store.add(new Emitter()); + readonly onDidChangeFeedback = this._onDidChangeFeedback.event; + private readonly _onDidChangeNavigation = this._store.add(new Emitter()); + readonly onDidChangeNavigation = this._onDidChangeNavigation.event; + + /** sessionResource → feedback items */ + private readonly _feedbackBySession = new Map(); + private readonly _sessionUpdatedOrder = new Map(); + private _sessionUpdatedSequence = 0; + private readonly _navigationAnchorBySession = new Map(); + + private _controllerRegistered = false; + private _nextThreadHandle = 1; + + constructor( + @ICommentService private readonly _commentService: ICommentService, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + ) { + super(); + + this._registerChatWidgetListeners(); + } + + private _registerChatWidgetListeners(): void { + for (const widget of this._chatWidgetService.getAllWidgets()) { + this._registerWidgetListeners(widget); + } + + this._store.add(this._chatWidgetService.onDidAddWidget(widget => { + this._registerWidgetListeners(widget); + })); + } + + private _registerWidgetListeners(widget: IChatWidget): void { + this._store.add(widget.attachmentModel.onDidChange(e => { + for (const deletedId of e.deleted) { + if (!deletedId.startsWith(AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX)) { + continue; + } + + const sessionResourceString = deletedId.slice(AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX.length); + if (!sessionResourceString) { + continue; + } + + const sessionResource = URI.parse(sessionResourceString); + if (this.getFeedback(sessionResource).length > 0) { + this.clearFeedback(sessionResource); + } + } + })); + } + + private _ensureController(): void { + if (this._controllerRegistered) { + return; + } + this._controllerRegistered = true; + + const self = this; + + const controller: ICommentController = { + id: AGENT_FEEDBACK_OWNER, + label: 'Agent Feedback', + features: {}, + contextValue: AGENT_FEEDBACK_CONTEXT_VALUE, + owner: AGENT_FEEDBACK_OWNER, + activeComment: undefined, + createCommentThreadTemplate: async () => { }, + updateCommentThreadTemplate: async () => { }, + deleteCommentThreadMain: () => { }, + toggleReaction: async () => { }, + getDocumentComments: async (resource: URI, _token: CancellationToken): Promise> => { + // Return threads for this resource from all sessions + const threads: CommentThread[] = []; + for (const [, sessionFeedback] of self._feedbackBySession) { + for (const f of sessionFeedback) { + if (f.resourceUri.toString() === resource.toString()) { + threads.push(self._createThread(f)); + } + } + } + return { + threads, + commentingRanges: { ranges: [], resource, fileComments: false }, + uniqueOwner: AGENT_FEEDBACK_OWNER, + }; + }, + getNotebookComments: async (_resource: URI, _token: CancellationToken): Promise => { + return { threads: [], uniqueOwner: AGENT_FEEDBACK_OWNER }; + }, + setActiveCommentAndThread: async () => { }, + }; + + this._commentService.registerCommentController(AGENT_FEEDBACK_OWNER, controller); + this._store.add({ dispose: () => this._commentService.unregisterCommentController(AGENT_FEEDBACK_OWNER) }); + + // Register delete action for our feedback threads + this._store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentFeedback.deleteThread', + title: localize('agentFeedback.delete', "Delete Feedback"), + icon: Codicon.trash, + menu: { + id: MenuId.CommentThreadTitle, + when: ContextKeyExpr.equals('commentController', AGENT_FEEDBACK_CONTEXT_VALUE), + group: 'navigation', + } + }); + } + run(accessor: ServicesAccessor, ...args: unknown[]): void { + const agentFeedbackService = accessor.get(IAgentFeedbackService); + const arg = args[0] as { thread?: { threadId?: string }; threadId?: string } | undefined; + const thread = arg?.thread ?? arg; + if (thread?.threadId) { + const sessionResource = self._findSessionForFeedback(thread.threadId); + if (sessionResource) { + agentFeedbackService.removeFeedback(sessionResource, thread.threadId); + } + } + } + })); + } + + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback { + this._ensureController(); + + const key = sessionResource.toString(); + let feedbackItems = this._feedbackBySession.get(key); + if (!feedbackItems) { + feedbackItems = []; + this._feedbackBySession.set(key, feedbackItems); + } + + const feedback: IAgentFeedback = { + id: generateUuid(), + text, + resourceUri, + range, + sessionResource, + }; + feedbackItems.push(feedback); + this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence); + this._onDidChangeNavigation.fire(sessionResource); + + this._syncThreads(sessionResource); + this._onDidChangeFeedback.fire({ sessionResource, feedbackItems }); + + return feedback; + } + + removeFeedback(sessionResource: URI, feedbackId: string): void { + const key = sessionResource.toString(); + const feedbackItems = this._feedbackBySession.get(key); + if (!feedbackItems) { + return; + } + + const idx = feedbackItems.findIndex(f => f.id === feedbackId); + if (idx >= 0) { + const removed = feedbackItems[idx]; + feedbackItems.splice(idx, 1); + this._activeThreadIds.delete(feedbackId); + if (this._navigationAnchorBySession.get(key) === feedbackId) { + this._navigationAnchorBySession.delete(key); + this._onDidChangeNavigation.fire(sessionResource); + } + if (feedbackItems.length > 0) { + this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence); + } else { + this._sessionUpdatedOrder.delete(key); + } + + // Fire updateComments with the thread in removed[] so the editor + // controller's onDidUpdateCommentThreads handler removes the zone widget + const thread = this._createThread(removed); + thread.isDisposed = true; + this._commentService.updateComments(AGENT_FEEDBACK_OWNER, { + added: [], + removed: [thread], + changed: [], + pending: [], + }); + + this._onDidChangeFeedback.fire({ sessionResource, feedbackItems }); + } + } + + /** + * Find which session a feedback item belongs to by its ID. + */ + _findSessionForFeedback(feedbackId: string): URI | undefined { + for (const [, feedbackItems] of this._feedbackBySession) { + const item = feedbackItems.find(f => f.id === feedbackId); + if (item) { + return item.sessionResource; + } + } + return undefined; + } + + getFeedback(sessionResource: URI): readonly IAgentFeedback[] { + return this._feedbackBySession.get(sessionResource.toString()) ?? []; + } + + getMostRecentSessionForResource(resourceUri: URI): URI | undefined { + let bestSession: URI | undefined; + let bestSequence = -1; + + for (const [, feedbackItems] of this._feedbackBySession) { + if (!feedbackItems.length) { + continue; + } + + const candidate = feedbackItems[0].sessionResource; + if (!this._sessionContainsResource(candidate, resourceUri, feedbackItems)) { + continue; + } + + const sequence = this._sessionUpdatedOrder.get(candidate.toString()) ?? 0; + if (sequence > bestSequence) { + bestSession = candidate; + bestSequence = sequence; + } + } + + return bestSession; + } + + private _sessionContainsResource(sessionResource: URI, resourceUri: URI, feedbackItems: readonly IAgentFeedback[]): boolean { + if (feedbackItems.some(item => isEqual(item.resourceUri, resourceUri))) { + return true; + } + + for (const editingSession of this._chatEditingService.editingSessionsObs.get()) { + if (!isEqual(editingSession.chatSessionResource, sessionResource)) { + continue; + } + + if (editingEntriesContainResource(editingSession.entries.get(), resourceUri)) { + return true; + } + } + + for (const session of this._agentSessionsService.model.sessions) { + if (!isEqual(session.resource, sessionResource)) { + continue; + } + + if (agentSessionContainsResource(session, resourceUri)) { + return true; + } + } + + return false; + } + + getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined { + const key = sessionResource.toString(); + const feedbackItems = this._feedbackBySession.get(key); + if (!feedbackItems?.length) { + this._navigationAnchorBySession.delete(key); + return undefined; + } + + const anchorId = this._navigationAnchorBySession.get(key); + let anchorIndex = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; + + if (anchorIndex < 0 && !next) { + anchorIndex = 0; + } + + const nextIndex = next + ? (anchorIndex + 1) % feedbackItems.length + : (anchorIndex - 1 + feedbackItems.length) % feedbackItems.length; + + const feedback = feedbackItems[nextIndex]; + this._navigationAnchorBySession.set(key, feedback.id); + this._onDidChangeNavigation.fire(sessionResource); + return feedback; + } + + getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing { + const key = sessionResource.toString(); + const feedbackItems = this._feedbackBySession.get(key) ?? []; + const anchorId = this._navigationAnchorBySession.get(key); + const activeIdx = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; + return { activeIdx, totalCount: feedbackItems.length }; + } + + clearFeedback(sessionResource: URI): void { + const key = sessionResource.toString(); + const feedbackItems = this._feedbackBySession.get(key); + if (feedbackItems && feedbackItems.length > 0) { + const removedThreads = feedbackItems.map(f => { + this._activeThreadIds.delete(f.id); + const thread = this._createThread(f); + thread.isDisposed = true; + return thread; + }); + + this._commentService.updateComments(AGENT_FEEDBACK_OWNER, { + added: [], + removed: removedThreads, + changed: [], + pending: [], + }); + } + this._feedbackBySession.delete(key); + this._sessionUpdatedOrder.delete(key); + this._navigationAnchorBySession.delete(key); + this._onDidChangeNavigation.fire(sessionResource); + this._onDidChangeFeedback.fire({ sessionResource, feedbackItems: [] }); + } + + /** Threads currently known to the comment service, keyed by feedback id */ + private readonly _activeThreadIds = new Set(); + + /** + * Sync feedback threads to the ICommentService using updateComments for + * incremental add/remove, which the editor controller listens to. + */ + private _syncThreads(_sessionResource: URI): void { + // Collect all current feedback IDs + const currentIds = new Set(); + const allFeedback: IAgentFeedback[] = []; + for (const [, sessionFeedback] of this._feedbackBySession) { + for (const f of sessionFeedback) { + currentIds.add(f.id); + allFeedback.push(f); + } + } + + // Determine added and removed + const added: CommentThread[] = []; + const removed: CommentThread[] = []; + + for (const f of allFeedback) { + if (!this._activeThreadIds.has(f.id)) { + added.push(this._createThread(f)); + } + } + + for (const id of this._activeThreadIds) { + if (!currentIds.has(id)) { + // Create a minimal thread just for removal (needs threadId and resource) + removed.push(this._createRemovedThread(id)); + } + } + + // Update tracking + this._activeThreadIds.clear(); + for (const id of currentIds) { + this._activeThreadIds.add(id); + } + + if (added.length || removed.length) { + this._commentService.updateComments(AGENT_FEEDBACK_OWNER, { + added, + removed, + changed: [], + pending: [], + }); + } + } + + private _createRemovedThread(feedbackId: string): CommentThread { + const noopEvent = Event.None; + return { + isDocumentCommentThread(): this is CommentThread { return true; }, + commentThreadHandle: -1, + controllerHandle: 0, + threadId: feedbackId, + resource: null, + range: undefined, + label: undefined, + contextValue: undefined, + comments: undefined, + onDidChangeComments: noopEvent, + collapsibleState: CommentThreadCollapsibleState.Collapsed, + initialCollapsibleState: CommentThreadCollapsibleState.Collapsed, + onDidChangeInitialCollapsibleState: noopEvent, + state: undefined, + applicability: undefined, + canReply: false, + input: undefined, + onDidChangeInput: noopEvent, + onDidChangeLabel: noopEvent, + onDidChangeCollapsibleState: noopEvent, + onDidChangeState: noopEvent, + onDidChangeCanReply: noopEvent, + isDisposed: true, + isTemplate: false, + }; + } + + private _createThread(feedback: IAgentFeedback): CommentThread { + const handle = this._nextThreadHandle++; + + const threadComment: Comment = { + uniqueIdInThread: 1, + body: feedback.text, + userName: 'You', + }; + + return new AgentFeedbackThread(handle, feedback.id, feedback.resourceUri.toString(), feedback.range, [threadComment]); + } +} + +/** + * A CommentThread implementation with proper emitters so the editor + * comment controller can react to state changes (collapse/expand). + */ +class AgentFeedbackThread implements CommentThread { + + private readonly _onDidChangeComments = new Emitter(); + readonly onDidChangeComments = this._onDidChangeComments.event; + + private readonly _onDidChangeCollapsibleState = new Emitter(); + readonly onDidChangeCollapsibleState = this._onDidChangeCollapsibleState.event; + + private readonly _onDidChangeInitialCollapsibleState = new Emitter(); + readonly onDidChangeInitialCollapsibleState = this._onDidChangeInitialCollapsibleState.event; + + private readonly _onDidChangeInput = new Emitter(); + readonly onDidChangeInput = this._onDidChangeInput.event; + + private readonly _onDidChangeLabel = new Emitter(); + readonly onDidChangeLabel = this._onDidChangeLabel.event; + + private readonly _onDidChangeState = new Emitter(); + readonly onDidChangeState = this._onDidChangeState.event; + + private readonly _onDidChangeCanReply = new Emitter(); + readonly onDidChangeCanReply = this._onDidChangeCanReply.event; + + readonly controllerHandle = 0; + readonly label = undefined; + readonly contextValue = undefined; + readonly applicability = undefined; + readonly input = undefined; + readonly isTemplate = false; + + private _collapsibleState = CommentThreadCollapsibleState.Collapsed; + get collapsibleState(): CommentThreadCollapsibleState { return this._collapsibleState; } + set collapsibleState(value: CommentThreadCollapsibleState) { + this._collapsibleState = value; + this._onDidChangeCollapsibleState.fire(value); + } + + readonly initialCollapsibleState = CommentThreadCollapsibleState.Collapsed; + readonly state = CommentThreadState.Unresolved; + readonly canReply = false; + isDisposed = false; + + constructor( + readonly commentThreadHandle: number, + readonly threadId: string, + readonly resource: string, + readonly range: IRange, + readonly comments: readonly Comment[], + ) { } + + isDocumentCommentThread(): this is CommentThread { + return true; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackAttachment.css b/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackAttachment.css new file mode 100644 index 0000000000000..c67d88f542272 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackAttachment.css @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agent-feedback-hover { + max-width: 400px; + padding: 4px 0; +} + +.agent-feedback-hover-title { + font-weight: bold; + font-size: 12px; + padding: 2px 8px 6px; + border-bottom: 1px solid var(--vscode-editorWidget-border); + margin-bottom: 4px; +} + +.agent-feedback-hover-list { + max-height: 300px; + overflow-y: auto; +} + +.agent-feedback-hover-file-header { + font-size: 11px; + font-weight: bold; + color: var(--vscode-foreground); + padding: 6px 8px 2px; + font-family: var(--monaco-monospace-font); +} + +.agent-feedback-hover-file-header:not(:first-child) { + border-top: 1px solid var(--vscode-editorWidget-border); + margin-top: 4px; + padding-top: 8px; +} + +.agent-feedback-hover-row { + padding: 4px 8px; + display: flex; + align-items: center; + gap: 4px; + border-radius: 4px; + cursor: pointer; + position: relative; +} + +.agent-feedback-hover-row:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.agent-feedback-hover-line { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.agent-feedback-hover-text { + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; + flex: 1; +} + +.agent-feedback-hover-remove { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--vscode-descriptionForeground); + opacity: 0; + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 4px; +} + +.agent-feedback-hover-row:hover .agent-feedback-hover-remove { + opacity: 1; +} + +.agent-feedback-hover-remove:hover { + color: var(--vscode-foreground); + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Attachment widget pill styling */ +.agent-feedback-attachment .chat-attached-context-pill { + display: flex; + align-items: center; + padding: 0 4px; +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackEditorOverlay.css b/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackEditorOverlay.css new file mode 100644 index 0000000000000..1acdbe228ce56 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackEditorOverlay.css @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agent-feedback-editor-overlay-widget { + padding: 2px 4px; + color: var(--vscode-foreground); + background-color: var(--vscode-editorWidget-background); + border-radius: 6px; + border: 1px solid var(--vscode-contrastBorder); + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + z-index: 10; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + overflow: hidden; +} + +.agent-feedback-editor-overlay-widget .action-item > .action-label { + padding: 4px 6px; + font-size: 11px; + line-height: 14px; + border-radius: 4px; +} + +.agent-feedback-editor-overlay-widget .monaco-action-bar .actions-container { + gap: 4px; +} + +.agent-feedback-editor-overlay-widget .action-item.primary > .action-label { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.monaco-workbench .agent-feedback-editor-overlay-widget .monaco-action-bar .action-item.primary > .action-label:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.agent-feedback-editor-overlay-widget .action-item > .action-label.codicon:not(.separator) { + color: var(--vscode-foreground); + width: 22px; + height: 22px; + padding: 0; + font-size: 16px; + line-height: 22px; + display: flex; + align-items: center; + justify-content: center; +} + +.agent-feedback-editor-overlay-widget .monaco-action-bar .action-item.disabled { + + > .action-label.codicon::before, + > .action-label.codicon, + > .action-label, + > .action-label:hover { + color: var(--vscode-button-separator); + opacity: 1; + } +} + +.agent-feedback-editor-overlay-widget .action-item.label-item { + font-variant-numeric: tabular-nums; +} + +.agent-feedback-editor-overlay-widget .monaco-action-bar .action-item.label-item > .action-label, +.agent-feedback-editor-overlay-widget .monaco-action-bar .action-item.label-item > .action-label:hover { + color: var(--vscode-foreground); + opacity: 1; +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index ff81e1273fa91..7fc854738a297 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -9,7 +9,6 @@ import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { IChatSessionTiming } from '../../common/chatService/chatService.js'; -import { IChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -37,7 +36,6 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes case AgentSessionProviders.Cloud: case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: - case AgentSessionProviders.Growth: return type; default: return undefined; @@ -97,11 +95,7 @@ export function isFirstPartyAgentSessionProvider(provider: AgentSessionProviders } } -export function getAgentCanContinueIn(provider: AgentSessionProviders, contribution?: IChatSessionsExtensionPoint): boolean { - // Read-only sessions (e.g., Growth) are passive/informational and cannot be delegation targets - if (contribution?.isReadOnly) { - return false; - } +export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean { switch (provider) { case AgentSessionProviders.Local: case AgentSessionProviders.Background: @@ -127,7 +121,7 @@ export function getAgentSessionProviderDescription(provider: AgentSessionProvide case AgentSessionProviders.Codex: return localize('chat.session.providerDescription.codex', "Opens a new Codex session in the editor. Codex sessions can be managed from the chat sessions view."); case AgentSessionProviders.Growth: - return localize('chat.session.providerDescription.growth', "Educational messages to help you learn Copilot features."); + return localize('chat.session.providerDescription.growth', "Learn about Copilot features."); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index f58192d8d3751..d74a3f6a63253 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -9,14 +9,14 @@ import { IContextMenuService } from '../../../../../platform/contextview/browser import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; import { $, append, EventHelper } from '../../../../../base/browser/dom.js'; -import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; +import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection, isSessionInProgressStatus } from './agentSessionsModel.js'; import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter, IAgentSessionsSorterOptions } from './agentSessionsViewer.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Throttler } from '../../../../../base/common/async.js'; import { ITreeContextMenuEvent } from '../../../../../base/browser/ui/tree/tree.js'; @@ -27,7 +27,7 @@ import { IAgentSessionsService } from './agentSessionsService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IListStyles } from '../../../../../base/browser/ui/list/listWidget.js'; import { IStyleOverride } from '../../../../../platform/theme/browser/defaultStyles.js'; -import { IAgentSessionsControl } from './agentSessions.js'; +import { getAgentSessionTime, IAgentSessionsControl } from './agentSessions.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { URI } from '../../../../../base/common/uri.js'; import { ISessionOpenOptions, openSession } from './agentSessionsOpener.js'; @@ -43,8 +43,10 @@ export interface IAgentSessionsControlOptions extends IAgentSessionsSorterOption getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; + collapseOlderSections?(): boolean; overrideSessionOpenOptions?(openEvent: IOpenEvent): ISessionOpenOptions; + overrideSessionOpen?(resource: URI, openOptions?: ISessionOpenOptions): Promise; notifySessionOpened?(resource: URI, widget: IChatWidget): void; } @@ -70,6 +72,9 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private readonly updateSessionsListThrottler = this._register(new Throttler()); + private readonly _onDidUpdate = this._register(new Emitter()); + readonly onDidUpdate: Event = this._onDidUpdate.event; + private visible: boolean = true; private focusedAgentSessionArchivedContextKey: IContextKey; @@ -142,6 +147,15 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo if (element.section === AgentSessionSection.Archived && this.options.filter.getExcludes().archived) { return true; // Archived section is collapsed when archived are excluded } + if (this.options.collapseOlderSections?.()) { + const olderSections = [AgentSessionSection.Week, AgentSessionSection.Older, AgentSessionSection.Archived]; + if (olderSections.includes(element.section)) { + return true; // Collapse older time sections if option is enabled + } + if (element.section === AgentSessionSection.Yesterday && this.hasTodaySessions()) { + return true; // Also collapse Yesterday when there are sessions from Today + } + } } return false; @@ -225,6 +239,17 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo })); } + private hasTodaySessions(): boolean { + const startOfToday = new Date().setHours(0, 0, 0, 0); + + return this.agentSessionsService.model.sessions.some(session => + !session.isArchived() && ( + isSessionInProgressStatus(session.status) || + getAgentSessionTime(session.timing) >= startOfToday + ) + ); + } + private async openAgentSession(e: IOpenEvent): Promise { const element = e.element; if (!element || isAgentSessionSection(element)) { @@ -237,9 +262,13 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo }); const options = this.options.overrideSessionOpenOptions?.(e) ?? e; - const widget = await this.instantiationService.invokeFunction(openSession, element, options); - if (widget) { - this.options.notifySessionOpened?.(element.resource, widget); + if (this.options.overrideSessionOpen) { + await this.options.overrideSessionOpen(element.resource, options); + } else { + const widget = await this.instantiationService.invokeFunction(openSession, element, options); + if (widget) { + this.options.notifySessionOpened?.(element.resource, widget); + } } } @@ -341,7 +370,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } async update(): Promise { - return this.updateSessionsListThrottler.queue(async () => this.sessionsList?.updateChildren()); + return this.updateSessionsListThrottler.queue(async () => { + await this.sessionsList?.updateChildren(); + + this._onDidUpdate.fire(); + }); } setVisible(visible: boolean): void { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 37a75d93f19d1..ea409d2eef2dd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -21,6 +21,7 @@ import { ILogService, LogLevel } from '../../../../../platform/log/common/log.js import { IProductService } from '../../../../../platform/product/common/productService.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js'; @@ -396,7 +397,8 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode @IInstantiationService private readonly instantiationService: IInstantiationService, @IStorageService private readonly storageService: IStorageService, @IProductService private readonly productService: IProductService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, ) { super(); @@ -429,6 +431,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType }) => this.resolve(chatSessionType))); this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined))); this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.updateItems([chatSessionType], CancellationToken.None))); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.resolve(undefined))); // State this._register(this.storageService.onWillSaveState(() => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 2a89832b7f4ee..bcc94cad564c3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -219,7 +219,8 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } // Separator (dot between badge and description) - template.separator.classList.toggle('has-separator', hasBadge && !hasDiff); + const hasDescription = template.description.textContent !== ''; + template.separator.classList.toggle('has-separator', hasBadge && hasDescription); // Status this.renderStatus(session, template); @@ -502,7 +503,7 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer { - static readonly ITEM_HEIGHT = 44; + static readonly ITEM_HEIGHT = 48; static readonly SECTION_HEIGHT = 26; getHeight(element: AgentSessionListItem): number { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 5db250de5b30e..b9863aae7dcbc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -82,7 +82,7 @@ .agent-session-item { display: flex; flex-direction: row; - padding: 4px 6px; + padding: 6px 6px; &.archived { color: var(--vscode-descriptionForeground); diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index e0df9ffe04f2d..7744f6a9e9f92 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -614,8 +614,7 @@ export class DefaultChatAttachmentWidget extends AbstractChatAttachmentWidget { } if (attachment.kind === 'symbol') { - const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.element)); - this._register(this.instantiationService.invokeFunction(hookUpSymbolAttachmentDragAndContextMenu, this.element, scopedContextKeyService, { ...attachment, kind: attachment.symbolKind }, MenuId.ChatInputSymbolAttachmentContext)); + this._register(this.instantiationService.invokeFunction(hookUpSymbolAttachmentDragAndContextMenu, this.element, this.contextKeyService, { ...attachment, kind: attachment.symbolKind }, MenuId.ChatInputSymbolAttachmentContext)); } // Handle click for string context attachments with context commands @@ -1115,19 +1114,15 @@ export function hookUpResourceAttachmentDragAndContextMenu(accessor: ServicesAcc return store; } -export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAccessor, widget: HTMLElement, scopedContextKeyService: IScopedContextKeyService, attachment: { name: string; value: Location; kind: SymbolKind }, contextMenuId: MenuId): IDisposable { +export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAccessor, widget: HTMLElement, parentContextKeyService: IContextKeyService, attachment: { name: string; value: Location; kind: SymbolKind }, contextMenuId: MenuId): IDisposable { const instantiationService = accessor.get(IInstantiationService); const languageFeaturesService = accessor.get(ILanguageFeaturesService); const textModelService = accessor.get(ITextModelService); + const contextMenuService = accessor.get(IContextMenuService); + const menuService = accessor.get(IMenuService); const store = new DisposableStore(); - // Context - store.add(setResourceContext(accessor, scopedContextKeyService, attachment.value.uri)); - - const chatResourceContext = chatAttachmentResourceContextKey.bindTo(scopedContextKeyService); - chatResourceContext.set(attachment.value.uri.toString()); - // Drag and drop widget.draggable = true; store.add(dom.addDisposableListener(widget, 'dragstart', e => { @@ -1143,26 +1138,57 @@ export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAcces e.dataTransfer?.setDragImage(widget, 0, 0); })); - // Context menu - const providerContexts: ReadonlyArray<[IContextKey, LanguageFeatureRegistry]> = [ - [EditorContextKeys.hasDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.definitionProvider], - [EditorContextKeys.hasReferenceProvider.bindTo(scopedContextKeyService), languageFeaturesService.referenceProvider], - [EditorContextKeys.hasImplementationProvider.bindTo(scopedContextKeyService), languageFeaturesService.implementationProvider], - [EditorContextKeys.hasTypeDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.typeDefinitionProvider], - ]; + // Context menu (context key service created eagerly for keybinding preconditions, + // but resource context and provider contexts are initialized lazily on first use) + const scopedContextKeyService = store.add(parentContextKeyService.createScoped(widget)); + chatAttachmentResourceContextKey.bindTo(scopedContextKeyService).set(attachment.value.uri.toString()); + store.add(setResourceContext(accessor, scopedContextKeyService, attachment.value.uri)); + + let providerContexts: ReadonlyArray<[IContextKey, LanguageFeatureRegistry]> | undefined; + + const ensureProviderContexts = () => { + if (!providerContexts) { + providerContexts = [ + [EditorContextKeys.hasDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.definitionProvider], + [EditorContextKeys.hasReferenceProvider.bindTo(scopedContextKeyService), languageFeaturesService.referenceProvider], + [EditorContextKeys.hasImplementationProvider.bindTo(scopedContextKeyService), languageFeaturesService.implementationProvider], + [EditorContextKeys.hasTypeDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.typeDefinitionProvider], + ]; + } + }; const updateContextKeys = async () => { + ensureProviderContexts(); const modelRef = await textModelService.createModelReference(attachment.value.uri); try { const model = modelRef.object.textEditorModel; - for (const [contextKey, registry] of providerContexts) { + for (const [contextKey, registry] of providerContexts!) { contextKey.set(registry.has(model)); } } finally { modelRef.dispose(); } }; - store.add(addBasicContextMenu(accessor, widget, scopedContextKeyService, contextMenuId, attachment.value, updateContextKeys)); + + store.add(dom.addDisposableListener(widget, dom.EventType.CONTEXT_MENU, async domEvent => { + const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent); + dom.EventHelper.stop(domEvent, true); + + try { + await updateContextKeys(); + } catch (e) { + console.error(e); + } + + contextMenuService.showContextMenu({ + contextKeyService: scopedContextKeyService, + getAnchor: () => event, + getActions: () => { + const menu = menuService.getMenuActions(contextMenuId, scopedContextKeyService, { arg: attachment.value }); + return getFlatContextMenuActions(menu); + }, + }); + })); return store; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 035ee4ffe0801..a14c1dc61e59c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -3,9 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { timeout } from '../../../../base/common/async.js'; import { Event } from '../../../../base/common/event.js'; -import { MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; @@ -14,7 +12,6 @@ import { registerEditorFeature } from '../../../../editor/common/editorFeatures. import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationNode, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; @@ -46,7 +43,7 @@ import { ChatTodoListService, IChatTodoListService } from '../common/tools/chatT import { ChatTransferService, IChatTransferService } from '../common/model/chatTransferService.js'; import { IChatVariablesService } from '../common/attachments/chatVariables.js'; import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/widget/chatWidgetHistoryService.js'; -import { AgentsControlClickBehavior, ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; +import { AgentsControlClickBehavior, ChatConfiguration } from '../common/constants.js'; import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } from '../common/ignoredFiles.js'; import { ILanguageModelsService, LanguageModelsService } from '../common/languageModels.js'; import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; @@ -63,16 +60,18 @@ import { IPromptsService } from '../common/promptSyntax/service/promptsService.j import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js'; +import { RenameToolContribution } from './tools/renameTool.js'; +import { UsagesToolContribution } from './tools/usagesTool.js'; import { IVoiceChatService, VoiceChatService } from '../common/voiceChatService.js'; import { registerChatAccessibilityActions } from './actions/chatAccessibilityActions.js'; import { AgentChatAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; -import { ACTION_ID_NEW_CHAT, ModeOpenChatGlobalAction, registerChatActions } from './actions/chatActions.js'; +import { ModeOpenChatGlobalAction, registerChatActions } from './actions/chatActions.js'; import { CodeBlockActionRendering, registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/chatCodeblockActions.js'; import { ChatContextContributions } from './actions/chatContext.js'; import { registerChatContextActions } from './actions/chatContextActions.js'; import { registerChatCopyActions } from './actions/chatCopyActions.js'; import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js'; -import { ChatSubmitAction, registerChatExecuteActions } from './actions/chatExecuteActions.js'; +import { registerChatExecuteActions } from './actions/chatExecuteActions.js'; import { registerChatFileTreeActions } from './actions/chatFileTreeActions.js'; import { ChatGettingStartedContribution } from './actions/chatGettingStarted.js'; import { registerChatExportActions } from './actions/chatImportExport.js'; @@ -90,7 +89,7 @@ import { ChatTransferContribution } from './actions/chatTransfer.js'; import { registerChatCustomizationDiagnosticsAction } from './actions/chatCustomizationDiagnosticsAction.js'; import './agentSessions/agentSessions.contribution.js'; import { backgroundAgentDisplayName } from './agentSessions/agentSessions.js'; -import { IAgentSessionsService } from './agentSessions/agentSessionsService.js'; + import { IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService } from './chat.js'; import { ChatAccessibilityService } from './accessibility/chatAccessibilityService.js'; import './attachments/chatAttachmentModel.js'; @@ -111,7 +110,7 @@ import { ChatEditorInput, ChatEditorInputSerializer } from './widgetHosts/editor import { ChatLayoutService } from './widget/chatLayoutService.js'; import { ChatLanguageModelsDataContribution, LanguageModelsConfigurationService } from './languageModelsConfigurationService.js'; import './chatManagement/chatManagement.contribution.js'; -import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; + import { ChatOutputRendererService, IChatOutputRendererService } from './chatOutputItemRenderer.js'; import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './chatParticipant.contribution.js'; import { ChatPasteProvidersFeature } from './widget/input/editor/chatPasteProviders.js'; @@ -132,7 +131,7 @@ import { LanguageModelToolsConfirmationService } from './tools/languageModelTool import { LanguageModelToolsService, globalAutoApproveDescription } from './tools/languageModelToolsService.js'; import './promptSyntax/promptCodingAgentActionContribution.js'; import './promptSyntax/promptToolsCodeLensProvider.js'; -import { showConfigureHooksQuickPick } from './promptSyntax/hookActions.js'; +import { ChatSlashCommandsContribution } from './chatSlashCommands.js'; import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; import { ConfigureToolSets, UserToolSetsContributions } from './tools/toolSetsContribution.js'; import { ChatViewsWelcomeHandler } from './viewsWelcome/chatViewsWelcomeHandler.js'; @@ -142,6 +141,10 @@ import { ChatWindowNotifier } from './chatWindowNotifier.js'; import { ChatRepoInfoContribution } from './chatRepoInfo.js'; import { VALID_PROMPT_FOLDER_PATTERN } from '../common/promptSyntax/utils/promptFilesLocator.js'; import { ChatTipService, IChatTipService } from './chatTipService.js'; +import { AgentFeedbackService, IAgentFeedbackService } from './agentFeedback/agentFeedbackService.js'; +import { AgentFeedbackAttachmentContribution } from './agentFeedback/agentFeedbackAttachment.js'; +import { AgentFeedbackEditorOverlay } from './agentFeedback/agentFeedbackEditorOverlay.js'; +import { registerAgentFeedbackEditorActions } from './agentFeedback/agentFeedbackEditorActions.js'; import { ChatQueuePickerRendering } from './widget/input/chatQueuePickerActionItem.js'; import { ExploreAgentDefaultModel } from './exploreAgentDefaultModel.js'; import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; @@ -281,6 +284,7 @@ configurationRegistry.registerConfiguration({ }, 'chat.tips.enabled': { type: 'boolean', + scope: ConfigurationScope.APPLICATION, description: nls.localize('chat.tips.enabled', "Controls whether tips are shown above user messages in chat. This is an experimental feature."), default: false, tags: ['experimental'], @@ -461,6 +465,11 @@ configurationRegistry.registerConfiguration({ default: false, description: nls.localize('chat.viewProgressBadge.enabled', "Show a progress badge on the chat view when an agent session is in progress that is opened in that view."), }, + [ChatConfiguration.ChatContextUsageEnabled]: { + type: 'boolean', + default: true, + description: nls.localize('chat.contextUsage.enabled', "Show the context window usage indicator in the chat input."), + }, [ChatConfiguration.NotifyWindowOnResponseReceived]: { type: 'boolean', default: true, @@ -635,12 +644,6 @@ configurationRegistry.registerConfiguration({ enumItemLabels: ExploreAgentDefaultModel.modelLabels, markdownEnumDescriptions: ExploreAgentDefaultModel.modelDescriptions }, - [ChatConfiguration.RequestQueueingEnabled]: { - type: 'boolean', - description: nls.localize('chat.requestQueuing.enabled.description', "When enabled, allows queuing additional messages while a request is in progress and steering the current request with a new message."), - default: true, - tags: ['experimental'], - }, [ChatConfiguration.RequestQueueingDefaultAction]: { type: 'string', enum: ['queue', 'steer'], @@ -654,7 +657,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.EditModeHidden]: { type: 'boolean', description: nls.localize('chat.editMode.hidden', "When enabled, hides the Edit mode from the chat mode picker."), - default: false, + default: true, tags: ['experimental'], experiment: { mode: 'auto' @@ -680,20 +683,24 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['experimental'], }, - ['chat.statusWidget.sku']: { - type: 'string', - enum: ['free', 'anonymous'], - enumDescriptions: [ - nls.localize('chat.statusWidget.sku.free', "Show status widget for free tier users."), - nls.localize('chat.statusWidget.sku.anonymous', "Show status widget for anonymous users.") - ], - description: nls.localize('chat.statusWidget.enabled.description', "Controls which user type should see the status widget in new chat sessions when quota is exceeded."), - default: undefined, + ['chat.statusWidget.anonymous']: { + type: 'boolean', + description: nls.localize('chat.statusWidget.anonymous.description', "Controls whether anonymous users see the status widget in new chat sessions when rate limited."), + default: false, tags: ['experimental', 'advanced'], experiment: { mode: 'auto' } }, + ['chat.noAuthWidget.enabled']: { + type: 'boolean', + description: nls.localize('chat.noAuthWidget.enabled.description', "Controls whether a welcome banner is shown for anonymous users prompting them to try Copilot."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, [mcpDiscoverySection]: { type: 'object', properties: Object.fromEntries(allDiscoverySources.map(k => [k, { type: 'boolean', description: discoverySourceSettingsLabel[k] }])), @@ -1006,6 +1013,15 @@ configurationRegistry.registerConfiguration({ }, } }, + [PromptsConfig.USE_CLAUDE_HOOKS]: { + type: 'boolean', + title: nls.localize('chat.useClaudeHooks.title', "Use Claude Hooks",), + markdownDescription: nls.localize('chat.useClaudeHooks.description', "Controls whether hooks from Claude configuration files can execute. When disabled, only Copilot-format hooks are used. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), + default: false, + restricted: true, + disallowConfigurationDefault: true, + tags: ['preview', 'prompts', 'hooks', 'agent'] + }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', scope: ConfigurationScope.RESOURCE, @@ -1074,6 +1090,24 @@ configurationRegistry.registerConfiguration({ markdownDescription: nls.localize('chat.agent.thinking.terminalTools', "When enabled, terminal tool calls are displayed inside the thinking dropdown with a simplified view."), tags: ['experimental'], }, + 'chat.tools.usagesTool.enabled': { + type: 'boolean', + default: true, + markdownDescription: nls.localize('chat.tools.usagesTool.enabled', "Controls whether the usages tool is available for finding references, definitions, and implementations of code symbols."), + tags: ['preview'], + experiment: { + mode: 'auto' + } + }, + 'chat.tools.renameTool.enabled': { + type: 'boolean', + default: true, + markdownDescription: nls.localize('chat.tools.renameTool.enabled', "Controls whether the rename tool is available for renaming code symbols across the workspace."), + tags: ['preview'], + experiment: { + mode: 'auto' + } + }, [ChatConfiguration.AutoExpandToolFailures]: { type: 'boolean', default: true, @@ -1094,6 +1128,15 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.GrowthNotificationEnabled]: { + type: 'boolean', + description: nls.localize('chat.growthNotification', "Controls whether to show a growth notification in the agent sessions view to encourage new users to try Copilot."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, [ChatConfiguration.RestoreLastPanelSession]: { type: 'boolean', description: nls.localize('chat.restoreLastPanelSession', "Controls whether the last session is restored in panel after restart."), @@ -1398,150 +1441,11 @@ AccessibleViewRegistry.register(new EditsChatAccessibilityHelp()); AccessibleViewRegistry.register(new AgentChatAccessibilityHelp()); registerEditorFeature(ChatInputBoxContentProvider); - -class ChatSlashStaticSlashCommandsContribution extends Disposable { - - static readonly ID = 'workbench.contrib.chatSlashStaticSlashCommands'; - - constructor( - @IChatSlashCommandService slashCommandService: IChatSlashCommandService, - @ICommandService commandService: ICommandService, - @IChatAgentService chatAgentService: IChatAgentService, - @IChatWidgetService chatWidgetService: IChatWidgetService, - @IInstantiationService instantiationService: IInstantiationService, - @IAgentSessionsService agentSessionsService: IAgentSessionsService, - ) { - super(); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'clear', - detail: nls.localize('clear', "Start a new chat and archive the current one"), - sortText: 'z2_clear', - executeImmediately: true, - locations: [ChatAgentLocation.Chat] - }, async (_prompt, _progress, _history, _location, sessionResource) => { - agentSessionsService.getSession(sessionResource)?.setArchived(true); - commandService.executeCommand(ACTION_ID_NEW_CHAT); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'hooks', - detail: nls.localize('hooks', "Configure hooks"), - sortText: 'z3_hooks', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await instantiationService.invokeFunction(showConfigureHooksQuickPick); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'debug', - detail: nls.localize('debug', "Show Chat Debug View"), - sortText: 'z3_debug', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await commandService.executeCommand('github.copilot.debug.showChatLogView'); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'agents', - detail: nls.localize('agents', "Configure custom agents"), - sortText: 'z3_agents', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.customagents'); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'skills', - detail: nls.localize('skills', "Configure skills"), - sortText: 'z3_skills', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.skills'); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'instructions', - detail: nls.localize('instructions', "Configure instructions"), - sortText: 'z3_instructions', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.instructions'); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'prompts', - detail: nls.localize('prompts', "Configure prompt files"), - sortText: 'z3_prompts', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.prompts'); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'help', - detail: '', - sortText: 'z1_help', - executeImmediately: true, - locations: [ChatAgentLocation.Chat], - modes: [ChatModeKind.Ask] - }, async (prompt, progress, _history, _location, sessionResource) => { - const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Chat); - const agents = chatAgentService.getAgents(); - - // Report prefix - if (defaultAgent?.metadata.helpTextPrefix) { - if (isMarkdownString(defaultAgent.metadata.helpTextPrefix)) { - progress.report({ content: defaultAgent.metadata.helpTextPrefix, kind: 'markdownContent' }); - } else { - progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextPrefix), kind: 'markdownContent' }); - } - progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); - } - - // Report agent list - const agentText = (await Promise.all(agents - .filter(a => !a.isDefault && !a.isCore) - .filter(a => a.locations.includes(ChatAgentLocation.Chat)) - .map(async a => { - const description = a.description ? `- ${a.description}` : ''; - const agentMarkdown = instantiationService.invokeFunction(accessor => agentToMarkdown(a, sessionResource, true, accessor)); - const agentLine = `- ${agentMarkdown} ${description}`; - const commandText = a.slashCommands.map(c => { - const description = c.description ? `- ${c.description}` : ''; - return `\t* ${agentSlashCommandToMarkdown(a, c, sessionResource)} ${description}`; - }).join('\n'); - - return (agentLine + '\n' + commandText).trim(); - }))).join('\n'); - progress.report({ content: new MarkdownString(agentText, { isTrusted: { enabledCommands: [ChatSubmitAction.ID] } }), kind: 'markdownContent' }); - - // Report help text ending - if (defaultAgent?.metadata.helpTextPostfix) { - progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); - if (isMarkdownString(defaultAgent.metadata.helpTextPostfix)) { - progress.report({ content: defaultAgent.metadata.helpTextPostfix, kind: 'markdownContent' }); - } else { - progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextPostfix), kind: 'markdownContent' }); - } - } - - // Without this, the response will be done before it renders and so it will not stream. This ensures that if the response starts - // rendering during the next 200ms, then it will be streamed. Once it starts streaming, the whole response streams even after - // it has received all response data has been received. - await timeout(200); - })); - } -} Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatSlashStaticSlashCommandsContribution.ID, ChatSlashStaticSlashCommandsContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatSlashCommandsContribution.ID, ChatSlashCommandsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatExtensionPointHandler.ID, ChatExtensionPointHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(LanguageModelToolsExtensionPointHandler.ID, LanguageModelToolsExtensionPointHandler, WorkbenchPhase.BlockRestore); @@ -1555,6 +1459,8 @@ registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, registerWorkbenchContribution2(ChatTeardownContribution.ID, ChatTeardownContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(RenameToolContribution.ID, RenameToolContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored); @@ -1562,6 +1468,7 @@ registerWorkbenchContribution2(ChatAgentRecommendation.ID, ChatAgentRecommendati registerWorkbenchContribution2(ChatEditingEditorAccessibility.ID, ChatEditingEditorAccessibility, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatQueuePickerRendering.ID, ChatQueuePickerRendering, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatEditingEditorOverlay.ID, ChatEditingEditorOverlay, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentFeedbackEditorOverlay.ID, AgentFeedbackEditorOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SimpleBrowserOverlay.ID, SimpleBrowserOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatEditingEditorContextKeys.ID, ChatEditingEditorContextKeys, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribution, WorkbenchPhase.BlockRestore); @@ -1573,6 +1480,7 @@ registerWorkbenchContribution2(UserToolSetsContributions.ID, UserToolSetsContrib registerWorkbenchContribution2(PromptLanguageFeaturesProvider.ID, PromptLanguageFeaturesProvider, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatWindowNotifier.ID, ChatWindowNotifier, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatRepoInfoContribution.ID, ChatRepoInfoContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeedbackAttachmentContribution, WorkbenchPhase.AfterRestored); registerChatActions(); registerChatAccessibilityActions(); @@ -1592,6 +1500,7 @@ registerNewChatActions(); registerChatContextActions(); registerChatDeveloperActions(); registerChatEditorActions(); +registerAgentFeedbackEditorActions(); registerChatElicitationActions(); registerChatToolActions(); registerLanguageModelActions(); @@ -1628,5 +1537,6 @@ registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.D registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); registerSingleton(IChatTipService, ChatTipService, InstantiationType.Delayed); +registerSingleton(IAgentFeedbackService, AgentFeedbackService, InstantiationType.Delayed); ChatWidget.CONTRIBS.push(ChatDynamicVariableModel); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 1cace4dc13431..e58d1fe7fba73 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -126,6 +126,12 @@ export interface IChatWidgetService { */ readonly onDidChangeFocusedWidget: Event; + /** + * Fires when the focused chat session changes, either because the focused widget + * changed or because the focused widget loaded a different session. + */ + readonly onDidChangeFocusedSession: Event; + /** * Reveals the widget, focusing its input unless `preserveFocus` is true. */ diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index b25f186b1e481..388edd3f00c23 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -25,7 +25,6 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IEditorPane } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; @@ -896,71 +895,3 @@ CommandsRegistry.registerCommand('_chat.editSessions.accept', async (accessor: S await editingSession.accept(...uris); } }); - -//#region View as Tree / View as List toggle - -export const CHAT_EDITS_VIEW_MODE_STORAGE_KEY = 'chat.editsViewMode'; -export const ChatEditsViewAsTreeActionId = 'chatEditing.viewAsTree'; -export const ChatEditsViewAsListActionId = 'chatEditing.viewAsList'; - -registerAction2(class ChatEditsViewAsTreeAction extends Action2 { - constructor() { - super({ - id: ChatEditsViewAsTreeActionId, - title: localize2('chatEditing.viewAsTree', "View as Tree"), - icon: Codicon.listFlat, - category: CHAT_CATEGORY, - menu: [ - { - id: MenuId.ChatEditingWidgetToolbar, - group: 'navigation', - order: 5, - when: ContextKeyExpr.and(hasAppliedChatEditsContextKey, ChatContextKeys.chatEditsInTreeView.negate()), - }, - { - id: MenuId.ChatEditingSessionChangesToolbar, - group: 'navigation', - order: 5, - when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.chatEditsInTreeView.negate()), - }, - ], - }); - } - - run(accessor: ServicesAccessor): void { - const storageService = accessor.get(IStorageService); - storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); - } -}); - -registerAction2(class ChatEditsViewAsListAction extends Action2 { - constructor() { - super({ - id: ChatEditsViewAsListActionId, - title: localize2('chatEditing.viewAsList', "View as List"), - icon: Codicon.listTree, - category: CHAT_CATEGORY, - menu: [ - { - id: MenuId.ChatEditingWidgetToolbar, - group: 'navigation', - order: 5, - when: ContextKeyExpr.and(hasAppliedChatEditsContextKey, ChatContextKeys.chatEditsInTreeView), - }, - { - id: MenuId.ChatEditingSessionChangesToolbar, - group: 'navigation', - order: 5, - when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.chatEditsInTreeView), - }, - ], - }); - } - - run(accessor: ServicesAccessor): void { - const storageService = accessor.get(IStorageService); - storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'list', StorageScope.PROFILE, StorageTarget.USER); - } -}); - -//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts index f2887d7900336..b26f5ced875aa 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts @@ -237,18 +237,8 @@ export class ChatEditingExplanationModelManager extends Disposable implements IC const totalChanges = fileChanges.reduce((sum, f) => sum + f.changes.length, 0); try { - // Select a high-end model for better understanding of all changes together - let models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'claude-3.5-sonnet' }); - if (!models.length) { - models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o' }); - } - if (!models.length) { - models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4' }); - } - if (!models.length) { - // Fallback to any available model - models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot' }); - } + // Select a model for understanding all changes together + const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); if (!models.length) { for (const fileData of fileChanges) { this._updateUriStatePartial(fileData.uri, { diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index bbc655588bf4d..a75b18c653548 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -631,7 +631,7 @@ interface ICapabilitiesColumnTemplateData extends IModelTableColumnTemplateData readonly metadataRow: HTMLElement; } -class CapabilitiesColumnRenderer extends ModelsTableColumnRenderer { +class CapabilitiesColumnRenderer extends ModelsTableColumnRenderer implements IDisposable { static readonly TEMPLATE_ID = 'capabilities'; readonly templateId: string = CapabilitiesColumnRenderer.TEMPLATE_ID; @@ -639,6 +639,10 @@ class CapabilitiesColumnRenderer extends ModelsTableColumnRenderer(); readonly onDidClickCapability = this._onDidClickCapability.event; + dispose(): void { + this._onDidClickCapability.dispose(); + } + renderTemplate(container: HTMLElement): ICapabilitiesColumnTemplateData { const disposables = new DisposableStore(); const elementDisposables = new DisposableStore(); @@ -1007,6 +1011,7 @@ export class ChatModelsWidget extends Disposable { const actionsColumnRenderer = this.instantiationService.createInstance(ActionsColumnRenderer, this.viewModel); const providerColumnRenderer = this.instantiationService.createInstance(ProviderColumnRenderer); + this.tableDisposables.add(capabilitiesColumnRenderer); this.tableDisposables.add(capabilitiesColumnRenderer.onDidClickCapability(capability => { const currentQuery = this.searchWidget.getValue(); const query = `@capability:${capability}`; diff --git a/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts index 695e5151d6bab..e5848bf941ace 100644 --- a/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts +++ b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts @@ -9,7 +9,8 @@ import { URI } from '../../../../base/common/uri.js'; import { linesDiffComputers } from '../../../../editor/common/diff/linesDiffComputers.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; +import { IFileService, FileOperationError, FileOperationResult } from '../../../../platform/files/common/files.js'; +import { detectEncodingFromBuffer } from '../../../services/textfile/common/encoding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; @@ -23,6 +24,7 @@ import * as nls from '../../../../nls.js'; const MAX_CHANGES = 100; const MAX_DIFFS_SIZE_BYTES = 900 * 1024; const MAX_SESSIONS_WITH_FULL_DIFFS = 5; +const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024; // 1 MB per file /** * Regex to match `url = ` lines in git config. */ @@ -121,9 +123,16 @@ async function generateUnifiedDiff( if (originalUri && changeType !== 'added') { try { - const originalFile = await fileService.readFile(originalUri); + const originalFile = await fileService.readFile(originalUri, { limits: { size: MAX_FILE_SIZE_BYTES } }); + const detected = detectEncodingFromBuffer({ buffer: originalFile.value, bytesRead: originalFile.value.byteLength }); + if (detected.seemsBinary) { + return undefined; // skip binary files + } originalContent = originalFile.value.toString(); - } catch { + } catch (e) { + if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_TOO_LARGE) { + return undefined; // skip files exceeding size limit + } if (changeType === 'modified') { return undefined; } @@ -132,9 +141,16 @@ async function generateUnifiedDiff( if (changeType !== 'deleted') { try { - const modifiedFile = await fileService.readFile(modifiedUri); + const modifiedFile = await fileService.readFile(modifiedUri, { limits: { size: MAX_FILE_SIZE_BYTES } }); + const detected = detectEncodingFromBuffer({ buffer: modifiedFile.value, bytesRead: modifiedFile.value.byteLength }); + if (detected.seemsBinary) { + return undefined; // skip binary files + } modifiedContent = modifiedFile.value.toString(); - } catch { + } catch (e) { + if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_TOO_LARGE) { + return undefined; // skip files exceeding size limit + } return undefined; } } @@ -597,7 +613,7 @@ export class ChatRepoInfoContribution extends Disposable implements IWorkbenchCo [ChatConfiguration.RepoInfoEnabled]: { type: 'boolean', description: nls.localize('chat.repoInfo.enabled', "Controls whether repository information (branch, commit, working tree diffs) is captured at the start of chat sessions for internal diagnostics."), - default: true, + default: false, } } }); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 8a6372af31d1a..bb70941b5ac4d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -168,6 +168,10 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint { - const existingSessionData = this._sessions.get(sessionResource); - if (existingSessionData) { - return existingSessionData.session; + { + const existingSessionData = this._sessions.get(sessionResource); + if (existingSessionData) { + return existingSessionData.session; + } } if (!(await raceCancellationError(this.canResolveChatSession(sessionResource), token))) { throw Error(`Can not find provider for ${sessionResource}`); } + // Check again after async provider resolution + { + const existingSessionData = this._sessions.get(sessionResource); + if (existingSessionData) { + return existingSessionData.session; + } + } + const resolvedType = this._resolveToPrimaryType(sessionResource.scheme) || sessionResource.scheme; const provider = this._contentProviders.get(resolvedType); if (!provider) { @@ -1005,6 +1019,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } const session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); + + // Make sure another session wasn't created while we were awaiting the provider + { + const existingSessionData = this._sessions.get(sessionResource); + if (existingSessionData) { + session.dispose(); + return existingSessionData.session; + } + } + const sessionData = new ContributedChatSessionData(session, sessionResource.scheme, sessionResource, session.options, resource => { sessionData.dispose(); this._sessions.delete(resource); @@ -1012,6 +1036,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._sessions.set(sessionResource, sessionData); + // Make sure any listeners are aware of the new session and its options + this._onDidChangeSessionOptions.fire(sessionResource); + return session; } @@ -1123,6 +1150,11 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return contribution?.customAgentTarget ?? Target.Undefined; } + public requiresCustomModelsForSessionType(chatSessionType: string): boolean { + const contribution = this._contributions.get(chatSessionType)?.contribution; + return !!contribution?.requiresCustomModels; + } + public getContentProviderSchemes(): string[] { return Array.from(this._contentProviders.keys()); } diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 5696f307b1e02..d6b80ecc2d36d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -41,12 +41,14 @@ import { IPreferencesService } from '../../../../services/preferences/common/pre import { IExtension, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatModeService } from '../../common/chatModes.js'; +import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY, CHAT_SETUP_ACTION_ID, CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../actions/chatActions.js'; import { ChatViewContainerId, IChatWidgetService } from '../chat.js'; import { chatViewsWelcomeRegistry } from '../viewsWelcome/chatViewsWelcome.js'; import { ChatSetupAnonymous } from './chatSetup.js'; import { ChatSetupController } from './chatSetupController.js'; +import { GrowthSessionController, registerGrowthSession } from './chatSetupGrowthSession.js'; import { AICodeActionsHelper, AINewSymbolNamesProvider, ChatCodeActionsProvider, SetupAgent } from './chatSetupProviders.js'; import { ChatSetup } from './chatSetupRunner.js'; @@ -71,6 +73,8 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @IExtensionService private readonly extensionService: IExtensionService, @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); @@ -83,6 +87,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr const controller = new Lazy(() => this._register(this.instantiationService.createInstance(ChatSetupController, context, requests))); this.registerSetupAgents(context, controller); + this.registerGrowthSession(chatEntitlementService); this.registerActions(context, requests, controller); this.registerUrlLinkHandler(); this.checkExtensionInstallation(context); @@ -171,6 +176,37 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr this._register(Event.runAndSubscribe(context.onDidChange, () => updateRegistration())); } + private registerGrowthSession(chatEntitlementService: ChatEntitlementService): void { + const growthSessionDisposables = markAsSingleton(new MutableDisposable()); + + const updateGrowthSession = () => { + const experimentEnabled = this.configurationService.getValue(ChatConfiguration.GrowthNotificationEnabled) === true; + // Show for users who don't have the Copilot extension installed yet. + // Additional conditions (e.g., anonymous, entitlement) can be layered here. + const shouldShow = experimentEnabled && !chatEntitlementService.sentiment.installed; + if (shouldShow && !growthSessionDisposables.value) { + const disposables = new DisposableStore(); + const controller = disposables.add(this.instantiationService.createInstance(GrowthSessionController)); + if (!controller.isDismissed) { + disposables.add(registerGrowthSession(this.chatSessionsService, controller)); + // Fully unregister when dismissed to prevent cached session from + // appearing during filtered model updates from other providers. + disposables.add(controller.onDidDismiss(() => { + growthSessionDisposables.clear(); + })); + growthSessionDisposables.value = disposables; + } else { + disposables.dispose(); + } + } else if (!shouldShow) { + growthSessionDisposables.clear(); + } + }; + + this._register(chatEntitlementService.onDidChangeSentiment(() => updateGrowthSession())); + updateGrowthSession(); + } + private registerActions(context: ChatEntitlementContext, requests: ChatEntitlementRequests, controller: Lazy): void { //#region Global Chat Setup Actions diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupGrowthSession.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupGrowthSession.ts new file mode 100644 index 0000000000000..1563924cdb3ea --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupGrowthSession.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionItemController, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; +import { IAgentSession } from '../agentSessions/agentSessionsModel.js'; +import { ISessionOpenerParticipant, ISessionOpenOptions, sessionOpenerRegistry } from '../agentSessions/agentSessionsOpener.js'; +import { IChatWidgetService } from '../chat.js'; +import { CHAT_OPEN_ACTION_ID, IChatViewOpenOptions } from '../actions/chatActions.js'; + +/** + * Core-side growth session controller that shows a single "attention needed" + * session item in the agent sessions view for anonymous/new users. + * + * When the user clicks the session, we open the chat panel (which triggers the + * anonymous setup flow). When the user opens chat at all, the badge is cleared. + * + * The session is shown at most once, tracked via a storage flag. + */ +export class GrowthSessionController extends Disposable implements IChatSessionItemController { + + static readonly STORAGE_KEY = 'chat.growthSession.dismissed'; + + private static readonly SESSION_URI = URI.from({ scheme: AgentSessionProviders.Growth, path: '/growth-welcome' }); + + private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); + readonly onDidChangeChatSessionItems: Event = this._onDidChangeChatSessionItems.event; + + private readonly _onDidDismiss = this._register(new Emitter()); + readonly onDidDismiss: Event = this._onDidDismiss.event; + + private readonly _created = Date.now(); + + private _dismissed: boolean; + get isDismissed(): boolean { return this._dismissed; } + + constructor( + @IStorageService private readonly storageService: IStorageService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this._dismissed = this.storageService.getBoolean(GrowthSessionController.STORAGE_KEY, StorageScope.APPLICATION, false); + + // Dismiss the growth session when the user opens chat. + // Wait until the workbench is fully restored so we skip widgets + // that were restored from a previous session at startup. + this.lifecycleService.when(LifecyclePhase.Restored).then(() => { + if (this._store.isDisposed || this._dismissed) { + return; + } + this._register(this.chatWidgetService.onDidAddWidget(() => { + this.dismiss(); + })); + }); + } + + get items(): readonly IChatSessionItem[] { + if (this._dismissed) { + return []; + } + + return [{ + resource: GrowthSessionController.SESSION_URI, + label: localize('growthSession.label', "Try Copilot"), + description: localize('growthSession.description', "GitHub Copilot is available. Try it for free."), + status: ChatSessionStatus.NeedsInput, + iconPath: Codicon.lightbulb, + timing: { + created: this._created, + lastRequestStarted: undefined, + lastRequestEnded: undefined, + }, + }]; + } + + async refresh(): Promise { + // Nothing to refresh -- this is a static, local-only session item + } + + private dismiss(): void { + if (this._dismissed) { + return; + } + + this.logService.trace('[GrowthSession] Dismissing growth session'); + this._dismissed = true; + this.storageService.store(GrowthSessionController.STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.USER); + + // Fire change event first so that listeners (like the model) see empty items + this._onDidChangeChatSessionItems.fire(); + // Then fire dismiss event which triggers unregistration of the controller. + this._onDidDismiss.fire(); + } +} + +/** + * Handles clicks on the growth session item in the agent sessions view. + * Opens a new local chat session with a pre-seeded welcome message. + * The user can then send messages that go through the normal agent. + */ +export class GrowthSessionOpenerParticipant implements ISessionOpenerParticipant { + + async handleOpenSession(accessor: ServicesAccessor, session: IAgentSession, _openOptions?: ISessionOpenOptions): Promise { + if (session.providerType !== AgentSessionProviders.Growth) { + return false; + } + + const commandService = accessor.get(ICommandService); + const opts: IChatViewOpenOptions = { + query: '', + isPartialQuery: true, + previousRequests: [{ + request: localize('growthSession.previousRequest', "Tell me about GitHub Copilot!"), + // allow-any-unicode-next-line + response: localize('growthSession.previousResponse', "Welcome to GitHub Copilot, your AI coding assistant! Here are some things you can try:\n\n- 🐛 *\"Help me debug this error\"* — paste an error message and get a fix\n- 🧪 *\"Write tests for my function\"* — select code and ask for unit tests\n- 💡 *\"Explain this code\"* — highlight something unfamiliar and ask what it does\n- 🚀 *\"Scaffold a REST API\"* — describe what you want and let Agent mode build it\n- 🎨 *\"Refactor this to be more readable\"* — select messy code and clean it up\n\nType anything below to get started!"), + }], + }; + await commandService.executeCommand(CHAT_OPEN_ACTION_ID, opts); + return true; + } +} + +/** + * Registers the growth session controller and opener participant. + * Returns a disposable that cleans up all registrations. + */ +export function registerGrowthSession(chatSessionsService: IChatSessionsService, growthController: GrowthSessionController): IDisposable { + const disposables = new DisposableStore(); + + // Register as session item controller so it appears in the sessions view + disposables.add(chatSessionsService.registerChatSessionItemController(AgentSessionProviders.Growth, growthController)); + + // Register opener participant so clicking the growth session opens chat + disposables.add(sessionOpenerRegistry.registerParticipant(new GrowthSessionOpenerParticipant())); + + return disposables; +} + +// #region Developer Actions + +registerAction2(class ResetGrowthSessionAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.resetGrowthSession', + title: localize2('resetGrowthSession', "Reset Growth Session Notification"), + category: localize2('developer', "Developer"), + f1: true, + }); + } + + run(accessor: ServicesAccessor): void { + const storageService = accessor.get(IStorageService); + storageService.remove(GrowthSessionController.STORAGE_KEY, StorageScope.APPLICATION); + } +}); + +// #endregion diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index e6a3847ea5890..ed6bad1e99315 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -252,7 +252,8 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { progress({ kind: 'progressMessage', - content: new MarkdownString(localize('waitingChat', "Getting chat ready...")), + content: new MarkdownString(localize('waitingChat', "Getting chat ready")), + shimmer: true, }); await this.forwardRequestToChat(requestModel, progress, chatService, languageModelsService, chatAgentService, chatWidgetService, languageModelToolsService); @@ -319,7 +320,8 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { const timeoutHandle = setTimeout(() => { progress({ kind: 'progressMessage', - content: new MarkdownString(localize('waitingChat2', "Chat is almost ready...")), + content: new MarkdownString(localize('waitingChat2', "Chat is almost ready")), + shimmer: true, }); }, 10000); @@ -602,13 +604,15 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { case ChatSetupStep.SigningIn: progress({ kind: 'progressMessage', - content: new MarkdownString(localize('setupChatSignIn2', "Signing in to {0}...", defaultAccountService.getDefaultAccountAuthenticationProvider().name)), + content: new MarkdownString(localize('setupChatSignIn2', "Signing in to {0}", defaultAccountService.getDefaultAccountAuthenticationProvider().name)), + shimmer: true, }); break; case ChatSetupStep.Installing: progress({ kind: 'progressMessage', - content: new MarkdownString(localize('installingChat', "Getting chat ready...")), + content: new MarkdownString(localize('installingChat', "Getting chat ready")), + shimmer: true, }); break; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index 4c51c01f5ad0e..9929aa7064e18 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -16,7 +16,7 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { createWorkbenchDialogOptions } from '../../../../../platform/dialogs/browser/dialog.js'; +import { createWorkbenchDialogOptions } from '../../../../browser/parts/dialogs/dialog.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; @@ -30,6 +30,7 @@ import { IChatWidgetService } from '../chat.js'; import { ChatSetupController } from './chatSetupController.js'; import { IChatSetupResult, ChatSetupAnonymous, InstallChatEvent, InstallChatClassification, ChatSetupStrategy, ChatSetupResultValue } from './chatSetup.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IHostService } from '../../../../services/host/browser/host.js'; const defaultChat = { publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', @@ -48,7 +49,7 @@ export class ChatSetup { let instance = ChatSetup.instance; if (!instance) { instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => { - return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService)); + return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService)); }); } @@ -71,6 +72,7 @@ export class ChatSetup { @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IHostService private readonly hostService: IHostService ) { } skipDialog(): void { @@ -176,7 +178,7 @@ export class ChatSetup { disableCloseButton: true, renderFooter: footer => footer.appendChild(this.createDialogFooter(disposables, options)), buttonOptions: buttons.map(button => button[2]) - }, this.keybindingService, this.layoutService) + }, this.keybindingService, this.layoutService, this.hostService) )); const { button } = await dialog.show(); diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts new file mode 100644 index 0000000000000..9c54a9062e598 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -0,0 +1,195 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { timeout } from '../../../../base/common/async.js'; +import { MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import * as nls from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IChatAgentService } from '../common/participants/chatAgents.js'; +import { IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; +import { IChatService } from '../common/chatService/chatService.js'; +import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; +import { ACTION_ID_NEW_CHAT } from './actions/chatActions.js'; +import { ChatSubmitAction, OpenModelPickerAction } from './actions/chatExecuteActions.js'; +import { ConfigureToolsAction } from './actions/chatToolActions.js'; +import { IAgentSessionsService } from './agentSessions/agentSessionsService.js'; +import { IChatWidgetService } from './chat.js'; +import { showConfigureHooksQuickPick } from './promptSyntax/hookActions.js'; +import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; + +export class ChatSlashCommandsContribution extends Disposable { + + static readonly ID = 'workbench.contrib.chatSlashCommands'; + + constructor( + @IChatSlashCommandService slashCommandService: IChatSlashCommandService, + @ICommandService commandService: ICommandService, + @IChatAgentService chatAgentService: IChatAgentService, + @IChatWidgetService chatWidgetService: IChatWidgetService, + @IInstantiationService instantiationService: IInstantiationService, + @IAgentSessionsService agentSessionsService: IAgentSessionsService, + @IChatService chatService: IChatService, + ) { + super(); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'clear', + detail: nls.localize('clear', "Start a new chat and archive the current one"), + sortText: 'z2_clear', + executeImmediately: true, + locations: [ChatAgentLocation.Chat] + }, async (_prompt, _progress, _history, _location, sessionResource) => { + agentSessionsService.getSession(sessionResource)?.setArchived(true); + commandService.executeCommand(ACTION_ID_NEW_CHAT); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'hooks', + detail: nls.localize('hooks', "Configure hooks"), + sortText: 'z3_hooks', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await instantiationService.invokeFunction(showConfigureHooksQuickPick); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'models', + detail: nls.localize('models', "Open the model picker"), + sortText: 'z3_models', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand(OpenModelPickerAction.ID); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'tools', + detail: nls.localize('tools', "Configure tools"), + sortText: 'z3_tools', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand(ConfigureToolsAction.ID); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'debug', + detail: nls.localize('debug', "Show Chat Debug View"), + sortText: 'z3_debug', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('github.copilot.debug.showChatLogView'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'agents', + detail: nls.localize('agents', "Configure custom agents"), + sortText: 'z3_agents', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.customagents'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'skills', + detail: nls.localize('skills', "Configure skills"), + sortText: 'z3_skills', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.skills'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'instructions', + detail: nls.localize('instructions', "Configure instructions"), + sortText: 'z3_instructions', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.instructions'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'prompts', + detail: nls.localize('prompts', "Configure prompt files"), + sortText: 'z3_prompts', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.prompts'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'rename', + detail: nls.localize('rename', "Rename this chat"), + sortText: 'z2_rename', + executeImmediately: false, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async (prompt, _progress, _history, _location, sessionResource) => { + const title = prompt.trim(); + if (title) { + chatService.setChatSessionTitle(sessionResource, title); + } + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'help', + detail: '', + sortText: 'z1_help', + executeImmediately: true, + locations: [ChatAgentLocation.Chat], + modes: [ChatModeKind.Ask] + }, async (prompt, progress, _history, _location, sessionResource) => { + const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Chat); + const agents = chatAgentService.getAgents(); + + // Report prefix + if (defaultAgent?.metadata.helpTextPrefix) { + if (isMarkdownString(defaultAgent.metadata.helpTextPrefix)) { + progress.report({ content: defaultAgent.metadata.helpTextPrefix, kind: 'markdownContent' }); + } else { + progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextPrefix), kind: 'markdownContent' }); + } + progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); + } + + // Report agent list + const agentText = (await Promise.all(agents + .filter(a => !a.isDefault && !a.isCore) + .filter(a => a.locations.includes(ChatAgentLocation.Chat)) + .map(async a => { + const description = a.description ? `- ${a.description}` : ''; + const agentMarkdown = instantiationService.invokeFunction(accessor => agentToMarkdown(a, sessionResource, true, accessor)); + const agentLine = `- ${agentMarkdown} ${description}`; + const commandText = a.slashCommands.map(c => { + const description = c.description ? `- ${c.description}` : ''; + return `\t* ${agentSlashCommandToMarkdown(a, c, sessionResource)} ${description}`; + }).join('\n'); + + return (agentLine + '\n' + commandText).trim(); + }))).join('\n'); + progress.report({ content: new MarkdownString(agentText, { isTrusted: { enabledCommands: [ChatSubmitAction.ID] } }), kind: 'markdownContent' }); + + // Report help text ending + if (defaultAgent?.metadata.helpTextPostfix) { + progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); + if (isMarkdownString(defaultAgent.metadata.helpTextPostfix)) { + progress.report({ content: defaultAgent.metadata.helpTextPostfix, kind: 'markdownContent' }); + } else { + progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextPostfix), kind: 'markdownContent' }); + } + } + + // Without this, the response will be done before it renders and so it will not stream. This ensures that if the response starts + // rendering during the next 200ms, then it will be streamed. Once it starts streaming, the whole response streams even after + // it has received all response data has been received. + await timeout(200); + })); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 862445cf50bcc..3a5d17812a83c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -9,8 +9,8 @@ import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../ import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; -import { ChatModeKind } from '../common/constants.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -20,6 +20,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { localize } from '../../../../nls.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; +import { localChatSessionType } from '../common/chatSessionsService.js'; export const IChatTipService = createDecorator('chatTipService'); @@ -38,24 +39,22 @@ export interface IChatTipService { readonly onDidDismissTip: Event; /** - * Fired when tips are disabled. + * Fired when the user navigates to a different tip (previous/next). */ - readonly onDidDisableTips: Event; + readonly onDidNavigateTip: Event; /** - * Gets a tip to show for a request, or undefined if a tip has already been shown this session. - * Only one tip is shown per conversation session (resets when switching conversations). - * Tips are suppressed if a welcome tip was already shown in this session. - * Tips are only shown for requests created after the current session started. - * @param requestId The unique ID of the request (used for stable rerenders). - * @param requestTimestamp The timestamp when the request was created. - * @param contextKeyService The context key service to evaluate tip eligibility. + * Fired when the tip widget is hidden without dismissing the tip. */ - getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined; + readonly onDidHideTip: Event; + + /** + * Fired when tips are disabled. + */ + readonly onDidDisableTips: Event; /** * Gets a tip to show on the welcome/getting-started view. - * Unlike {@link getNextTip}, this does not require a request and skips request-timestamp checks. * Returns the same tip on repeated calls for stable rerenders. */ getWelcomeTip(contextKeyService: IContextKeyService): IChatTip | undefined; @@ -72,10 +71,33 @@ export interface IChatTipService { */ dismissTip(): void; + /** + * Hides the tip widget without permanently dismissing the tip. + * The tip may be shown again in a future session. + */ + hideTip(): void; + /** * Disables tips permanently by setting the `chat.tips.enabled` configuration to false. */ disableTips(): Promise; + + /** + * Navigates to the next tip in the catalog without permanently dismissing the current one. + * @param contextKeyService The context key service to evaluate tip eligibility. + */ + navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined; + + /** + * Navigates to the previous tip in the catalog without permanently dismissing the current one. + * @param contextKeyService The context key service to evaluate tip eligibility. + */ + navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined; + + /** + * Clears all dismissed tips so they can be shown again. + */ + clearDismissedTips(): void; } export interface ITipDefinition { @@ -90,6 +112,11 @@ export interface ITipDefinition { * Command IDs that are allowed to be executed from this tip's markdown. */ readonly enabledCommands?: string[]; + /** + * Chat model IDs for which this tip is eligible. + * Compared against the lowercased `chatModelId` context key. + */ + readonly onlyWhenModelIds?: readonly string[]; /** * Command IDs that, if ever executed in this workspace, make this tip ineligible. * The tip won't be shown if the user has already performed the action it suggests. @@ -106,18 +133,6 @@ export interface ITipDefinition { * The tip won't be shown if the tool it describes has already been used. */ readonly excludeWhenToolsInvoked?: string[]; - /** - * Tool set reference names. If any tool belonging to one of these tool sets - * has ever been invoked in this workspace, the tip becomes ineligible. - * Unlike {@link excludeWhenToolsInvoked}, this does not require listing - * individual tool IDs, it checks all tools that belong to the named sets. - */ - readonly excludeWhenAnyToolSetToolInvoked?: string[]; - /** - * Tool set reference names where at least one must be registered for the tip to be eligible. - * If none of the listed tool sets are registered, the tip is not shown. - */ - readonly requiresAnyToolSetRegistered?: string[]; /** * If set, exclude this tip when prompt files of the specified type exist in the workspace. */ @@ -134,6 +149,12 @@ export interface ITipDefinition { * Static catalog of tips. Each tip has an optional when clause for eligibility. */ const TIP_CATALOG: ITipDefinition[] = [ + { + id: 'tip.switchToAuto', + message: localize('tip.switchToAuto', "Tip: Using gpt-4.1? Try switching to [Auto](command:workbench.action.chat.openModelPicker) in the model picker for better coding performance."), + enabledCommands: ['workbench.action.chat.openModelPicker'], + onlyWhenModelIds: ['gpt-4.1'], + }, { id: 'tip.agentMode', message: localize('tip.agentMode', "Tip: Try [Agents](command:workbench.action.chat.openEditSession) to make edits across your project and run commands."), @@ -161,15 +182,18 @@ const TIP_CATALOG: ITipDefinition[] = [ { id: 'tip.undoChanges', message: localize('tip.undoChanges', "Tip: Select Restore Checkpoint to undo changes until that point in the chat conversation."), - when: ContextKeyExpr.or( - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Edit), + when: ContextKeyExpr.and( + ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + ContextKeyExpr.or( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Edit), + ), ), excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint', 'workbench.action.chat.restoreLastCheckpoint'], }, { id: 'tip.customInstructions', - message: localize('tip.customInstructions', "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) to give the agent relevant project-specific context when starting a task."), + message: localize('tip.customInstructions', "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) apply coding conventions across all agent sessions."), enabledCommands: ['workbench.action.chat.generateInstructions'], excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, }, @@ -183,7 +207,7 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.skill', - message: localize('tip.skill', "Tip: [Create a skill](command:workbench.command.new.skill) to apply domain-specific workflows and instructions, only when needed."), + message: localize('tip.skill', "Tip: [Create a skill](command:workbench.command.new.skill) to teach the agent specialized workflows, loaded only when relevant."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), enabledCommands: ['workbench.command.new.skill'], excludeWhenCommandsExecuted: ['workbench.command.new.skill'], @@ -191,7 +215,7 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.messageQueueing', - message: localize('tip.messageQueueing', "Tip: Send follow-up and steering messages while the agent is working. They'll be queued and processed in order."), + message: localize('tip.messageQueueing', "Tip: Steer the agent mid-task by sending follow-up messages. They queue and apply in order."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenCommandsExecuted: ['workbench.action.chat.queueMessage', 'workbench.action.chat.steerWithMessage'], }, @@ -210,16 +234,6 @@ const TIP_CATALOG: ITipDefinition[] = [ when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['renderMermaidDiagram'], }, - { - id: 'tip.githubRepo', - message: localize('tip.githubRepo', "Tip: Mention a GitHub repository (@owner/repo) in your prompt to let the agent search code, browse issues, and explore pull requests from that repo."), - when: ContextKeyExpr.and( - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - ContextKeyExpr.notEquals('gitOpenRepositoryCount', '0'), - ), - excludeWhenAnyToolSetToolInvoked: ['github', 'github-pull-request'], - requiresAnyToolSetRegistered: ['github', 'github-pull-request'], - }, { id: 'tip.subagents', message: localize('tip.subagents', "Tip: Ask the agent to work in parallel to complete large tasks faster."), @@ -265,9 +279,6 @@ export class TipEligibilityTracker extends Disposable { private readonly _pendingModes: Set; private readonly _pendingTools: Set; - /** Tool set reference names monitored via {@link ITipDefinition.excludeWhenAnyToolSetToolInvoked}. */ - private readonly _monitoredToolSets: Set; - private readonly _commandListener = this._register(new MutableDisposable()); private readonly _toolListener = this._register(new MutableDisposable()); @@ -333,13 +344,6 @@ export class TipEligibilityTracker extends Disposable { } } - this._monitoredToolSets = new Set(); - for (const tip of tips) { - for (const name of tip.excludeWhenAnyToolSetToolInvoked ?? []) { - this._monitoredToolSets.add(name); - } - } - // --- Set up command listener (auto-disposes when all seen) -------------- if (this._pendingCommands.size > 0) { @@ -358,44 +362,17 @@ export class TipEligibilityTracker extends Disposable { // --- Set up tool listener (auto-disposes when all seen) ----------------- - if (this._pendingTools.size > 0 || this._monitoredToolSets.size > 0) { + if (this._pendingTools.size > 0) { this._toolListener.value = this._languageModelToolsService.onDidInvokeTool(e => { - let changed = false; - // Track explicit tool IDs if (this._pendingTools.has(e.toolId)) { this._invokedTools.add(e.toolId); this._pendingTools.delete(e.toolId); - changed = true; - } - // Track tools belonging to monitored tool sets - if (this._monitoredToolSets.size > 0 && !this._invokedTools.has(e.toolId)) { - for (const setName of this._monitoredToolSets) { - const toolSet = this._languageModelToolsService.getToolSetByName(setName); - if (toolSet) { - for (const tool of toolSet.getTools()) { - if (tool.id === e.toolId) { - this._invokedTools.add(e.toolId); - // Remove set name from monitoring since ANY tool from the set excludes the tip. - // The tip remains excluded via _invokedTools even after we stop monitoring. - this._monitoredToolSets.delete(setName); - changed = true; - break; - } - } - } - if (changed) { - break; - } - } - } - - if (changed) { this._persistSet(TipEligibilityTracker._TOOLS_STORAGE_KEY, this._invokedTools); } - if (this._pendingTools.size === 0 && this._monitoredToolSets.size === 0) { + if (this._pendingTools.size === 0) { this._toolListener.clear(); } }); @@ -477,30 +454,10 @@ export class TipEligibilityTracker extends Disposable { } } } - if (tip.excludeWhenAnyToolSetToolInvoked) { - for (const setName of tip.excludeWhenAnyToolSetToolInvoked) { - const toolSet = this._languageModelToolsService.getToolSetByName(setName); - if (toolSet) { - for (const tool of toolSet.getTools()) { - if (this._invokedTools.has(tool.id)) { - this._logService.debug('#ChatTips: tip excluded because tool set tool was invoked', tip.id, setName, tool.id); - return true; - } - } - } - } - } if (tip.excludeWhenPromptFilesExist && this._excludedByFiles.has(tip.id)) { this._logService.debug('#ChatTips: tip excluded because prompt files exist', tip.id); return true; } - if (tip.requiresAnyToolSetRegistered) { - const hasAny = tip.requiresAnyToolSetRegistered.some(name => this._languageModelToolsService.getToolSetByName(name)); - if (!hasAny) { - this._logService.debug('#ChatTips: tip excluded because no required tool sets are registered', tip.id); - return true; - } - } return false; } @@ -551,21 +508,14 @@ export class ChatTipService extends Disposable implements IChatTipService { private readonly _onDidDismissTip = this._register(new Emitter()); readonly onDidDismissTip = this._onDidDismissTip.event; - private readonly _onDidDisableTips = this._register(new Emitter()); - readonly onDidDisableTips = this._onDidDisableTips.event; + private readonly _onDidNavigateTip = this._register(new Emitter()); + readonly onDidNavigateTip = this._onDidNavigateTip.event; - /** - * Timestamp when the current session started. - * Used to only show tips for requests created after this time. - * Resets on each {@link resetSession} call. - */ - private _sessionStartedAt = Date.now(); + private readonly _onDidHideTip = this._register(new Emitter()); + readonly onDidHideTip = this._onDidHideTip.event; - /** - * Whether a chatResponse tip has already been shown in this conversation - * session. Only one response tip is shown per session. - */ - private _hasShownRequestTip = false; + private readonly _onDidDisableTips = this._register(new Emitter()); + readonly onDidDisableTips = this._onDidDisableTips.event; /** * The request ID that was assigned a tip (for stable rerenders). @@ -593,10 +543,8 @@ export class ChatTipService extends Disposable implements IChatTipService { } resetSession(): void { - this._hasShownRequestTip = false; this._shownTip = undefined; this._tipRequestId = undefined; - this._sessionStartedAt = Date.now(); } dismissTip(): void { @@ -605,7 +553,13 @@ export class ChatTipService extends Disposable implements IChatTipService { dismissed.push(this._shownTip.id); this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.PROFILE, StorageTarget.MACHINE); } - this._hasShownRequestTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidDismissTip.fire(); + } + + clearDismissedTips(): void { + this._storageService.remove(ChatTipService._DISMISSED_TIP_KEY, StorageScope.PROFILE); this._shownTip = undefined; this._tipRequestId = undefined; this._onDidDismissTip.fire(); @@ -636,15 +590,20 @@ export class ChatTipService extends Disposable implements IChatTipService { } } + hideTip(): void { + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidHideTip.fire(); + } + async disableTips(): Promise { - this._hasShownRequestTip = false; this._shownTip = undefined; this._tipRequestId = undefined; - await this._configurationService.updateValue('chat.tips.enabled', false); + await this._configurationService.updateValue('chat.tips.enabled', false, ConfigurationTarget.APPLICATION); this._onDidDisableTips.fire(); } - getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined { + getWelcomeTip(contextKeyService: IContextKeyService): IChatTip | undefined { // Check if tips are enabled if (!this._configurationService.getValue('chat.tips.enabled')) { return undefined; @@ -655,52 +614,52 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } - // Check if this is the request that was assigned a tip (for stable rerenders) - if (this._tipRequestId === requestId && this._shownTip) { - return this._createTip(this._shownTip); - } - - // A new request arrived while we already showed a tip, hide the old one - if (this._hasShownRequestTip && this._tipRequestId && this._tipRequestId !== requestId) { - this._shownTip = undefined; - this._tipRequestId = undefined; - this._onDidDismissTip.fire(); + // Only show tips in the main chat panel, not in terminal/editor inline chat + if (!this._isChatLocation(contextKeyService)) { return undefined; } - // Only show one tip per session - if (this._hasShownRequestTip) { + // Don't show tips when chat quota is exceeded, the upgrade widget is more relevant + if (this._isChatQuotaExceeded(contextKeyService)) { return undefined; } - // Only show tips for requests created after the current session started. - // This prevents showing tips for old requests being re-rendered. - if (requestTimestamp < this._sessionStartedAt) { - return undefined; + // Return the already-shown tip for stable rerenders + if (this._tipRequestId === 'welcome' && this._shownTip) { + if (!this._isEligible(this._shownTip, contextKeyService)) { + const nextTip = this._findNextEligibleTip(this._shownTip.id, contextKeyService); + if (nextTip) { + this._shownTip = nextTip; + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, nextTip.id, StorageScope.PROFILE, StorageTarget.USER); + const tip = this._createTip(nextTip); + this._onDidNavigateTip.fire(tip); + return tip; + } + } + return this._createTip(this._shownTip); } - return this._pickTip(requestId, contextKeyService); - } + const tip = this._pickTip('welcome', contextKeyService); - getWelcomeTip(contextKeyService: IContextKeyService): IChatTip | undefined { - // Check if tips are enabled - if (!this._configurationService.getValue('chat.tips.enabled')) { - return undefined; - } + return tip; + } - // Only show tips for Copilot - if (!this._isCopilotEnabled()) { + private _findNextEligibleTip(currentTipId: string, contextKeyService: IContextKeyService): ITipDefinition | undefined { + const currentIndex = TIP_CATALOG.findIndex(tip => tip.id === currentTipId); + if (currentIndex === -1) { return undefined; } - // Return the already-shown tip for stable rerenders - if (this._tipRequestId === 'welcome' && this._shownTip) { - return this._createTip(this._shownTip); + const dismissedIds = new Set(this._getDismissedTipIds()); + for (let i = 1; i < TIP_CATALOG.length; i++) { + const idx = (currentIndex + i) % TIP_CATALOG.length; + const candidate = TIP_CATALOG[idx]; + if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { + return candidate; + } } - const tip = this._pickTip('welcome', contextKeyService); - - return tip; + return undefined; } private _pickTip(sourceId: string, contextKeyService: IContextKeyService): IChatTip | undefined { @@ -750,14 +709,54 @@ export class ChatTipService extends Disposable implements IChatTipService { this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.PROFILE, StorageTarget.USER); // Record that we've shown a tip this session - this._hasShownRequestTip = sourceId !== 'welcome'; this._tipRequestId = sourceId; this._shownTip = selectedTip; return this._createTip(selectedTip); } + navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined { + return this._navigateTip(1, contextKeyService); + } + + navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined { + return this._navigateTip(-1, contextKeyService); + } + + private _navigateTip(direction: 1 | -1, contextKeyService: IContextKeyService): IChatTip | undefined { + if (!this._shownTip) { + return undefined; + } + + const currentIndex = TIP_CATALOG.findIndex(t => t.id === this._shownTip!.id); + if (currentIndex === -1) { + return undefined; + } + + const dismissedIds = new Set(this._getDismissedTipIds()); + 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._shownTip = candidate; + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.PROFILE, StorageTarget.USER); + const tip = this._createTip(candidate); + this._onDidNavigateTip.fire(tip); + return tip; + } + } + + return undefined; + } + private _isEligible(tip: ITipDefinition, contextKeyService: IContextKeyService): boolean { + if (tip.onlyWhenModelIds?.length) { + const currentModelId = this._getCurrentChatModelId(contextKeyService); + const isModelMatch = tip.onlyWhenModelIds.some(modelId => currentModelId === modelId || currentModelId.startsWith(`${modelId}-`)); + if (!isModelMatch) { + return false; + } + } if (tip.when && !contextKeyService.contextMatchesRules(tip.when)) { this._logService.debug('#ChatTips: tip is not eligible due to when clause', tip.id, tip.when.serialize()); return false; @@ -769,6 +768,51 @@ export class ChatTipService extends Disposable implements IChatTipService { return true; } + private _getCurrentChatModelId(contextKeyService: IContextKeyService): string { + const normalize = (modelId: string | undefined): string => { + const normalizedModelId = modelId?.toLowerCase() ?? ''; + if (!normalizedModelId) { + return ''; + } + + if (normalizedModelId.includes('/')) { + return normalizedModelId.split('/').at(-1) ?? ''; + } + + return normalizedModelId; + }; + + const contextKeyModelId = normalize(contextKeyService.getContextKeyValue(ChatContextKeys.chatModelId.key)); + if (contextKeyModelId) { + return contextKeyModelId; + } + + const location = contextKeyService.getContextKeyValue(ChatContextKeys.location.key) ?? ChatAgentLocation.Chat; + const sessionType = contextKeyService.getContextKeyValue(ChatContextKeys.chatSessionType.key) ?? ''; + const candidateStorageKeys = sessionType + ? [`chat.currentLanguageModel.${location}.${sessionType}`, `chat.currentLanguageModel.${location}`] + : [`chat.currentLanguageModel.${location}`]; + + for (const storageKey of candidateStorageKeys) { + const persistedModelIdentifier = this._storageService.get(storageKey, StorageScope.APPLICATION); + const persistedModelId = normalize(persistedModelIdentifier); + if (persistedModelId) { + return persistedModelId; + } + } + + return ''; + } + + private _isChatLocation(contextKeyService: IContextKeyService): boolean { + const location = contextKeyService.getContextKeyValue(ChatContextKeys.location.key); + return !location || location === ChatAgentLocation.Chat; + } + + private _isChatQuotaExceeded(contextKeyService: IContextKeyService): boolean { + return contextKeyService.getContextKeyValue(ChatContextKeys.chatQuotaExceeded.key) === true; + } + private _isCopilotEnabled(): boolean { const defaultChatAgent = this._productService.defaultChatAgent; return !!defaultChatAgent?.chatExtensionId; diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index 5b3393e59d23c..15f6f5b34f604 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -172,6 +172,9 @@ function getDefaultContentSnippet(promptType: PromptsType, name: string | undefi `name: ${name ?? '${1:prompt-name}'}`, `description: \${2:Describe when to use this prompt}`, `---`, + ``, + ``, + ``, `\${3:Define the prompt content here. You can include instructions, examples, and any other relevant information to guide the AI's responses.}`, ].join('\n'); case PromptsType.instructions: @@ -182,14 +185,20 @@ function getDefaultContentSnippet(promptType: PromptsType, name: string | undefi `paths:`, `. - "src/**/*.ts"`, `---`, + ``, + ``, + ``, `\${2:Provide coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.}`, ].join('\n'); } else { return [ `---`, - `description: \${1:Describe when these instructions should be loaded}`, + `description: \${1:Describe when these instructions should be loaded by the agent based on task context}`, `# applyTo: '\${1|**,**/*.ts|}' # when provided, instructions will automatically be added to the request context when the pattern matches an attached file`, `---`, + ``, + ``, + ``, `\${2:Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.}`, ].join('\n'); } @@ -201,6 +210,9 @@ function getDefaultContentSnippet(promptType: PromptsType, name: string | undefi `description: \${2:Describe what this custom agent does and when to use it.}`, `tools: Read, Grep, Glob, Bash # specify the tools this agent can use. If not set, all enabled tools are allowed.`, `---`, + ``, + ``, + ``, `\${4:Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation.}`, ].join('\n'); } else { @@ -211,6 +223,9 @@ function getDefaultContentSnippet(promptType: PromptsType, name: string | undefi `argument-hint: \${3:The inputs this agent expects, e.g., "a task to implement" or "a question to answer".}`, `# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed.`, `---`, + ``, + ``, + ``, `\${4:Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation.}`, ].join('\n'); } @@ -220,6 +235,9 @@ function getDefaultContentSnippet(promptType: PromptsType, name: string | undefi `name: ${name ?? '${1:skill-name}'}`, `description: \${2:Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.}`, `---`, + ``, + ``, + ``, `\${3:Define the functionality provided by this skill, including detailed instructions and examples}`, ].join('\n'); default: 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 d79e8dad50eab..5113db79b5cfe 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -17,6 +17,7 @@ import { ICommandService } from '../../../../../../platform/commands/common/comm import { getCleanPromptName } from '../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../newPromptFileActions.js'; +import { GENERATE_INSTRUCTIONS_COMMAND_ID, GENERATE_INSTRUCTION_COMMAND_ID, GENERATE_PROMPT_COMMAND_ID, GENERATE_SKILL_COMMAND_ID, GENERATE_AGENT_COMMAND_ID } from '../../actions/chatActions.js'; import { IKeyMods, IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator } from '../../../../../../platform/quickinput/common/quickInput.js'; import { askForPromptFileName } from './askForPromptName.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -168,18 +169,33 @@ const NEW_INSTRUCTIONS_FILE_OPTION: IPromptPickerQuickPickItem = { }; /** - * A quick pick item that starts the 'Update Instructions' command. + * A quick pick item that starts the 'Generate Workspace Instructions' command. */ -const UPDATE_INSTRUCTIONS_OPTION: IPromptPickerQuickPickItem = { +const GENERATE_WORKSPACE_INSTRUCTIONS_OPTION: IPromptPickerQuickPickItem = { type: 'item', - label: `$(refresh) ${localize( - 'commands.update-instructions.select-dialog.label', - 'Generate agent instructions...', + label: `$(sparkle) ${localize( + 'commands.generate-workspace-instructions.select-dialog.label', + 'Generate workspace instructions with agent...', )}`, pickable: false, alwaysShow: true, buttons: [newHelpButton(PromptsType.instructions)], - commandId: 'workbench.action.chat.generateInstructions', + commandId: GENERATE_INSTRUCTIONS_COMMAND_ID, +}; + +/** + * A quick pick item that starts the 'Generate On-demand Instruction' command. + */ +const GENERATE_INSTRUCTION_OPTION: IPromptPickerQuickPickItem = { + type: 'item', + label: `$(sparkle) ${localize( + 'commands.generate-instruction.select-dialog.label', + 'Generate on-demand instruction with agent...', + )}`, + pickable: false, + alwaysShow: true, + buttons: [newHelpButton(PromptsType.instructions)], + commandId: GENERATE_INSTRUCTION_COMMAND_ID, }; /** @@ -212,6 +228,51 @@ const NEW_SKILL_FILE_OPTION: IPromptPickerQuickPickItem = { commandId: NEW_SKILL_COMMAND_ID, }; +/** + * A quick pick item that generates a prompt file with agent. + */ +const GENERATE_PROMPT_OPTION: IPromptPickerQuickPickItem = { + type: 'item', + label: `$(sparkle) ${localize( + 'commands.generate-prompt.select-dialog.label', + 'Generate prompt with agent...', + )}`, + pickable: false, + alwaysShow: true, + buttons: [newHelpButton(PromptsType.prompt)], + commandId: GENERATE_PROMPT_COMMAND_ID, +}; + +/** + * A quick pick item that generates a skill with agent. + */ +const GENERATE_SKILL_OPTION: IPromptPickerQuickPickItem = { + type: 'item', + label: `$(sparkle) ${localize( + 'commands.generate-skill.select-dialog.label', + 'Generate skill with agent...', + )}`, + pickable: false, + alwaysShow: true, + buttons: [newHelpButton(PromptsType.skill)], + commandId: GENERATE_SKILL_COMMAND_ID, +}; + +/** + * A quick pick item that generates a custom agent with agent. + */ +const GENERATE_AGENT_OPTION: IPromptPickerQuickPickItem = { + type: 'item', + label: `$(sparkle) ${localize( + 'commands.generate-agent.select-dialog.label', + 'Generate agent with agent...', + )}`, + pickable: false, + alwaysShow: true, + buttons: [newHelpButton(PromptsType.agent)], + commandId: GENERATE_AGENT_COMMAND_ID, +}; + /** * Button that opens a prompt file in the editor. */ @@ -257,8 +318,8 @@ const MAKE_VISIBLE_BUTTON: IQuickInputButton = { * Button that sets a prompt file to be invisible. */ const MAKE_INVISIBLE_BUTTON: IQuickInputButton = { - tooltip: localize('makeInvisible', "Hide from agent picker"), - iconClass: ThemeIcon.asClassName(Codicon.eyeClosed), + tooltip: localize('makeInvisible', "Shown in chat view agent picker. Click to hide."), + iconClass: ThemeIcon.asClassName(Codicon.eye), }; export class PromptFilePickers { @@ -462,13 +523,13 @@ export class PromptFilePickers { private _getNewItems(type: PromptsType): IPromptPickerQuickPickItem[] { switch (type) { case PromptsType.prompt: - return [NEW_PROMPT_FILE_OPTION]; + return [NEW_PROMPT_FILE_OPTION, GENERATE_PROMPT_OPTION]; case PromptsType.instructions: - return [NEW_INSTRUCTIONS_FILE_OPTION, UPDATE_INSTRUCTIONS_OPTION]; + return [NEW_INSTRUCTIONS_FILE_OPTION, GENERATE_INSTRUCTION_OPTION, GENERATE_WORKSPACE_INSTRUCTIONS_OPTION]; case PromptsType.agent: - return [NEW_AGENT_FILE_OPTION]; + return [NEW_AGENT_FILE_OPTION, GENERATE_AGENT_OPTION]; case PromptsType.skill: - return [NEW_SKILL_FILE_OPTION]; + return [NEW_SKILL_FILE_OPTION, GENERATE_SKILL_OPTION]; default: throw new Error(`Unknown prompt type '${type}'.`); } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts index 96e913156aaf9..302ab0356b31c 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts @@ -44,7 +44,7 @@ export class PromptFileRewriter { this.rewriteAttribute(model, '', toolsAttr.range); return; } else { - this.rewriteTools(model, newTools, toolsAttr.value.range, toolsAttr.value.type === 'string'); + this.rewriteTools(model, newTools, toolsAttr.value.range, toolsAttr.value.type === 'scalar'); } } @@ -77,7 +77,7 @@ export class PromptFileRewriter { if (!nameAttr) { return; } - if (nameAttr.value.type === 'string' && nameAttr.value.value === newName) { + if (nameAttr.value.type === 'scalar' && nameAttr.value.value === newName) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index 021f38acfd457..538f5d4ac2d68 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -72,21 +72,21 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider return undefined; } let value = toolsAttr.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type !== 'array') { + if (value.type !== 'sequence') { return undefined; } const items = value.items; - const selectedTools = items.filter(item => item.type === 'string').map(item => item.value); + const selectedTools = items.filter(item => item.type === 'scalar').map(item => item.value); const codeLens: CodeLens = { range: toolsAttr.range.collapseToStart(), command: { title: localize('configure-tools.capitalized.ellipsis', "Configure Tools..."), id: this.cmdId, - arguments: [model, toolsAttr.range, toolsAttr.value.type === 'string', selectedTools, target] + arguments: [model, toolsAttr.range, toolsAttr.value.type === 'scalar', selectedTools, target] } }; return { lenses: [codeLens] }; diff --git a/src/vs/workbench/contrib/chat/browser/sessionResourceMatching.ts b/src/vs/workbench/contrib/chat/browser/sessionResourceMatching.ts new file mode 100644 index 0000000000000..5e30a35b967ad --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionResourceMatching.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { isIChatSessionFileChange2 } from '../common/chatSessionsService.js'; +import { IModifiedFileEntry } from '../common/editing/chatEditingService.js'; +import { IAgentSession } from './agentSessions/agentSessionsModel.js'; + +export function editingEntriesContainResource(entries: readonly IModifiedFileEntry[], resourceUri: URI): boolean { + for (const entry of entries) { + if (isEqual(entry.modifiedURI, resourceUri) || isEqual(entry.originalURI, resourceUri)) { + return true; + } + } + + return false; +} + +export function agentSessionContainsResource(session: IAgentSession, resourceUri: URI): boolean { + if (!(session.changes instanceof Array)) { + return false; + } + + for (const change of session.changes) { + if (isIChatSessionFileChange2(change)) { + if (isEqual(change.uri, resourceUri) || (change.originalUri && isEqual(change.originalUri, resourceUri)) || (change.modifiedUri && isEqual(change.modifiedUri, resourceUri))) { + return true; + } + } else if (isEqual(change.modifiedUri, resourceUri) || (change.originalUri && isEqual(change.originalUri, resourceUri))) { + return true; + } + } + + return false; +} diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 80612e6daf317..f7d22ff52f2dd 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -668,7 +668,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo 'languageModelToolInvoked', { result, - chatSessionId: dto.context?.sessionId, + chatSessionId: dto.context?.sessionResource ? chatSessionResourceToId(dto.context.sessionResource) : undefined, toolId: tool.data.id, toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined, toolSourceKind: tool.data.source.type, @@ -794,7 +794,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo parameters: dto.parameters, toolCallId: dto.callId, chatRequestId: dto.chatRequestId, - chatSessionId: dto.context?.sessionId, chatSessionResource: dto.context?.sessionResource, chatInteractionId: dto.chatInteractionId, modelId: dto.modelId, diff --git a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts new file mode 100644 index 0000000000000..fef06150ff0a7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; +import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; +import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { rename } from '../../../../../editor/contrib/rename/browser/rename.js'; +import { localize } from '../../../../../nls.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { ChatModel } from '../../common/model/chatModel.js'; +import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../../common/tools/languageModelToolsService.js'; +import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js'; +import { errorResult, findLineNumber, findSymbolColumn, ISymbolToolInput, resolveToolUri } from './toolHelpers.js'; + +export const RenameToolId = 'vscode_renameSymbol'; + +interface IRenameToolInput extends ISymbolToolInput { + newName: string; +} + +const BaseModelDescription = `Rename a code symbol across the workspace using the language server's rename functionality. This performs a precise, semantics-aware rename that updates all references. + +Input: +- "symbol": The exact current name of the symbol to rename. +- "newName": The new name for the symbol. +- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "filePath": A workspace-relative file path (e.g. "src/utils/helpers.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "lineContent": A substring of the line of code where the symbol appears. This is used to locate the exact position in the file. Must be the actual text from the file - do NOT fabricate it. + +IMPORTANT: The file and line do NOT need to be the definition of the symbol. Any occurrence works - a usage, an import, a call site, etc. You can pick whichever occurrence is most convenient. + +If the tool returns an error, retry with corrected input - ensure the file path is correct, the line content matches the actual file content, and the symbol name appears in that line.`; + +export class RenameTool extends Disposable implements IToolImpl { + + private readonly _onDidUpdateToolData = this._store.add(new Emitter()); + readonly onDidUpdateToolData = this._onDidUpdateToolData.event; + + constructor( + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ITextModelService private readonly _textModelService: ITextModelService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IChatService private readonly _chatService: IChatService, + @IBulkEditService private readonly _bulkEditService: IBulkEditService, + ) { + super(); + + this._store.add(Event.debounce( + this._languageFeaturesService.renameProvider.onDidChange, + () => { }, + 2000 + )((() => this._onDidUpdateToolData.fire()))); + } + + getToolData(): IToolData { + const languageIds = this._languageFeaturesService.renameProvider.registeredLanguageIds; + + let modelDescription = BaseModelDescription; + if (languageIds.has('*')) { + modelDescription += '\n\nSupported for all languages.'; + } else if (languageIds.size > 0) { + const sorted = [...languageIds].sort(); + modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; + } else { + modelDescription += '\n\nNo languages currently have rename providers registered.'; + } + + return { + id: RenameToolId, + toolReferenceName: 'rename', + canBeReferencedInPrompt: false, + icon: ThemeIcon.fromId(Codicon.rename.id), + displayName: localize('tool.rename.displayName', 'Rename Symbol'), + userDescription: localize('tool.rename.userDescription', 'Rename a symbol across the workspace'), + modelDescription, + source: ToolDataSource.Internal, + when: ContextKeyExpr.has('config.chat.tools.renameTool.enabled'), + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'The exact current name of the symbol to rename.' + }, + newName: { + type: 'string', + description: 'The new name for the symbol.' + }, + uri: { + type: 'string', + description: 'A full URI of a file where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".' + }, + filePath: { + type: 'string', + description: 'A workspace-relative file path where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".' + }, + lineContent: { + type: 'string', + description: 'A substring of the line of code where the symbol appears. Used to locate the exact position. Must be actual text from the file.' + } + }, + required: ['symbol', 'newName', 'lineContent'] + } + }; + } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const input = context.parameters as IRenameToolInput; + return { + invocationMessage: localize('tool.rename.invocationMessage', 'Renaming `{0}` to `{1}`', input.symbol, input.newName), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { + const input = invocation.parameters as IRenameToolInput; + + // --- resolve URI --- + const uri = resolveToolUri(input, this._workspaceContextService); + if (!uri) { + return errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); + } + + // --- open text model --- + const ref = await this._textModelService.createModelReference(uri); + try { + const model = ref.object.textEditorModel; + + if (!this._languageFeaturesService.renameProvider.has(model)) { + return errorResult(`No rename provider available for this file's language. The rename tool may not support this language.`); + } + + // --- find line containing lineContent --- + const lineNumber = findLineNumber(model, input.lineContent); + if (lineNumber === undefined) { + return errorResult(`Could not find line content "${input.lineContent}" in ${uri.toString()}. Provide the exact text from the line where the symbol appears.`); + } + + // --- find symbol in that line --- + const lineText = model.getLineContent(lineNumber); + const column = findSymbolColumn(lineText, input.symbol); + if (column === undefined) { + return errorResult(`Could not find symbol "${input.symbol}" in the matched line. Ensure the symbol name is correct and appears in the provided line content.`); + } + + const position = new Position(lineNumber, column); + + // --- perform rename --- + const renameResult = await rename(this._languageFeaturesService.renameProvider, model, position, input.newName); + + if (renameResult.rejectReason) { + return errorResult(`Rename rejected: ${renameResult.rejectReason}`); + } + + if (renameResult.edits.length === 0) { + return errorResult(`Rename produced no edits.`); + } + + // --- apply edits via chat response stream --- + if (invocation.context) { + const chatModel = this._chatService.getSession(invocation.context.sessionResource) as ChatModel | undefined; + const request = chatModel?.getRequests().at(-1); + + if (chatModel && request) { + // Group text edits by URI + const editsByUri = new ResourceMap(); + for (const edit of renameResult.edits) { + if (ResourceTextEdit.is(edit)) { + let edits = editsByUri.get(edit.resource); + if (!edits) { + edits = []; + editsByUri.set(edit.resource, edits); + } + edits.push(edit.textEdit); + } + } + + // Push edits through the chat response stream + for (const [editUri, edits] of editsByUri) { + chatModel.acceptResponseProgress(request, { + kind: 'textEdit', + uri: editUri, + edits: [], + }); + chatModel.acceptResponseProgress(request, { + kind: 'textEdit', + uri: editUri, + edits, + }); + chatModel.acceptResponseProgress(request, { + kind: 'textEdit', + uri: editUri, + edits: [], + done: true, + }); + } + + return this._successResult(input, editsByUri.size, renameResult.edits.length); + } + } + + // Fallback: apply via bulk edit service when no chat context is available + await this._bulkEditService.apply(renameResult); + const fileCount = new ResourceSet(renameResult.edits.filter(ResourceTextEdit.is).map(e => e.resource)).size; + return this._successResult(input, fileCount, renameResult.edits.length); + + } finally { + ref.dispose(); + } + } + + private _successResult(input: IRenameToolInput, fileCount: number, editCount: number): IToolResult { + const text = editCount === 1 + ? localize('tool.rename.oneEdit', "Renamed `{0}` to `{1}` - 1 edit in {2} file.", input.symbol, input.newName, fileCount) + : localize('tool.rename.edits', "Renamed `{0}` to `{1}` - {2} edits across {3} files.", input.symbol, input.newName, editCount, fileCount); + const result = createToolSimpleTextResult(text); + result.toolResultMessage = new MarkdownString(text); + return result; + } + +} + + + +export class RenameToolContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chat.renameTool'; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const renameTool = this._store.add(instantiationService.createInstance(RenameTool)); + + let registration: IDisposable | undefined; + const registerRenameTool = () => { + registration?.dispose(); + toolsService.flushToolUpdates(); + const toolData = renameTool.getToolData(); + registration = toolsService.registerTool(toolData, renameTool); + }; + registerRenameTool(); + this._store.add(renameTool.onDidUpdateToolData(registerRenameTool)); + this._store.add({ + dispose: () => { + registration?.dispose(); + } + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts b/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts new file mode 100644 index 0000000000000..28284cad6ae7f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IToolResult } from '../../common/tools/languageModelToolsService.js'; +import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js'; + +export interface ISymbolToolInput { + symbol: string; + uri?: string; + filePath?: string; + lineContent: string; +} + +/** + * Resolves a URI from tool input. Accepts either a full URI string or a + * workspace-relative file path. + */ +export function resolveToolUri(input: ISymbolToolInput, workspaceContextService: IWorkspaceContextService): URI | undefined { + if (input.uri) { + return URI.parse(input.uri); + } + if (input.filePath) { + const folders = workspaceContextService.getWorkspace().folders; + if (folders.length === 1) { + return folders[0].toResource(input.filePath); + } + // try each folder, return the first + for (const folder of folders) { + return folder.toResource(input.filePath); + } + } + return undefined; +} + +/** + * Finds the line number in the model that matches the given line content. + * Whitespace is normalized so that extra spaces in the input still match. + * + * @returns The 1-based line number, or `undefined` if not found. + */ +export function findLineNumber(model: ITextModel, lineContent: string): number | undefined { + const parts = lineContent.trim().split(/\s+/); + const pattern = parts.map(escapeRegExpCharacters).join('\\s+'); + const matches = model.findMatches(pattern, false, true, false, null, false, 1); + if (matches.length === 0) { + return undefined; + } + return matches[0].range.startLineNumber; +} + +/** + * Finds the 1-based column of a symbol within a line of text using word + * boundary matching. + * + * @returns The 1-based column, or `undefined` if not found. + */ +export function findSymbolColumn(lineText: string, symbol: string): number | undefined { + const pattern = new RegExp(`\\b${escapeRegExpCharacters(symbol)}\\b`); + const match = pattern.exec(lineText); + if (match) { + return match.index + 1; // 1-based column + } + return undefined; +} + +/** + * Creates an error tool result with the given message as both the content + * and the tool result message. + */ +export function errorResult(message: string): IToolResult { + const result = createToolSimpleTextResult(message); + result.toolResultMessage = new MarkdownString(message); + return result; +} diff --git a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts new file mode 100644 index 0000000000000..075977bc799a1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts @@ -0,0 +1,331 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { relativePath } from '../../../../../base/common/resources.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { Location, LocationLink } from '../../../../../editor/common/languages.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { getDefinitionsAtPosition, getImplementationsAtPosition, getReferencesAtPosition } from '../../../../../editor/contrib/gotoSymbol/browser/goToSymbol.js'; +import { localize } from '../../../../../nls.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { ISearchService, QueryType, resultIsMatch } from '../../../../services/search/common/search.js'; +import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress, } from '../../common/tools/languageModelToolsService.js'; +import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js'; +import { errorResult, findLineNumber, findSymbolColumn, ISymbolToolInput, resolveToolUri } from './toolHelpers.js'; + +export const UsagesToolId = 'vscode_listCodeUsages'; + +const BaseModelDescription = `Find all usages (references, definitions, and implementations) of a code symbol across the workspace. This tool locates where a symbol is referenced, defined, or implemented. + +Input: +- "symbol": The exact name of the symbol to search for (function, class, method, variable, type, etc.). +- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "filePath": A workspace-relative file path (e.g. "src/utils/helpers.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "lineContent": A substring of the line of code where the symbol appears. This is used to locate the exact position in the file. Must be the actual text from the file - do NOT fabricate it. + +IMPORTANT: The file and line do NOT need to be the definition of the symbol. Any occurrence works - a usage, an import, a call site, etc. You can pick whichever occurrence is most convenient. + +If the tool returns an error, retry with corrected input - ensure the file path is correct, the line content matches the actual file content, and the symbol name appears in that line.`; + +export class UsagesTool extends Disposable implements IToolImpl { + + private readonly _onDidUpdateToolData = this._store.add(new Emitter()); + readonly onDidUpdateToolData = this._onDidUpdateToolData.event; + + constructor( + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @IModelService private readonly _modelService: IModelService, + @ISearchService private readonly _searchService: ISearchService, + @ITextModelService private readonly _textModelService: ITextModelService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + ) { + super(); + + this._store.add(Event.debounce( + this._languageFeaturesService.referenceProvider.onDidChange, + () => { }, + 2000 + )((() => this._onDidUpdateToolData.fire()))); + } + + getToolData(): IToolData { + const languageIds = this._languageFeaturesService.referenceProvider.registeredLanguageIds; + + let modelDescription = BaseModelDescription; + if (languageIds.has('*')) { + modelDescription += '\n\nSupported for all languages.'; + } else if (languageIds.size > 0) { + const sorted = [...languageIds].sort(); + modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; + } else { + modelDescription += '\n\nNo languages currently have reference providers registered.'; + } + + return { + id: UsagesToolId, + toolReferenceName: 'usages', + canBeReferencedInPrompt: false, + icon: ThemeIcon.fromId(Codicon.references.id), + displayName: localize('tool.usages.displayName', 'List Code Usages'), + userDescription: localize('tool.usages.userDescription', 'Find references, definitions, and implementations of a symbol'), + modelDescription, + source: ToolDataSource.Internal, + when: ContextKeyExpr.has('config.chat.tools.usagesTool.enabled'), + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'The exact name of the symbol (function, class, method, variable, type, etc.) to find usages of.' + }, + uri: { + type: 'string', + description: 'A full URI of a file where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".' + }, + filePath: { + type: 'string', + description: 'A workspace-relative file path where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".' + }, + lineContent: { + type: 'string', + description: 'A substring of the line of code where the symbol appears. Used to locate the exact position. Must be actual text from the file.' + } + }, + required: ['symbol', 'lineContent'] + } + }; + } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const input = context.parameters as ISymbolToolInput; + return { + invocationMessage: localize('tool.usages.invocationMessage', 'Analyzing usages of `{0}`', input.symbol), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { + const input = invocation.parameters as ISymbolToolInput; + + // --- resolve URI --- + const uri = resolveToolUri(input, this._workspaceContextService); + if (!uri) { + return errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); + } + + // --- open text model --- + const ref = await this._textModelService.createModelReference(uri); + try { + const model = ref.object.textEditorModel; + + if (!this._languageFeaturesService.referenceProvider.has(model)) { + return errorResult(`No reference provider available for this file's language. The usages tool may not support this language.`); + } + + // --- find line containing lineContent --- + const lineNumber = findLineNumber(model, input.lineContent); + if (lineNumber === undefined) { + return errorResult(`Could not find line content "${input.lineContent}" in ${uri.toString()}. Provide the exact text from the line where the symbol appears.`); + } + + // --- find symbol in that line --- + const lineText = model.getLineContent(lineNumber); + const column = findSymbolColumn(lineText, input.symbol); + if (column === undefined) { + return errorResult(`Could not find symbol "${input.symbol}" in the matched line. Ensure the symbol name is correct and appears in the provided line content.`); + } + + const position = new Position(lineNumber, column); + + // --- query references, definitions, implementations in parallel --- + const [definitions, references, implementations] = await Promise.all([ + getDefinitionsAtPosition(this._languageFeaturesService.definitionProvider, model, position, false, token), + getReferencesAtPosition(this._languageFeaturesService.referenceProvider, model, position, false, false, token), + getImplementationsAtPosition(this._languageFeaturesService.implementationProvider, model, position, false, token), + ]); + + if (references.length === 0) { + const result = createToolSimpleTextResult(`No usages found for \`${input.symbol}\`.`); + result.toolResultMessage = new MarkdownString(localize('tool.usages.noResults', 'Analyzed usages of `{0}`, no results', input.symbol)); + return result; + } + + // --- classify and format results with previews --- + const previews = await this._getLinePreviews(input.symbol, references, token); + + const lines: string[] = []; + lines.push(`${references.length} usages of \`${input.symbol}\`:\n`); + + for (let i = 0; i < references.length; i++) { + const ref = references[i]; + const kind = this._classifyReference(ref, definitions, implementations); + const startLine = Range.lift(ref.range).startLineNumber; + const preview = previews[i]; + if (preview) { + lines.push(``); + lines.push(`\t${preview}`); + lines.push(``); + } else { + lines.push(``); + } + } + + const text = lines.join('\n'); + const result = createToolSimpleTextResult(text); + + result.toolResultMessage = references.length === 1 + ? new MarkdownString(localize('tool.usages.oneResult', 'Analyzed usages of `{0}`, 1 result', input.symbol)) + : new MarkdownString(localize('tool.usages.results', 'Analyzed usages of `{0}`, {1} results', input.symbol, references.length)); + + result.toolResultDetails = references.map((r): Location => ({ uri: r.uri, range: r.range })); + + return result; + } finally { + ref.dispose(); + } + } + + private async _getLinePreviews(symbol: string, references: LocationLink[], token: CancellationToken): Promise<(string | undefined)[]> { + const previews: (string | undefined)[] = new Array(references.length); + + // Build a lookup: (uriString, lineNumber) → index in references array + const lookup = new Map(); + const needSearch = new ResourceSet(); + + for (let i = 0; i < references.length; i++) { + const ref = references[i]; + const lineNumber = Range.lift(ref.range).startLineNumber; + + // Try already-open models first + const existingModel = this._modelService.getModel(ref.uri); + if (existingModel) { + previews[i] = existingModel.getLineContent(lineNumber).trim(); + } else { + lookup.set(`${ref.uri.toString()}:${lineNumber}`, i); + needSearch.add(ref.uri); + } + } + + if (needSearch.size === 0 || token.isCancellationRequested) { + return previews; + } + + // Use ISearchService to search for the symbol name, restricted to the + // referenced files. This is backed by ripgrep for file:// URIs. + try { + // Build includePattern from workspace-relative paths + const folders = this._workspaceContextService.getWorkspace().folders; + const relativePaths: string[] = []; + for (const uri of needSearch) { + const folder = this._workspaceContextService.getWorkspaceFolder(uri); + if (folder) { + const rel = relativePath(folder.uri, uri); + if (rel) { + relativePaths.push(rel); + } + } + } + + if (relativePaths.length > 0) { + const includePattern: Record = {}; + if (relativePaths.length === 1) { + includePattern[relativePaths[0]] = true; + } else { + includePattern[`{${relativePaths.join(',')}}`] = true; + } + + const searchResult = await this._searchService.textSearch( + { + type: QueryType.Text, + contentPattern: { pattern: escapeRegExpCharacters(symbol), isRegExp: true, isWordMatch: true }, + folderQueries: folders.map(f => ({ folder: f.uri })), + includePattern, + }, + token, + ); + + for (const fileMatch of searchResult.results) { + if (!fileMatch.results) { + continue; + } + for (const textMatch of fileMatch.results) { + if (!resultIsMatch(textMatch)) { + continue; + } + for (const range of textMatch.rangeLocations) { + const lineNumber = range.source.startLineNumber + 1; // 0-based → 1-based + const key = `${fileMatch.resource.toString()}:${lineNumber}`; + const idx = lookup.get(key); + if (idx !== undefined) { + previews[idx] = textMatch.previewText.trim(); + lookup.delete(key); + } + } + } + } + } + } catch { + // search might fail, leave remaining previews as undefined + } + + return previews; + } + + private _classifyReference(ref: LocationLink, definitions: LocationLink[], implementations: LocationLink[]): string { + if (definitions.some(d => this._overlaps(ref, d))) { + return 'definition'; + } + if (implementations.some(d => this._overlaps(ref, d))) { + return 'implementation'; + } + return 'reference'; + } + + private _overlaps(a: LocationLink, b: LocationLink): boolean { + if (a.uri.toString() !== b.uri.toString()) { + return false; + } + return Range.areIntersectingOrTouching(a.range, b.range); + } + +} + +export class UsagesToolContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chat.usagesTool'; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const usagesTool = this._store.add(instantiationService.createInstance(UsagesTool)); + + let registration: IDisposable | undefined; + const registerUsagesTool = () => { + registration?.dispose(); + toolsService.flushToolUpdates(); + const toolData = usagesTool.getToolData(); + registration = toolsService.registerTool(toolData, usagesTool); + }; + registerUsagesTool(); + this._store.add(usagesTool.onDidUpdateToolData(registerUsagesTool)); + this._store.add({ dispose: () => registration?.dispose() }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts index 7dcf175a3f470..4fd9d3e5337a0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts @@ -9,7 +9,7 @@ import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button. import { IListRenderer, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -112,14 +112,14 @@ export class ChatCheckpointFileChangesSummaryContentPart extends Disposable impl private renderViewAllFileChangesButton(container: HTMLElement): IDisposable { const button = container.appendChild($('.chat-view-changes-icon')); - this.hoverService.setupDelayedHover(button, () => ({ + const hoverDisposable = this.hoverService.setupDelayedHover(button, () => ({ content: localize2('chat.viewFileChangesSummary', 'View All File Changes') })); button.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple)); button.setAttribute('role', 'button'); button.tabIndex = 0; - return dom.addDisposableListener(button, 'click', (e) => { + return combinedDisposable(hoverDisposable, dom.addDisposableListener(button, 'click', (e) => { const resources: { originalUri: URI; modifiedUri?: URI }[] = this.fileChangesDiffsObservable.get().map(diff => ({ originalUri: diff.originalURI, modifiedUri: diff.modifiedURI @@ -141,7 +141,7 @@ export class ChatCheckpointFileChangesSummaryContentPart extends Disposable impl ); this.editorGroupsService.activeGroup.openEditor(input); dom.EventHelper.stop(e, true); - }); + })); } private renderFilesList(container: HTMLElement): IDisposable { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts new file mode 100644 index 0000000000000..7939aaa309915 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../../base/common/codicons.js'; +import { createMarkdownCommandLink, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { localize } from '../../../../../../nls.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; +import './media/chatDisabledClaudeHooksContent.css'; + +export class ChatDisabledClaudeHooksContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + constructor( + _context: IChatContentPartRenderContext, + @IOpenerService private readonly _openerService: IOpenerService, + @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, + ) { + super(); + + this.domNode = dom.$('.chat-disabled-claude-hooks'); + const messageContainer = dom.$('.chat-disabled-claude-hooks-message'); + + const icon = dom.$('.chat-disabled-claude-hooks-icon'); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.info)); + + const enableLink = createMarkdownCommandLink({ + title: localize('chat.disabledClaudeHooks.enableLink', "Enable"), + id: 'workbench.action.openSettings', + arguments: [PromptsConfig.USE_CLAUDE_HOOKS], + }); + const message = localize('chat.disabledClaudeHooks.message', "Claude Code hooks are available for this workspace. {0}", enableLink); + const content = new MarkdownString(message, { isTrusted: true }); + + const rendered = this._register(this._markdownRendererService.render(content, { + actionHandler: (href) => openLinkFromMarkdown(this._openerService, href, true), + })); + + messageContainer.appendChild(icon); + messageContainer.appendChild(rendered.element); + this.domNode.appendChild(messageContainer); + } + + hasSameContent(other: IChatRendererContent): boolean { + return other.kind === 'disabledClaudeHooks'; + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts index d3cd2bd310e9a..d499fda339e62 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts @@ -23,7 +23,7 @@ import { getFlatContextMenuActions } from '../../../../../../platform/actions/br import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; import { IResourceStat } from '../../../../../../platform/dnd/browser/dnd.js'; import { ITextResourceEditorInput } from '../../../../../../platform/editor/common/editor.js'; @@ -125,8 +125,6 @@ export class InlineAnchorWidget extends Disposable { public static readonly className = 'chat-inline-anchor-widget'; - private readonly _chatResourceContext: IContextKey; - readonly data: ContentRefData; constructor( @@ -158,9 +156,6 @@ export class InlineAnchorWidget extends Disposable { ? { kind: 'symbol', symbol: inlineReference.inlineReference } : { uri: inlineReference.inlineReference }; - const contextKeyService = this._register(originalContextKeyService.createScoped(element)); - this._chatResourceContext = chatAttachmentResourceContextKey.bindTo(contextKeyService); - element.classList.add(InlineAnchorWidget.className, 'show-file-icons'); let iconText: Array; @@ -168,7 +163,6 @@ export class InlineAnchorWidget extends Disposable { let location: { readonly uri: URI; readonly range?: IRange }; - let updateContextKeys: (() => Promise) | undefined; if (this.data.kind === 'symbol') { const symbol = this.data.symbol; @@ -176,7 +170,7 @@ export class InlineAnchorWidget extends Disposable { iconText = [this.data.symbol.name]; iconClasses = ['codicon', ...getIconClasses(modelService, languageService, undefined, undefined, SymbolKinds.toIcon(symbol.kind))]; - this._store.add(instantiationService.invokeFunction(accessor => hookUpSymbolAttachmentDragAndContextMenu(accessor, element, contextKeyService, { value: symbol.location, name: symbol.name, kind: symbol.kind }, MenuId.ChatInlineSymbolAnchorContext))); + this._store.add(instantiationService.invokeFunction(accessor => hookUpSymbolAttachmentDragAndContextMenu(accessor, element, originalContextKeyService, { value: symbol.location, name: symbol.name, kind: symbol.kind }, MenuId.ChatInlineSymbolAnchorContext))); } else { location = this.data; @@ -209,10 +203,10 @@ export class InlineAnchorWidget extends Disposable { refreshIconClasses(); })); - const isFolderContext = ExplorerFolderContext.bindTo(contextKeyService); + let isDirectory = false; fileService.stat(location.uri) .then(stat => { - isFolderContext.set(stat.isDirectory); + isDirectory = stat.isDirectory; if (stat.isDirectory) { fileKind = FileKind.FOLDER; refreshIconClasses(); @@ -221,15 +215,20 @@ export class InlineAnchorWidget extends Disposable { .catch(() => { }); // Context menu + const contextKeyService = this._register(originalContextKeyService.createScoped(element)); + chatAttachmentResourceContextKey.bindTo(contextKeyService).set(location.uri.toString()); + const isFolderContext = ExplorerFolderContext.bindTo(contextKeyService); + let contextMenuInitialized = false; this._register(dom.addDisposableListener(element, dom.EventType.CONTEXT_MENU, async domEvent => { const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent); dom.EventHelper.stop(domEvent, true); - try { - await updateContextKeys?.(); - } catch (e) { - console.error(e); + if (!contextMenuInitialized) { + contextMenuInitialized = true; + const resourceContextKey = new StaticResourceContextKey(contextKeyService, fileService, languageService, modelService); + resourceContextKey.set(location.uri); } + isFolderContext.set(isDirectory); if (this._store.isDisposed) { return; @@ -255,10 +254,6 @@ export class InlineAnchorWidget extends Disposable { } } - const resourceContextKey = new StaticResourceContextKey(contextKeyService, fileService, languageService, modelService); - resourceContextKey.set(location.uri); - this._chatResourceContext.set(location.uri.toString()); - const iconEl = dom.$('span.icon'); iconEl.classList.add(...iconClasses); element.replaceChildren(iconEl, dom.$('span.icon-label', {}, ...iconText)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 4951f32d5f93a..d4b4cec56b25f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -214,7 +214,13 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP try { const parsedBody = parseLocalFileData(text); range = parsedBody.range && Range.lift(parsedBody.range); - textModel = this.textModelService.createModelReference(parsedBody.uri).then(ref => ref.object.textEditorModel); + const modelRefPromise = this.textModelService.createModelReference(parsedBody.uri); + textModel = modelRefPromise.then(ref => { + if (!this._store.isDisposed) { + this._register(ref); + } + return ref.object.textEditorModel; + }); } catch (e) { return $('div'); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts index cd6169b793e09..560e9ecf84916 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts @@ -260,7 +260,6 @@ export class ChatMarkdownDecorationsRenderer { return container; } - private injectKeybindingHint(a: HTMLAnchorElement, href: string, keybindingService: IKeybindingService): void { const command = href.match(/command:([^\)]+)/)?.[1]; if (command) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts index 3e7c9c9166709..101b84d981099 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts @@ -138,6 +138,7 @@ export class ChatMcpServersInteractionContentPart extends Disposable implements true, // forceShowMessage undefined, // icon undefined, // toolInvocation + false, // no shimmer for now )); this.domNode.appendChild(this.workingProgressPart.domNode); } 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 28d1f62c1c1a8..03f160cdd7296 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -7,7 +7,7 @@ import { $, append } from '../../../../../../base/browser/dom.js'; import { alert } from '../../../../../../base/browser/ui/aria/aria.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; @@ -33,6 +33,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP private readonly showSpinner: boolean; private readonly isHidden: boolean; private readonly renderedMessage = this._register(new MutableDisposable()); + private readonly _fileWidgetStore = this._register(new DisposableStore()); private currentContent: IMarkdownString; constructor( @@ -43,6 +44,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP forceShowMessage: boolean | undefined, icon: ThemeIcon | undefined, private readonly toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized | undefined, + shimmer: boolean | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService, @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, @IConfigurationService private readonly configurationService: IConfigurationService @@ -64,14 +66,20 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP // this step is in progress, communicate it to SR users alert(progress.content.value); } - const codicon = icon ? icon : this.showSpinner ? ThemeIcon.modify(Codicon.loading, 'spin') : Codicon.check; + const isLoadingIcon = icon && ThemeIcon.isEqual(icon, ThemeIcon.modify(Codicon.loading, 'spin')); + const useShimmer = shimmer ?? ((!icon || isLoadingIcon) && this.showSpinner); + // if we have shimmer, don't show spinner + const codicon = useShimmer ? Codicon.check : (icon ?? (this.showSpinner ? ThemeIcon.modify(Codicon.loading, 'spin') : Codicon.check)); const result = this.chatContentMarkdownRenderer.render(progress.content); result.element.classList.add('progress-step'); - renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._fileWidgetStore); const tooltip: IMarkdownString | undefined = this.createApprovalMessage(); const progressPart = this._register(instantiationService.createInstance(ChatProgressSubPart, result.element, codicon, tooltip)); this.domNode = progressPart.domNode; + if (useShimmer) { + this.domNode.classList.add('shimmer-progress'); + } this.renderedMessage.value = result; } @@ -83,7 +91,8 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP // Render the new message const result = this._register(this.chatContentMarkdownRenderer.render(content)); result.element.classList.add('progress-step'); - renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + this._fileWidgetStore.clear(); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._fileWidgetStore); // Replace the old message container with the new one if (this.renderedMessage.value) { @@ -162,10 +171,9 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl ) { const progressMessage: IChatProgressMessage = { kind: 'progressMessage', - content: new MarkdownString().appendText(localize('workingMessage', "Working...")) + content: new MarkdownString().appendText(localize('workingMessage', "Working")) }; - super(progressMessage, chatContentMarkdownRenderer, context, undefined, undefined, undefined, undefined, instantiationService, chatMarkdownAnchorService, configurationService); - this.domNode.classList.add('working-progress'); + super(progressMessage, chatContentMarkdownRenderer, context, undefined, undefined, undefined, undefined, true, instantiationService, chatMarkdownAnchorService, configurationService); this._register(languageModelToolsService.onDidPrepareToolCallBecomeUnresponsive(e => { if (isEqual(context.element.sessionResource, e.sessionResource)) { this.updateMessage(new MarkdownString(localize('toolCallUnresponsive', "Waiting for tool '{0}' to respond...", e.toolData.displayName))); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 6a0b310b92555..d1284a8d1e601 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../base/browser/dom.js'; +import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; +import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { InputBox } from '../../../../../../base/browser/ui/inputbox/inputBox.js'; @@ -55,6 +58,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private readonly _multiSelectCheckboxes: Map = new Map(); private readonly _freeformTextareas: Map = new Map(); private readonly _inputBoxes: DisposableStore = this._register(new DisposableStore()); + private readonly _questionRenderStore = this._register(new MutableDisposable()); /** * Disposable store for interactive UI components (header, nav buttons, etc.) @@ -66,6 +70,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent public readonly carousel: IChatQuestionCarousel, context: IChatContentPartRenderContext, private readonly _options: IChatQuestionCarouselOptions, + @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, @IHoverService private readonly _hoverService: IHoverService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, ) { @@ -233,7 +238,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const question = this.carousel.questions[this._currentIndex]; if (question) { const questionText = question.message ?? question.title; - const messageContent = typeof questionText === 'string' ? questionText : questionText.value; + const messageContent = this.getQuestionText(questionText); const questionCount = this.carousel.questions.length; const alertMessage = questionCount === 1 ? messageContent @@ -265,6 +270,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private clearInteractiveResources(): void { // Dispose interactive UI disposables (header, nav buttons, etc.) this._interactiveUIStore.clear(); + this._questionRenderStore.clear(); this._inputBoxes.clear(); this._textInputBoxes.clear(); this._singleSelectItems.clear(); @@ -400,7 +406,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } const questionText = question.message ?? question.title; - const messageContent = typeof questionText === 'string' ? questionText : questionText.value; + const messageContent = this.getQuestionText(questionText); const questionCount = this.carousel.questions.length; if (questionCount === 1) { @@ -429,6 +435,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent return; } + const questionRenderStore = new DisposableStore(); + this._questionRenderStore.value = questionRenderStore; + // Clear previous input boxes and stale references this._inputBoxes.clear(); this._textInputBoxes.clear(); @@ -452,26 +461,29 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const questionText = question.message ?? question.title; if (questionText) { const title = dom.$('.chat-question-title'); - const messageContent = typeof questionText === 'string' - ? questionText - : questionText.value; + const messageContent = this.getQuestionText(questionText); title.setAttribute('aria-label', messageContent); - // Check for subtitle in parentheses at the end - const parenMatch = messageContent.match(/^(.+?)\s*(\([^)]+\))\s*$/); - if (parenMatch) { - // Main title (bold) - const mainTitle = dom.$('span.chat-question-title-main'); - mainTitle.textContent = parenMatch[1]; - title.appendChild(mainTitle); - - // Subtitle in parentheses (normal weight) - const subtitle = dom.$('span.chat-question-title-subtitle'); - subtitle.textContent = ' ' + parenMatch[2]; - title.appendChild(subtitle); + if (isMarkdownString(questionText)) { + const renderedTitle = questionRenderStore.add(this._markdownRendererService.render(MarkdownString.lift(questionText))); + title.appendChild(renderedTitle.element); } else { - title.textContent = messageContent; + // Check for subtitle in parentheses at the end + const parenMatch = messageContent.match(/^(.+?)\s*(\([^)]+\))\s*$/); + if (parenMatch) { + // Main title (bold) + const mainTitle = dom.$('span.chat-question-title-main'); + mainTitle.textContent = parenMatch[1]; + title.appendChild(mainTitle); + + // Subtitle in parentheses (normal weight) + const subtitle = dom.$('span.chat-question-title-subtitle'); + subtitle.textContent = ' ' + parenMatch[2]; + title.appendChild(subtitle); + } else { + title.textContent = messageContent; + } } headerRow.appendChild(title); } @@ -1082,7 +1094,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private renderSummary(): void { // If no answers, show skipped message if (this._answers.size === 0) { - this.renderSkippedMessage(); + if (this.carousel.isUsed) { + this.renderSkippedMessage(); + } return; } @@ -1178,6 +1192,14 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } } + private getQuestionText(questionText: string | IMarkdownString): string { + if (typeof questionText === 'string') { + return questionText; + } + + return renderAsPlaintext(questionText); + } + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { // does not have same content when it is not skipped and is active and we stop the response if (!this._isSkipped && !this.carousel.isUsed && isResponseVM(element) && element.isComplete) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts index f94a9c168cd51..d62abbb822cb4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts @@ -299,7 +299,7 @@ class CollapsibleListDelegate implements IListVirtualDelegate { +class CollapsibleListRenderer implements IListRenderer { static TEMPLATE_ID = 'chatCollapsibleListRenderer'; readonly templateId: string = CollapsibleListRenderer.TEMPLATE_ID; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 5e03ac8c2479b..d706fedd7b479 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -8,10 +8,10 @@ import { $, AnimationFrameScheduler, DisposableResizeObserver } from '../../../. import { Codicon } from '../../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; +import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; import { IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { rcut } from '../../../../../../base/common/strings.js'; -import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -25,11 +25,12 @@ import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { ChatCollapsibleMarkdownContentPart } from './chatCollapsibleMarkdownContentPart.js'; import { EditorPool } from './chatContentCodePools.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { renderFileWidgets } from './chatInlineAnchorWidget.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; import { CollapsibleListPool } from './chatReferencesContentPart.js'; import { createThinkingIcon, getToolInvocationIcon } from './chatThinkingContentPart.js'; -import './media/chatSubagentContent.css'; import { ChatToolInvocationPart } from './toolInvocationParts/chatToolInvocationPart.js'; +import './media/chatSubagentContent.css'; const MAX_TITLE_LENGTH = 100; @@ -97,6 +98,11 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen private userManuallyExpanded: boolean = false; private autoExpandedForConfirmation: boolean = false; + // Persistent title elements for shimmer + private titleShimmerSpan: HTMLElement | undefined; + private titleDetailContainer: HTMLElement | undefined; + private titleDetailRendered: IRenderedMarkdown | undefined; + /** * Check if a tool invocation is the parent subagent tool (the tool that spawns a subagent). * A parent subagent tool has subagent toolSpecificData but no subAgentInvocationId. @@ -174,17 +180,30 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen const node = this.domNode; node.classList.add('chat-thinking-box', 'chat-thinking-fixed-mode', 'chat-subagent-part'); + if (!this.element.isComplete) { + node.classList.add('chat-thinking-active'); + } + + // Apply shimmer to the initial title when still active + if (!this.element.isComplete && this._collapseButton) { + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + this.titleShimmerSpan = $('span.chat-thinking-title-shimmer'); + this.titleShimmerSpan.textContent = initialTitle; + labelElement.appendChild(this.titleShimmerSpan); + } + // Note: wrapper is created lazily in initContent(), so we can't set its style here if (this._collapseButton && !this.element.isComplete) { - this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + this._collapseButton.icon = Codicon.circleFilled; } this._register(autorun(r => { this.expanded.read(r); if (this._collapseButton) { if (!this.element.isComplete && this.isActive) { - this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + this._collapseButton.icon = Codicon.circleFilled; } else { this._collapseButton.icon = Codicon.check; } @@ -331,6 +350,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen public markAsInactive(): void { this.isActive = false; + this.domNode.classList.remove('chat-thinking-active'); if (this._collapseButton) { this._collapseButton.icon = Codicon.check; } @@ -348,11 +368,51 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen private updateTitle(): void { const prefix = this.agentName || localize('chat.subagent.prefix', 'Subagent'); - let finalLabel = `${prefix}: ${this.description}`; - if (this.currentRunningToolMessage && this.isActive) { - finalLabel += ` \u2014 ${this.currentRunningToolMessage}`; + const shimmerText = `${prefix}: ${this.description}`; + const toolCallText = this.currentRunningToolMessage && this.isActive ? ` \u2014 ${this.currentRunningToolMessage}` : ``; + + if (!this._collapseButton) { + return; + } + + const labelElement = this._collapseButton.labelElement; + + // Ensure the persistent shimmer span exists + if (!this.titleShimmerSpan || !this.titleShimmerSpan.parentElement) { + labelElement.textContent = ''; + this.titleShimmerSpan = $('span.chat-thinking-title-shimmer'); + labelElement.appendChild(this.titleShimmerSpan); } - this.setTitleWithWidgets(new MarkdownString(finalLabel), this.instantiationService, this.chatMarkdownAnchorService, this.chatContentMarkdownRenderer); + this.titleShimmerSpan.textContent = shimmerText; + + // Dispose previous detail rendering + if (this.titleDetailRendered) { + this.titleDetailRendered.dispose(); + this.titleDetailRendered = undefined; + } + + if (!toolCallText) { + if (this.titleDetailContainer) { + this.titleDetailContainer.remove(); + this.titleDetailContainer = undefined; + } + } else { + const result = this.chatContentMarkdownRenderer.render(new MarkdownString(toolCallText)); + result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail'); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + this.titleDetailRendered = result; + + if (this.titleDetailContainer) { + this.titleDetailContainer.replaceWith(result.element); + } else { + labelElement.appendChild(result.element); + } + this.titleDetailContainer = result.element; + } + + const fullLabel = `${shimmerText}${toolCallText}`; + this._collapseButton.element.ariaLabel = fullLabel; + this._collapseButton.element.ariaExpanded = String(this.isExpanded()); } private updateHover(): void { @@ -603,6 +663,17 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen factory: () => { domNode: HTMLElement; disposable?: IDisposable }, hookPart: IChatHookPart ): void { + // update title with hook message + const hookMessage = hookPart.stopReason + ? (hookPart.toolDisplayName + ? localize('hook.subagent.blocked', 'Blocked {0}', hookPart.toolDisplayName) + : localize('hook.subagent.blockedGeneric', 'Blocked by hook')) + : (hookPart.toolDisplayName + ? localize('hook.subagent.warning', 'Warning for {0}', hookPart.toolDisplayName) + : localize('hook.subagent.warningGeneric', 'Hook warning')); + this.currentRunningToolMessage = hookMessage; + this.updateTitle(); + if (this.isExpanded() || this.hasExpandedOnce) { const result = factory(); this.appendHookItemToDOM(result.domNode, hookPart); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts index 9125e269fb32b..c9e3d0c4a962e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts @@ -132,7 +132,7 @@ export class ChatSuggestNextWidget extends Disposable { return false; } const provider = getAgentSessionProvider(c.type); - return provider !== undefined && getAgentCanContinueIn(provider, c); + return provider !== undefined && getAgentCanContinueIn(provider); }); if (showContinueOn && availableContributions.length > 0) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTaskContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTaskContentPart.ts index b112e4bd61678..fb8a8b4569d11 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTaskContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTaskContentPart.ts @@ -38,7 +38,7 @@ export class ChatTaskContentPart extends Disposable implements IChatContentPart true; this.isSettled = isSettled; const showSpinner = !isSettled && !context.element.isComplete; - const progressPart = this._register(instantiationService.createInstance(ChatProgressContentPart, task, chatContentMarkdownRenderer, context, showSpinner, true, undefined, undefined)); + const progressPart = this._register(instantiationService.createInstance(ChatProgressContentPart, task, chatContentMarkdownRenderer, context, showSpinner, true, undefined, undefined, undefined)); this.domNode = progressPart.domNode; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index e3ddd1312adbe..e59763850e62e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -20,6 +20,7 @@ import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/m import { extractCodeblockUrisFromText } from '../../../common/widget/annotations.js'; import { basename } from '../../../../../../base/common/resources.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; +import { renderFileWidgets } from './chatInlineAnchorWidget.js'; import { localize } from '../../../../../../nls.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -97,6 +98,7 @@ interface ILazyToolItem { toolInvocationId?: string; toolInvocationOrMarkdown?: IChatToolInvocation | IChatToolInvocationSerialized | IChatMarkdownContent; originalParent?: HTMLElement; + isHook?: boolean; } interface ILazyThinkingItem { @@ -115,25 +117,25 @@ const enum WorkingMessageCategory { } const thinkingMessages = [ - localize('chat.thinking.thinking.1', 'Thinking...'), - localize('chat.thinking.thinking.2', 'Reasoning...'), - localize('chat.thinking.thinking.3', 'Considering...'), - localize('chat.thinking.thinking.4', 'Analyzing...'), - localize('chat.thinking.thinking.5', 'Evaluating...'), + localize('chat.thinking.thinking.1', 'Thinking'), + localize('chat.thinking.thinking.2', 'Reasoning'), + localize('chat.thinking.thinking.3', 'Considering'), + localize('chat.thinking.thinking.4', 'Analyzing'), + localize('chat.thinking.thinking.5', 'Evaluating'), ]; const terminalMessages = [ - localize('chat.thinking.terminal.1', 'Executing...'), - localize('chat.thinking.terminal.2', 'Running...'), - localize('chat.thinking.terminal.3', 'Processing...'), + localize('chat.thinking.terminal.1', 'Executing'), + localize('chat.thinking.terminal.2', 'Running'), + localize('chat.thinking.terminal.3', 'Processing'), ]; const toolMessages = [ - localize('chat.thinking.tool.1', 'Processing...'), - localize('chat.thinking.tool.2', 'Preparing...'), - localize('chat.thinking.tool.3', 'Loading...'), - localize('chat.thinking.tool.4', 'Analyzing...'), - localize('chat.thinking.tool.5', 'Evaluating...'), + localize('chat.thinking.tool.1', 'Processing'), + localize('chat.thinking.tool.2', 'Preparing'), + localize('chat.thinking.tool.3', 'Loading'), + localize('chat.thinking.tool.4', 'Analyzing'), + localize('chat.thinking.tool.5', 'Evaluating'), ]; export class ChatThinkingContentPart extends ChatCollapsibleContentPart implements IChatContentPart { @@ -146,7 +148,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private content: IChatThinkingPart; private currentThinkingValue: string; private currentTitle: string; - private defaultTitle = localize('chat.thinking.header', 'Working...'); + private defaultTitle = localize('chat.thinking.header', 'Working'); private textContainer!: HTMLElement; private markdownResult: IRenderedMarkdown | undefined; private wrapper!: HTMLElement; @@ -159,6 +161,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private appendedItemCount: number = 0; private isActive: boolean = true; private toolInvocations: (IChatToolInvocation | IChatToolInvocationSerialized)[] = []; + private hookCount: number = 0; private singleItemInfo: { element: HTMLElement; originalParent: HTMLElement; originalNextSibling: Node | null } | undefined; private lazyItems: ILazyItem[] = []; private hasExpandedOnce: boolean = false; @@ -171,6 +174,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private pendingScrollDisposable: IDisposable | undefined; private mutationObserverDisposable: IDisposable | undefined; private isUpdatingDimensions: boolean = false; + private titleShimmerSpan: HTMLElement | undefined; + private titleDetailContainer: HTMLElement | undefined; + private titleDetailRendered: IRenderedMarkdown | undefined; private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { let pool = this.availableMessagesByCategory.get(category); @@ -206,7 +212,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen ) { const initialText = extractTextFromPart(content); const extractedTitle = extractTitleFromThinkingContent(initialText) - ?? 'Working...'; + ?? 'Working'; super(extractedTitle, context, undefined, hoverService); @@ -244,6 +250,20 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const node = this.domNode; node.classList.add('chat-thinking-box'); + if (!this.streamingCompleted && !this.element.isComplete) { + if (!this.fixedScrollingMode) { + node.classList.add('chat-thinking-active'); + } + } + + if (!this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this._collapseButton) { + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + this.titleShimmerSpan = $('span.chat-thinking-title-shimmer'); + this.titleShimmerSpan.textContent = extractedTitle; + labelElement.appendChild(this.titleShimmerSpan); + } + if (this.fixedScrollingMode) { node.classList.add('chat-thinking-fixed-mode'); this.currentTitle = this.defaultTitle; @@ -259,20 +279,28 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (isExpanded) { this._collapseButton.icon = Codicon.chevronDown; } else { - this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + this._collapseButton.icon = Codicon.circleFilled; } } } })); this._register(autorun(r => { + const isExpanded = this._isExpanded.read(r); // Materialize lazy items when first expanded - if (this._isExpanded.read(r) && !this.hasExpandedOnce && this.lazyItems.length > 0) { + if (isExpanded && !this.hasExpandedOnce && this.lazyItems.length > 0) { this.hasExpandedOnce = true; for (const item of this.lazyItems) { this.materializeLazyItem(item); } } + + // If expanded but content matches title and there's nothing else to show, revert immediately + if (isExpanded && !this.shouldAllowExpansion()) { + this.setExpanded(false); + return; + } + // Fire when expanded/collapsed this._onDidChangeHeight.fire(); })); @@ -290,12 +318,17 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const expanded = this.isExpanded(); if (expanded) { + // Just expanded: show plain 'Working' with no detail this.setTitle(this.defaultTitle, true); this.currentTitle = this.defaultTitle; - } else if (this.lastExtractedTitle) { - const collapsedLabel = this.lastExtractedTitle ?? ''; - this.setTitle(collapsedLabel); - this.currentTitle = collapsedLabel; + } else { + // Just collapsed: show latest tool/thinking title with 'Working:' prefix + if (this.lastExtractedTitle) { + this.setTitle(this.lastExtractedTitle); + } else { + this.setTitle(this.defaultTitle, true); + this.currentTitle = this.defaultTitle; + } } })); } @@ -325,7 +358,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen // Create the persistent working spinner element only if still streaming if (!this.streamingCompleted && !this.element.isComplete) { this.workingSpinnerElement = $('.chat-thinking-item.chat-thinking-spinner-item'); - const spinnerIcon = createThinkingIcon(ThemeIcon.modify(Codicon.loading, 'spin')); + const spinnerIcon = createThinkingIcon(Codicon.circleFilled); this.workingSpinnerElement.appendChild(spinnerIcon); this.workingSpinnerLabel = $('span.chat-thinking-spinner-label'); this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(WorkingMessageCategory.Thinking); @@ -533,14 +566,18 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } - private updateDropdownClickability(): void { - if (!this.wrapper) { - return; + private shouldAllowExpansion(): boolean { + // Multiple tool invocations or lazy items mean there's content to show + if (this.toolInvocationCount > 0 || this.lazyItems.length > 0) { + return true; } - if (this.wrapper.children.length > 1 || this.toolInvocationCount > 0 || this.lazyItems.length > 0) { - this.setDropdownClickable(true); - return; + // Count meaningful children in the wrapper (exclude the working spinner) + if (this.wrapper) { + const meaningfulChildren = Array.from(this.wrapper.children).filter(child => child !== this.workingSpinnerElement).length; + if (meaningfulChildren > 1) { + return true; + } } const contentWithoutTitle = this.currentThinkingValue.trim(); @@ -552,8 +589,16 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen }; const strippedContent = stripMarkdown(contentWithoutTitle); - const shouldDisable = !strippedContent || strippedContent === titleToCompare; - this.setDropdownClickable(!shouldDisable); + // If content is empty or matches the title exactly, nothing to expand + return !(!strippedContent || strippedContent === titleToCompare); + } + + private updateDropdownClickability(): void { + const allowExpansion = this.shouldAllowExpansion(); + if (!allowExpansion && this.isExpanded()) { + this.setExpanded(false); + } + this.setDropdownClickable(allowExpansion); } private appendToWrapper(element: HTMLElement): void { @@ -631,6 +676,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen public markAsInactive(): void { this.isActive = false; + this.domNode.classList.remove('chat-thinking-active'); this.processPendingRemovals(); if (this.workingSpinnerElement) { this.workingSpinnerElement.remove(); @@ -646,6 +692,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this.wrapper) { this.wrapper.classList.remove('chat-thinking-streaming'); } + this.domNode.classList.remove('chat-thinking-active'); this.streamingCompleted = true; if (this.mutationObserverDisposable) { @@ -762,13 +809,13 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen - For reasoning/thinking: "Considered", "Planned", "Analyzed", "Reviewed", "Evaluated" - Choose the synonym that best fits the context - PRIORITY RULE - BLOCKED/DENIED CONTENT: - - If any item mentions being "blocked" (e.g. "Tried to use X, but was blocked"), it MUST be reflected in the title - - Blocked content takes priority over all other tool calls - - Use natural phrasing like "Tried to , but was blocked" or "Attempted but was denied" - - If there are both blocked items AND normal tool calls, mention both: e.g. "Tried to run terminal but was blocked, edited file.ts" +${this.hookCount > 0 ? `BLOCKED/DENIED CONTENT (hooks detected): + - Only mention "blocked" if the content explicitly includes hook results that blocked or warned about a tool (e.g. "Blocked terminal" or "Warning for read_file") + - If blocked items are present alongside normal tool calls, briefly note the block but do NOT let it dominate the summary: e.g. "Updated file.ts, blocked terminal" + + ` : `IMPORTANT: Do NOT use words like "blocked", "denied", or "tried" in the summary - there are no hooks or blocked items in this content. Just summarize normally. - RULES FOR TOOL CALLS: + `}RULES FOR TOOL CALLS: 1. If the SAME file was both edited AND read: Use a combined phrase like "Reviewed and updated " 2. If exactly ONE file was edited: Start with an edit synonym + "" (include actual filename) 3. If exactly ONE file was read: Start with a read synonym + "" (include actual filename) @@ -807,13 +854,12 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen - "Edited Button.tsx, Edited Button.css, Edited index.ts" → "Modified 3 files" - "Searched codebase for error handling" → "Looked up error handling" - EXAMPLES WITH BLOCKED CONTENT: - - "Tried to use Run in Terminal, but was blocked" → "Tried to run command, but was blocked" - - "Tried to use Run in Terminal, but was blocked, Edited config.ts" → "Tried to run command but was blocked, edited config.ts" - - "Tried to use Edit File, but was blocked, Tried to use Run in Terminal, but was blocked" → "Tried to use 2 tools, but was blocked" - - "Used Read File, but received a warning, Edited utils.ts" → "Read file with a warning, edited utils.ts" +${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): + - "Blocked terminal, Edited config.ts" → "Edited config.ts, terminal was blocked" + - "Blocked terminal, Blocked read_file" → "Two tools were blocked by hooks" + - "Warning for read_file, Edited utils.ts" → "Edited utils.ts with a hook warning" - EXAMPLES WITH REASONING HEADERS (no tools): + ` : ''}EXAMPLES WITH REASONING HEADERS (no tools): - "Analyzing component architecture" → "Considered component architecture" - "Planning refactor strategy" → "Planned refactor strategy" - "Reviewing error handling approach, Considering edge cases" → "Analyzed error handling approach" @@ -917,6 +963,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this.wrapper) { this.wrapper.classList.remove('chat-thinking-streaming'); } + this.domNode.classList.remove('chat-thinking-active'); this.streamingCompleted = true; if (this._collapseButton) { @@ -970,7 +1017,8 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen lazy: new Lazy(factory), toolInvocationId, toolInvocationOrMarkdown, - originalParent + originalParent, + isHook: !toolInvocationOrMarkdown && !!toolInvocationId }; this.lazyItems.push(item); } @@ -988,9 +1036,14 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return false; } + const removedItem = this.lazyItems[index]; this.lazyItems.splice(index, 1); this.appendedItemCount--; - this.toolInvocationCount--; + if (removedItem.kind === 'tool' && removedItem.isHook) { + this.hookCount = Math.max(0, this.hookCount - 1); + } else { + this.toolInvocationCount--; + } const toolInvocationsIndex = this.toolInvocations.findIndex(t => (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolId === toolInvocationId @@ -1057,7 +1110,14 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return; } - this.toolInvocationCount++; + // Track hooks separately: if toolInvocationOrMarkdown is undefined, it's a hook item + const isHook = !toolInvocationOrMarkdown; + if (isHook) { + this.hookCount++; + } else { + this.toolInvocationCount++; + } + let toolCallLabel: string; const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); @@ -1318,18 +1378,67 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } if (omitPrefix) { - this.setTitleWithWidgets(new MarkdownString(title), this.instantiationService, this.chatMarkdownAnchorService, this.chatContentMarkdownRenderer); + if (this._collapseButton) { + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + const plainSpan = $('span'); + plainSpan.textContent = title; + labelElement.appendChild(plainSpan); + this._collapseButton.element.ariaLabel = title; + } + this.titleShimmerSpan = undefined; + this.titleDetailContainer = undefined; + if (this.titleDetailRendered) { + this.titleDetailRendered.dispose(); + this.titleDetailRendered = undefined; + } this.currentTitle = title; return; } - const thinkingLabel = `Working: ${title}`; + this.lastExtractedTitle = title; + const thinkingLabel = `Working: ${title}`; this.currentTitle = thinkingLabel; - this.setTitleWithWidgets(new MarkdownString(thinkingLabel), this.instantiationService, this.chatMarkdownAnchorService, this.chatContentMarkdownRenderer); + + if (!this._collapseButton) { + return; + } + + const labelElement = this._collapseButton.labelElement; + + // Ensure the persistent shimmer span exists + if (!this.titleShimmerSpan || !this.titleShimmerSpan.parentElement) { + labelElement.textContent = ''; + this.titleShimmerSpan = $('span.chat-thinking-title-shimmer'); + labelElement.appendChild(this.titleShimmerSpan); + } + this.titleShimmerSpan.textContent = 'Working: '; + + // Dispose previous detail rendering + if (this.titleDetailRendered) { + this.titleDetailRendered.dispose(); + this.titleDetailRendered = undefined; + } + + const result = this.chatContentMarkdownRenderer.render(new MarkdownString(title)); + result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail'); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + this.titleDetailRendered = result; + + if (this.titleDetailContainer) { + // Replace old detail in-place + this.titleDetailContainer.replaceWith(result.element); + } else { + labelElement.appendChild(result.element); + } + this.titleDetailContainer = result.element; + + this._collapseButton.element.ariaLabel = thinkingLabel; + this._collapseButton.element.ariaExpanded = String(this.isExpanded()); } hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { - if (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized' || other.kind === 'markdownContent') { + if (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized' || other.kind === 'markdownContent' || other.kind === 'hook') { return true; } @@ -1345,6 +1454,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.markdownResult.dispose(); this.markdownResult = undefined; } + if (this.titleDetailRendered) { + this.titleDetailRendered.dispose(); + this.titleDetailRendered = undefined; + } if (this.workingSpinnerElement) { this.workingSpinnerElement.remove(); this.workingSpinnerElement = undefined; 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 7a5ba17dcaa9a..256999f36bd9b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -6,17 +6,20 @@ import './media/chatTipContent.css'; import * as dom from '../../../../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; -import { status } from '../../../../../../base/browser/ui/aria/aria.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 { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { localize, localize2 } from '../../../../../../nls.js'; import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; -import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatTip, IChatTipService } from '../../chatTipService.js'; @@ -30,6 +33,7 @@ export class ChatTipContentPart extends Disposable { public readonly onDidHide = this._onDidHide.event; private readonly _renderedContent = this._register(new MutableDisposable()); + private readonly _toolbar = this._register(new MutableDisposable()); private readonly _inChatTipContextKey: IContextKey; @@ -41,6 +45,7 @@ export class ChatTipContentPart extends Disposable { @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IMenuService private readonly _menuService: IMenuService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -61,11 +66,21 @@ export class ChatTipContentPart extends Disposable { const nextTip = this._getNextTip(); if (nextTip) { this._renderTip(nextTip); + dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => this.focus()); } else { this._onDidHide.fire(); } })); + this._register(this._chatTipService.onDidNavigateTip(tip => { + this._renderTip(tip); + dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => this.focus()); + })); + + this._register(this._chatTipService.onDidHideTip(() => { + this._onDidHide.fire(); + })); + this._register(this._chatTipService.onDidDisableTips(() => { this._onDidHide.fire(); })); @@ -93,20 +108,99 @@ export class ChatTipContentPart extends Disposable { private _renderTip(tip: IChatTip): void { dom.clearNode(this.domNode); + this._toolbar.clear(); + this.domNode.appendChild(renderIcon(Codicon.lightbulb)); const markdownContent = this._renderer.render(tip.content); this._renderedContent.value = markdownContent; this.domNode.appendChild(markdownContent.element); + + // Toolbar with previous, next, and dismiss actions via MenuWorkbenchToolBar + const toolbarContainer = $('.chat-tip-toolbar'); + this._toolbar.value = this._instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, MenuId.ChatTipToolbar, { + menuOptions: { + shouldForwardArgs: true, + }, + }); + this.domNode.appendChild(toolbarContainer); + const textContent = markdownContent.element.textContent ?? localize('chatTip', "Chat tip"); const hasLink = /\[.*?\]\(.*?\)/.test(tip.content.value); const ariaLabel = hasLink - ? localize('chatTipWithAction', "{0} Tab to the action.", textContent) + ? localize('chatTipWithAction', "{0} Tab to reach the action.", textContent) : textContent; this.domNode.setAttribute('aria-label', ariaLabel); - status(ariaLabel); } } +//#region Tip toolbar actions + +registerAction2(class PreviousTipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.previousTip', + title: localize2('chatTip.previous', "Previous tip"), + icon: Codicon.chevronLeft, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 1, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const chatTipService = accessor.get(IChatTipService); + const contextKeyService = accessor.get(IContextKeyService); + chatTipService.navigateToPreviousTip(contextKeyService); + } +}); + +registerAction2(class NextTipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.nextTip', + title: localize2('chatTip.next', "Next tip"), + icon: Codicon.chevronRight, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 2, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const chatTipService = accessor.get(IChatTipService); + const contextKeyService = accessor.get(IContextKeyService); + chatTipService.navigateToNextTip(contextKeyService); + } +}); + +registerAction2(class DismissTipToolbarAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.dismissTipToolbar', + title: localize2('chatTip.dismissButton', "Dismiss tip"), + icon: Codicon.check, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 3, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).dismissTip(); + } +}); + +//#endregion + //#region Tip context menu actions registerAction2(class DismissTipAction extends Action2 { @@ -133,17 +227,51 @@ registerAction2(class DisableTipsAction extends Action2 { super({ id: 'workbench.action.chat.disableTips', title: localize2('chatTip.disableTips', "Disable tips"), + icon: Codicon.bellSlash, f1: false, menu: [{ id: MenuId.ChatTipContext, group: 'chatTip', order: 2, + }, { + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 5, }] }); } override async run(accessor: ServicesAccessor): Promise { - await accessor.get(IChatTipService).disableTips(); + const dialogService = accessor.get(IDialogService); + const chatTipService = accessor.get(IChatTipService); + const commandService = accessor.get(ICommandService); + + const { result } = await dialogService.prompt({ + message: localize('chatTip.disableConfirmTitle', "Disable tips?"), + custom: { + markdownDetails: [{ + markdown: new MarkdownString(localize('chatTip.disableConfirmDetail', "New tips are added frequently to help you get the most out of Copilot. You can re-enable tips anytime from the `chat.tips.enabled` setting.")), + }], + }, + buttons: [ + { + label: localize('chatTip.disableConfirmButton', "Disable tips"), + run: () => true, + }, + { + label: localize('chatTip.openSettingButton', "Open Setting"), + run: () => { + commandService.executeCommand('workbench.action.openSettings', 'chat.tips.enabled'); + return false; + }, + }, + ], + cancelButton: true, + }); + + if (result) { + await chatTipService.disableTips(); + } } }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts index bcaa53fee11d0..d78995dd4534d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts @@ -221,7 +221,7 @@ export class CodeBlockPart extends Disposable { }); const toolbarElement = dom.append(this.element, $('.interactive-result-code-block-toolbar')); - const editorScopedService = this.editor.contextKeyService.createScoped(toolbarElement); + const editorScopedService = this._register(this.editor.contextKeyService.createScoped(toolbarElement)); const editorScopedInstantiationService = this._register(scopedInstantiationService.createChild(new ServiceCollection([IContextKeyService, editorScopedService]))); this.toolbar = this._register(editorScopedInstantiationService.createInstance(MenuWorkbenchToolBar, toolbarElement, menuId, { menuOptions: { diff --git a/extensions/simple-browser/extension-browser.webpack.config.js b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatDisabledClaudeHooksContent.css similarity index 62% rename from extensions/simple-browser/extension-browser.webpack.config.js rename to src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatDisabledClaudeHooksContent.css index b758f2d8155a3..5874eda712ca9 100644 --- a/extensions/simple-browser/extension-browser.webpack.config.js +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatDisabledClaudeHooksContent.css @@ -2,12 +2,13 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' - } -}); +.chat-disabled-claude-hooks-message { + display: flex; + align-items: center; + gap: 8px; + font-style: italic; + font-size: 12px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; +} 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 98c05a0bc8cfa..57a3a06ca786b 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 @@ -3,484 +3,412 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.chat-question-carousel-container { - margin: 8px 0; - border: 1px solid var(--vscode-chat-requestBorder); - border-radius: 4px; - display: flex; - flex-direction: column; - overflow: hidden; - container-type: inline-size; -} - -.chat-question-carousel-summary { - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 16px; +/* question carousel - this is above edits and todos */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty { + display: none; } -.chat-question-summary-item { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: baseline; - gap: 0; - font-size: var(--vscode-chat-font-size-body-s); -} - -.chat-question-summary-label { - color: var(--vscode-descriptionForeground); - word-wrap: break-word; - overflow-wrap: break-word; -} - -.chat-question-summary-label::after { - content: ': '; - white-space: pre; -} - -.chat-question-summary-answer-title { - color: var(--vscode-foreground); - font-weight: 600; - word-wrap: break-word; - overflow-wrap: break-word; -} - -.chat-question-summary-answer-desc { - color: var(--vscode-foreground); - word-wrap: break-word; - overflow-wrap: break-word; -} - -.chat-question-summary-skipped { - color: var(--vscode-descriptionForeground); - font-style: italic; - font-size: var(--vscode-chat-font-size-body-s); -} - -.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-header-row .chat-question-title { - flex: 1; - min-width: 0; - word-wrap: break-word; - overflow-wrap: break-word; -} - -/* Close button container in header */ -.chat-question-close-container { - flex-shrink: 0; -} - -.chat-question-close-container .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-close-container .monaco-button.chat-question-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; -} - -/* Footer row with step indicator and navigation */ -.chat-question-footer-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 16px; - border-top: 1px solid var(--vscode-chat-requestBorder); - background: var(--vscode-chat-requestBackground); -} - -/* Step indicator (e.g., "2/4") */ -.chat-question-step-indicator { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-descriptionForeground); -} - -.chat-question-carousel-nav { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; - margin-left: auto; -} - -.chat-question-nav-arrows { - display: flex; - align-items: center; - gap: 4px; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none; -} - -/* Secondary buttons (prev, next) use gray secondary background */ -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev, -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next { - background: var(--vscode-button-secondaryBackground) !important; - color: var(--vscode-button-secondaryForeground) !important; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev:hover:not(.disabled), -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next:hover:not(.disabled) { - background: var(--vscode-button-secondaryHoverBackground) !important; -} - -/* Submit button (next on last question) uses primary background */ -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit { - background: var(--vscode-button-background) !important; - color: var(--vscode-button-foreground) !important; - width: auto; - min-width: auto; - padding: 0 8px; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit:hover:not(.disabled) { - background: var(--vscode-button-hoverBackground) !important; -} - -/* Close button uses transparent background */ -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close { - background: transparent !important; - color: var(--vscode-foreground) !important; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; +/* input specific styling */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container { + margin: 0; + border: 1px solid var(--vscode-input-border, transparent); + background-color: var(--vscode-editor-background); + border-radius: 4px; } -.chat-question-carousel-content { +/* general questions styling */ +.interactive-session .chat-question-carousel-container { + margin: 8px 0; + border: 1px solid var(--vscode-chat-requestBorder); + border-radius: 4px; display: flex; flex-direction: column; - background: var(--vscode-chat-requestBackground); - padding: 8px 16px 10px 16px; overflow: hidden; + container-type: inline-size; } -.chat-question-title { - font-weight: 500; - font-size: var(--vscode-chat-font-size-body-s); - margin: 0; -} - -.chat-question-title-main { - font-weight: 500; -} - -.chat-question-title-subtitle { - font-weight: normal; - color: var(--vscode-descriptionForeground); -} - -.chat-question-message { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-foreground); - margin: 0; - line-height: 1.5; -} +/* container and header */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container { + width: 100%; + position: relative; -.chat-question-message p { - margin-top: 0; - margin-bottom: 4px; + .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; + 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; + 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); + } + + p { + margin: 0; + } + } + + .chat-question-title-main { + font-weight: 500; + } + + .chat-question-title-subtitle { + font-weight: normal; + color: var(--vscode-descriptionForeground); + } + } + + .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; + } + } + } + } } -.chat-question-input-container { +/* questions list and freeform area */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-input-container { display: flex; flex-direction: column; margin-top: 4px; min-width: 0; -} -/* List-style selection UI (similar to QuickPick) */ -.chat-question-list { - display: flex; - flex-direction: column; - gap: 3px; - outline: none; - padding: 4px 0; -} - -.chat-question-list:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -.chat-question-list-item { - display: flex; - align-items: center; - gap: 8px; - padding: 3px 8px; - cursor: pointer; - border-radius: 3px; - user-select: none; -} - -.chat-question-list-item:hover { - background-color: var(--vscode-list-hoverBackground); -} - -.interactive-input-part .chat-question-carousel-widget-container .chat-question-input-container { + /* some hackiness to get the focus looking right */ .chat-question-list-item:focus:not(.selected), .chat-question-list:focus { outline: none; } -} - -/* Single-select: highlight entire row when selected */ -.chat-question-list-item.selected { - background-color: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); -} + .chat-question-list:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } -.chat-question-list:focus-within .chat-question-list-item.selected { - outline-width: 1px; - outline-style: solid; - outline-offset: -1px; - outline-color: var(--vscode-focusBorder); -} + .chat-question-list:focus-within .chat-question-list-item.selected { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + outline-color: var(--vscode-focusBorder); + } -.chat-question-list-item.selected:hover { - background-color: var(--vscode-list-activeSelectionBackground); -} + .chat-question-list { + display: flex; + flex-direction: column; + gap: 3px; + outline: none; + padding: 4px 0; + + .chat-question-list-item { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 8px; + cursor: pointer; + border-radius: 3px; + user-select: none; + + .chat-question-list-indicator { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + } + + .chat-question-list-indicator.codicon-check { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + /* Label in list item */ + .chat-question-list-label { + font-size: var(--vscode-chat-font-size-body-s); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .chat-question-list-label-title { + font-weight: 600; + } + + .chat-question-list-label-desc { + font-weight: normal; + color: var(--vscode-descriptionForeground); + } + } + + .chat-question-list-item:hover { + background-color: var(--vscode-list-hoverBackground); + } + + /* Single-select: highlight entire row when selected */ + .chat-question-list-item.selected { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + + .chat-question-label { + color: var(--vscode-list-activeSelectionForeground); + } + + .chat-question-list-label-desc { + color: var(--vscode-list-activeSelectionForeground); + opacity: 0.8; + } + + .chat-question-list-indicator.codicon-check { + color: var(--vscode-list-activeSelectionForeground); + } + + .chat-question-list-number { + background-color: transparent; + color: var(--vscode-list-activeSelectionForeground); + border-color: var(--vscode-list-activeSelectionForeground); + border-bottom-color: var(--vscode-list-activeSelectionForeground); + box-shadow: none; + } + } + + .chat-question-list-item.selected:hover { + background-color: var(--vscode-list-activeSelectionBackground); + } + + /* Checkbox for multi-select */ + .chat-question-list-checkbox { + flex-shrink: 0; + } + + .chat-question-list-checkbox.monaco-custom-toggle { + margin-right: 0; + } + } -/* todo: change to use keybinding service so we don't have to recreate this */ -.chat-question-list-number, -.chat-question-freeform-number { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 14px; - padding: 0px 4px; - border-style: solid; - border-width: 1px; - border-radius: 3px; - font-size: 11px; - font-weight: normal; - background-color: var(--vscode-keybindingLabel-background); - color: var(--vscode-keybindingLabel-foreground); - border-color: var(--vscode-keybindingLabel-border); - border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); - box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); - flex-shrink: 0; -} + .chat-question-freeform { + margin-left: 8px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + .chat-question-freeform-number { + height: fit-content; + } + + /* this is probably legacy too */ + .chat-question-freeform-label { + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + } + + .chat-question-freeform-textarea { + width: 100%; + min-height: 24px; + max-height: 200px; + padding: 3px 8px; + border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border-radius: 4px; + resize: none; + font-family: var(--vscode-chat-font-family, inherit); + font-size: var(--vscode-chat-font-size-body-s); + box-sizing: border-box; + overflow-y: hidden; + align-content: center; + } + + .chat-question-freeform-textarea:focus { + outline: 1px solid var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); + } + + .chat-question-freeform-textarea::placeholder { + color: var(--vscode-input-placeholderForeground); + } -.chat-question-freeform-number { - height: fit-content; -} + } -.chat-question-list-item.selected .chat-question-list-number { - background-color: transparent; - color: var(--vscode-list-activeSelectionForeground); - border-color: var(--vscode-list-activeSelectionForeground); - border-bottom-color: var(--vscode-list-activeSelectionForeground); - box-shadow: none; + /* todo: change to use keybinding service so we don't have to recreate this */ + .chat-question-list-number, + .chat-question-freeform-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 14px; + padding: 0px 4px; + border-style: solid; + border-width: 1px; + border-radius: 3px; + font-size: 11px; + font-weight: normal; + background-color: var(--vscode-keybindingLabel-background); + color: var(--vscode-keybindingLabel-foreground); + border-color: var(--vscode-keybindingLabel-border); + border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); + box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); + flex-shrink: 0; + } } -/* Selection indicator (checkmark) for single select - positioned on right */ -.chat-question-list-indicator { - width: 16px; - height: 16px; +/* footer with step indicator and nav buttons */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-footer-row { display: flex; + justify-content: space-between; align-items: center; - justify-content: center; - flex-shrink: 0; - margin-left: auto; -} + padding: 4px 16px; + border-top: 1px solid var(--vscode-chat-requestBorder); + background: var(--vscode-chat-requestBackground); -.chat-question-list-indicator.codicon-check { - color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); -} + .chat-question-step-indicator { + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + } -.chat-question-list-item.selected .chat-question-list-indicator.codicon-check { - color: var(--vscode-list-activeSelectionForeground); -} + .chat-question-carousel-nav { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: auto; + } -/* Checkbox for multi-select */ -.chat-question-list-checkbox { - flex-shrink: 0; -} + .chat-question-nav-arrows { + display: flex; + align-items: center; + gap: 4px; + } -.chat-question-list-checkbox.monaco-custom-toggle { - margin-right: 0; -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none; + } + /* Secondary buttons (prev, next) use gray secondary background */ + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev, + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next { + background: var(--vscode-button-secondaryBackground) !important; + color: var(--vscode-button-secondaryForeground) !important; + } -/* Label in list item */ -.chat-question-list-label { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-foreground); - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev:hover:not(.disabled), + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next:hover:not(.disabled) { + background: var(--vscode-button-secondaryHoverBackground) !important; + } -.chat-question-list-label-title { - font-weight: 600; -} + /* Submit button (next on last question) uses primary background */ + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit { + background: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; + width: auto; + min-width: auto; + padding: 0 8px; + } -.chat-question-list-label-desc { - font-weight: normal; - color: var(--vscode-descriptionForeground); -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit:hover:not(.disabled) { + background: var(--vscode-button-hoverBackground) !important; + } -.chat-question-list-item.selected .chat-question-list-label { - color: var(--vscode-list-activeSelectionForeground); -} + /* Close button uses transparent background */ + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close { + background: transparent !important; + color: var(--vscode-foreground) !important; + } -.chat-question-list-item.selected .chat-question-list-label-desc { - color: var(--vscode-list-activeSelectionForeground); - opacity: 0.8; -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } -/* Legacy styles for backwards compatibility (to be removed) */ -.chat-question-options { - display: flex; - flex-direction: column; - gap: 0; - min-width: 0; } -.chat-question-option { +/* summary (after finished) */ +.interactive-session .chat-question-carousel-summary { display: flex; - align-items: flex-start; + flex-direction: column; gap: 8px; - padding: 3px 0; - min-width: 0; -} - -.chat-question-option input[type="radio"], -.chat-question-option input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - width: 18px; - height: 18px; - min-width: 18px; - min-height: 18px; - flex-shrink: 0; - margin: 0; - border: 1px solid var(--vscode-checkbox-border); - background-color: var(--vscode-checkbox-background); - border-radius: 3px; - cursor: pointer; - position: relative; - outline: none; -} - -.chat-question-option input[type="radio"] { - border-radius: 50%; -} - -.chat-question-option input[type="radio"]:checked, -.chat-question-option input[type="checkbox"]:checked { - background-color: var(--vscode-checkbox-selectBackground, var(--vscode-checkbox-background)); -} - -.chat-question-option input[type="checkbox"]:checked::after { - content: ''; - position: absolute; - top: 3px; - left: 6px; - width: 4px; - height: 8px; - border: solid var(--vscode-checkbox-foreground); - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -.chat-question-option input[type="radio"]:checked::after { - content: ''; - position: absolute; - top: 4px; - left: 4px; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--vscode-checkbox-foreground); -} - -.chat-question-option input[type="radio"]:focus, -.chat-question-option input[type="checkbox"]:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 1px; -} - -.chat-question-option input[type="radio"]:hover, -.chat-question-option input[type="checkbox"]:hover { - background-color: var(--vscode-inputOption-hoverBackground); -} - -.chat-question-option label { - flex: 1; - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-foreground); - cursor: pointer; - word-wrap: break-word; - overflow-wrap: break-word; - min-width: 0; -} + padding: 8px 16px; + margin-bottom: 4px; + .chat-question-summary-item { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + gap: 0; + font-size: var(--vscode-chat-font-size-body-s); + } -.chat-question-freeform { - margin-left: 8px; - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; -} + .chat-question-summary-label { + color: var(--vscode-descriptionForeground); + word-wrap: break-word; + overflow-wrap: break-word; + } -.chat-question-freeform-label { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-descriptionForeground); -} + .chat-question-summary-label::after { + content: ': '; + white-space: pre; + } -.chat-question-freeform-textarea { - width: 100%; - min-height: 24px; - max-height: 200px; - padding: 3px 8px; - border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); - background-color: var(--vscode-input-background); - color: var(--vscode-input-foreground); - border-radius: 4px; - resize: none; - font-family: var(--vscode-chat-font-family, inherit); - font-size: var(--vscode-chat-font-size-body-s); - box-sizing: border-box; - overflow-y: hidden; - align-content: center; -} + .chat-question-summary-answer-title { + color: var(--vscode-foreground); + font-weight: 600; + word-wrap: break-word; + overflow-wrap: break-word; + } -.chat-question-freeform-textarea:focus { - outline: 1px solid var(--vscode-focusBorder); - border-color: var(--vscode-focusBorder); -} + .chat-question-summary-answer-desc { + color: var(--vscode-foreground); + word-wrap: break-word; + overflow-wrap: break-word; + } -.chat-question-freeform-textarea::placeholder { - color: var(--vscode-input-placeholderForeground); + .chat-question-summary-skipped { + color: var(--vscode-descriptionForeground); + font-style: italic; + font-size: var(--vscode-chat-font-size-body-s); + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index b34ce7a6cd494..553704389064c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -32,6 +32,35 @@ .rendered-markdown.collapsible-title-content { line-height: unset; } + + .rendered-markdown.chat-thinking-title-detail { + display: inline; + + > p { + display: inline; + } + } + } + + &.chat-thinking-active > .chat-used-context-label .monaco-button.monaco-icon-button { + + .codicon.codicon-circle-filled { + display: none; + } + + .chat-thinking-title-shimmer { + background: linear-gradient(90deg, + var(--vscode-descriptionForeground) 0%, + var(--vscode-descriptionForeground) 30%, + var(--vscode-chat-thinkingShimmer) 50%, + var(--vscode-descriptionForeground) 70%, + var(--vscode-descriptionForeground) 100%); + background-size: 400% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: chat-thinking-shimmer 2s linear infinite; + } } /* shimmer animation stuffs */ 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 3077e67d2b5d3..43832fd58ce10 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 @@ -3,38 +3,44 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.interactive-item-container .chat-tip-widget { - display: flex; - align-items: center; - gap: 4px; - margin-bottom: 8px; - padding: 6px 10px; - border-radius: 4px; - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar { + opacity: 0; + pointer-events: none; +} + +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar > .monaco-toolbar { + position: absolute; + top: -20px; + right: 10px; + height: 26px; + line-height: 26px; background-color: var(--vscode-editorWidget-background); - font-size: var(--vscode-chat-font-size-body-s); - font-family: var(--vscode-chat-font-family, inherit); - color: var(--vscode-descriptionForeground); - position: relative; - overflow: hidden; + border: 1px solid var(--vscode-chat-requestBorder); + border-radius: var(--vscode-cornerRadius-medium); + z-index: 100; + transition: opacity 0.1s ease-in-out; } -.interactive-item-container .chat-tip-widget .codicon-lightbulb { - font-size: 12px; - color: var(--vscode-notificationsWarningIcon-foreground); +.chat-getting-started-tip-container .chat-tip-widget:hover .chat-tip-toolbar, +.chat-getting-started-tip-container .chat-tip-widget:focus-within .chat-tip-toolbar { + opacity: 1; + pointer-events: auto; } -.interactive-item-container .chat-tip-widget a { - color: var(--vscode-textLink-foreground); +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item { + height: 24px; + width: 24px; + margin: 1px 2px; } -.interactive-item-container .chat-tip-widget a:hover, -.interactive-item-container .chat-tip-widget a:active { - color: var(--vscode-textLink-activeForeground); +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label { + color: var(--vscode-descriptionForeground); + padding: 4px; } -.interactive-item-container .chat-tip-widget .rendered-markdown p { - margin: 0; +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); } .chat-getting-started-tip-container { @@ -53,11 +59,11 @@ background-color: var(--vscode-editorWidget-background); border-radius: 4px 4px 0 0; border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); - border-bottom: none; /* Seamless attachment to input below */ + border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); font-size: var(--vscode-chat-font-size-body-s); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); - overflow: hidden; + position: relative; } .chat-getting-started-tip-container .chat-tip-widget a { @@ -77,17 +83,3 @@ .chat-getting-started-tip-container .chat-tip-widget .rendered-markdown p { margin: 0; } - -/* Override bubble styling for tip widget's rendered markdown in chat editor */ -.interactive-session:not(.chat-widget > .interactive-session) { - .interactive-item-container.interactive-request .value .chat-tip-widget .rendered-markdown { - background-color: transparent; - border-radius: 0; - padding: 0; - max-width: none; - margin-left: 0; - width: auto; - margin-bottom: 0; - position: static; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts index 96c8fd5f7c780..4742a98d3d952 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts @@ -105,7 +105,8 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { this.provideScreenReaderStatus(content); } - return this.instantiationService.createInstance(ChatProgressContentPart, progressMessage, this.renderer, this.context, undefined, true, this.getIcon(), this.toolInvocation); + const isAskQuestionsTool = this.toolInvocation.toolId === 'copilot_askQuestions'; + return this.instantiationService.createInstance(ChatProgressContentPart, progressMessage, this.renderer, this.context, undefined, true, this.getIcon(), this.toolInvocation, isAskQuestionsTool ? undefined : false); } private getAnnouncementKey(kind: 'progress' | 'complete'): string { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts index 3e3e3cc12d050..ae3d2c18d6147 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts @@ -86,7 +86,8 @@ export class ChatToolStreamingSubPart extends BaseChatToolInvocationSubPart { undefined, true, this.getIcon(), - toolInvocation + toolInvocation, + false )); dom.reset(container, part.domNode); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index dd074702d2bec..37c8ecfc743e9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -58,7 +58,7 @@ import { IChatAgentMetadata } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatTextEditGroup } from '../../common/model/chatModel.js'; import { chatSubcommandLeader } from '../../common/requestParser/chatParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; @@ -86,6 +86,7 @@ import { ChatQuestionCarouselPart } from './chatContentParts/chatQuestionCarouse import { ChatExtensionsContentPart } from './chatContentParts/chatExtensionsContentPart.js'; import { ChatMarkdownContentPart, codeblockHasClosingBackticks } from './chatContentParts/chatMarkdownContentPart.js'; import { ChatMcpServersInteractionContentPart } from './chatContentParts/chatMcpServersInteractionContentPart.js'; +import { ChatDisabledClaudeHooksContentPart } from './chatContentParts/chatDisabledClaudeHooksContentPart.js'; import { ChatMultiDiffContentPart } from './chatContentParts/chatMultiDiffContentPart.js'; import { ChatProgressContentPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js'; import { ChatPullRequestContentPart } from './chatContentParts/chatPullRequestContentPart.js'; @@ -95,7 +96,6 @@ import { ChatTaskContentPart } from './chatContentParts/chatTaskContentPart.js'; import { ChatTextEditContentPart } from './chatContentParts/chatTextEditContentPart.js'; import { ChatThinkingContentPart } from './chatContentParts/chatThinkingContentPart.js'; import { ChatSubagentContentPart } from './chatContentParts/chatSubagentContentPart.js'; -import { ChatTipContentPart } from './chatContentParts/chatTipContentPart.js'; import { ChatTreeContentPart, TreePool } from './chatContentParts/chatTreeContentPart.js'; import { ChatWorkspaceEditContentPart } from './chatContentParts/chatWorkspaceEditContentPart.js'; import { ChatToolInvocationPart } from './chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; @@ -104,7 +104,6 @@ import { ChatEditorOptions } from './chatOptions.js'; import { ChatCodeBlockContentProvider, CodeBlockPart } from './chatContentParts/codeBlockPart.js'; import { autorun, observableValue } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; -import { IChatTipService } from '../chatTipService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; @@ -187,8 +186,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(); private readonly _autoReply: ChatQuestionCarouselAutoReply; - private _activeTipPart: ChatTipContentPart | undefined; - private readonly _notifiedQuestionCarousels = new Set(); private readonly _questionCarouselToast = this._register(new DisposableStore()); @@ -258,7 +255,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) || lastPart.kind === 'mcpServersStarting' || + lastPart.kind === 'disabledClaudeHooks' || lastPart.kind === 'hook' ) { return true; @@ -1074,32 +1061,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.chatTipService.getNextTip(element.id, element.timestamp, this.contextKeyService), - ); - templateData.value.appendChild(tipPart.domNode); - this._activeTipPart = tipPart; - templateData.elementDisposables.add(tipPart); - templateData.elementDisposables.add(tipPart.onDidHide(() => { - tipPart.domNode.remove(); - if (this._activeTipPart === tipPart) { - this._activeTipPart = undefined; - } - })); - templateData.elementDisposables.add({ - dispose: () => { - if (this._activeTipPart === tipPart) { - this._activeTipPart = undefined; - } - } - }); - } - let inlineSlashCommandRendered = false; content.forEach((data, contentIndex) => { const context: IChatContentPartRenderContext = { @@ -1208,8 +1169,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer, contentForThisTurn: ReadonlyArray, element: IChatResponseViewModel, elementIndex: number, templateData: IChatListItemTemplate): void { const renderedParts = templateData.renderedParts ?? []; templateData.renderedParts = renderedParts; + const lastMarkdownIndex = partsToRender.findLastIndex(part => part?.kind === 'markdownContent'); partsToRender.forEach((partToRender, contentIndex) => { const alreadyRenderedPart = templateData.renderedParts?.[contentIndex]; + const isFinalAnswerPart = partToRender?.kind === 'markdownContent' && contentIndex === lastMarkdownIndex && element.isComplete; if (!partToRender) { // null=no change @@ -1227,7 +1190,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer, contentToRender: ReadonlyArray, element: ChatTreeItem): ReadonlyArray { const diff: (IChatRendererContent | null)[] = []; + const elementIsComplete = isResponseVM(element) && element.isComplete; + const lastMarkdownContentIndex = contentToRender.findLastIndex(part => part.kind === 'markdownContent'); for (let i = 0; i < contentToRender.length; i++) { const content = contentToRender[i]; const renderedPart = renderedParts[i]; + const isFinalAnswerPart = content.kind === 'markdownContent' && i === lastMarkdownContentIndex && elementIsComplete; + + if (isFinalAnswerPart && this.isRenderedPartInsideThinking(renderedPart)) { + diff.push(content); + continue; + } if (!renderedPart || !renderedPart.hasSameContent(content, contentToRender.slice(i + 1), element)) { diff.push(content); @@ -1433,6 +1404,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer c.kind === 'thinking' || c.kind === 'toolInvocation' || c.kind === 'toolInvocationSerialized') : -1; + const isFinalAnswerPart = isFinalRenderPass && context.contentIndex > lastPinnedPartIndex; if (!this.hasCodeblockUri(markdown) || isFinalAnswerPart) { this.finalizeCurrentThinkingPart(context, templateData); } @@ -2548,6 +2537,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer = { supportsFileAttachments: true, supportsToolAttachments: true, @@ -164,6 +182,7 @@ const supportsAllAttachments: Required = { supportsProblemAttachments: true, supportsSymbolAttachments: true, supportsTerminalAttachments: true, + supportsPromptAttachments: true, }; const DISCLAIMER = localize('chatDisclaimer', "AI responses may be inaccurate."); @@ -237,6 +256,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private readonly welcomePart: MutableDisposable = this._register(new MutableDisposable()); private readonly _gettingStartedTipPart = this._register(new MutableDisposable()); + private _gettingStartedTipPartRef: ChatTipContentPart | undefined; private readonly chatSuggestNextWidget: ChatSuggestNextWidget; @@ -315,6 +335,7 @@ export class ChatWidget extends Disposable implements IChatWidget { .parseChatRequest(this.viewModel.sessionResource, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, + attachmentCapabilities: this.attachmentCapabilities, forcedAgent: this._lockedAgent?.id ? this.chatAgentService.getAgent(this._lockedAgent.id) : undefined }); this._onDidChangeParsedInput.fire(); @@ -400,6 +421,23 @@ export class ChatWidget extends Disposable implements IChatWidget { this._register(this.chatEntitlementService.onDidChangeAnonymous(() => this.renderWelcomeViewContentIfNeeded())); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('chat.tips.enabled')) { + if (!this.configurationService.getValue('chat.tips.enabled')) { + // Clear the existing tip so it doesn't linger + if (this.inputPart) { + this._gettingStartedTipPartRef = undefined; + this._gettingStartedTipPart.clear(); + const tipContainer = this.inputPart.gettingStartedTipContainerElement; + dom.clearNode(tipContainer); + dom.setVisibility(false, tipContainer); + } + } else { + this.updateChatViewVisibility(); + } + } + })); + this._register(bindContextKey(decidedChatEditingResourceContextKey, contextKeyService, (reader) => { const currentSession = this._editingSession.read(reader); if (!currentSession) { @@ -597,6 +635,14 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.input.height.get() + this.listWidget.contentHeight + this.chatSuggestNextWidget.height; } + get scrollTop(): number { + return this.listWidget.scrollTop; + } + + set scrollTop(value: number) { + this.listWidget.scrollTop = value; + } + get attachmentModel(): ChatAttachmentModel { return this.input.attachmentModel; } @@ -634,6 +680,11 @@ export class ChatWidget extends Disposable implements IChatWidget { this.renderWelcomeViewContentIfNeeded(); this.createList(this.listContainer, { editable: !isInlineChat(this) && !isQuickChat(this), ...this.viewOptions.rendererOptions, renderStyle }); + // Forward scroll events from the parent container margins (outside the max-width area) to the chat list + this._register(dom.addDisposableListener(parent, dom.EventType.MOUSE_WHEEL, (e: IMouseWheelEvent) => { + this.listWidget.delegateScrollFromMouseWheelEvent(e); + })); + // Update the font family and size this._register(autorun(reader => { const fontFamily = this.chatLayoutService.fontFamily.read(reader); @@ -749,12 +800,16 @@ export class ChatWidget extends Disposable implements IChatWidget { } toggleTipFocus(): boolean { - if (this.listWidget.hasTipFocus()) { + if (this._gettingStartedTipPartRef?.hasFocus()) { this.focusInput(); return true; } - return this.listWidget.focusTip(); + if (!this._gettingStartedTipPartRef) { + return false; + } + this._gettingStartedTipPartRef.focus(); + return true; } hasInputFocus(): boolean { @@ -767,7 +822,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } const previous = this.parsedChatRequest; - this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel.sessionResource, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind }); + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel.sessionResource, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, attachmentCapabilities: this.attachmentCapabilities }); if (!previous || !IParsedChatRequest.equals(previous, this.parsedChatRequest)) { this._onDidChangeParsedInput.fire(); } @@ -860,6 +915,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } else { // Dispose the cached tip part so the next empty state picks a // fresh (rotated) tip instead of re-showing the stale one. + this._gettingStartedTipPartRef = undefined; this._gettingStartedTipPart.clear(); dom.clearNode(tipContainer); dom.setVisibility(false, tipContainer); @@ -953,11 +1009,14 @@ export class ChatWidget extends Disposable implements IChatWidget { () => this.chatTipService.getWelcomeTip(this.contextKeyService), )); tipContainer.appendChild(tipPart.domNode); + this._gettingStartedTipPartRef = tipPart; store.add(tipPart.onDidHide(() => { tipPart.domNode.remove(); + this._gettingStartedTipPartRef = undefined; this._gettingStartedTipPart.clear(); dom.setVisibility(false, tipContainer); + this.focusInput(); })); this._gettingStartedTipPart.value = store; @@ -1873,6 +1932,25 @@ export class ChatWidget extends Disposable implements IChatWidget { this.renderFollowups(); this.renderChatSuggestNextWidget(); })); + let previousModelIdentifier: string | undefined; + this._register(autorun(reader => { + const modelIdentifier = this.inputPart.selectedLanguageModel.read(reader)?.identifier; + if (previousModelIdentifier === undefined) { + previousModelIdentifier = modelIdentifier; + return; + } + + if (previousModelIdentifier === modelIdentifier) { + return; + } + + previousModelIdentifier = modelIdentifier; + if (!this._gettingStartedTipPartRef) { + return; + } + + this.chatTipService.getWelcomeTip(this.contextKeyService); + })); this._register(autorun(r => { const toolSetIds = new Set(); @@ -1978,12 +2056,19 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeItems(); })); this._sessionIsEmptyContextKey.set(model.getRequests().length === 0); - const updatePendingRequestKeys = () => { - const pendingCount = model.getPendingRequests().length; + let lastSteeringCount = 0; + const updatePendingRequestKeys = (announceSteering: boolean) => { + const pendingRequests = model.getPendingRequests(); + const pendingCount = pendingRequests.length; this._hasPendingRequestsContextKey.set(pendingCount > 0); + const steeringCount = pendingRequests.filter(pending => pending.kind === ChatRequestQueueKind.Steering).length; + if (announceSteering && steeringCount > 0 && lastSteeringCount === 0) { + status(localize('chat.pendingRequests.steeringQueued', "Steering")); + } + lastSteeringCount = steeringCount; }; - updatePendingRequestKeys(); - this.viewModelDisposables.add(model.onDidChangePendingRequests(() => updatePendingRequestKeys())); + updatePendingRequestKeys(false); + this.viewModelDisposables.add(model.onDidChangePendingRequests(() => updatePendingRequestKeys(true))); this.refreshParsedInput(); this.viewModelDisposables.add(model.onDidChange((e) => { @@ -2161,6 +2246,18 @@ export class ChatWidget extends Disposable implements IChatWidget { // remove the slash command from the input requestInput.input = this.parsedInput.parts.filter(part => !(part instanceof ChatRequestSlashPromptPart)).map(part => part.text).join('').trim(); + const promptPath = slashCommand.promptPath; + const promptRunEvent: ChatPromptRunEvent = { + storage: promptPath.storage, + }; + if (promptPath.storage === PromptsStorage.extension) { + promptRunEvent.extensionId = promptPath.extension.identifier.value; + promptRunEvent.promptName = slashCommand.name; + } else { + promptRunEvent.promptNameHash = hash(slashCommand.name).toString(16); + } + this.telemetryService.publicLog2('chat.promptRun', promptRunEvent); + const input = requestInput.input.trim(); requestInput.input = `Follow instructions in [${basename(parseResult.uri)}](${parseResult.uri.toString()}).`; if (input) { @@ -2228,6 +2325,16 @@ export class ChatWidget extends Disposable implements IChatWidget { const model = this.viewModel.model; const requestInProgress = model.requestInProgress.get(); + // Cancel the request if the user chooses to take a different path. + // This is a bit of a heuristic for the common case of tool confirmation+reroute. + // But we don't do this if there are queued messages, because we would either + // discard them or need a prompt (as in `confirmPendingRequestsBeforeSend`) + // which could be a surprising behavior if the user finishes typing a steering + // request just as confirmation is triggered. + if (model.requestNeedsInput.get() && !model.getPendingRequests().length) { + this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource); + options.queue ??= ChatRequestQueueKind.Queued; + } if (requestInProgress) { options.queue ??= ChatRequestQueueKind.Queued; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts index 9e5ce02f368c8..136fe05787f7a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts @@ -36,6 +36,9 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService private readonly _onDidChangeFocusedWidget = this._register(new Emitter()); readonly onDidChangeFocusedWidget = this._onDidChangeFocusedWidget.event; + private readonly _onDidChangeFocusedSession = this._register(new Emitter()); + readonly onDidChangeFocusedSession = this._onDidChangeFocusedSession.event; + constructor( @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IViewsService private readonly viewsService: IViewsService, @@ -222,6 +225,7 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService this._lastFocusedWidget = widget; this._onDidChangeFocusedWidget.fire(widget); + this._onDidChangeFocusedSession.fire(); } register(newWidget: IChatWidget): IDisposable { @@ -239,6 +243,10 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService return combinedDisposable( newWidget.onDidFocus(() => this.setLastFocusedWidget(newWidget)), newWidget.onDidChangeViewModel(({ previousSessionResource, currentSessionResource }) => { + if (this._lastFocusedWidget === newWidget && !isEqual(previousSessionResource, currentSessionResource)) { + this._onDidChangeFocusedSession.fire(); + } + if (!previousSessionResource || (currentSessionResource && isEqual(previousSessionResource, currentSessionResource))) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts deleted file mode 100644 index 8917b4edfa8fe..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts +++ /dev/null @@ -1,636 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { addDisposableListener } from '../../../../../../base/browser/dom.js'; -import { ITreeRenderer, ITreeNode, IObjectTreeElement, ObjectTreeElementCollapseState } from '../../../../../../base/browser/ui/tree/tree.js'; -import { IIdentityProvider, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js'; -import { Codicon } from '../../../../../../base/common/codicons.js'; -import { comparePaths } from '../../../../../../base/common/comparers.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { matchesSomeScheme, Schemas } from '../../../../../../base/common/network.js'; -import { basename } from '../../../../../../base/common/path.js'; -import { basenameOrAuthority, dirname, isEqual, isEqualAuthority, isEqualOrParent } from '../../../../../../base/common/resources.js'; -import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; -import { ThemeIcon } from '../../../../../../base/common/themables.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { localize } from '../../../../../../nls.js'; -import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; -import { MenuId } from '../../../../../../platform/actions/common/actions.js'; -import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; -import { FileKind } from '../../../../../../platform/files/common/files.js'; -import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; -import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { IOpenEvent, WorkbenchList, WorkbenchObjectTree } from '../../../../../../platform/list/browser/listService.js'; -import { IProductService } from '../../../../../../platform/product/common/productService.js'; -import { IStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js'; -import { isDark } from '../../../../../../platform/theme/common/theme.js'; -import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; -import { IResourceLabel, ResourceLabels } from '../../../../../browser/labels.js'; -import { SETTINGS_AUTHORITY } from '../../../../../services/preferences/common/preferences.js'; -import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; -import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../../common/chatService/chatService.js'; -import { chatEditingWidgetFileStateContextKey, IChatEditingSession } from '../../../common/editing/chatEditingService.js'; -import { CHAT_EDITS_VIEW_MODE_STORAGE_KEY } from '../../chatEditing/chatEditingActions.js'; -import { createFileIconThemableTreeContainerScope } from '../../../../files/browser/views/explorerView.js'; -import { CollapsibleListPool, IChatCollapsibleListItem, ICollapsibleListTemplate } from '../chatContentParts/chatReferencesContentPart.js'; -import { IDisposableReference } from '../chatContentParts/chatCollections.js'; - -const $ = dom.$; - -/** - * Represents a folder node in the tree view. - */ -export interface IChatEditsFolderElement { - readonly kind: 'folder'; - readonly uri: URI; - readonly children: IChatCollapsibleListItem[]; -} - -/** - * Union type for elements in the chat edits tree. - */ -export type IChatEditsTreeElement = IChatCollapsibleListItem | IChatEditsFolderElement; - -/** - * Find the common ancestor directory among a set of URIs. - * Returns undefined if the URIs have no common ancestor (different schemes/authorities). - */ -function findCommonAncestorUri(uris: readonly URI[]): URI | undefined { - if (uris.length === 0) { - return undefined; - } - let common = uris[0]; - for (let i = 1; i < uris.length; i++) { - while (!isEqualOrParent(uris[i], common)) { - const parent = dirname(common); - if (isEqual(parent, common)) { - return undefined; // reached filesystem root - } - common = parent; - } - } - return common; -} - -/** - * Convert a flat list of chat edits items into a tree grouped by directory. - * Files at the common ancestor directory are shown at the root level without a folder row. - */ -export function buildEditsTree(items: readonly IChatCollapsibleListItem[]): IObjectTreeElement[] { - // Group files by their directory - const folderMap = new Map(); - const itemsWithoutUri: IChatCollapsibleListItem[] = []; - - for (const item of items) { - if (item.kind === 'reference' && URI.isUri(item.reference)) { - const folderUri = dirname(item.reference); - const key = folderUri.toString(); - let group = folderMap.get(key); - if (!group) { - group = { uri: folderUri, items: [] }; - folderMap.set(key, group); - } - group.items.push(item); - } else { - itemsWithoutUri.push(item); - } - } - - const result: IObjectTreeElement[] = []; - - // Add items without URIs as top-level items (e.g., warnings) - for (const item of itemsWithoutUri) { - result.push({ element: item }); - } - - if (folderMap.size === 0) { - return result; - } - - // Find common ancestor so we can flatten files at the root level - const folderUris = [...folderMap.values()].map(f => f.uri); - const commonAncestor = findCommonAncestorUri(folderUris); - - // Sort folders by path - const sortedFolders = [...folderMap.values()].sort((a, b) => - comparePaths(a.uri.fsPath, b.uri.fsPath) - ); - - // Emit folders first, then root-level files (matching search tree behavior) - const rootFiles: IObjectTreeElement[] = []; - for (const folder of sortedFolders) { - const isAtCommonAncestor = commonAncestor && isEqual(folder.uri, commonAncestor); - if (isAtCommonAncestor) { - // Files at the common ancestor go at the root level, after all folders - for (const item of folder.items) { - rootFiles.push({ element: item }); - } - } else { - const folderElement: IChatEditsFolderElement = { - kind: 'folder', - uri: folder.uri, - children: folder.items, - }; - result.push({ - element: folderElement, - children: folder.items.map(item => ({ element: item as IChatEditsTreeElement })), - collapsible: true, - collapsed: ObjectTreeElementCollapseState.PreserveOrExpanded, - }); - } - } - - // Root-level files come after folders - result.push(...rootFiles); - - return result; -} - -/** - * Convert a flat list into tree elements without grouping (list mode). - */ -export function buildEditsList(items: readonly IChatCollapsibleListItem[]): IObjectTreeElement[] { - return items.map(item => ({ element: item as IChatEditsTreeElement })); -} - -/** - * Delegate for the chat edits tree that returns element heights and template IDs. - */ -export class ChatEditsTreeDelegate implements IListVirtualDelegate { - getHeight(_element: IChatEditsTreeElement): number { - return 22; - } - - getTemplateId(element: IChatEditsTreeElement): string { - if (element.kind === 'folder') { - return ChatEditsFolderRenderer.TEMPLATE_ID; - } - return ChatEditsFileTreeRenderer.TEMPLATE_ID; - } -} - -/** - * Identity provider for the chat edits tree. - * Provides stable string IDs so the tree can preserve collapse/selection state across updates. - */ -export class ChatEditsTreeIdentityProvider implements IIdentityProvider { - getId(element: IChatEditsTreeElement): string { - if (element.kind === 'folder') { - return `folder:${element.uri.toString()}`; - } - if (element.kind === 'warning') { - return `warning:${element.content.value}`; - } - const ref = element.reference; - if (typeof ref === 'string') { - return `ref:${ref}`; - } else if (URI.isUri(ref)) { - return `file:${ref.toString()}`; - } else { - // eslint-disable-next-line local/code-no-in-operator - return `file:${'uri' in ref ? ref.uri.toString() : String(ref)}`; - } - } -} - -interface IChatEditsFolderTemplate { - readonly label: IResourceLabel; - readonly templateDisposables: DisposableStore; -} - -/** - * Renderer for folder elements in the chat edits tree. - */ -export class ChatEditsFolderRenderer implements ITreeRenderer { - static readonly TEMPLATE_ID = 'chatEditsFolderRenderer'; - readonly templateId = ChatEditsFolderRenderer.TEMPLATE_ID; - - constructor( - private readonly labels: ResourceLabels, - private readonly labelService: ILabelService, - ) { } - - renderTemplate(container: HTMLElement): IChatEditsFolderTemplate { - const templateDisposables = new DisposableStore(); - const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); - return { label, templateDisposables }; - } - - renderElement(node: ITreeNode, _index: number, templateData: IChatEditsFolderTemplate): void { - const element = node.element; - if (element.kind !== 'folder') { - return; - } - const relativeLabel = this.labelService.getUriLabel(element.uri, { relative: true }); - templateData.label.setResource( - { resource: element.uri, name: relativeLabel || basename(element.uri.path) }, - { fileKind: FileKind.FOLDER, fileDecorations: undefined } - ); - } - - disposeTemplate(templateData: IChatEditsFolderTemplate): void { - templateData.templateDisposables.dispose(); - } -} - -/** - * Tree renderer for file elements in the chat edits tree. - * Adapted from CollapsibleListRenderer to work with ITreeNode. - */ -export class ChatEditsFileTreeRenderer implements ITreeRenderer { - static readonly TEMPLATE_ID = 'chatEditsFileRenderer'; - readonly templateId = ChatEditsFileTreeRenderer.TEMPLATE_ID; - - constructor( - private readonly labels: ResourceLabels, - private readonly menuId: MenuId | undefined, - @IThemeService private readonly themeService: IThemeService, - @IProductService private readonly productService: IProductService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - ) { } - - renderTemplate(container: HTMLElement): ICollapsibleListTemplate { - const templateDisposables = new DisposableStore(); - const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); - - const fileDiffsContainer = $('.working-set-line-counts'); - const addedSpan = dom.$('.working-set-lines-added'); - const removedSpan = dom.$('.working-set-lines-removed'); - fileDiffsContainer.appendChild(addedSpan); - fileDiffsContainer.appendChild(removedSpan); - label.element.appendChild(fileDiffsContainer); - - let toolbar; - let actionBarContainer; - let contextKeyService; - if (this.menuId) { - actionBarContainer = $('.chat-collapsible-list-action-bar'); - contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer)); - const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); - toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, this.menuId, { menuOptions: { shouldForwardArgs: true, arg: undefined } })); - label.element.appendChild(actionBarContainer); - } - - return { templateDisposables, label, toolbar, actionBarContainer, contextKeyService, fileDiffsContainer, addedSpan, removedSpan }; - } - - private getReferenceIcon(data: IChatContentReference): URI | ThemeIcon | undefined { - if (ThemeIcon.isThemeIcon(data.iconPath)) { - return data.iconPath; - } else { - return isDark(this.themeService.getColorTheme().type) && data.iconPath?.dark - ? data.iconPath?.dark - : data.iconPath?.light; - } - } - - renderElement(node: ITreeNode, _index: number, templateData: ICollapsibleListTemplate): void { - const data = node.element; - if (data.kind === 'folder') { - return; - } - - if (data.kind === 'warning') { - templateData.label.setResource({ name: data.content.value }, { icon: Codicon.warning }); - return; - } - - const reference = data.reference; - const icon = this.getReferenceIcon(data); - templateData.label.element.style.display = 'flex'; - let arg: URI | undefined; - // eslint-disable-next-line local/code-no-in-operator - if (typeof reference === 'object' && 'variableName' in reference) { - if (reference.value) { - const uri = URI.isUri(reference.value) ? reference.value : reference.value.uri; - templateData.label.setResource( - { - resource: uri, - name: basenameOrAuthority(uri), - description: `#${reference.variableName}`, - // eslint-disable-next-line local/code-no-in-operator - range: 'range' in reference.value ? reference.value.range : undefined, - }, { icon, title: data.options?.status?.description ?? data.title }); - } else if (reference.variableName.startsWith('kernelVariable')) { - const variable = reference.variableName.split(':')[1]; - const asVariableName = `${variable}`; - const label = `Kernel variable`; - templateData.label.setLabel(label, asVariableName, { title: data.options?.status?.description }); - } else { - templateData.label.setLabel('Unknown variable type: ' + reference.variableName); - } - } else if (typeof reference === 'string') { - templateData.label.setLabel(reference, undefined, { iconPath: URI.isUri(icon) ? icon : undefined, title: data.options?.status?.description ?? data.title }); - } else { - // eslint-disable-next-line local/code-no-in-operator - const uri = 'uri' in reference ? reference.uri : reference; - arg = uri; - if (uri.scheme === 'https' && isEqualAuthority(uri.authority, 'github.com') && uri.path.includes('/tree/')) { - templateData.label.setResource({ resource: uri, name: basename(uri.path) }, { icon: Codicon.github, title: data.title }); - } else if (uri.scheme === this.productService.urlProtocol && isEqualAuthority(uri.authority, SETTINGS_AUTHORITY)) { - const settingId = uri.path.substring(1); - templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId) }); - } else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) { - templateData.label.setResource({ resource: uri, name: uri.toString(true) }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(true) }); - } else { - templateData.label.setFile(uri, { - fileKind: FileKind.FILE, - fileDecorations: undefined, - // eslint-disable-next-line local/code-no-in-operator - range: 'range' in reference ? reference.range : undefined, - title: data.options?.status?.description ?? data.title, - }); - } - } - - for (const selector of ['.monaco-icon-suffix-container', '.monaco-icon-name-container']) { - // eslint-disable-next-line no-restricted-syntax - const element = templateData.label.element.querySelector(selector); - if (element) { - if (data.options?.status?.kind === ChatResponseReferencePartStatusKind.Omitted || data.options?.status?.kind === ChatResponseReferencePartStatusKind.Partial) { - element.classList.add('warning'); - } else { - element.classList.remove('warning'); - } - } - } - - if (data.state !== undefined) { - if (templateData.actionBarContainer) { - const diffMeta = data?.options?.diffMeta; - if (diffMeta) { - if (!templateData.fileDiffsContainer || !templateData.addedSpan || !templateData.removedSpan) { - return; - } - templateData.addedSpan.textContent = `+${diffMeta.added}`; - templateData.removedSpan.textContent = `-${diffMeta.removed}`; - templateData.fileDiffsContainer.setAttribute('aria-label', localize('chatEditingSession.fileCounts', '{0} lines added, {1} lines removed', diffMeta.added, diffMeta.removed)); - } - // eslint-disable-next-line no-restricted-syntax - templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified'); - } - if (templateData.toolbar) { - templateData.toolbar.context = arg; - } - if (templateData.contextKeyService) { - chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state); - } - } - } - - disposeTemplate(templateData: ICollapsibleListTemplate): void { - templateData.templateDisposables.dispose(); - } -} - -/** - * Widget that renders the chat edits file list, supporting both flat list and tree views. - * Manages the lifecycle of the underlying tree or list widget, and handles toggling between modes. - */ -export class ChatEditsListWidget extends Disposable { - private readonly _onDidFocus = this._register(new Emitter()); - readonly onDidFocus: Event = this._onDidFocus.event; - - private readonly _onDidOpen = this._register(new Emitter>()); - readonly onDidOpen: Event> = this._onDidOpen.event; - - private _tree: WorkbenchObjectTree | undefined; - private _list: IDisposableReference> | undefined; - - private readonly _listPool: CollapsibleListPool; - private readonly _widgetDisposables = this._register(new DisposableStore()); - private readonly _chatEditsInTreeView: IContextKey; - - private _currentContainer: HTMLElement | undefined; - private _currentSession: IChatEditingSession | null = null; - private _lastEntries: readonly IChatCollapsibleListItem[] = []; - - get currentSession(): IChatEditingSession | null { - return this._currentSession; - } - - get selectedElements(): URI[] { - const edits: URI[] = []; - if (this._tree) { - for (const element of this._tree.getSelection()) { - if (element && element.kind === 'reference' && URI.isUri(element.reference)) { - edits.push(element.reference); - } - } - } else if (this._list) { - for (const element of this._list.object.getSelectedElements()) { - if (element.kind === 'reference' && URI.isUri(element.reference)) { - edits.push(element.reference); - } - } - } - return edits; - } - - constructor( - private readonly onDidChangeVisibility: Event, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextKeyService contextKeyService: IContextKeyService, - @IStorageService private readonly storageService: IStorageService, - @IThemeService private readonly themeService: IThemeService, - @ILabelService private readonly labelService: ILabelService, - ) { - super(); - - this._listPool = this._register(this.instantiationService.createInstance( - CollapsibleListPool, - this.onDidChangeVisibility, - MenuId.ChatEditingWidgetModifiedFilesToolbar, - { verticalScrollMode: ScrollbarVisibility.Visible }, - )); - - this._chatEditsInTreeView = ChatContextKeys.chatEditsInTreeView.bindTo(contextKeyService); - this._chatEditsInTreeView.set(this._isTreeMode); - - this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_EDITS_VIEW_MODE_STORAGE_KEY, this._store)(() => { - const isTree = this._isTreeMode; - this._chatEditsInTreeView.set(isTree); - if (this._currentContainer) { - this.create(this._currentContainer, this._currentSession); - this.setEntries(this._lastEntries); - } - })); - } - - private get _isTreeMode(): boolean { - return this.storageService.get(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, StorageScope.PROFILE, 'list') === 'tree'; - } - - /** - * Creates the appropriate widget (tree or list) inside the given container. - * Must be called before {@link setEntries}. - */ - create(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { - this._currentContainer = container; - this._currentSession = chatEditingSession; - this.clear(); - dom.clearNode(container); - - if (this._isTreeMode) { - this._createTree(container, chatEditingSession); - } else { - this._createList(container, chatEditingSession); - } - } - - /** - * Rebuild the widget (e.g. after a view mode toggle). - */ - rebuild(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { - this.create(container, chatEditingSession); - } - - /** - * Whether the current view mode has changed since the widget was last created. - */ - get needsRebuild(): boolean { - if (this._isTreeMode) { - return !this._tree; - } - return !this._list; - } - - /** - * Update the displayed entries. - */ - setEntries(entries: readonly IChatCollapsibleListItem[]): void { - this._lastEntries = entries; - if (this._tree) { - const treeElements = this._isTreeMode - ? buildEditsTree(entries) - : buildEditsList(entries); - - // Use the file entry count for height, not the tree-expanded count, - // so height stays consistent when toggling between tree and list modes - const maxItemsShown = 6; - const itemsShown = Math.min(entries.length, maxItemsShown); - const height = itemsShown * 22; - this._tree.layout(height); - this._tree.getHTMLElement().style.height = `${height}px`; - this._tree.setChildren(null, treeElements); - } else if (this._list) { - const maxItemsShown = 6; - const itemsShown = Math.min(entries.length, maxItemsShown); - const height = itemsShown * 22; - const list = this._list.object; - list.layout(height); - list.getHTMLElement().style.height = `${height}px`; - list.splice(0, list.length, entries); - } - } - - /** - * Dispose the current tree or list widget without disposing the outer widget. - */ - clear(): void { - this._widgetDisposables.clear(); - this._tree = undefined; - this._list = undefined; - } - - private _createTree(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { - const resourceLabels = this._widgetDisposables.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeVisibility })); - const treeContainer = dom.$('.chat-used-context-list'); - this._widgetDisposables.add(createFileIconThemableTreeContainerScope(treeContainer, this.themeService)); - - const tree = this._widgetDisposables.add(this.instantiationService.createInstance( - WorkbenchObjectTree, - 'ChatEditsTree', - treeContainer, - new ChatEditsTreeDelegate(), - [ - new ChatEditsFolderRenderer(resourceLabels, this.labelService), - this.instantiationService.createInstance(ChatEditsFileTreeRenderer, resourceLabels, MenuId.ChatEditingWidgetModifiedFilesToolbar), - ], - { - alwaysConsumeMouseWheel: false, - accessibilityProvider: { - getAriaLabel: (element: IChatEditsTreeElement) => { - if (element.kind === 'folder') { - return this.labelService.getUriLabel(element.uri, { relative: true }); - } - if (element.kind === 'warning') { - return element.content.value; - } - const reference = element.reference; - if (typeof reference === 'string') { - return reference; - } else if (URI.isUri(reference)) { - return this.labelService.getUriBasenameLabel(reference); - // eslint-disable-next-line local/code-no-in-operator - } else if ('uri' in reference) { - return this.labelService.getUriBasenameLabel(reference.uri); - } else { - return ''; - } - }, - getWidgetAriaLabel: () => localize('chatEditsTree', "Changed Files"), - }, - identityProvider: new ChatEditsTreeIdentityProvider(), - verticalScrollMode: ScrollbarVisibility.Visible, - hideTwistiesOfChildlessElements: true, - } - )); - - tree.updateOptions({ enableStickyScroll: false }); - - this._tree = tree; - - this._widgetDisposables.add(tree.onDidChangeFocus(() => { - this._onDidFocus.fire(); - })); - - this._widgetDisposables.add(tree.onDidOpen(e => { - this._onDidOpen.fire(e); - })); - - this._widgetDisposables.add(addDisposableListener(tree.getHTMLElement(), 'click', () => { - this._onDidFocus.fire(); - }, true)); - - dom.append(container, tree.getHTMLElement()); - } - - private _createList(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { - this._list = this._listPool.get(); - const list = this._list.object; - this._widgetDisposables.add(this._list); - - this._widgetDisposables.add(list.onDidFocus(() => { - this._onDidFocus.fire(); - })); - - this._widgetDisposables.add(list.onDidOpen(async (e) => { - if (e.element) { - this._onDidOpen.fire({ - element: e.element as IChatEditsTreeElement, - editorOptions: e.editorOptions, - sideBySide: e.sideBySide, - browserEvent: e.browserEvent, - }); - } - })); - - this._widgetDisposables.add(addDisposableListener(list.getHTMLElement(), 'click', () => { - this._onDidFocus.fire(); - }, true)); - - dom.append(container, list.getHTMLElement()); - } - - override dispose(): void { - this.clear(); - super.dispose(); - } -} 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 839f0e04caada..4e246bc55abfe 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -31,6 +31,7 @@ import { mixin } from '../../../../../../base/common/objects.js'; import { autorun, derived, derivedOpts, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; import { isEqual } from '../../../../../../base/common/resources.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import { assertType } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../../../editor/browser/config/editorConfiguration.js'; @@ -62,6 +63,7 @@ import { registerAndCreateHistoryNavigationContext } from '../../../../../../pla import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { WorkbenchList } from '../../../../../../platform/list/browser/listService.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; import { bindContextKey } from '../../../../../../platform/observable/common/platformObservableUtils.js'; @@ -101,18 +103,18 @@ import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; -import { IChatWidget, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; -import { ChatEditingShowChangesAction, ChatEditsViewAsListActionId, ChatEditsViewAsTreeActionId, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; +import { IChatWidget, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; +import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { resizeImage } from '../../chatImageUtils.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; import { IChatContextService } from '../../contextContrib/chatContextService.js'; +import { IDisposableReference } from '../chatContentParts/chatCollections.js'; import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../chatContentParts/chatQuestionCarouselPart.js'; import { IChatContentPartRenderContext } from '../chatContentParts/chatContentParts.js'; -import { IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; +import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; import { ChatTodoListWidget } from '../chatContentParts/chatTodoListWidget.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; -import { ChatEditsListWidget } from './chatEditsTree.js'; import { ChatFollowups } from './chatFollowups.js'; import { ChatInputPartWidgetController } from './chatInputPartWidgets.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; @@ -207,6 +209,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private readonly _chatQuestionCarouselWidget = this._register(new MutableDisposable()); private readonly _chatQuestionCarouselDisposables = this._register(new DisposableStore()); private _currentQuestionCarouselResponseId: string | undefined; + private _currentQuestionCarouselSessionResource: URI | undefined; + private _hasQuestionCarouselContextKey: IContextKey | undefined; private readonly _chatEditingTodosDisposables = this._register(new DisposableStore()); private _lastEditingSessionResource: URI | undefined; @@ -345,12 +349,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private editingSentRequestKey!: IContextKey; private chatModeKindKey: IContextKey; private chatModeNameKey: IContextKey; + private chatModelIdKey: IContextKey; private withinEditSessionKey: IContextKey; private filePartOfEditSessionKey: IContextKey; private chatSessionHasOptions: IContextKey; private chatSessionOptionsValid: IContextKey; private agentSessionTypeKey: IContextKey; private chatSessionHasCustomAgentTarget: IContextKey; + private chatSessionHasTargetedModels: IContextKey; private modelWidget: ModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; private sessionTargetWidget: SessionTypePickerActionItem | undefined; @@ -428,11 +434,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _workingSetLinesRemovedSpan = new Lazy(() => dom.$('.working-set-lines-removed')); private readonly _chatEditsActionsDisposables: DisposableStore = this._register(new DisposableStore()); + private readonly _chatEditsDisposables: DisposableStore = this._register(new DisposableStore()); private readonly _renderingChatEdits = this._register(new MutableDisposable()); - private readonly _chatEditsListWidget = this._register(new MutableDisposable()); + private _chatEditsListPool: CollapsibleListPool; + private _chatEditList: IDisposableReference> | undefined; get selectedElements(): URI[] { - return this._chatEditsListWidget.value?.selectedElements ?? []; + const edits = []; + const editsList = this._chatEditList?.object; + const selectedElements = editsList?.getSelectedElements() ?? []; + for (const element of selectedElements) { + if (element.kind === 'reference' && URI.isUri(element.reference)) { + edits.push(element.reference); + } + } + return edits; } private _attemptedWorkingSetEntriesCount: number = 0; @@ -461,6 +477,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _emptyInputState: ObservableMemento; private _chatSessionIsEmpty = false; private _pendingDelegationTarget: AgentSessionProviders | undefined = undefined; + private _currentSessionType: string | undefined = undefined; constructor( // private readonly editorOptions: ChatEditorOptions, // TODO this should be used @@ -548,8 +565,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.inputEditorHasText = ChatContextKeys.inputHasText.bindTo(contextKeyService); this.chatCursorAtTop = ChatContextKeys.inputCursorAtTop.bindTo(contextKeyService); this.inputEditorHasFocus = ChatContextKeys.inputHasFocus.bindTo(contextKeyService); + this._hasQuestionCarouselContextKey = ChatContextKeys.Editing.hasQuestionCarousel.bindTo(contextKeyService); this.chatModeKindKey = ChatContextKeys.chatModeKind.bindTo(contextKeyService); this.chatModeNameKey = ChatContextKeys.chatModeName.bindTo(contextKeyService); + this.chatModelIdKey = ChatContextKeys.chatModelId.bindTo(contextKeyService); this.withinEditSessionKey = ChatContextKeys.withinEditSessionDiff.bindTo(contextKeyService); this.filePartOfEditSessionKey = ChatContextKeys.filePartOfEditSession.bindTo(contextKeyService); this.chatSessionHasOptions = ChatContextKeys.chatSessionHasModels.bindTo(contextKeyService); @@ -564,6 +583,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } this.chatSessionHasCustomAgentTarget = ChatContextKeys.chatSessionHasCustomAgentTarget.bindTo(contextKeyService); + this.chatSessionHasTargetedModels = ChatContextKeys.chatSessionHasTargetedModels.bindTo(contextKeyService); this.history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, this.location)); @@ -588,6 +608,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.inputEditor.updateOptions(newOptions); })); + this._chatEditsListPool = this._register(this.instantiationService.createInstance(CollapsibleListPool, this._onDidChangeVisibility.event, MenuId.ChatEditingWidgetModifiedFilesToolbar, { verticalScrollMode: ScrollbarVisibility.Visible })); + this._hasFileAttachmentContextKey = ChatContextKeys.hasFileAttachments.bindTo(contextKeyService); this.initSelectedModel(); @@ -604,8 +626,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // We've changed models and the current one is no longer available. Select a new one const selectedModel = this._currentLanguageModel ? this.getModels().find(m => m.identifier === this._currentLanguageModel.get()?.identifier) : undefined; - const selectedModelNotAvailable = this._currentLanguageModel && (!selectedModel?.metadata.isUserSelectable); - if (!this.currentLanguageModel || selectedModelNotAvailable) { + if (!this.currentLanguageModel || !selectedModel) { this.setCurrentLanguageModelToDefault(); } })); @@ -619,6 +640,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); this._register(autorun(reader => { const lm = this._currentLanguageModel.read(reader); + this.chatModelIdKey.set(lm?.metadata.id.toLowerCase() ?? ''); if (lm?.metadata.name) { this.accessibilityService.alert(lm.metadata.name); } @@ -666,10 +688,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private getSelectedModelStorageKey(): string { + const sessionType = this._currentSessionType; + if (sessionType && this.hasModelsTargetingSessionType()) { + return `chat.currentLanguageModel.${this.location}.${sessionType}`; + } return `chat.currentLanguageModel.${this.location}`; } private getSelectedModelIsDefaultStorageKey(): string { + const sessionType = this._currentSessionType; + if (sessionType && this.hasModelsTargetingSessionType()) { + return `chat.currentLanguageModel.${this.location}.${sessionType}.isDefault`; + } return `chat.currentLanguageModel.${this.location}.isDefault`; } @@ -908,11 +938,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - // Sync selected model + // Sync selected model - validate it belongs to the current session's model pool if (state?.selectedModel) { const lm = this._currentLanguageModel.get(); if (!lm || lm.identifier !== state.selectedModel.identifier) { - this.setCurrentLanguageModel(state.selectedModel); + if (this.isModelValidForCurrentSession(state.selectedModel)) { + this.setCurrentLanguageModel(state.selectedModel); + } else { + // Model from state doesn't belong to this session's pool - use default + this.setCurrentLanguageModelToDefault(); + } } } @@ -963,6 +998,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier) { this._currentLanguageModel.set(model, undefined); + // Record usage for the recently used models list + this.languageModelsService.recordModelUsage(model.identifier); + if (this.cachedWidth) { // For quick chat and editor chat, relayout because the input may need to shrink to accomodate the model name this.layout(this.cachedWidth); @@ -978,7 +1016,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private checkModelSupported(): void { const lm = this._currentLanguageModel.get(); - if (lm && (!this.modelSupportedForDefaultAgent(lm) || !this.modelSupportedForInlineChat(lm))) { + if (lm && (!this.modelSupportedForDefaultAgent(lm) || !this.modelSupportedForInlineChat(lm) || !this.isModelValidForCurrentSession(lm))) { this.setCurrentLanguageModelToDefault(); } } @@ -1035,12 +1073,135 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.storageService.store(CachedLanguageModelsKey, models, StorageScope.APPLICATION, StorageTarget.MACHINE); } models.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); - return models.filter(entry => entry.metadata?.isUserSelectable && this.modelSupportedForDefaultAgent(entry) && this.modelSupportedForInlineChat(entry)); + + const sessionType = this.getCurrentSessionType(); + if (sessionType) { + // Session has a specific chat session type - show only models that target + // this session type, if any such models exist. + return models.filter(entry => entry.metadata?.targetChatSessionType === sessionType && entry.metadata?.isUserSelectable); + } + + // No session type or no targeted models - show general models (those without + // a targetChatSessionType) filtered by the standard criteria. + return models.filter(entry => !entry.metadata?.targetChatSessionType && entry.metadata?.isUserSelectable && this.modelSupportedForDefaultAgent(entry) && this.modelSupportedForInlineChat(entry)); + } + + /** + * Get the chat session type for the current session, if any. + * Uses the delegate or `getChatSessionFromInternalUri` to determine the session type. + */ + private getCurrentSessionType(): string | undefined { + const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); + if (delegateSessionType) { + return delegateSessionType; + } + const sessionResource = this._widget?.viewModel?.model.sessionResource; + const ctx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; + return ctx?.chatSessionType; + } + + /** + * Check if any registered models target the current session type. + * This is used to set the context key that controls model picker visibility. + */ + private hasModelsTargetingSessionType(): boolean { + const sessionType = this.getCurrentSessionType(); + if (!sessionType) { + return false; + } + return this.languageModelsService.getLanguageModelIds().some(modelId => { + const metadata = this.languageModelsService.lookupLanguageModel(modelId); + return metadata?.targetChatSessionType === sessionType; + }); + } + + /** + * Check if a model is valid for the current session's model pool. + * If the session has targeted models, the model must target this session type. + * If no models target this session, the model must not have a targetChatSessionType. + */ + private isModelValidForCurrentSession(model: ILanguageModelChatMetadataAndIdentifier): boolean { + if (this.hasModelsTargetingSessionType()) { + // Session has targeted models - model must match + return model.metadata.targetChatSessionType === this.getCurrentSessionType(); + } + // No targeted models - model must not be session-specific + return !model.metadata.targetChatSessionType; + } + + /** + * Validate that the current model belongs to the current session's pool. + * Called when switching sessions to prevent cross-contamination. + */ + private checkModelInSessionPool(): void { + const lm = this._currentLanguageModel.get(); + if (lm && !this.isModelValidForCurrentSession(lm)) { + this.setCurrentLanguageModelToDefault(); + } + } + + /** + * Pre-select the model in the model picker based on the `modelId` from the + * last request in the current session's history. This ensures that when a + * contributed chat session is reopened, the model picker shows the model + * that was last used - providing continuity. + */ + private preselectModelFromSessionHistory(): void { + const sessionResource = this._widget?.viewModel?.model.sessionResource; + const ctx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; + const requiresCustomModels = ctx && this.chatSessionsService.requiresCustomModelsForSessionType(ctx.chatSessionType); + if (!requiresCustomModels) { + return; + } + + const requests = this._widget?.viewModel?.model.getRequests(); + if (!requests || requests.length === 0) { + return; + } + + // Find the modelId from the last request that has one + let lastModelId: string | undefined; + for (let i = requests.length - 1; i >= 0; i--) { + if (requests[i].modelId) { + lastModelId = requests[i].modelId; + break; + } + } + + if (!lastModelId) { + return; + } + + const tryMatch = () => { + const models = this.getModels(); + // Try exact identifier match first (e.g. "copilot/gpt-4o") + let match = models.find(m => m.identifier === lastModelId); + if (!match) { + // Fallback: match on metadata.id (short model ID from the extension) + match = models.find(m => m.metadata.id === lastModelId); + } + return match; + }; + + const match = tryMatch(); + if (match) { + this.setCurrentLanguageModel(match); + return; + } + + // Models may not be loaded yet - wait for them + this._waitForPersistedLanguageModel.value = this.languageModelsService.onDidChangeLanguageModels(() => { + const found = tryMatch(); + if (found) { + this._waitForPersistedLanguageModel.clear(); + this.setCurrentLanguageModel(found); + } + }); } private setCurrentLanguageModelToDefault() { const allModels = this.getModels(); - const defaultModel = allModels.find(m => m.metadata.isDefaultForLocation[this.location]) || allModels.find(m => m.metadata.isUserSelectable); + const defaultModel = allModels.find(m => m.metadata.isDefaultForLocation[this.location]) || allModels[0]; if (defaultModel) { this.setCurrentLanguageModel(defaultModel); } @@ -1425,6 +1586,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const customAgentTarget = ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType); this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); + // Check if this session type requires custom models + const requiresCustomModels = ctx && this.chatSessionsService.requiresCustomModelsForSessionType(ctx.chatSessionType); + this.chatSessionHasTargetedModels.set(!!requiresCustomModels); + // Handle agent option from session - set initial mode if (customAgentTarget) { const agentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, agentOptionId); @@ -1739,14 +1904,32 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - this._register(widget.onDidChangeViewModel(() => { + this._register(widget.onDidChangeViewModel((e: IChatWidgetViewModelChangeEvent) => { this._pendingDelegationTarget = undefined; // Update agentSessionType when view model changes this.updateAgentSessionTypeContextKey(); this.refreshChatSessionPickers(); this.tryUpdateWidgetController(); this.updateContextUsageWidget(); - this.clearQuestionCarousel(); + if (this._currentQuestionCarouselSessionResource && (!e.currentSessionResource || !isEqual(this._currentQuestionCarouselSessionResource, e.currentSessionResource))) { + this.clearQuestionCarousel(); + } + + // Track the current session type and re-initialize model selection + // when the session type changes (different session types may have + // different model pools via targetChatSessionType). + const newSessionType = this.getCurrentSessionType(); + if (newSessionType !== this._currentSessionType) { + this._currentSessionType = newSessionType; + this.initSelectedModel(); + } + + // Validate that the current model belongs to the new session's pool + this.checkModelInSessionPool(); + + // For contributed sessions with history, pre-select the model + // from the last request so the user resumes with the same model. + this.preselectModelFromSessionHistory(); })); let elements; @@ -2000,7 +2183,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.setCurrentLanguageModel(model); this.renderAttachedContext(); }, - getModels: () => this.getModels() + getModels: () => this.getModels(), + canManageModels: () => !this.getCurrentSessionType() }; return this.modelWidget = this.instantiationService.createInstance(ModelPickerActionItem, action, undefined, itemDelegate, pickerOptions); } else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) { @@ -2436,11 +2620,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // track the response id and session this._currentQuestionCarouselResponseId = isResponseVM(context.element) ? context.element.requestId : undefined; + this._currentQuestionCarouselSessionResource = isResponseVM(context.element) ? context.element.sessionResource : undefined; const part = this._chatQuestionCarouselDisposables.add( this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, options) ); this._chatQuestionCarouselWidget.value = part; + this._hasQuestionCarouselContextKey?.set(true); dom.clearNode(this.chatQuestionCarouselContainer); dom.append(this.chatQuestionCarouselContainer, part.domNode); @@ -2455,6 +2641,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._chatQuestionCarouselDisposables.clear(); this._chatQuestionCarouselWidget.clear(); this._currentQuestionCarouselResponseId = undefined; + this._currentQuestionCarouselSessionResource = undefined; + this._hasQuestionCarouselContextKey?.set(false); dom.clearNode(this.chatQuestionCarouselContainer); } get questionCarouselResponseId(): string | undefined { @@ -2584,7 +2772,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ); } else { dom.clearNode(this.chatEditingSessionWidgetContainer); - this._chatEditsListWidget.value?.clear(); + this._chatEditsDisposables.clear(); + this._chatEditList = undefined; } }); } @@ -2677,8 +2866,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }) : undefined, disableWhileRunning: isSessionMenu, buttonConfigProvider: (action) => { - if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID - || action.id === ChatEditsViewAsTreeActionId || action.id === ChatEditsViewAsListActionId) { + if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID) { return { showIcon: true, showLabel: false, isSecondary: true }; } return undefined; @@ -2729,51 +2917,54 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge workingSetContainer.classList.toggle('collapsed', collapsed); })); - if (!this._chatEditsListWidget.value || this._chatEditsListWidget.value.needsRebuild) { - if (!this._chatEditsListWidget.value) { - const widget = this.instantiationService.createInstance(ChatEditsListWidget, this._onDidChangeVisibility.event); - this._chatEditsListWidget.value = widget; - this._register(widget.onDidFocus(() => this._onDidFocus.fire())); - this._register(widget.onDidOpen(async (e) => { - const element = e.element; - if (!element || element.kind === 'folder' || element.kind === 'warning') { + if (!this._chatEditList) { + this._chatEditList = this._chatEditsListPool.get(); + const list = this._chatEditList.object; + this._chatEditsDisposables.add(this._chatEditList); + this._chatEditsDisposables.add(list.onDidFocus(() => { + this._onDidFocus.fire(); + })); + this._chatEditsDisposables.add(list.onDidOpen(async (e) => { + if (e.element?.kind === 'reference' && URI.isUri(e.element.reference)) { + const modifiedFileUri = e.element.reference; + const originalUri = e.element.options?.originalUri; + + if (e.element.options?.isDeletion && originalUri) { + await this.editorService.openEditor({ + resource: originalUri, // instead of modified, because modified will not exist + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); return; } - if (element.kind === 'reference' && URI.isUri(element.reference)) { - const modifiedFileUri = element.reference; - const originalUri = element.options?.originalUri; - - if (element.options?.isDeletion && originalUri) { - await this.editorService.openEditor({ - resource: originalUri, - options: e.editorOptions - }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); - return; - } - - if (originalUri) { - await this.editorService.openEditor({ - original: { resource: originalUri }, - modified: { resource: modifiedFileUri }, - options: e.editorOptions - }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); - return; - } - // Use the widget's current session, not a stale closure - const entry = widget.currentSession?.getEntry(modifiedFileUri); - const pane = await this.editorService.openEditor({ - resource: modifiedFileUri, + // If there's a originalUri, open as diff editor + if (originalUri) { + await this.editorService.openEditor({ + original: { resource: originalUri }, + modified: { resource: modifiedFileUri }, options: e.editorOptions }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } - if (pane) { - entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus); - } + const entry = chatEditingSession?.getEntry(modifiedFileUri); + + const pane = await this.editorService.openEditor({ + resource: modifiedFileUri, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + + if (pane) { + entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus); } - })); - } - this._chatEditsListWidget.value.rebuild(workingSetContainer, chatEditingSession); + } + })); + this._chatEditsDisposables.add(addDisposableListener(list.getHTMLElement(), 'click', e => { + if (!this.hasFocus()) { + this._onDidFocus.fire(); + } + }, true)); + dom.append(workingSetContainer, list.getHTMLElement()); dom.append(innerContainer, workingSetContainer); } @@ -2786,7 +2977,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // entries, while background chat sessions use session file changes. const allEntries = editEntries.concat(sessionFileEntries); - this._chatEditsListWidget.value?.setEntries(allEntries); + const maxItemsShown = 6; + const itemsShown = Math.min(allEntries.length, maxItemsShown); + const height = itemsShown * 22; + const list = this._chatEditList!.object; + list.layout(height); + list.getHTMLElement().style.height = `${height}px`; + list.splice(0, list.length, allEntries); })); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts new file mode 100644 index 0000000000000..8e7831378c1b0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -0,0 +1,513 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { renderIcon, renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { KeyCode } from '../../../../../../base/common/keyCodes.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { ActionListItemKind, IActionListItem, IActionListOptions } from '../../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { MANAGE_CHAT_COMMAND_ID } from '../../../common/constants.js'; +import { ICuratedModel, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; +import { IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import * as semver from '../../../../../../base/common/semver/semver.js'; + +function isVersionAtLeast(current: string, required: string): boolean { + const currentSemver = semver.coerce(current); + if (!currentSemver) { + return false; + } + return semver.gte(currentSemver, required); +} + +/** + * Section identifiers for collapsible groups in the model picker. + */ +const ModelPickerSection = { + Other: 'other', +} as const; + +type ChatModelChangeClassification = { + owner: 'lramos15'; + comment: 'Reporting when the model picker is switched'; + fromModel?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous chat model' }; + toModel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new chat model' }; +}; + +type ChatModelChangeEvent = { + fromModel: string | TelemetryTrustedValue | undefined; + toModel: string | TelemetryTrustedValue; +}; + +function createModelItem( + action: IActionWidgetDropdownAction & { section?: string }, +): IActionListItem { + return { + item: action, + kind: ActionListItemKind.Action, + label: action.label, + description: action.description, + group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, + hideIcon: false, + section: action.section, + }; +} + +function createModelAction( + model: ILanguageModelChatMetadataAndIdentifier, + selectedModelId: string | undefined, + onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, + section?: string, +): IActionWidgetDropdownAction & { section?: string } { + return { + id: model.identifier, + enabled: true, + icon: model.metadata.statusIcon, + checked: model.identifier === selectedModelId, + class: undefined, + description: model.metadata.multiplier ?? model.metadata.detail, + tooltip: model.metadata.name, + label: model.metadata.name, + section, + run: () => onSelect(model), + }; +} + +/** + * Builds the grouped items for the model picker dropdown. + * + * Layout: + * 1. Auto (always first) + * 2. Recently used + curated models (merged, sorted alphabetically, no header) + * 3. Other Models (collapsible toggle, sorted alphabetically) + * - Last item is "Manage Models..." + */ +function buildModelPickerItems( + models: ILanguageModelChatMetadataAndIdentifier[], + selectedModelId: string | undefined, + recentModelIds: string[], + curatedModels: ICuratedModel[], + isProUser: boolean, + currentVSCodeVersion: string, + onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, + commandService: ICommandService, + openerService: IOpenerService, + upgradePlanUrl: string | undefined, +): IActionListItem[] { + const items: IActionListItem[] = []; + + // Collect all available models + const allModelsMap = new Map(); + for (const model of models) { + allModelsMap.set(model.identifier, model); + } + + // Build a secondary lookup by metadata.id for flexible matching + const modelsByMetadataId = new Map(); + for (const model of models) { + modelsByMetadataId.set(model.metadata.id, model); + } + + // Track which model IDs have been placed in the promoted group + const placed = new Set(); + + // --- 1. Auto --- + const isAutoSelected = !selectedModelId || !allModelsMap.has(selectedModelId); + const defaultModel = models.find(m => Object.values(m.metadata.isDefaultForLocation).some(v => v)); + const autoDescription = defaultModel?.metadata.multiplier ?? defaultModel?.metadata.detail; + items.push(createModelItem({ + id: 'auto', + enabled: true, + checked: isAutoSelected, + class: undefined, + tooltip: localize('chat.modelPicker.auto', "Auto"), + label: localize('chat.modelPicker.auto', "Auto"), + description: autoDescription, + run: () => { + if (defaultModel) { + onSelect(defaultModel); + } + } + })); + + // --- 2. Promoted models (recently used + curated, merged & sorted alphabetically) --- + const promotedModels: ILanguageModelChatMetadataAndIdentifier[] = []; + const unavailableCurated: { curated: ICuratedModel; reason: 'upgrade' | 'update' | 'admin' }[] = []; + + // Add recently used (skip the default model - it's already represented by "Auto") + for (const id of recentModelIds) { + const model = allModelsMap.get(id); + if (model && !placed.has(model.identifier) && model !== defaultModel) { + promotedModels.push(model); + placed.add(model.identifier); + } + } + + // Add curated - available ones become promoted, unavailable ones become disabled entries + for (const curated of curatedModels) { + const model = allModelsMap.get(curated.id) ?? modelsByMetadataId.get(curated.id); + if (model && !placed.has(model.identifier)) { + promotedModels.push(model); + placed.add(model.identifier); + } else if (!model) { + // Model is not available - determine reason + if (!isProUser) { + unavailableCurated.push({ curated, reason: 'upgrade' }); + } else if (curated.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, curated.minVSCodeVersion)) { + unavailableCurated.push({ curated, reason: 'update' }); + } else { + unavailableCurated.push({ curated, reason: 'admin' }); + } + } + } + + // Sort alphabetically for a stable list + promotedModels.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + + if (promotedModels.length > 0 || unavailableCurated.length > 0) { + items.push({ + kind: ActionListItemKind.Separator, + }); + for (const model of promotedModels) { + const action = createModelAction(model, selectedModelId, onSelect); + items.push(createModelItem(action)); + } + + // Unavailable curated models shown as disabled with action button + for (const { curated, reason } of unavailableCurated) { + const label = reason === 'upgrade' + ? localize('chat.modelPicker.upgrade', "Upgrade") + : reason === 'update' + ? localize('chat.modelPicker.update', "Update VS Code") + : localize('chat.modelPicker.adminEnable', "Contact Admin"); + const onButtonClick = reason === 'upgrade' && upgradePlanUrl + ? () => openerService.open(URI.parse(upgradePlanUrl)) + : reason === 'update' + ? () => commandService.executeCommand('update.checkForUpdate') + : () => { }; + items.push({ + item: { + id: curated.id, + enabled: false, + checked: false, + class: undefined, + tooltip: label, + label: curated.id, + description: label, + run: () => { } + }, + kind: ActionListItemKind.Action, + label: curated.id, + descriptionButton: { label, onDidClick: onButtonClick }, + disabled: true, + group: { title: '', icon: Codicon.blank }, + hideIcon: false, + className: 'unavailable-model', + }); + } + } + + // --- 3. Other Models (collapsible) --- + const otherModels: ILanguageModelChatMetadataAndIdentifier[] = []; + for (const model of models) { + if (!placed.has(model.identifier)) { + // Skip the default model - it's already represented by the top-level "Auto" entry + const isDefault = Object.values(model.metadata.isDefaultForLocation).some(v => v); + if (isDefault) { + continue; + } + otherModels.push(model); + } + } + + if (otherModels.length > 0) { + items.push({ + kind: ActionListItemKind.Separator, + }); + items.push({ + item: { + id: 'otherModels', + enabled: true, + checked: false, + class: undefined, + tooltip: localize('chat.modelPicker.otherModels', "Other Models"), + label: localize('chat.modelPicker.otherModels', "Other Models"), + run: () => { /* toggle handled by isSectionToggle */ } + }, + kind: ActionListItemKind.Action, + label: localize('chat.modelPicker.otherModels', "Other Models"), + group: { title: '', icon: Codicon.chevronDown }, + hideIcon: false, + section: ModelPickerSection.Other, + isSectionToggle: true, + }); + for (const model of otherModels) { + const action = createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other); + items.push(createModelItem(action)); + } + + // "Manage Models..." entry inside Other Models section, styled as a link + items.push({ + item: { + id: 'manageModels', + enabled: true, + checked: false, + class: 'manage-models-action', + tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), + label: localize('chat.manageModels', "Manage Models..."), + icon: Codicon.settingsGear, + run: () => { + commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); + } + }, + kind: ActionListItemKind.Action, + label: localize('chat.manageModels', "Manage Models..."), + group: { title: '', icon: Codicon.settingsGear }, + hideIcon: false, + section: ModelPickerSection.Other, + className: 'manage-models-link', + }); + } + + return items; +} + +/** + * Returns the ActionList options for the model picker (filter + collapsed sections). + */ +function getModelPickerListOptions(): IActionListOptions { + return { + showFilter: true, + collapsedByDefault: new Set([ModelPickerSection.Other]), + minWidth: 300, + }; +} + +export type ModelPickerBadge = 'info' | 'warning'; + +/** + * A model selection dropdown widget. + * + * Renders a button showing the currently selected model name. + * On click, opens a grouped picker popup with: + * Auto → Promoted (recently used + curated) → Other Models (collapsed with search). + * + * The widget owns its state - set models, selection, and curated IDs via setters. + * Listen for selection changes via `onDidChangeSelection`. + */ +export class ModelPickerWidget extends Disposable { + + private readonly _onDidChangeSelection = this._register(new Emitter()); + readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; + + private _models: ILanguageModelChatMetadataAndIdentifier[] = []; + private _selectedModel: ILanguageModelChatMetadataAndIdentifier | undefined; + private _badge: ModelPickerBadge | undefined; + + private _domNode: HTMLElement | undefined; + private _badgeIcon: HTMLElement | undefined; + + get selectedModel(): ILanguageModelChatMetadataAndIdentifier | undefined { + return this._selectedModel; + } + + get domNode(): HTMLElement | undefined { + return this._domNode; + } + + constructor( + @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, + @ICommandService private readonly _commandService: ICommandService, + @IOpenerService private readonly _openerService: IOpenerService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IProductService private readonly _productService: IProductService, + @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, + ) { + super(); + } + + setModels(models: ILanguageModelChatMetadataAndIdentifier[]): void { + this._models = models; + this._renderLabel(); + } + + setSelectedModel(model: ILanguageModelChatMetadataAndIdentifier | undefined): void { + this._selectedModel = model; + this._renderLabel(); + } + + setBadge(badge: ModelPickerBadge | undefined): void { + this._badge = badge; + this._updateBadge(); + } + + render(container: HTMLElement): void { + this._domNode = dom.append(container, dom.$('a.action-label')); + this._domNode.tabIndex = 0; + this._domNode.setAttribute('role', 'button'); + this._domNode.setAttribute('aria-haspopup', 'true'); + this._domNode.setAttribute('aria-expanded', 'false'); + + this._badgeIcon = dom.append(this._domNode, dom.$('span.model-picker-badge')); + this._updateBadge(); + + this._renderLabel(); + + // Open picker on click + this._register(dom.addDisposableListener(this._domNode, dom.EventType.MOUSE_DOWN, (e) => { + if (e.button !== 0) { + return; // only left click + } + dom.EventHelper.stop(e, true); + this.show(); + })); + + // Open picker on Enter/Space + this._register(dom.addDisposableListener(this._domNode, dom.EventType.KEY_DOWN, (e) => { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { + dom.EventHelper.stop(e, true); + this.show(); + } + })); + } + + show(anchor?: HTMLElement): void { + const anchorElement = anchor ?? this._domNode; + if (!anchorElement) { + return; + } + + // Mark new models as seen immediately when the picker is opened + this._languageModelsService.markNewModelsAsSeen(); + + const previousModel = this._selectedModel; + + const onSelect = (model: ILanguageModelChatMetadataAndIdentifier) => { + this._telemetryService.publicLog2('chat.modelChange', { + fromModel: previousModel?.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(previousModel.identifier) : 'unknown', + toModel: model.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(model.identifier) : 'unknown' + }); + this._selectedModel = model; + this._renderLabel(); + this._onDidChangeSelection.fire(model); + }; + + const isPro = isProUser(this._entitlementService.entitlement); + const curatedModels = this._languageModelsService.getCuratedModels(); + const curatedForTier = isPro ? curatedModels.paid : curatedModels.free; + + const items = buildModelPickerItems( + this._models, + this._selectedModel?.identifier, + this._languageModelsService.getRecentlyUsedModelIds(7), + curatedForTier, + isPro, + this._productService.version, + onSelect, + this._commandService, + this._openerService, + this._productService.defaultChatAgent?.upgradePlanUrl, + ); + + const listOptions = getModelPickerListOptions(); + const previouslyFocusedElement = dom.getActiveElement(); + + const delegate = { + onSelect: (action: IActionWidgetDropdownAction) => { + this._actionWidgetService.hide(); + action.run(); + }, + onHide: () => { + this._domNode?.setAttribute('aria-expanded', 'false'); + if (dom.isHTMLElement(previouslyFocusedElement)) { + previouslyFocusedElement.focus(); + } + } + }; + + this._domNode?.setAttribute('aria-expanded', 'true'); + + this._actionWidgetService.show( + 'ChatModelPicker', + false, + items, + delegate, + 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', + }, + listOptions + ); + } + + private _updateBadge(): void { + if (this._badgeIcon) { + if (this._badge) { + const icon = this._badge === 'info' ? Codicon.info : Codicon.warning; + dom.reset(this._badgeIcon, renderIcon(icon)); + this._badgeIcon.style.display = ''; + this._badgeIcon.classList.toggle('info', this._badge === 'info'); + this._badgeIcon.classList.toggle('warning', this._badge === 'warning'); + } else { + this._badgeIcon.style.display = 'none'; + } + } + } + + private _renderLabel(): void { + if (!this._domNode) { + return; + } + + const { name, statusIcon } = this._selectedModel?.metadata || {}; + const domChildren: (HTMLElement | string)[] = []; + + if (statusIcon) { + const iconElement = renderIcon(statusIcon); + domChildren.push(iconElement); + } + + domChildren.push(dom.$('span.chat-input-picker-label', undefined, name ?? localize('chat.modelPicker.auto', "Auto"))); + + // Badge icon between label and chevron + if (this._badgeIcon) { + domChildren.push(this._badgeIcon); + } + + domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); + + dom.reset(this._domNode, ...domChildren); + + // Aria + const modelName = this._selectedModel?.metadata.name ?? localize('chat.modelPicker.auto', "Auto"); + this._domNode.ariaLabel = localize('chat.modelPicker.ariaLabel', "Pick Model, {0}", modelName); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts index 5d5b59ccca8fd..fac726fe312a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts @@ -61,7 +61,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { this._primaryActionAction = this._register(new Action( 'chat.queuePickerPrimary', isSteerDefault ? localize('chat.steerWithMessage', "Steer with Message") : localize('chat.queueMessage', "Add to Queue"), - ThemeIcon.asClassName(Codicon.send), + ThemeIcon.asClassName(Codicon.arrowUp), !!contextKeyService.getContextKeyValue(ChatContextKeys.inputHasText.key), () => this._runDefaultAction() )); @@ -194,7 +194,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { label: localize('chat.sendImmediately', "Stop and Send"), tooltip: '', enabled: true, - icon: Codicon.send, + icon: Codicon.arrowUp, class: undefined, hover: { content: localize('chat.sendImmediately.hover', "Cancel the current request and send this message immediately."), diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts index 03954d815ded7..8a5da97aff018 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts @@ -4,26 +4,39 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; +import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { ChatEntitlement, ChatEntitlementContextKeys, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { IChatService } from '../../../common/chatService/chatService.js'; import { CHAT_SETUP_ACTION_ID } from '../../actions/chatActions.js'; import { ChatInputPartWidgetsRegistry, IChatInputPartWidget } from './chatInputPartWidgets.js'; import './media/chatStatusWidget.css'; const $ = dom.$; +const DISMISS_STORAGE_KEY = 'chat.noAuthWidget.dismissed'; + /** * Widget that displays a status message with an optional action button. - * Only shown for free tier users when the setting is enabled (experiment controlled via onExP tag). + * Handles three cases: + * - 'free': Quota exceeded for free tier users + * - 'anonymous': Quota exceeded for anonymous users + * - 'anonymousWelcome': Welcome banner for anonymous users who haven't used chat yet (experiment controlled) */ export class ChatStatusWidget extends Disposable implements IChatInputPartWidget { @@ -36,78 +49,146 @@ export class ChatStatusWidget extends Disposable implements IChatInputPartWidget constructor( @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @IChatService private readonly chatService: IChatService, @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IHoverService private readonly hoverService: IHoverService, + @IStorageService private readonly storageService: IStorageService, @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); this.domNode = $('.chat-status-widget'); this.domNode.style.display = 'none'; - this.initializeIfEnabled(); + void this.initializeIfEnabled(); } - private initializeIfEnabled(): void { + private async initializeIfEnabled(): Promise { const entitlement = this.chatEntitlementService.entitlement; const isAnonymous = this.chatEntitlementService.anonymous; - // Free tier is always enabled, anonymous is controlled by experiment via chat.statusWidget.sku - const enabledSku = this.configurationService.getValue('chat.statusWidget.sku'); + const anonymousEnabled = this.configurationService.getValue('chat.statusWidget.anonymous'); + const enabledBanner = this.configurationService.getValue('chat.noAuthWidget.enabled'); + const bannerDismissed = this.storageService.getBoolean(DISMISS_STORAGE_KEY, StorageScope.PROFILE, false); + + const quotaExceeded = this.chatEntitlementService.quotas.chat?.percentRemaining === 0; - if (isAnonymous && enabledSku === 'anonymous') { + if (quotaExceeded && isAnonymous && anonymousEnabled) { this.createWidgetContent('anonymous'); - } else if (entitlement === ChatEntitlement.Free) { + this.domNode.style.display = ''; + } else if (quotaExceeded && entitlement === ChatEntitlement.Free) { this.createWidgetContent('free'); - } else { - return; + this.domNode.style.display = ''; + } else if (isAnonymous && enabledBanner && !bannerDismissed) { + try { + const history = await this.chatService.getHistorySessionItems(); + if (this._store.isDisposed) { + return; + } + if (history.length === 0 && this.chatEntitlementService.anonymous && + this.configurationService.getValue('chat.noAuthWidget.enabled') && + !this.storageService.getBoolean(DISMISS_STORAGE_KEY, StorageScope.PROFILE, false)) { + this.createWidgetContent('anonymousWelcome'); + this.domNode.style.display = ''; + this.telemetryService.publicLog2('workbenchActionExecuted', { + id: 'chatStatusWidget.welcomeShown', + from: 'chatStatusWidget' + }); + } + } catch { + // best-effort: banner won't show if history check fails + } } - - this.domNode.style.display = ''; } get height(): number { return this.domNode.style.display === 'none' ? 0 : this.domNode.offsetHeight; } - private createWidgetContent(enabledSku: 'free' | 'anonymous'): void { + private createWidgetContent(mode: 'free' | 'anonymous' | 'anonymousWelcome'): void { const contentContainer = $('.chat-status-content'); this.messageElement = $('.chat-status-message'); - contentContainer.appendChild(this.messageElement); - - const actionContainer = $('.chat-status-action'); - this.actionButton = this._register(new Button(actionContainer, { - ...defaultButtonStyles, - supportIcons: true - })); - this.actionButton.element.classList.add('chat-status-button'); - - if (enabledSku === 'anonymous') { - const message = localize('chat.anonymousRateLimited.message', "You've reached the limit for chat messages. Try Copilot Pro for free."); - const buttonLabel = localize('chat.anonymousRateLimited.signIn', "Sign In"); - this.messageElement.textContent = message; - this.actionButton.label = buttonLabel; - this.actionButton.element.ariaLabel = localize('chat.anonymousRateLimited.signIn.ariaLabel', "{0} {1}", message, buttonLabel); + + let dismissButton: HTMLElement | undefined; + + if (mode === 'anonymousWelcome') { + const copilotIcon = renderIcon(Codicon.copilot); + copilotIcon.classList.add('chat-status-icon'); + copilotIcon.setAttribute('aria-hidden', 'true'); + contentContainer.appendChild(copilotIcon); + contentContainer.appendChild(this.messageElement); + + this.messageElement.textContent = localize('chat.anonymousWelcome.message', "GitHub Copilot is now enabled."); + + // Dismiss button (X) + dismissButton = $('.chat-status-dismiss'); + dismissButton.setAttribute('role', 'button'); + dismissButton.tabIndex = 0; + const dismissLabel = localize('chat.anonymousWelcome.dismiss', "Dismiss"); + dismissButton.setAttribute('aria-label', dismissLabel); + const dismissIcon = renderIcon(Codicon.close); + dismissIcon.setAttribute('aria-hidden', 'true'); + dismissButton.appendChild(dismissIcon); + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), dismissButton, dismissLabel)); + + const handleDismiss = (e: Event) => { + e.stopPropagation(); + this.telemetryService.publicLog2('workbenchActionExecuted', { + id: 'chatStatusWidget.welcomeDismiss', + from: 'chatStatusWidget' + }); + this.storageService.store(DISMISS_STORAGE_KEY, true, StorageScope.PROFILE, StorageTarget.USER); + this.domNode.style.display = 'none'; + }; + + this._register(dom.addDisposableListener(dismissButton, 'click', handleDismiss)); + this._register(dom.addStandardDisposableListener(dismissButton, dom.EventType.KEY_DOWN, (e: StandardKeyboardEvent) => { + if (e.equals(KeyCode.Enter) || e.equals(KeyCode.Space)) { + e.preventDefault(); + handleDismiss(e.browserEvent); + } + })); } else { - const message = localize('chat.freeQuotaExceeded.message', "You've reached the limit for chat messages."); - const buttonLabel = localize('chat.freeQuotaExceeded.upgrade', "Upgrade"); - this.messageElement.textContent = message; - this.actionButton.label = buttonLabel; - this.actionButton.element.ariaLabel = localize('chat.freeQuotaExceeded.upgrade.ariaLabel', "{0} {1}", message, buttonLabel); + contentContainer.appendChild(this.messageElement); + const actionContainer = $('.chat-status-action'); + this.actionButton = this._register(new Button(actionContainer, { + ...defaultButtonStyles, + supportIcons: true + })); + this.actionButton.element.classList.add('chat-status-button'); + + if (mode === 'anonymous') { + const message = localize('chat.anonymousRateLimited.message', "You've reached the limit for chat messages. Sign in to use Copilot Free."); + const buttonLabel = localize('chat.anonymousRateLimited.signIn', "Sign In"); + this.messageElement.textContent = message; + this.actionButton.label = buttonLabel; + this.actionButton.element.ariaLabel = localize('chat.anonymousRateLimited.signIn.ariaLabel', "{0} {1}", message, buttonLabel); + } else { + const message = localize('chat.freeQuotaExceeded.message', "You've reached the limit for chat messages."); + const buttonLabel = localize('chat.freeQuotaExceeded.upgrade', "Upgrade"); + this.messageElement.textContent = message; + this.actionButton.label = buttonLabel; + this.actionButton.element.ariaLabel = localize('chat.freeQuotaExceeded.upgrade.ariaLabel', "{0} {1}", message, buttonLabel); + } + + this._register(this.actionButton.onDidClick(async () => { + const commandId = this.chatEntitlementService.anonymous + ? CHAT_SETUP_ACTION_ID + : 'workbench.action.chat.upgradePlan'; + this.telemetryService.publicLog2('workbenchActionExecuted', { + id: commandId, + from: 'chatStatusWidget' + }); + await this.commandService.executeCommand(commandId); + })); + + this.domNode.appendChild(actionContainer); } - this._register(this.actionButton.onDidClick(async () => { - const commandId = this.chatEntitlementService.anonymous - ? CHAT_SETUP_ACTION_ID - : 'workbench.action.chat.upgradePlan'; - this.telemetryService.publicLog2('workbenchActionExecuted', { - id: commandId, - from: 'chatStatusWidget' - }); - await this.commandService.executeCommand(commandId); - })); - this.domNode.appendChild(contentContainer); - this.domNode.appendChild(actionContainer); + if (dismissButton) { + this.domNode.appendChild(dismissButton); + } } } @@ -115,10 +196,9 @@ ChatInputPartWidgetsRegistry.register( ChatStatusWidget.ID, ChatStatusWidget, ContextKeyExpr.and( - ChatContextKeys.chatQuotaExceeded, ChatContextKeys.chatSessionIsEmpty, ContextKeyExpr.or( - ChatContextKeys.Entitlement.planFree, + ContextKeyExpr.and(ChatContextKeys.chatQuotaExceeded, ChatContextKeys.Entitlement.planFree), ChatEntitlementContextKeys.chatAnonymous ) ) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index 37a075a3970bc..9e5218f2afbf1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -54,8 +54,7 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return true; // Always show active session type } - const contribution = this.chatSessionsService.getChatSessionContribution(type); - return getAgentCanContinueIn(type, contribution); + return getAgentCanContinueIn(type); } protected override _getSessionCategory(sessionTypeItem: ISessionTypeItem) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index fdadd6673579c..4188fb18fd0be 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -197,18 +197,18 @@ class SlashCommandCompletions extends Disposable { return null; } - if (widget.lockedAgentId) { + if (widget.lockedAgentId && !widget.attachmentCapabilities.supportsPromptAttachments) { return null; } - // Filter out commands that are not user-invokable (hidden from / menu) - const userInvokableCommands = promptCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); - if (userInvokableCommands.length === 0) { + // Filter out commands that are not user-invocable (hidden from / menu) + const userInvocableCommands = promptCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); + if (userInvocableCommands.length === 0) { return null; } return { - suggestions: userInvokableCommands.map((c, i): CompletionItem => { + suggestions: userInvocableCommands.map((c, i): CompletionItem => { const label = `/${c.name}`; const description = c.description; return { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatStatusWidget.css b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatStatusWidget.css index 1dac7ac8072d0..fb2a3f74a9352 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatStatusWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatStatusWidget.css @@ -34,6 +34,12 @@ white-space: nowrap; } +.interactive-session .interactive-input-part > .chat-input-widgets-container .chat-status-widget .chat-status-icon { + color: var(--vscode-textLink-foreground); + margin-right: 6px; + flex-shrink: 0; +} + .interactive-session .interactive-input-part > .chat-input-widgets-container .chat-status-widget .chat-status-action { flex-shrink: 0; padding-right: 4px; @@ -46,6 +52,32 @@ height: 22px; } +.interactive-session .interactive-input-part > .chat-input-widgets-container .chat-status-widget .chat-status-dismiss { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-foreground); + opacity: 0.7; + flex-shrink: 0; + margin-right: 4px; +} + +.interactive-session .interactive-input-part > .chat-input-widgets-container .chat-status-widget .chat-status-dismiss:hover { + opacity: 1; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.interactive-session .interactive-input-part > .chat-input-widgets-container .chat-status-widget .chat-status-dismiss:focus-visible { + opacity: 1; + background-color: var(--vscode-toolbar-hoverBackground); + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + .interactive-session .interactive-input-part > .chat-input-widgets-container:has(.chat-status-widget:not([style*="display: none"])) + .chat-todo-list-widget-container .chat-todo-list-widget { border-top-left-radius: 0; border-top-right-radius: 0; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 071230853fa71..b429dfde84a39 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -10,7 +10,7 @@ import { coalesce } from '../../../../../../base/common/arrays.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { groupBy } from '../../../../../../base/common/collections.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; -import { autorun, IObservable } from '../../../../../../base/common/observable.js'; +import { autorun, IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; @@ -33,6 +33,7 @@ import { getOpenChatActionIdForMode } from '../../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../actions/chatExecuteActions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; export interface IModePickerDelegate { readonly currentMode: IObservable; @@ -48,6 +49,7 @@ export interface IModePickerDelegate { const builtinDefaultIcon = (mode: IChatMode) => { switch (mode.name.get().toLowerCase()) { case 'ask': return Codicon.ask; + case 'edit': return Codicon.edit; case 'plan': return Codicon.tasklist; default: return undefined; } @@ -68,8 +70,11 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { @ICommandService commandService: ICommandService, @IProductService private readonly _productService: IProductService, @ITelemetryService telemetryService: ITelemetryService, - @IOpenerService openerService: IOpenerService + @IOpenerService openerService: IOpenerService, + @IWorkbenchAssignmentService assignmentService: IWorkbenchAssignmentService, ) { + const assignments = observableValue<{ showOldAskMode: boolean }>('modePickerAssignments', { showOldAskMode: false }); + // Get custom agent target (if filtering is enabled) const customAgentTarget = delegate.customAgentTarget?.() ?? Target.Undefined; @@ -176,28 +181,29 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { }; }; - const isUserDefinedCustomAgent = (mode: IChatMode): boolean => { - if (mode.isBuiltin || !mode.source) { - return false; - } - return mode.source.storage === PromptsStorage.local || mode.source.storage === PromptsStorage.user; - }; - const actionProviderWithCustomAgentTarget: IActionWidgetDropdownActionProvider = { getActions: () => { const modes = chatModeService.getModes(); const currentMode = delegate.currentMode.get(); const filteredCustomModes = modes.custom.filter(mode => { const target = mode.target.get(); - return isUserDefinedCustomAgent(mode) && (target === customAgentTarget); + return target === customAgentTarget || target === Target.Undefined; }); + const customModes = groupBy( + filteredCustomModes, + mode => isModeConsideredBuiltIn(mode, this._productService) ? 'builtin' : 'custom'); // Always include the default "Agent" option first const checked = currentMode.id === ChatMode.Agent.id; const defaultAction = { ...makeAction(ChatMode.Agent, ChatMode.Agent), checked }; - + defaultAction.category = builtInCategory; + const builtInActions = customModes.builtin?.map(mode => { + const action = makeActionFromCustomMode(mode, currentMode); + action.category = builtInCategory; + return action; + }) ?? []; // Add filtered custom modes - const customActions = filteredCustomModes.map(mode => makeActionFromCustomMode(mode, currentMode)); - return [defaultAction, ...customActions]; + const customActions = customModes.custom?.map(mode => makeActionFromCustomMode(mode, currentMode)) ?? []; + return [defaultAction, ...builtInActions, ...customActions]; } }; @@ -207,33 +213,33 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const currentMode = delegate.currentMode.get(); const agentMode = modes.builtin.find(mode => mode.id === ChatMode.Agent.id); - const shouldHideEditMode = configurationService.getValue(ChatConfiguration.EditModeHidden) && chatAgentService.hasToolsAgent && currentMode.id !== ChatMode.Edit.id; - const otherBuiltinModes = modes.builtin.filter(mode => { - if (mode.id === ChatMode.Agent.id) { - return false; - } - if (shouldHideEditMode && mode.id === ChatMode.Edit.id) { - return false; - } - if (mode.id === ChatMode.Ask.id) { - return false; + return mode.id !== ChatMode.Agent.id && shouldShowBuiltInMode(mode, assignments.get()); + }); + const filteredCustomModes = modes.custom.filter(mode => { + if (isModeConsideredBuiltIn(mode, this._productService)) { + return shouldShowBuiltInMode(mode, assignments.get()); } return true; }); // Filter out 'implement' mode from the dropdown - it's available for handoffs but not user-selectable const customModes = groupBy( - modes.custom, + filteredCustomModes, mode => isModeConsideredBuiltIn(mode, this._productService) ? 'builtin' : 'custom'); - const customBuiltinModeActions = customModes.builtin?.map(mode => { + const modeSupportsVSCode = (mode: IChatMode) => { + const target = mode.target.get(); + return target === Target.Undefined || target === Target.VSCode; + }; + + const customBuiltinModeActions = customModes.builtin?.filter(modeSupportsVSCode)?.map(mode => { const action = makeActionFromCustomMode(mode, currentMode); action.category = agentModeDisabledViaPolicy ? policyDisabledCategory : builtInCategory; return action; }) ?? []; customBuiltinModeActions.sort((a, b) => a.label.localeCompare(b.label)); - const customModeActions = customModes.custom?.map(mode => makeActionFromCustomMode(mode, currentMode)) ?? []; + const customModeActions = customModes.custom?.filter(modeSupportsVSCode)?.map(mode => makeActionFromCustomMode(mode, currentMode)) ?? []; customModeActions.sort((a, b) => a.label.localeCompare(b.label)); const orderedModes = coalesce([ @@ -264,6 +270,13 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { this.renderLabel(this.element); } })); + + assignmentService.getTreatment('chat.showOldAskMode').then(showOldAskMode => { + assignments.set({ showOldAskMode: showOldAskMode === 'enabled' }, undefined); + }); + this._register(assignmentService.onDidRefetchAssignments(async () => { + assignments.set({ showOldAskMode: await assignmentService.getTreatment('chat.showOldAskMode') === 'enabled' }, undefined); + })); } private getModePickerActionBarActions(): IAction[] { @@ -321,3 +334,21 @@ function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductServic } return !isOrganizationPromptFile(modeUri, mode.source.extensionId, productService); } + +function shouldShowBuiltInMode(mode: IChatMode, assignments: { showOldAskMode: boolean }): boolean { + // The built-in "Edit" mode is deprecated, but still supported for older conversations. + if (mode.id === ChatMode.Edit.id) { + return false; + } + + // The "Ask" mode is a special case - we want to show either the old or new version based on the assignment, but not both + // We still support the old "Ask" mode for conversations that already use it. + if (mode.id === ChatMode.Ask.id) { + return assignments.showOldAskMode; + } + if (mode.name.get().toLowerCase() === 'ask') { + return !assignments.showOldAskMode; + } + + return true; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index 5a64dde9506db..525e9ee8236ef 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -29,6 +29,7 @@ export interface IModelPickerDelegate { readonly currentModel: IObservable; setModel(model: ILanguageModelChatMetadataAndIdentifier): void; getModels(): ILanguageModelChatMetadataAndIdentifier[]; + canManageModels(): boolean; } type ChatModelChangeClassification = { @@ -165,9 +166,10 @@ export class ModelPickerActionItem extends ChatInputPickerActionViewItem { run: () => { } }; + const baseActionBarActionProvider = getModelPickerActionBarActionProvider(commandService, chatEntitlementService, productService); const modelPickerActionWidgetOptions: Omit = { actionProvider: modelDelegateToWidgetActionsProvider(delegate, telemetryService, pickerOptions), - actionBarActionProvider: getModelPickerActionBarActionProvider(commandService, chatEntitlementService, productService), + actionBarActionProvider: { getActions: () => delegate.canManageModels() ? baseActionBarActionProvider.getActions() : [] }, reporter: { id: 'ChatModelPicker', name: 'ChatModelPicker', includeOptions: true }, }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts new file mode 100644 index 0000000000000..c19279bee0abe --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.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 { getActiveWindow } from '../../../../../../base/browser/dom.js'; +import { IManagedHoverContent } from '../../../../../../base/browser/ui/hover/hover.js'; +import { getBaseLayerHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegate2.js'; +import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { BaseActionViewItem } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../../../base/common/actions.js'; +import { MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { localize } from '../../../../../../nls.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; +import { ModelPickerWidget } from './chatModelPicker.js'; +import { IModelPickerDelegate } from './modelPickerActionItem.js'; + +/** + * Enhanced action view item for selecting a language model in the chat interface. + * + * Wraps a {@link ModelPickerWidget} and adapts it for use in an action bar, + * providing curated model suggestions, upgrade prompts, and grouped layout. + */ +export class EnhancedModelPickerActionItem extends BaseActionViewItem { + private readonly _pickerWidget: ModelPickerWidget; + private readonly _managedHover = this._register(new MutableDisposable()); + + constructor( + action: IAction, + delegate: IModelPickerDelegate, + private readonly pickerOptions: IChatInputPickerOptions, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + ) { + super(undefined, action); + + this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget)); + this._pickerWidget.setModels(delegate.getModels()); + this._pickerWidget.setSelectedModel(delegate.currentModel.get()); + this._updateBadge(); + + // Sync delegate → widget when model list or selection changes externally + this._register(autorun(t => { + const model = delegate.currentModel.read(t); + this._pickerWidget.setSelectedModel(model); + this._updateTooltip(); + })); + + // Sync widget → delegate when user picks a model + this._register(this._pickerWidget.onDidChangeSelection(model => { + delegate.setModel(model); + })); + + // Update models when language models change + this._register(this.languageModelsService.onDidChangeLanguageModels(() => { + this._pickerWidget.setModels(delegate.getModels()); + })); + + // Update badge when new models appear + this._register(this.languageModelsService.onDidChangeNewModelIds(() => this._updateBadge())); + } + + override render(container: HTMLElement): void { + this._pickerWidget.render(container); + this.element = this._pickerWidget.domNode; + this._updateTooltip(); + container.classList.add('chat-input-picker-item'); + } + + private _getAnchorElement(): HTMLElement { + if (this.element && getActiveWindow().document.contains(this.element)) { + return this.element; + } + return this.pickerOptions.getOverflowAnchor?.() ?? this.element!; + } + + public openModelPicker(): void { + this._showPicker(); + } + + public show(): void { + this._showPicker(); + } + + private _showPicker(): void { + this._pickerWidget.show(this._getAnchorElement()); + } + + private _updateBadge(): void { + const hasNew = this.languageModelsService.getNewModelIds().length > 0; + this._pickerWidget.setBadge(hasNew ? 'info' : undefined); + } + + private _updateTooltip(): void { + if (!this.element) { + return; + } + const hoverContent = this._getHoverContents(); + if (typeof hoverContent === 'string' && hoverContent) { + this._managedHover.value = getBaseLayerHoverDelegate().setupManagedHover( + getDefaultHoverDelegate('mouse'), + this.element, + hoverContent + ); + } else { + this._managedHover.clear(); + } + } + + private _getHoverContents(): IManagedHoverContent | undefined { + let label = localize('chat.modelPicker.label', "Pick Model"); + const keybindingLabel = this.keybindingService.lookupKeybinding(this._action.id, this._contextKeyService)?.getLabel(); + if (keybindingLabel) { + label += ` (${keybindingLabel})`; + } + const { statusIcon, tooltip } = this._pickerWidget.selectedModel?.metadata || {}; + return statusIcon && tooltip ? `${label} • ${tooltip}` : label; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index c14a5416cda22..7095abd9ecdb4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -163,10 +163,6 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const contributions = this.chatSessionsService.getAllChatSessionContributions(); for (const contribution of contributions) { - if (contribution.isReadOnly) { - continue; // Read-only sessions are not interactive and should not appear in session target picker - } - const agentSessionType = getAgentSessionProvider(contribution.type); if (!agentSessionType) { continue; @@ -209,7 +205,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const labelElements = []; labelElements.push(...renderLabelWithIcons(`$(${icon.id})`)); - if (currentType !== AgentSessionProviders.Local || !this.pickerOptions.onlyShowIconsForDefaultActions.get()) { + if (!this.pickerOptions.onlyShowIconsForDefaultActions.get()) { labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); } labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 7e106073f2963..436d71dae9006 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -124,6 +124,7 @@ 0% { background-position: 120% 0; } + 100% { background-position: -120% 0; } @@ -793,12 +794,12 @@ have to be updated for changes to the rules above, or to support more deeply nes margin-right: 3px; } -.interactive-session .chat-input-container { +.monaco-workbench .interactive-session .chat-input-container { box-sizing: border-box; cursor: text; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-large); padding: 0 6px 6px 6px; /* top padding is inside the editor widget */ width: 100%; @@ -835,13 +836,13 @@ have to be updated for changes to the rules above, or to support more deeply nes position: relative; } -.interactive-session .chat-editing-session .chat-editing-session-container { +.monaco-workbench .interactive-session .chat-editing-session .chat-editing-session-container { padding: 4px 3px 4px 3px; box-sizing: border-box; background-color: var(--vscode-editor-background); border: 1px solid var(--vscode-input-border, transparent); border-bottom: none; - border-radius: 4px 4px 0 0; + border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; display: flex; flex-direction: column; gap: 2px; @@ -935,7 +936,7 @@ have to be updated for changes to the rules above, or to support more deeply nes border-radius: 2px; border: none; background-color: unset; - color: var(--vscode-foreground) + color: var(--vscode-descriptionForeground); } .monaco-button:focus-visible { @@ -1077,23 +1078,6 @@ have to be updated for changes to the rules above, or to support more deeply nes position: relative; } -/* question carousel - this is above edits and todos */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container { - width: 100%; - position: relative; -} - -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty { - display: none; -} - -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container { - margin: 0px; - border: 1px solid var(--vscode-input-border, transparent); - background-color: var(--vscode-editor-background); - border-radius: 4px; -} - /* Chat Todo List Widget Container - mirrors chat-editing-session styling */ .interactive-session .interactive-input-part > .chat-todo-list-widget-container { margin-bottom: -4px; @@ -1108,8 +1092,7 @@ have to be updated for changes to the rules above, or to support more deeply nes border: 1px solid var(--vscode-input-border, transparent); background-color: var(--vscode-editor-background); border-bottom: none; - border-top-left-radius: 4px; - border-top-right-radius: 4px; + border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; flex-direction: column; gap: 2px; overflow: hidden; @@ -1315,6 +1298,10 @@ have to be updated for changes to the rules above, or to support more deeply nes padding-left: 4px; } +.interactive-session .chat-editor-container .monaco-editor .view-lines { + padding-left: 4px; +} + .interactive-session .chat-input-toolbars { display: flex; } @@ -1347,14 +1334,27 @@ have to be updated for changes to the rules above, or to support more deeply nes .action-label { min-width: 0px; overflow: hidden; + position: relative; .chat-input-picker-label { overflow: hidden; text-overflow: ellipsis; } - .codicon-warning { - color: var(--vscode-problemsWarningIcon-foreground); + .model-picker-badge { + display: inline-flex; + align-items: center; + margin-left: 4px; + flex-shrink: 0; + font-size: 12px; + + .codicon.codicon-info { + color: var(--vscode-problemsInfoIcon-foreground) !important; + } + + .codicon.codicon-warning { + color: var(--vscode-problemsWarningIcon-foreground) !important; + } } span + .chat-input-picker-label { @@ -1378,6 +1378,20 @@ have to be updated for changes to the rules above, or to support more deeply nes } } +/* Manage Models link style in model picker */ +.action-widget .monaco-list-row.action.manage-models-link { + color: var(--vscode-textLink-foreground) !important; +} + +.action-widget .monaco-list-row.action.manage-models-link .codicon { + color: var(--vscode-textLink-foreground) !important; +} + +.action-widget .monaco-list-row.action.manage-models-link:hover, +.action-widget .monaco-list-row.action.manage-models-link.focused { + color: var(--vscode-textLink-activeForeground) !important; +} + .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label, @@ -1386,11 +1400,16 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 3px 0px 3px 6px; display: flex; align-items: center; + color: var(--vscode-descriptionForeground); } +.monaco-workbench .interactive-session .chat-input-toolbars .monaco-action-bar .action-item .codicon.codicon, +.monaco-workbench .interactive-session .chat-input-toolbars .action-label .codicon.codicon { + color: var(--vscode-descriptionForeground) !important; +} -.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label .codicon-chevron-down, -.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label .codicon-chevron-down { +.monaco-workbench .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label .codicon-chevron-down, +.monaco-workbench .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label .codicon-chevron-down { font-size: 12px; margin-left: 2px; } @@ -2125,12 +2144,6 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } -/* Tree view: remove twistie indent for leaf (non-collapsible) file rows */ -.interactive-session .chat-editing-session-list .monaco-tl-twistie:not(.collapsible) { - width: 0; - padding-right: 0; -} - .interactive-session .chat-summary-list .monaco-list .monaco-list-row { border-radius: 4px; } @@ -2267,8 +2280,13 @@ have to be updated for changes to the rules above, or to support more deeply nes } } - /* shimmer animation for working progress only */ - &.working-progress:has(.codicon-loading) .rendered-markdown.progress-step > p { + /* hide spinner icon for shimmer progress, shimmer handles animation */ + &.shimmer-progress > .codicon { + display: none; + } + + /* shimmer animation for shimmer progress */ + &.shimmer-progress .rendered-markdown.progress-step > p { background: linear-gradient( 90deg, var(--vscode-descriptionForeground) 0%, diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index fffb5a27aa879..8a1c71fb6156b 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -9,7 +9,9 @@ import { raceCancellationError } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../base/common/uri.js'; import * as nls from '../../../../../../nls.js'; +import { ITextResourceConfigurationService } from '../../../../../../editor/common/services/textResourceConfiguration.js'; import { IContextKeyService, IScopedContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IEditorOptions } from '../../../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -18,10 +20,12 @@ import { IStorageService } from '../../../../../../platform/storage/common/stora import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { editorBackground, editorForeground, inputBackground } from '../../../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; -import { EditorPane } from '../../../../../browser/parts/editor/editorPane.js'; +import { AbstractEditorWithViewState } from '../../../../../browser/parts/editor/editorWithViewState.js'; import { IEditorOpenContext } from '../../../../../common/editor.js'; +import { EditorInput } from '../../../../../common/editor/editorInput.js'; import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../../../common/theme.js'; -import { IEditorGroup } from '../../../../../services/editor/common/editorGroupsService.js'; +import { IEditorGroup, IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatModel, IChatModelInputState, IExportableChatData, ISerializableChatData } from '../../../common/model/chatModel.js'; import { IChatService } from '../../../common/chatService/chatService.js'; @@ -45,7 +49,13 @@ export interface IChatEditorOptions extends IEditorOptions { }; } -export class ChatEditor extends EditorPane { +export interface IChatEditorViewState { + scrollTop: number; +} + +export class ChatEditor extends AbstractEditorWithViewState { + private static readonly VIEW_STATE_KEY = 'chatEditorViewState'; + private _widget!: ChatWidget; public get widget(): ChatWidget { return this._widget; @@ -63,13 +73,16 @@ export class ChatEditor extends EditorPane { group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, - @IInstantiationService private readonly instantiationService: IInstantiationService, + @IInstantiationService instantiationService: IInstantiationService, @IStorageService storageService: IStorageService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, + @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService, + @IEditorService editorService: IEditorService, + @IEditorGroupsService editorGroupService: IEditorGroupsService, ) { - super(ChatEditorInput.EditorID, group, telemetryService, themeService, storageService); + super(ChatEditorInput.EditorID, group, ChatEditor.VIEW_STATE_KEY, telemetryService, instantiationService, storageService, textResourceConfigurationService, themeService, editorService, editorGroupService); } private async clear() { @@ -138,7 +151,6 @@ export class ChatEditor extends EditorPane { } override clearInput(): void { - this.saveState(); this.widget.setModel(undefined); super.clearInput(); } @@ -242,6 +254,11 @@ export class ChatEditor extends EditorPane { this.updateModel(editorModel.model); + const viewState = this.loadEditorViewState(input, context); + if (viewState) { + this._widget.scrollTop = viewState.scrollTop; + } + if (isContributedChatSession && options?.title?.preferred && input.sessionResource) { this.chatService.setChatSessionTitle(input.sessionResource, options.title.preferred); } @@ -255,6 +272,21 @@ export class ChatEditor extends EditorPane { this.widget.setModel(model); } + protected computeEditorViewState(_resource: URI): IChatEditorViewState | undefined { + if (!this._widget) { + return undefined; + } + return { scrollTop: this._widget.scrollTop }; + } + + protected tracksEditorViewState(input: EditorInput): boolean { + return input instanceof ChatEditorInput; + } + + protected toEditorViewStateResource(input: EditorInput): URI | undefined { + return (input as ChatEditorInput).sessionResource; + } + override layout(dimension: dom.Dimension, position?: dom.IDomPosition | undefined): void { this.dimension = dimension; if (this.widget) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts index 708621ded2bb6..9653b59d817b9 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { revive } from '../../../../../../base/common/marshalling.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { truncate } from '../../../../../../base/common/strings.js'; @@ -335,7 +336,8 @@ export class ChatEditorInputSerializer implements IEditorSerializer { deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined { try { // Old inputs have a session id for local session - const parsed: ISerializedChatEditorInput & { readonly sessionId: string | undefined } = JSON.parse(serializedEditor); + // Use revive to properly restore URIs and other special objects in options.target.data + const parsed = revive(JSON.parse(serializedEditor)); // First if we have a modern session resource, use that if (parsed.sessionResource) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index 5b48266f16a4a..36c6db7e50d6d 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -15,7 +15,9 @@ import { IHoverService } from '../../../../../../platform/hover/browser/hover.js import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../../common/constants.js'; import { IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatContextUsageDetails, IChatContextUsageData } from './chatContextUsageDetails.js'; @@ -121,12 +123,15 @@ export class ChatContextUsageWidget extends Disposable { private readonly _contextUsageOpenedKey: IContextKey; + private _enabled: boolean; + constructor( @IHoverService private readonly hoverService: IHoverService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); @@ -149,6 +154,19 @@ export class ChatContextUsageWidget extends Disposable { this._contextUsageOpenedKey.set(true); } + // Track enabled state from configuration + this._enabled = this.configurationService.getValue(ChatConfiguration.ChatContextUsageEnabled) !== false; + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.ChatContextUsageEnabled)) { + this._enabled = this.configurationService.getValue(ChatConfiguration.ChatContextUsageEnabled) !== false; + if (!this._enabled) { + this.hide(); + } else if (this.currentData) { + this.show(); + } + } + })); + // Set up hover - will be configured when data is available this.setupHover(); } @@ -227,11 +245,21 @@ export class ChatContextUsageWidget extends Disposable { update(lastRequest: IChatRequestModel | undefined): void { this._lastRequestDisposable.clear(); - if (!lastRequest?.response || !lastRequest.modelId) { + if (!lastRequest) { + // New/empty chat session clear everything + this.currentData = undefined; this.hide(); return; } + if (!lastRequest.response || !lastRequest.modelId) { + // Pending request keep old data visible if available + if (!this.currentData) { + this.hide(); + } + return; + } + const response = lastRequest.response; const modelId = lastRequest.modelId; @@ -251,7 +279,9 @@ export class ChatContextUsageWidget extends Disposable { const maxOutputTokens = modelMetadata?.maxOutputTokens; if (!usage || !maxInputTokens || maxInputTokens <= 0 || !maxOutputTokens || maxOutputTokens <= 0) { - this.hide(); + if (!this.currentData) { + this.hide(); + } return; } @@ -282,6 +312,9 @@ export class ChatContextUsageWidget extends Disposable { } private show(): void { + if (!this._enabled) { + return; + } if (this.domNode.style.display === 'none') { this.domNode.style.display = ''; this._isVisible.set(true, undefined); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index c11c625bc9c1a..df7195252c14a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -63,6 +63,7 @@ import { IAgentSessionsService } from '../../agentSessions/agentSessionsService. import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidget.js'; import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; interface IChatViewPaneState extends Partial { /** @deprecated */ @@ -119,6 +120,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @ICommandService private readonly commandService: ICommandService, @IActivityService private readonly activityService: IActivityService, + @IWorkbenchEnvironmentService private readonly workbenchEnvironmentService: IWorkbenchEnvironmentService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -493,8 +495,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const editorOverflowWidgetsDomNode = this.layoutService.getContainer(getWindow(chatControlsContainer)).appendChild($('.chat-editor-overflow.monaco-editor')); this._register(toDisposable(() => editorOverflowWidgetsDomNode.remove())); - // Chat Title - this.createChatTitleControl(chatControlsContainer); + // Chat Title (unless we are hosted in the chat bar) + if (this.viewDescriptorService.getViewLocationById(this.id) !== ViewContainerLocation.ChatBar) { + this.createChatTitleControl(chatControlsContainer); + } // Chat Widget const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); @@ -516,7 +520,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { }, editorOverflowWidgetsDomNode, enableImplicitContext: true, - enableWorkingSet: 'explicit', + enableWorkingSet: this.workbenchEnvironmentService.isSessionsWindow + ? 'implicit' + : 'explicit', supportsChangingModes: true, dndContainer: parent, }, @@ -657,7 +663,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { //#region Model Management - private async applyModel(): Promise { + private applyModel(): void { + this.restoringSession = this._applyModel(); + this.restoringSession.finally(() => this.restoringSession = undefined); + } + + private async _applyModel(): Promise { const sessionResource = this.getTransferredOrPersistedSessionInfo(); const modelRef = sessionResource ? await this.chatService.getOrRestoreSession(sessionResource) : undefined; await this.showModel(modelRef); @@ -742,6 +753,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } async loadSession(sessionResource: URI): Promise { + // Wait for any in-progress session restore (e.g. from onDidChangeAgents) + // to finish first, so our showModel call is guaranteed to be the last one. + if (this.restoringSession) { + await this.restoringSession; + } + return this.progressService.withProgress({ location: ChatViewId, delay: 200 }, async () => { let queue: Promise = Promise.resolve(); diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 94bb20e75affc..b97209bce1587 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -45,6 +45,7 @@ export namespace ChatContextKeys { 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).") }); + export const chatModelId = new RawContextKey('chatModelId', '', { type: 'string', description: localize('chatModelId', "The short id of the currently selected chat model (for example 'gpt-4.1').") }); export const supported = ContextKeyExpr.or(IsWebContext.negate(), RemoteNameContext.notEqualsTo(''), ContextKeyExpr.has('config.chat.experimental.serverlessWebEnabled')); export const enabled = new RawContextKey('chatIsEnabled', false, { type: 'boolean', description: localize('chatIsEnabled', "True when chat is enabled because a default chat participant is activated with an implementation.") }); @@ -58,6 +59,12 @@ export namespace ChatContextKeys { * which means the mode picker should be shown with filtered custom agents. */ export const chatSessionHasCustomAgentTarget = new RawContextKey('chatSessionHasCustomAgentTarget', false, { type: 'boolean', description: localize('chatSessionHasCustomAgentTarget', "True when the chat session has a customAgentTarget defined to filter modes.") }); + /** + * True when the current chat session has models that specifically target it + * via `targetChatSessionType`, which means the model picker should be shown + * even when the widget is locked to a coding agent. + */ + export const chatSessionHasTargetedModels = new RawContextKey('chatSessionHasTargetedModels', false, { type: 'boolean', description: localize('chatSessionHasTargetedModels', "True when the chat session has language models that target it via targetChatSessionType.") }); export const agentSupportsAttachments = new RawContextKey('agentSupportsAttachments', false, { type: 'boolean', description: localize('agentSupportsAttachments', "True when the chat agent supports attachments.") }); export const withinEditSessionDiff = new RawContextKey('withinEditSessionDiff', false, { type: 'boolean', description: localize('withinEditSessionDiff', "True when the chat widget dispatches to the edit session chat.") }); export const filePartOfEditSession = new RawContextKey('filePartOfEditSession', false, { type: 'boolean', description: localize('filePartOfEditSession', "True when the chat widget is within a file with an edit session.") }); @@ -96,6 +103,7 @@ export namespace ChatContextKeys { export const Editing = { hasToolConfirmation: new RawContextKey('chatHasToolConfirmation', false, { type: 'boolean', description: localize('chatEditingHasToolConfirmation', "True when a tool confirmation is present.") }), hasElicitationRequest: new RawContextKey('chatHasElicitationRequest', false, { type: 'boolean', description: localize('chatEditingHasElicitationRequest', "True when a chat elicitation request is pending.") }), + hasQuestionCarousel: new RawContextKey('chatHasQuestionCarousel', false, { type: 'boolean', description: localize('chatEditingHasQuestionCarousel', "True when a question carousel is rendered in the chat input.") }), }; export const Tools = { @@ -120,8 +128,6 @@ export namespace ChatContextKeys { export const hasMultipleAgentSessionsSelected = new RawContextKey('agentSessionHasMultipleSelected', false, { type: 'boolean', description: localize('agentSessionHasMultipleSelected', "True when multiple agent sessions are selected.") }); export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); - export const chatEditsInTreeView = new RawContextKey('chatEditsInTreeView', false, { type: 'boolean', description: localize('chatEditsInTreeView', "True when the chat edits working set is displayed as a tree.") }); - export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); export const contextUsageHasBeenOpened = new RawContextKey('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") }); diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index af40f1b5b331a..58a14a2ad8966 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -286,13 +286,24 @@ export interface IDebugVariableEntry extends IBaseChatRequestVariableEntry { readonly type?: string; } +export interface IAgentFeedbackVariableEntry extends IBaseChatRequestVariableEntry { + readonly kind: 'agentFeedback'; + readonly sessionResource: URI; + readonly feedbackItems: ReadonlyArray<{ + readonly id: string; + readonly text: string; + readonly resourceUri: URI; + readonly range: IRange; + }>; +} + export type IChatRequestVariableEntry = IGenericChatRequestVariableEntry | IChatRequestImplicitVariableEntry | IChatRequestPasteVariableEntry | ISymbolVariableEntry | ICommandResultVariableEntry | IDiagnosticVariableEntry | IImageVariableEntry | IChatRequestToolEntry | IChatRequestToolSetEntry | IChatRequestDirectoryEntry | IChatRequestFileEntry | INotebookOutputVariableEntry | IElementVariableEntry | IPromptFileVariableEntry | IPromptTextVariableEntry | ISCMHistoryItemVariableEntry | ISCMHistoryItemChangeVariableEntry | ISCMHistoryItemChangeRangeVariableEntry | ITerminalVariableEntry - | IChatRequestStringVariableEntry | IChatRequestWorkspaceVariableEntry | IDebugVariableEntry; + | IChatRequestStringVariableEntry | IChatRequestWorkspaceVariableEntry | IDebugVariableEntry | IAgentFeedbackVariableEntry; export namespace IChatRequestVariableEntry { @@ -361,6 +372,10 @@ export function isDebugVariableEntry(obj: IChatRequestVariableEntry): obj is IDe return obj.kind === 'debugVariable'; } +export function isAgentFeedbackVariableEntry(obj: IChatRequestVariableEntry): obj is IAgentFeedbackVariableEntry { + return obj.kind === 'agentFeedback'; +} + export function isPasteVariableEntry(obj: IChatRequestVariableEntry): obj is IChatRequestPasteVariableEntry { return obj.kind === 'paste'; } diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index f4d5712527aca..a099229a2702a 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -122,7 +122,7 @@ export class ChatModeService extends Disposable implements IChatModeService { agentInstructions: cachedMode.modeInstructions ?? { content: cachedMode.body ?? '', toolReferences: [] }, handOffs: cachedMode.handOffs, target: cachedMode.target ?? Target.Undefined, - visibility: cachedMode.visibility ?? { userInvokable: true, agentInvokable: cachedMode.infer !== false }, + visibility: cachedMode.visibility ?? { userInvocable: true, agentInvocable: cachedMode.infer !== false }, agents: cachedMode.agents, source: reviveChatModeSource(cachedMode.source) ?? { storage: PromptsStorage.local } }; @@ -154,7 +154,7 @@ export class ChatModeService extends Disposable implements IChatModeService { const seenUris = new Set(); for (const customMode of customModes) { - if (!customMode.visibility.userInvokable) { + if (!customMode.visibility.userInvocable) { continue; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 47c0fc7008445..610e43616bf93 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -229,6 +229,7 @@ export class ChatMultiDiffData implements IChatMultiDiffData { export interface IChatProgressMessage { content: IMarkdownString; kind: 'progressMessage'; + shimmer?: boolean; } export interface IChatTask extends IChatTaskDto { @@ -909,6 +910,10 @@ export interface IChatMcpServersStartingSerialized { didStartServerIds?: string[]; } +export interface IChatDisabledClaudeHooksPart { + readonly kind: 'disabledClaudeHooks'; +} + export class ChatMcpServersStarting implements IChatMcpServersStarting { public readonly kind = 'mcpServersStarting'; @@ -973,7 +978,8 @@ export type IChatProgress = | IChatMcpServersStarting | IChatMcpServersStartingSerialized | IChatHookPart - | IChatExternalToolInvocationUpdate; + | IChatExternalToolInvocationUpdate + | IChatDisabledClaudeHooksPart; export interface IChatFollowup { kind: 'reply'; @@ -1440,6 +1446,7 @@ export interface IChatSessionContext { readonly chatSessionType: string; readonly chatSessionResource: URI; readonly isUntitled: boolean; + readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | { id: string; name: string } }>; } export const KEYWORD_ACTIVIATION_SETTING_ID = 'accessibility.voice.keywordActivation'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 5740a3345c789..e8df0b6017ef0 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -25,9 +25,10 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { Progress } from '../../../../../platform/progress/common/progress.js'; -import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { InlineChatConfigKeys } from '../../../inlineChat/common/inlineChat.js'; import { IMcpService } from '../../../mcp/common/mcpTypes.js'; import { awaitStatsForSession } from '../chat.js'; @@ -155,6 +156,7 @@ export class ChatService extends Disposable implements IChatService { @IChatSessionsService private readonly chatSessionService: IChatSessionsService, @IMcpService private readonly mcpService: IMcpService, @IPromptsService private readonly promptsService: IPromptsService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(); @@ -340,7 +342,17 @@ export class ChatService extends Disposable implements IChatService { return; } - const sessionResource = LocalChatSessionUri.forSession(session.sessionId); + let sessionResource: URI; + // Non-local sessions store the full uri as the sessionId, so try parsing that first + if (session.sessionId.includes(':')) { + try { + sessionResource = URI.parse(session.sessionId, true); + } catch { + // Noop + } + } + sessionResource ??= LocalChatSessionUri.forSession(session.sessionId); + const sessionRef = await this.getOrRestoreSession(sessionResource); if (sessionRef?.object.editingSession) { await chatEditingSessionIsReady(sessionRef.object.editingSession); @@ -629,7 +641,7 @@ export class ChatService extends Disposable implements IChatService { undefined, // locationData undefined, // attachments false, // Do not treat as requests completed, else edit pills won't show. - undefined, + message.modelId, undefined, message.id ); @@ -777,21 +789,16 @@ export class ChatService extends Disposable implements IChatService { } const hasPendingRequest = this._pendingRequests.has(sessionResource); - const hasPendingQueue = model.getPendingRequests().length > 0; if (options?.queue) { - return this.queuePendingRequest(model, sessionResource, request, options); + const queued = this.queuePendingRequest(model, sessionResource, request, options); + this.processPendingRequests(sessionResource); + return queued; } else if (hasPendingRequest) { this.trace('sendRequest', `Session ${sessionResource} already has a pending request`); return { kind: 'rejected', reason: 'Request already in progress' }; } - if (options?.queue && hasPendingQueue) { - const queued = this.queuePendingRequest(model, sessionResource, request, options); - this.processNextPendingRequest(model); - return queued; - } - const requests = model.getRequests(); for (let i = requests.length - 1; i >= 0; i -= 1) { const request = requests[i]; @@ -908,8 +915,13 @@ export class ChatService extends Disposable implements IChatService { // Collect hooks from hook .json files let collectedHooks: IChatRequestHooks | undefined; + let hasDisabledClaudeHooks = false; try { - collectedHooks = await this.promptsService.getHooks(token); + const hooksInfo = await this.promptsService.getHooks(token); + if (hooksInfo) { + collectedHooks = hooksInfo.hooks; + hasDisabledClaudeHooks = hooksInfo.hasDisabledClaudeHooks; + } } catch (error) { this.logService.warn('[ChatService] Failed to collect hooks:', error); } @@ -1050,6 +1062,15 @@ export class ChatService extends Disposable implements IChatService { } completeResponseCreated(); + // Check for disabled Claude Code hooks and notify the user once per workspace + const disabledClaudeHooksDismissedKey = 'chat.disabledClaudeHooks.notification'; + if (!this.storageService.getBoolean(disabledClaudeHooksDismissedKey, StorageScope.WORKSPACE)) { + this.storageService.store(disabledClaudeHooksDismissedKey, true, StorageScope.WORKSPACE, StorageTarget.USER); + if (hasDisabledClaudeHooks) { + progressCallback([{ kind: 'disabledClaudeHooks' }]); + } + } + // MCP autostart: only run for native VS Code sessions (sidebar, new editors) but not for extension contributed sessions that have inputType set. if (model.canUseTools) { const autostartResult = new ChatMcpServersStarting(this.mcpService.autostart(token)); @@ -1114,6 +1135,10 @@ export class ChatService extends Disposable implements IChatService { completeResponseCreated(); this.trace('sendRequest', `Provider returned response for session ${model.sessionResource}`); + if (rawResult.errorDetails?.isRateLimited) { + this.chatEntitlementService.markAnonymousRateLimited(); + } + shouldProcessPending = !rawResult.errorDetails && !token.isCancellationRequested; request.response?.complete(); if (agentOrCommandFollowups) { @@ -1147,9 +1172,12 @@ export class ChatService extends Disposable implements IChatService { let shouldProcessPending = false; const rawResponsePromise = sendRequestInternal(); // Note- requestId is not known at this point, assigned later - this._pendingRequests.set(model.sessionResource, this.instantiationService.createInstance(CancellableRequest, source, undefined)); + const cancellableRequest = this.instantiationService.createInstance(CancellableRequest, source, undefined); + this._pendingRequests.set(model.sessionResource, cancellableRequest); rawResponsePromise.finally(() => { - this._pendingRequests.deleteAndDispose(model.sessionResource); + if (this._pendingRequests.get(model.sessionResource) === cancellableRequest) { + this._pendingRequests.deleteAndDispose(model.sessionResource); + } // Process the next pending request from the queue if any if (shouldProcessPending) { this.processNextPendingRequest(model); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 839510819e7c1..8ccd21ac64711 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -93,6 +93,7 @@ export interface IChatSessionsExtensionPoint { * Custom agents without a `target` property are also shown in all filtered lists */ readonly customAgentTarget?: Target; + readonly requiresCustomModels?: boolean; } export interface IChatSessionItem { @@ -135,6 +136,7 @@ export type IChatSessionHistoryItem = { participant: string; command?: string; variableData?: IChatRequestVariableData; + modelId?: string; } | { type: 'response'; parts: IChatProgress[]; @@ -260,7 +262,7 @@ export interface IChatSessionsService { /** * Fired when options for a chat session change. */ - onDidChangeSessionOptions: Event; + readonly onDidChangeSessionOptions: Event; /** * Get the capabilities for a specific session type @@ -273,7 +275,11 @@ export interface IChatSessionsService { */ getCustomAgentTargetForSessionType(chatSessionType: string): Target; - onDidChangeOptionGroups: Event; + /** + * Returns whether the session type requires custom models. When true, the model picker should show filtered custom models. + */ + requiresCustomModelsForSessionType(chatSessionType: string): boolean; + readonly onDidChangeOptionGroups: Event; getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined; setOptionGroupsForSessionType(chatSessionType: string, handle: number, optionGroups?: IChatSessionProviderOptionGroup[]): void; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 2440311f09bba..64a1f9b5ba830 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -13,7 +13,6 @@ export enum ChatConfiguration { AgentEnabled = 'chat.agent.enabled', PlanAgentDefaultModel = 'chat.planAgent.defaultModel', ExploreAgentDefaultModel = 'chat.exploreAgent.defaultModel', - RequestQueueingEnabled = 'chat.requestQueuing.enabled', RequestQueueingDefaultAction = 'chat.requestQueuing.defaultAction', AgentStatusEnabled = 'chat.agentsControl.enabled', EditorAssociations = 'chat.editorAssociations', @@ -43,12 +42,14 @@ export enum ChatConfiguration { ChatViewSessionsGrouping = 'chat.viewSessions.grouping', ChatViewSessionsOrientation = 'chat.viewSessions.orientation', ChatViewProgressBadgeEnabled = 'chat.viewProgressBadge.enabled', + ChatContextUsageEnabled = 'chat.contextUsage.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', RestoreLastPanelSession = 'chat.restoreLastPanelSession', ExitAfterDelegation = 'chat.exitAfterDelegation', AgentsControlClickBehavior = 'chat.agentsControl.clickBehavior', ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', + GrowthNotificationEnabled = 'chat.growthNotification.enabled', } /** diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index f01c341dd9a54..4f71f64b7fec7 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { SequencerByKey } from '../../../../base/common/async.js'; +import { SequencerByKey, timeout } from '../../../../base/common/async.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IStringDictionary } from '../../../../base/common/collections.js'; @@ -13,6 +13,7 @@ import { hash } from '../../../../base/common/hash.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { IJSONSchema, TypeFromJsonSchema } from '../../../../base/common/jsonSchema.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { equals } from '../../../../base/common/objects.js'; import Severity from '../../../../base/common/severity.js'; import { format, isFalsyOrWhitespace } from '../../../../base/common/strings.js'; @@ -25,6 +26,8 @@ import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../pla import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; import { IQuickInputService, QuickInputHideReason } from '../../../../platform/quickinput/common/quickInput.js'; import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -195,6 +198,12 @@ export interface ILanguageModelChatMetadata { readonly agentMode?: boolean; readonly editTools?: ReadonlyArray; }; + /** + * When set, this model is only shown in the model picker for the specified chat session type. + * Models with this property are excluded from the general model picker and only appear + * when the user is in a session matching this type. + */ + readonly targetChatSessionType?: string; } export namespace ILanguageModelChatMetadata { @@ -357,6 +366,55 @@ export interface ILanguageModelsService { configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise; migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise; + + /** + * Returns the most recently used model identifiers, ordered by most-recent-first. + * @param maxCount Maximum number of entries to return (default 7). + */ + getRecentlyUsedModelIds(maxCount?: number): string[]; + + /** + * Records that a model was used, updating the recently used list. + */ + recordModelUsage(modelIdentifier: string): void; + + /** + * Returns the curated models from the models control manifest, + * separated into free and paid tiers. + */ + getCuratedModels(): ICuratedModels; + + /** + * Returns the IDs of curated models that are marked as new and have not been seen yet. + */ + getNewModelIds(): string[]; + + /** + * Fires when the set of new (unseen) model IDs changes. + */ + readonly onDidChangeNewModelIds: Event; + + /** + * Marks all new models as seen, clearing the new badge. + */ + markNewModelsAsSeen(): void; + + /** + * Observable map of restricted chat participant names to allowed extension publisher/IDs. + * Fetched from the chat control manifest. + */ + readonly restrictedChatParticipants: IObservable<{ [name: string]: string[] }>; +} + +export interface ICuratedModel { + readonly id: string; + readonly isNew?: boolean; + readonly minVSCodeVersion?: string; +} + +export interface ICuratedModels { + readonly free: ICuratedModel[]; + readonly paid: ICuratedModel[]; } const languageModelChatProviderType = { @@ -445,6 +503,27 @@ export const languageModelChatProviderExtensionPoint = ExtensionsRegistry.regist }); const CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY = 'chatModelPickerPreferences'; +const CHAT_MODEL_RECENTLY_USED_STORAGE_KEY = 'chatModelRecentlyUsed'; +const CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY = 'chatModelSeenNewModels'; +const CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY = 'chat.participantNameRegistry'; +const CHAT_CURATED_MODELS_STORAGE_KEY = 'chat.curatedModels'; + +interface IRawCuratedModel { + readonly id: string; + readonly isNew?: boolean; + readonly minVSCodeVersion?: string; + readonly paidOnly?: boolean; +} + +interface IChatControlResponse { + readonly version: number; + readonly restrictedChatParticipants: { [name: string]: string[] }; + readonly curatedModels?: (string | IRawCuratedModel)[]; +} + +function normalizeCuratedModels(models: (string | IRawCuratedModel)[]): IRawCuratedModel[] { + return models.map(m => typeof m === 'string' ? { id: m } : m); +} export class LanguageModelsService implements ILanguageModelsService { @@ -470,6 +549,20 @@ export class LanguageModelsService implements ILanguageModelsService { private readonly _onLanguageModelChange = this._store.add(new Emitter()); readonly onDidChangeLanguageModels: Event = this._onLanguageModelChange.event; + private _recentlyUsedModelIds: string[] = []; + private _curatedModels: ICuratedModels = { free: [], paid: [] }; + private _newModelIds: Set = new Set(); + private _seenNewModelIds: Set = new Set(); + + private _chatControlUrl: string | undefined; + private _chatControlDisposed = false; + + private readonly _restrictedChatParticipants = observableValue<{ [name: string]: string[] }>(this, Object.create(null)); + readonly restrictedChatParticipants: IObservable<{ [name: string]: string[] }> = this._restrictedChatParticipants; + + private readonly _onDidChangeNewModelIds = this._store.add(new Emitter()); + readonly onDidChangeNewModelIds: Event = this._onDidChangeNewModelIds.event; + constructor( @IExtensionService private readonly _extensionService: IExtensionService, @ILogService private readonly _logService: ILogService, @@ -478,9 +571,14 @@ export class LanguageModelsService implements ILanguageModelsService { @ILanguageModelsConfigurationService private readonly _languageModelsConfigurationService: ILanguageModelsConfigurationService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @ISecretStorageService private readonly _secretStorageService: ISecretStorageService, + @IProductService private readonly _productService: IProductService, + @IRequestService private readonly _requestService: IRequestService, ) { this._hasUserSelectableModels = ChatContextKeys.languageModelsAreUserSelectable.bindTo(_contextKeyService); this._modelPickerUserPreferences = this._readModelPickerPreferences(); + this._recentlyUsedModelIds = this._readRecentlyUsedModels(); + this._seenNewModelIds = this._readSeenNewModels(); + this._initChatControlData(); this._store.add(this._storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY, this._store)(() => this._onDidChangeModelPickerPreferences())); this._store.add(this.onDidChangeLanguageModels(() => this._hasUserSelectableModels.set(this._modelCache.size > 0 && Array.from(this._modelCache.values()).some(model => model.isUserSelectable)))); @@ -1287,7 +1385,167 @@ export class LanguageModelsService implements ILanguageModelsService { await this.addLanguageModelsProviderGroup(name, vendor, configuration); } + //#region Recently used models + + private _readRecentlyUsedModels(): string[] { + return this._storageService.getObject(CHAT_MODEL_RECENTLY_USED_STORAGE_KEY, StorageScope.PROFILE, []); + } + + private _saveRecentlyUsedModels(): void { + this._storageService.store(CHAT_MODEL_RECENTLY_USED_STORAGE_KEY, this._recentlyUsedModelIds, StorageScope.PROFILE, StorageTarget.USER); + } + + getRecentlyUsedModelIds(maxCount: number = 7): string[] { + // Filter to only include models that still exist in the cache + return this._recentlyUsedModelIds + .filter(id => this._modelCache.has(id)) + .slice(0, maxCount); + } + + recordModelUsage(modelIdentifier: string): void { + // Remove if already present (to move to front) + const index = this._recentlyUsedModelIds.indexOf(modelIdentifier); + if (index !== -1) { + this._recentlyUsedModelIds.splice(index, 1); + } + // Add to front + this._recentlyUsedModelIds.unshift(modelIdentifier); + // Cap at a reasonable max to avoid unbounded growth + if (this._recentlyUsedModelIds.length > 20) { + this._recentlyUsedModelIds.length = 20; + } + this._saveRecentlyUsedModels(); + } + + //#endregion + + //#region Curated models + + getCuratedModels(): ICuratedModels { + return this._curatedModels; + } + + private _setCuratedModels(models: IRawCuratedModel[]): void { + const toPublic = (m: IRawCuratedModel): ICuratedModel => ({ id: m.id, isNew: m.isNew, minVSCodeVersion: m.minVSCodeVersion }); + this._curatedModels = { + free: models.filter(m => !m.paidOnly).map(toPublic), + paid: models.filter(m => m.paidOnly).map(toPublic), + }; + + const newIds = new Set(); + for (const model of models) { + if (model.isNew) { + newIds.add(model.id); + } + } + this._newModelIds = newIds; + this._onDidChangeNewModelIds.fire(); + } + + getNewModelIds(): string[] { + const result: string[] = []; + for (const id of this._newModelIds) { + if (!this._seenNewModelIds.has(id)) { + result.push(id); + } + } + return result; + } + + markNewModelsAsSeen(): void { + let changed = false; + for (const id of this._newModelIds) { + if (!this._seenNewModelIds.has(id)) { + this._seenNewModelIds.add(id); + changed = true; + } + } + if (changed) { + this._saveSeenNewModels(); + this._onDidChangeNewModelIds.fire(); + } + } + + private _readSeenNewModels(): Set { + return new Set(this._storageService.getObject(CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY, StorageScope.PROFILE, [])); + } + + private _saveSeenNewModels(): void { + this._storageService.store(CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY, [...this._seenNewModelIds], StorageScope.PROFILE, StorageTarget.USER); + } + + //#endregion + + //#region Chat control data + + private _initChatControlData(): void { + this._chatControlUrl = this._productService.chatParticipantRegistry; + if (!this._chatControlUrl) { + return; + } + + // Restore participant registry from storage + const raw = this._storageService.get(CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY, StorageScope.APPLICATION); + try { + this._restrictedChatParticipants.set(JSON.parse(raw ?? '{}'), undefined); + } catch (err) { + this._storageService.remove(CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY, StorageScope.APPLICATION); + } + + // Restore curated models from storage + const rawCurated = this._storageService.get(CHAT_CURATED_MODELS_STORAGE_KEY, StorageScope.APPLICATION); + try { + const curated = JSON.parse(rawCurated ?? '[]'); + if (Array.isArray(curated)) { + this._setCuratedModels(normalizeCuratedModels(curated)); + } + } catch (err) { + this._storageService.remove(CHAT_CURATED_MODELS_STORAGE_KEY, StorageScope.APPLICATION); + } + + this._refreshChatControlData(); + } + + private _refreshChatControlData(): void { + if (this._chatControlDisposed) { + return; + } + + this._fetchChatControlData() + .catch(err => this._logService.warn('Failed to fetch chat control data', err)) + .then(() => timeout(5 * 60 * 1000)) // every 5 minutes + .then(() => this._refreshChatControlData()); + } + + private async _fetchChatControlData(): Promise { + const context = await this._requestService.request({ type: 'GET', url: this._chatControlUrl! }, CancellationToken.None); + + if (context.res.statusCode !== 200) { + throw new Error('Could not get chat control data.'); + } + + const result = await asJson(context); + + if (!result || result.version !== 1) { + throw new Error('Unexpected chat control response.'); + } + + // Update restricted chat participants + const registry = result.restrictedChatParticipants; + this._restrictedChatParticipants.set(registry, undefined); + this._storageService.store(CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY, JSON.stringify(registry), StorageScope.APPLICATION, StorageTarget.MACHINE); + + // Update curated models + if (result.curatedModels && Array.isArray(result.curatedModels)) { + this._setCuratedModels(normalizeCuratedModels(result.curatedModels)); + this._storageService.store(CHAT_CURATED_MODELS_STORAGE_KEY, JSON.stringify(result.curatedModels), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + } + + //#endregion + dispose() { + this._chatControlDisposed = true; this._store.dispose(); this._providers.clear(); } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 3b741cb825a9e..e804c91874405 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -29,7 +29,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { ChatToolInvocation } from './chatProgressTypes/chatToolInvocation.js'; import { ToolDataSource, IToolData } from '../tools/languageModelToolsService.js'; @@ -207,7 +207,8 @@ export type IChatProgressResponseContent = | IChatElicitationRequestSerialized | IChatClearToPreviousToolInvocation | IChatMcpServersStarting - | IChatMcpServersStartingSerialized; + | IChatMcpServersStartingSerialized + | IChatDisabledClaudeHooksPart; export type IChatProgressResponseContentSerialized = Exclude; const nonHistoryKinds = new Set(['toolInvocation', 'toolInvocationSerialized', 'undoStop']); @@ -502,6 +504,7 @@ class AbstractResponse implements IResponse { case 'multiDiffData': case 'mcpServersStarting': case 'questionCarousel': + case 'disabledClaudeHooks': // Ignore continue; case 'toolInvocation': @@ -1390,6 +1393,7 @@ export interface IChatModel extends IDisposable { toExport(): IExportableChatData; toJSON(): ISerializableChatData; readonly contributedChatSession: IChatSessionContext | undefined; + setContributedChatSession(session: IChatSessionContext | undefined): void; readonly repoData: IExportableRepoData | undefined; setRepoData(data: IExportableRepoData | undefined): void; @@ -1421,7 +1425,7 @@ interface ISerializableChatResponseData { timeSpentWaiting?: number; } -export type SerializedChatResponsePart = IMarkdownString | IChatResponseProgressFileTreeData | IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatThinkingPart | IChatProgressResponseContentSerialized | IChatQuestionCarousel; +export type SerializedChatResponsePart = IMarkdownString | IChatResponseProgressFileTreeData | IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatThinkingPart | IChatProgressResponseContentSerialized | IChatQuestionCarousel | IChatDisabledClaudeHooksPart; export interface ISerializableChatRequestData extends ISerializableChatResponseData { requestId: string; @@ -2308,6 +2312,17 @@ export class ChatModel extends Disposable implements IChatModel { modelState = { value: ResponseModelState.Cancelled, completedAt: Date.now() }; } + // Mark question carousels as used after + // deserialization. After a reload, the extension is no longer listening for + // their responses, so they cannot be interacted with. + if (raw.response) { + for (const part of raw.response) { + if (hasKey(part, { kind: true }) && (part.kind === 'questionCarousel')) { + part.isUsed = true; + } + } + } + request.response = new ChatResponseModel({ responseContent: raw.response ?? [new MarkdownString(raw.response)], session: this, diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index b1b876d8f1f41..767cb087be6a4 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -81,6 +81,7 @@ const responsePartSchema = Adapt.v('chatAgentNameService'); -type IChatParticipantRegistry = { [name: string]: string[] }; - -interface IChatParticipantRegistryResponse { - readonly version: number; - readonly restrictedChatParticipants: IChatParticipantRegistry; -} - export interface IChatAgentNameService { _serviceBrand: undefined; getAgentNameRestriction(chatAgentData: IChatAgentData): boolean; @@ -674,64 +664,11 @@ export interface IChatAgentNameService { export class ChatAgentNameService implements IChatAgentNameService { - private static readonly StorageKey = 'chat.participantNameRegistry'; - declare _serviceBrand: undefined; - private readonly url!: string; - private registry = observableValue(this, Object.create(null)); - private disposed = false; - constructor( - @IProductService productService: IProductService, - @IRequestService private readonly requestService: IRequestService, - @ILogService private readonly logService: ILogService, - @IStorageService private readonly storageService: IStorageService + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, ) { - if (!productService.chatParticipantRegistry) { - return; - } - - this.url = productService.chatParticipantRegistry; - - const raw = storageService.get(ChatAgentNameService.StorageKey, StorageScope.APPLICATION); - - try { - this.registry.set(JSON.parse(raw ?? '{}'), undefined); - } catch (err) { - storageService.remove(ChatAgentNameService.StorageKey, StorageScope.APPLICATION); - } - - this.refresh(); - } - - private refresh(): void { - if (this.disposed) { - return; - } - - this.update() - .catch(err => this.logService.warn('Failed to fetch chat participant registry', err)) - .then(() => timeout(5 * 60 * 1000)) // every 5 minutes - .then(() => this.refresh()); - } - - private async update(): Promise { - const context = await this.requestService.request({ type: 'GET', url: this.url }, CancellationToken.None); - - if (context.res.statusCode !== 200) { - throw new Error('Could not get extensions report.'); - } - - const result = await asJson(context); - - if (!result || result.version !== 1) { - throw new Error('Unexpected chat participant registry response.'); - } - - const registry = result.restrictedChatParticipants; - this.registry.set(registry, undefined); - this.storageService.store(ChatAgentNameService.StorageKey, JSON.stringify(registry), StorageScope.APPLICATION, StorageTarget.MACHINE); } /** @@ -751,7 +688,7 @@ export class ChatAgentNameService implements IChatAgentNameService { private checkAgentNameRestriction(name: string, chatAgentData: IChatAgentData): IObservable { // Registry is a map of name to an array of extension publisher IDs or extension IDs that are allowed to use it. // Look up the list of extensions that are allowed to use this name - const allowList = this.registry.map(registry => registry[name.toLowerCase()]); + const allowList = this.languageModelsService.restrictedChatParticipants.map(registry => registry[name.toLowerCase()]); return allowList.map(allowList => { if (!allowList) { return true; @@ -760,10 +697,6 @@ export class ChatAgentNameService implements IChatAgentNameService { return allowList.some(id => equalsIgnoreCase(id, id.includes('.') ? chatAgentData.extensionId.value : chatAgentData.extensionPublisherId)); }); } - - dispose() { - this.disposed = true; - } } export function getFullyQualifiedId(chatAgentData: IChatAgentData): string { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts index 13bb2e6a70cab..f2d6dd208104b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts @@ -43,13 +43,17 @@ function registerChatFilesExtensionPoint(point: ChatContributionPoint) { type: 'object', defaultSnippets: [{ body: { - path: './relative/path/to/file.md', + path: point === ChatContributionPoint.chatSkills + ? './relative/path/to/skill-name/SKILL.md' + : './relative/path/to/file.md', } }], required: ['path'], properties: { path: { - description: localize('chatContribution.property.path', 'Path to the file relative to the extension root.'), + description: point === ChatContributionPoint.chatSkills + ? localize('chatContribution.property.path.skills', 'Path to the SKILL.md file relative to the extension root. The folder name must match the "name" property in SKILL.md.') + : localize('chatContribution.property.path', 'Path to the file relative to the extension root.'), type: 'string' }, name: { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 2ca88074c2675..50c007fc1168d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -349,8 +349,8 @@ export class ComputeAutomaticInstructions { const agentSkills = await this._promptsService.findAgentSkills(token); // Filter out skills with disableModelInvocation=true (they can only be triggered manually via /name) - const modelInvokableSkills = agentSkills?.filter(skill => !skill.disableModelInvocation); - if (modelInvokableSkills && modelInvokableSkills.length > 0) { + const modelInvocableSkills = agentSkills?.filter(skill => !skill.disableModelInvocation); + if (modelInvocableSkills && modelInvocableSkills.length > 0) { const useSkillAdherencePrompt = this._configurationService.getValue(PromptsConfig.USE_SKILL_ADHERENCE_PROMPT); entries.push(''); if (useSkillAdherencePrompt) { @@ -372,7 +372,7 @@ export class ComputeAutomaticInstructions { entries.push('Each skill comes with a description of the topic and a file path that contains the detailed instructions.'); entries.push(`When a user asks you to perform a task that falls within the domain of a skill, use the ${readTool.variable} tool to acquire the full instructions from the file URI.`); } - for (const skill of modelInvokableSkills) { + for (const skill of modelInvocableSkills) { entries.push(''); entries.push(`${skill.name}`); if (skill.description) { @@ -387,7 +387,7 @@ export class ComputeAutomaticInstructions { if (runSubagentTool && this._configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents)) { const canUseAgent = (() => { if (!this._enabledSubagents || this._enabledSubagents.includes('*')) { - return (agent: ICustomAgent) => agent.visibility.agentInvokable; + return (agent: ICustomAgent) => agent.visibility.agentInvocable; } else { const subagents = this._enabledSubagents; return (agent: ICustomAgent) => subagents.includes(agent.name); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index 89d415a99ef4f..1f0b9da69ca91 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -110,6 +110,11 @@ export namespace PromptsConfig { */ export const USE_CHAT_HOOKS = 'chat.useHooks'; + /** + * Configuration key for enabling Claude hooks. + */ + export const USE_CLAUDE_HOOKS = 'chat.useClaudeHooks'; + /** * Configuration key for enabling stronger skill adherence prompt (experimental). */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts index 076f9f2d1ebca..8de8c06dfba12 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts @@ -39,7 +39,7 @@ export class PromptHeaderDefinitionProvider implements DefinitionProvider { } const agentAttr = header.getAttribute(PromptHeaderAttributes.agent) ?? header.getAttribute(PromptHeaderAttributes.mode); - if (agentAttr && agentAttr.value.type === 'string' && agentAttr.range.containsPosition(position)) { + if (agentAttr && agentAttr.value.type === 'scalar' && agentAttr.range.containsPosition(position)) { const agent = this.chatModeService.findModeByName(agentAttr.value.value); if (agent && agent.uri) { return { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts index bd4ecc7de0fe7..704d5cd620815 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts @@ -119,17 +119,17 @@ export class PromptCodeActionProvider implements CodeActionProvider { return; } let value = toolsAttr.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type !== 'array') { + if (value.type !== 'sequence') { return; } const values = value.items; const deprecatedNames = new Lazy(() => this.languageModelToolsService.getDeprecatedFullReferenceNames()); const edits: TextEdit[] = []; for (const item of values) { - if (item.type !== 'string') { + if (item.type !== 'scalar') { continue; } const newNames = deprecatedNames.value.get(item.value); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 9035e6bf40400..5e60a57cf89e0 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -15,7 +15,7 @@ import { IChatModeService } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; import { IPromptsService, Target } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; -import { ClaudeHeaderAttributes, IArrayValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { ClaudeHeaderAttributes, ISequenceValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; import { getAttributeDescription, getTarget, getValidAttributeNames, claudeAgentAttributes, claudeRulesAttributes, knownClaudeTools, knownGithubCopilotTools, IValueEntry } from './promptValidator.js'; import { localize } from '../../../../../../nls.js'; import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper.js'; @@ -161,7 +161,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (promptType === PromptsType.prompt || promptType === PromptsType.agent) { if (attribute.key === PromptHeaderAttributes.model) { - if (attribute.value.type === 'array') { + if (attribute.value.type === 'sequence') { // if the position is inside the tools metadata, we provide tool name completions const getValues = async () => { if (target === Target.Claude) { @@ -175,10 +175,10 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { } if (attribute.key === PromptHeaderAttributes.tools || attribute.key === ClaudeHeaderAttributes.disallowedTools) { let value = attribute.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type === 'array') { + if (value.type === 'sequence') { // if the position is inside the tools metadata, we provide tool name completions const getValues = async () => { if (target === Target.GitHubCopilot) { @@ -195,7 +195,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { } } if (attribute.key === PromptHeaderAttributes.agents) { - if (attribute.value.type === 'array') { + if (attribute.value.type === 'sequence') { return this.provideArrayCompletions(model, position, attribute.value, async () => { return await this.promptsService.getCustomAgents(CancellationToken.None); }); @@ -299,7 +299,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return [{ name: '["*"]' }]; } break; - case PromptHeaderAttributes.userInvokable: + case PromptHeaderAttributes.userInvocable: if (promptType === PromptsType.agent || promptType === PromptsType.skill) { return [{ name: 'true' }, { name: 'false' }]; } @@ -317,7 +317,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { const result = []; for (const model of this.languageModelsService.getLanguageModelIds()) { const metadata = this.languageModelsService.lookupLanguageModel(model); - if (metadata && metadata.isUserSelectable !== false) { + if (metadata && metadata.isUserSelectable !== false && !metadata.targetChatSessionType) { if (!agentModeOnly || ILanguageModelChatMetadata.suitableForAgentMode(metadata)) { result.push({ name: ILanguageModelChatMetadata.asQualifiedName(metadata), @@ -329,12 +329,12 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return result; } - private async provideArrayCompletions(model: ITextModel, position: Position, arrayValue: IArrayValue, getValues: () => Promise>): Promise { + private async provideArrayCompletions(model: ITextModel, position: Position, arrayValue: ISequenceValue, getValues: () => Promise>): Promise { const getSuggestions = async (toolRange: Range, currentItem?: IValue) => { const suggestions: CompletionItem[] = []; const entries = await getValues(); const quotePreference = getQuotePreference(arrayValue, model); - const existingValues = new Set(arrayValue.items.filter(item => item !== currentItem).filter(item => item.type === 'string').map(item => item.value)); + const existingValues = new Set(arrayValue.items.filter(item => item !== currentItem).filter(item => item.type === 'scalar').map(item => item.value)); for (const entry of entries) { const entryName = entry.name; if (existingValues.has(entryName)) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index c1df4ed4ab0c6..8fe87c45a10ad 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -87,7 +87,7 @@ export class PromptHoverProvider implements HoverProvider { case PromptHeaderAttributes.handOffs: return this.getHandsOffHover(attribute, position, target); case PromptHeaderAttributes.infer: - return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invokable` and `disable-model-invocation` instead.'), attribute.range); + return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invocable` and `disable-model-invocation` instead.'), attribute.range); default: return this.createHover(description, attribute.range); } @@ -99,12 +99,12 @@ export class PromptHoverProvider implements HoverProvider { private getToolHover(node: IHeaderAttribute, position: Position, baseMessage: string, target: Target): Hover | undefined { let value = node.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type === 'array') { + if (value.type === 'sequence') { for (const toolName of value.items) { - if (toolName.type === 'string' && toolName.range.containsPosition(position)) { + if (toolName.type === 'scalar' && toolName.range.containsPosition(position)) { const description = this.getToolHoverByName(toolName.value, toolName.range, target); if (description) { return description; @@ -181,14 +181,14 @@ export class PromptHoverProvider implements HoverProvider { } return undefined; }; - if (node.value.type === 'string') { + if (node.value.type === 'scalar') { const hover = modelHoverContent(node.value.value); if (hover) { return hover; } - } else if (node.value.type === 'array') { + } else if (node.value.type === 'sequence') { for (const item of node.value.items) { - if (item.type === 'string' && item.range.containsPosition(position)) { + if (item.type === 'scalar' && item.range.containsPosition(position)) { const hover = modelHoverContent(item.value); if (hover) { return hover; @@ -202,7 +202,7 @@ export class PromptHoverProvider implements HoverProvider { private getAgentHover(agentAttribute: IHeaderAttribute, position: Position, baseMessage: string): Hover | undefined { const lines: string[] = []; const value = agentAttribute.value; - if (value.type === 'string' && value.range.containsPosition(position)) { + if (value.type === 'scalar' && value.range.containsPosition(position)) { const agent = this.chatModeService.findModeByName(value.value); if (agent) { const description = agent.description.get() || (isBuiltinChatMode(agent) ? localize('promptHeader.prompt.agent.builtInDesc', 'Built-in agent') : localize('promptHeader.prompt.agent.customDesc', 'Custom agent')); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 802e88203dd31..8b9a1b018cfe6 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -16,7 +16,7 @@ import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, SpecedToolAliases } from '../../tools/languageModelToolsService.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { GithubPromptHeaderAttributes, IArrayValue, IHeaderAttribute, IStringValue, parseCommaSeparatedList, ParsedPromptFile, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { GithubPromptHeaderAttributes, ISequenceValue, IHeaderAttribute, IScalarValue, parseCommaSeparatedList, ParsedPromptFile, PromptHeader, PromptHeaderAttributes, IValue } from '../promptFileParser.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; @@ -67,7 +67,7 @@ export class PromptValidator { } const nameAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.name); - if (!nameAttribute || nameAttribute.value.type !== 'string') { + if (!nameAttribute || nameAttribute.value.type !== 'scalar') { return; } @@ -186,6 +186,7 @@ export class PromptValidator { case PromptsType.agent: { this.validateTarget(attributes, report); this.validateInfer(attributes, report); + this.validateUserInvocable(attributes, report); this.validateUserInvokable(attributes, report); this.validateDisableModelInvocation(attributes, report); this.validateTools(attributes, ChatModeKind.Agent, target, report); @@ -200,6 +201,7 @@ export class PromptValidator { } case PromptsType.skill: + this.validateUserInvocable(attributes, report); this.validateUserInvokable(attributes, report); this.validateDisableModelInvocation(attributes, report); break; @@ -251,7 +253,7 @@ export class PromptValidator { if (!nameAttribute) { return; } - if (nameAttribute.value.type !== 'string') { + if (nameAttribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.nameMustBeString', "The 'name' attribute must be a string."), nameAttribute.range, MarkerSeverity.Error)); return; } @@ -266,7 +268,7 @@ export class PromptValidator { if (!descriptionAttribute) { return; } - if (descriptionAttribute.value.type !== 'string') { + if (descriptionAttribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.descriptionMustBeString', "The 'description' attribute must be a string."), descriptionAttribute.range, MarkerSeverity.Error)); return; } @@ -281,7 +283,7 @@ export class PromptValidator { if (!argumentHintAttribute) { return; } - if (argumentHintAttribute.value.type !== 'string') { + if (argumentHintAttribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.argumentHintMustBeString', "The 'argument-hint' attribute must be a string."), argumentHintAttribute.range, MarkerSeverity.Error)); return; } @@ -296,26 +298,26 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'string' && attribute.value.type !== 'array') { + if (attribute.value.type !== 'scalar' && attribute.value.type !== 'sequence') { report(toMarker(localize('promptValidator.modelMustBeStringOrArray', "The 'model' attribute must be a string or an array of strings."), attribute.value.range, MarkerSeverity.Error)); return; } const modelNames: [string, Range][] = []; - if (attribute.value.type === 'string') { + if (attribute.value.type === 'scalar') { const modelName = attribute.value.value.trim(); if (modelName.length === 0) { report(toMarker(localize('promptValidator.modelMustBeNonEmpty', "The 'model' attribute must be a non-empty string."), attribute.value.range, MarkerSeverity.Error)); return; } modelNames.push([modelName, attribute.value.range]); - } else if (attribute.value.type === 'array') { + } else if (attribute.value.type === 'sequence') { if (attribute.value.items.length === 0) { report(toMarker(localize('promptValidator.modelArrayMustNotBeEmpty', "The 'model' array must not be empty."), attribute.value.range, MarkerSeverity.Error)); return; } for (const item of attribute.value.items) { - if (item.type !== 'string') { + if (item.type !== 'scalar') { report(toMarker(localize('promptValidator.modelArrayMustContainStrings', "The 'model' array must contain only strings."), item.range, MarkerSeverity.Error)); return; } @@ -354,7 +356,7 @@ export class PromptValidator { if (!attribute) { continue; } - if (attribute.value.type !== 'string') { + if (attribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.claude.attributeMustBeString', "The '{0}' attribute must be a string.", claudeAttributeName), attribute.value.range, MarkerSeverity.Error)); continue; } else { @@ -391,7 +393,7 @@ export class PromptValidator { if (!attribute) { return undefined; // default agent for prompts is Agent } - if (attribute.value.type !== 'string') { + if (attribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.attributeMustBeString', "The '{0}' attribute must be a string.", attribute.key), attribute.value.range, MarkerSeverity.Error)); return undefined; } @@ -403,7 +405,7 @@ export class PromptValidator { return this.validateAgentValue(attribute.value, report); } - private validateAgentValue(value: IStringValue, report: (markers: IMarkerData) => void): IChatMode | undefined { + private validateAgentValue(value: IScalarValue, report: (markers: IMarkerData) => void): IChatMode | undefined { const agents = this.chatModeService.getModes(); const availableAgents = []; @@ -429,10 +431,10 @@ export class PromptValidator { report(toMarker(localize('promptValidator.toolsOnlyInAgent', "The 'tools' attribute is only supported when using agents. Attribute will be ignored."), attribute.range, MarkerSeverity.Warning)); } let value = attribute.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type !== 'array') { + if (value.type !== 'sequence') { report(toMarker(localize('promptValidator.toolsMustBeArrayOrMap', "The 'tools' attribute must be an array or a comma separated string."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -443,12 +445,12 @@ export class PromptValidator { } } - private validateVSCodeTools(valueItem: IArrayValue, report: (markers: IMarkerData) => void) { + private validateVSCodeTools(valueItem: ISequenceValue, report: (markers: IMarkerData) => void) { if (valueItem.items.length > 0) { const available = new Set(this.languageModelToolsService.getFullReferenceNames()); const deprecatedNames = this.languageModelToolsService.getDeprecatedFullReferenceNames(); for (const item of valueItem.items) { - if (item.type !== 'string') { + if (item.type !== 'scalar') { report(toMarker(localize('promptValidator.eachToolMustBeString', "Each tool name in the 'tools' attribute must be a string."), item.range, MarkerSeverity.Error)); } else if (item.value) { if (!available.has(item.value)) { @@ -475,7 +477,7 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'string') { + if (attribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.applyToMustBeString', "The 'applyTo' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -503,12 +505,12 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'array') { + if (attribute.value.type !== 'sequence') { report(toMarker(localize('promptValidator.pathsMustBeArray', "The 'paths' attribute must be an array of glob patterns."), attribute.value.range, MarkerSeverity.Error)); return; } for (const item of attribute.value.items) { - if (item.type !== 'string') { + if (item.type !== 'scalar') { report(toMarker(localize('promptValidator.eachPathMustBeString', "Each entry in the 'paths' attribute must be a string."), item.range, MarkerSeverity.Error)); continue; } @@ -533,7 +535,7 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'array' && attribute.value.type !== 'string') { + if (attribute.value.type !== 'sequence' && attribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.excludeAgentMustBeArray', "The 'excludeAgent' attribute must be an string or array."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -544,12 +546,12 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'array') { + if (attribute.value.type !== 'sequence') { report(toMarker(localize('promptValidator.handoffsMustBeArray', "The 'handoffs' attribute must be an array."), attribute.value.range, MarkerSeverity.Error)); return; } for (const item of attribute.value.items) { - if (item.type !== 'object') { + if (item.type !== 'map') { report(toMarker(localize('promptValidator.eachHandoffMustBeObject', "Each handoff in the 'handoffs' attribute must be an object with 'label', 'agent', 'prompt' and optional 'send'."), item.range, MarkerSeverity.Error)); continue; } @@ -557,34 +559,34 @@ export class PromptValidator { for (const prop of item.properties) { switch (prop.key.value) { case 'label': - if (prop.value.type !== 'string' || prop.value.value.trim().length === 0) { + if (prop.value.type !== 'scalar' || prop.value.value.trim().length === 0) { report(toMarker(localize('promptValidator.handoffLabelMustBeNonEmptyString', "The 'label' property in a handoff must be a non-empty string."), prop.value.range, MarkerSeverity.Error)); } break; case 'agent': - if (prop.value.type !== 'string' || prop.value.value.trim().length === 0) { + if (prop.value.type !== 'scalar' || prop.value.value.trim().length === 0) { report(toMarker(localize('promptValidator.handoffAgentMustBeNonEmptyString', "The 'agent' property in a handoff must be a non-empty string."), prop.value.range, MarkerSeverity.Error)); } else { this.validateAgentValue(prop.value, report); } break; case 'prompt': - if (prop.value.type !== 'string') { + if (prop.value.type !== 'scalar') { report(toMarker(localize('promptValidator.handoffPromptMustBeString', "The 'prompt' property in a handoff must be a string."), prop.value.range, MarkerSeverity.Error)); } break; case 'send': - if (prop.value.type !== 'boolean') { + if (!isTrueOrFalse(prop.value)) { report(toMarker(localize('promptValidator.handoffSendMustBeBoolean', "The 'send' property in a handoff must be a boolean."), prop.value.range, MarkerSeverity.Error)); } break; case 'showContinueOn': - if (prop.value.type !== 'boolean') { + if (!isTrueOrFalse(prop.value)) { report(toMarker(localize('promptValidator.handoffShowContinueOnMustBeBoolean', "The 'showContinueOn' property in a handoff must be a boolean."), prop.value.range, MarkerSeverity.Error)); } break; case 'model': - if (prop.value.type !== 'string') { + if (prop.value.type !== 'scalar') { report(toMarker(localize('promptValidator.handoffModelMustBeString', "The 'model' property in a handoff must be a string."), prop.value.range, MarkerSeverity.Error)); } break; @@ -604,7 +606,7 @@ export class PromptValidator { if (!attribute) { return; } - report(toMarker(localize('promptValidator.inferDeprecated', "The 'infer' attribute is deprecated in favour of 'user-invokable' and 'disable-model-invocation'."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.inferDeprecated', "The 'infer' attribute is deprecated in favour of 'user-invocable' and 'disable-model-invocation'."), attribute.value.range, MarkerSeverity.Error)); } private validateTarget(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { @@ -612,7 +614,7 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'string') { + if (attribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.targetMustBeString', "The 'target' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -627,15 +629,23 @@ export class PromptValidator { } } - private validateUserInvokable(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { - const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.userInvokable); + private validateUserInvocable(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { + const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.userInvocable); if (!attribute) { return; } - if (attribute.value.type !== 'boolean') { - report(toMarker(localize('promptValidator.userInvokableMustBeBoolean', "The 'user-invokable' attribute must be a boolean."), attribute.value.range, MarkerSeverity.Error)); + if (!isTrueOrFalse(attribute.value)) { + report(toMarker(localize('promptValidator.userInvocableMustBeBoolean', "The 'user-invocable' attribute must be 'true' or 'false'."), attribute.value.range, MarkerSeverity.Error)); + return; + } + } + + private validateUserInvokable(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { + const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.userInvokable); + if (!attribute) { return; } + report(toMarker(localize('promptValidator.userInvokableDeprecated', "The 'user-invokable' attribute is deprecated. Use 'user-invocable' instead."), attribute.range, MarkerSeverity.Warning)); } private validateDisableModelInvocation(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { @@ -643,8 +653,8 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'boolean') { - report(toMarker(localize('promptValidator.disableModelInvocationMustBeBoolean', "The 'disable-model-invocation' attribute must be a boolean."), attribute.value.range, MarkerSeverity.Error)); + if (!isTrueOrFalse(attribute.value)) { + report(toMarker(localize('promptValidator.disableModelInvocationMustBeBoolean', "The 'disable-model-invocation' attribute must be 'true' or 'false'."), attribute.value.range, MarkerSeverity.Error)); return; } } @@ -654,7 +664,7 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'array') { + if (attribute.value.type !== 'sequence') { report(toMarker(localize('promptValidator.agentsMustBeArray', "The 'agents' attribute must be an array."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -667,7 +677,7 @@ export class PromptValidator { // Check each item is a string and agent exists const agentNames: string[] = []; for (const item of attribute.value.items) { - if (item.type !== 'string') { + if (item.type !== 'scalar') { report(toMarker(localize('promptValidator.eachAgentMustBeString', "Each agent name in the 'agents' attribute must be a string."), item.range, MarkerSeverity.Error)); } else if (item.value) { agentNames.push(item.value); @@ -687,11 +697,18 @@ export class PromptValidator { } } +function isTrueOrFalse(value: IValue): boolean { + if (value.type === 'scalar') { + return (value.value === 'true' || value.value === 'false') && value.format === 'none'; + } + return false; +} + const allAttributeNames: Record = { [PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint], [PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent], - [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], - [PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.license, PromptHeaderAttributes.compatibility, PromptHeaderAttributes.metadata, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], + [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], + [PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.license, PromptHeaderAttributes.compatibility, PromptHeaderAttributes.metadata, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter }; const githubCopilotAgentAttributeNames = [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.tools, PromptHeaderAttributes.target, GithubPromptHeaderAttributes.mcpServers, PromptHeaderAttributes.infer]; @@ -718,7 +735,7 @@ export function getValidAttributeNames(promptType: PromptsType, includeNonRecomm } export function isNonRecommendedAttribute(attributeName: string): boolean { - return attributeName === PromptHeaderAttributes.advancedOptions || attributeName === PromptHeaderAttributes.excludeAgent || attributeName === PromptHeaderAttributes.mode || attributeName === PromptHeaderAttributes.infer; + return attributeName === PromptHeaderAttributes.advancedOptions || attributeName === PromptHeaderAttributes.excludeAgent || attributeName === PromptHeaderAttributes.mode || attributeName === PromptHeaderAttributes.infer || attributeName === PromptHeaderAttributes.userInvokable; } export function getAttributeDescription(attributeName: string, promptType: PromptsType, target: Target): string | undefined { @@ -749,8 +766,8 @@ export function getAttributeDescription(attributeName: string, promptType: Promp return localize('promptHeader.skill.description', 'The description of the skill. The description is added to every request and will be used by the agent to decide when to load the skill.'); case PromptHeaderAttributes.argumentHint: return localize('promptHeader.skill.argumentHint', 'Hint shown during autocomplete to indicate expected arguments. Example: [issue-number] or [filename] [format]'); - case PromptHeaderAttributes.userInvokable: - return localize('promptHeader.skill.userInvokable', 'Set to false to hide from the / menu. Use for background knowledge users should not invoke directly. Default: true.'); + case PromptHeaderAttributes.userInvocable: + return localize('promptHeader.skill.userInvocable', 'Set to false to hide from the / menu. Use for background knowledge users should not invoke directly. Default: true.'); case PromptHeaderAttributes.disableModelInvocation: return localize('promptHeader.skill.disableModelInvocation', 'Set to true to prevent the agent from automatically loading this skill. Use for workflows you want to trigger manually with /name. Default: false.'); } @@ -775,8 +792,8 @@ export function getAttributeDescription(attributeName: string, promptType: Promp return localize('promptHeader.agent.infer', 'Controls visibility of the agent.'); case PromptHeaderAttributes.agents: return localize('promptHeader.agent.agents', 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.'); - case PromptHeaderAttributes.userInvokable: - return localize('promptHeader.agent.userInvokable', 'Whether the agent can be selected and invoked by users in the UI.'); + case PromptHeaderAttributes.userInvocable: + return localize('promptHeader.agent.userInvocable', 'Whether the agent can be selected and invoked by users in the UI.'); case PromptHeaderAttributes.disableModelInvocation: return localize('promptHeader.agent.disableModelInvocation', 'If true, prevents the agent from being invoked as a subagent.'); } @@ -867,33 +884,33 @@ export function mapClaudeTools(claudeToolNames: readonly string[]): string[] { export const claudeAgentAttributes: Record = { 'name': { - type: 'string', + type: 'scalar', description: localize('attribute.name', "Unique identifier using lowercase letters and hyphens (required)"), }, 'description': { - type: 'string', + type: 'scalar', description: localize('attribute.description', "When to delegate to this subagent (required)"), }, 'tools': { - type: 'array', + type: 'sequence', description: localize('attribute.tools', "Array of tools the subagent can use. Inherits all tools if omitted"), defaults: ['Read, Edit, Bash'], items: knownClaudeTools }, 'disallowedTools': { - type: 'array', + type: 'sequence', description: localize('attribute.disallowedTools', "Tools to deny, removed from inherited or specified list"), defaults: ['Write, Edit, Bash'], items: knownClaudeTools }, 'model': { - type: 'string', + type: 'scalar', description: localize('attribute.model', "Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit."), defaults: ['sonnet', 'opus', 'haiku', 'inherit'], enums: knownClaudeModels }, 'permissionMode': { - type: 'string', + type: 'scalar', description: localize('attribute.permissionMode', "Permission mode: default, acceptEdits, dontAsk, bypassPermissions, or plan."), defaults: ['default', 'acceptEdits', 'dontAsk', 'bypassPermissions', 'plan'], enums: [ @@ -906,11 +923,11 @@ export const claudeAgentAttributes: Record = { 'description': { - type: 'string', + type: 'scalar', description: localize('attribute.rules.description', "A description of what this rule covers, used to provide context about when it applies."), }, 'paths': { - type: 'array', + type: 'sequence', description: localize('attribute.rules.paths', "Array of glob patterns that describe for which files the rule applies. Based on these patterns, the file is automatically included in the prompt when the context contains a file that matches.\nExample: `['src/**/*.ts', 'test/**']`"), }, }; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index 778cf3a82454b..e11732310ec92 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -7,8 +7,9 @@ import { Iterable } from '../../../../../base/common/iterator.js'; import { dirname, joinPath } from '../../../../../base/common/resources.js'; import { splitLinesIncludeSeparators } from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; -import { parse, YamlNode, YamlParseError, Position as YamlPosition } from '../../../../../base/common/yaml.js'; +import { parse, YamlNode, YamlParseError } from '../../../../../base/common/yaml.js'; import { Range } from '../../../../../editor/common/core/range.js'; +import { PositionOffsetTransformer } from '../../../../../editor/common/core/text/positionToOffsetImpl.js'; import { Target } from './service/promptsService.js'; export class PromptFileParser { @@ -82,6 +83,7 @@ export namespace PromptHeaderAttributes { export const metadata = 'metadata'; export const agents = 'agents'; export const userInvokable = 'user-invokable'; + export const userInvocable = 'user-invocable'; export const disableModelInvocation = 'disable-model-invocation'; } @@ -106,19 +108,38 @@ export class PromptHeader { private get _parsedHeader(): ParsedHeader { if (this._parsed === undefined) { const yamlErrors: YamlParseError[] = []; - const lines = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join(''); - const node = parse(lines, yamlErrors); + const headerContent = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join(''); + const node = parse(headerContent, yamlErrors); + const transformer = new PositionOffsetTransformer(headerContent); + const asRange = ({ startOffset, endOffset }: { startOffset: number; endOffset: number }): Range => { + const startPos = transformer.getPosition(startOffset), endPos = transformer.getPosition(endOffset); + const headerDelta = this.range.startLineNumber - 1; + return new Range(startPos.lineNumber + headerDelta, startPos.column, endPos.lineNumber + headerDelta, endPos.column); + }; + const asValue = (node: YamlNode): IValue => { + switch (node.type) { + case 'scalar': + return { type: 'scalar', value: node.value, range: asRange(node), format: node.format }; + case 'sequence': + return { type: 'sequence', items: node.items.map(item => asValue(item)), range: asRange(node) }; + case 'map': { + const properties = node.properties.map(property => ({ key: asValue(property.key) as IScalarValue, value: asValue(property.value) })); + return { type: 'map', properties, range: asRange(node) }; + } + } + }; + const attributes = []; - const errors: ParseError[] = yamlErrors.map(err => ({ message: err.message, range: this.asRange(err), code: err.code })); + const errors: ParseError[] = yamlErrors.map(err => ({ message: err.message, range: asRange(err), code: err.code })); if (node) { - if (node.type !== 'object') { + if (node.type !== 'map') { errors.push({ message: 'Invalid header, expecting pairs', range: this.range, code: 'INVALID_YAML' }); } else { for (const property of node.properties) { attributes.push({ key: property.key.value, - range: this.asRange({ start: property.key.start, end: property.value.end }), - value: this.asValue(property.value) + range: asRange({ startOffset: property.key.startOffset, endOffset: property.value.endOffset }), + value: asValue(property.value) }); } } @@ -128,29 +149,6 @@ export class PromptHeader { return this._parsed; } - private asRange({ start, end }: { start: YamlPosition; end: YamlPosition }): Range { - return new Range(this.range.startLineNumber + start.line, start.character + 1, this.range.startLineNumber + end.line, end.character + 1); - } - - private asValue(node: YamlNode): IValue { - switch (node.type) { - case 'string': - return { type: 'string', value: node.value, range: this.asRange(node) }; - case 'number': - return { type: 'number', value: node.value, range: this.asRange(node) }; - case 'boolean': - return { type: 'boolean', value: node.value, range: this.asRange(node) }; - case 'null': - return { type: 'null', value: node.value, range: this.asRange(node) }; - case 'array': - return { type: 'array', items: node.items.map(item => this.asValue(item)), range: this.asRange(node) }; - case 'object': { - const properties = node.properties.map(property => ({ key: this.asValue(property.key) as IStringValue, value: this.asValue(property.value) })); - return { type: 'object', properties, range: this.asRange(node) }; - } - } - } - public get attributes(): IHeaderAttribute[] { return this._parsedHeader.attributes; } @@ -165,7 +163,7 @@ export class PromptHeader { private getStringAttribute(key: string): string | undefined { const attribute = this._parsedHeader.attributes.find(attr => attr.key === key); - if (attribute?.value.type === 'string') { + if (attribute?.value.type === 'scalar') { return attribute.value.value; } return undefined; @@ -209,11 +207,7 @@ export class PromptHeader { } public get infer(): boolean | undefined { - const attribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.infer); - if (attribute?.value.type === 'boolean') { - return attribute.value.value; - } - return undefined; + return this.getBooleanAttribute(PromptHeaderAttributes.infer); } public get tools(): string[] | undefined { @@ -222,13 +216,13 @@ export class PromptHeader { return undefined; } let value = toolsAttribute.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type === 'array') { + if (value.type === 'sequence') { const tools: string[] = []; for (const item of value.items) { - if (item.type === 'string' && item.value) { + if (item.type === 'scalar' && item.value) { tools.push(item.value); } } @@ -242,11 +236,11 @@ export class PromptHeader { if (!handoffsAttribute) { return undefined; } - if (handoffsAttribute.value.type === 'array') { + if (handoffsAttribute.value.type === 'sequence') { // Array format: list of objects: { agent, label, prompt, send?, showContinueOn?, model? } const handoffs: IHandOff[] = []; for (const item of handoffsAttribute.value.items) { - if (item.type === 'object') { + if (item.type === 'map') { let agent: string | undefined; let label: string | undefined; let prompt: string | undefined; @@ -254,17 +248,17 @@ export class PromptHeader { let showContinueOn: boolean | undefined; let model: string | undefined; for (const prop of item.properties) { - if (prop.key.value === 'agent' && prop.value.type === 'string') { + if (prop.key.value === 'agent' && prop.value.type === 'scalar') { agent = prop.value.value; - } else if (prop.key.value === 'label' && prop.value.type === 'string') { + } else if (prop.key.value === 'label' && prop.value.type === 'scalar') { label = prop.value.value; - } else if (prop.key.value === 'prompt' && prop.value.type === 'string') { + } else if (prop.key.value === 'prompt' && prop.value.type === 'scalar') { prompt = prop.value.value; - } else if (prop.key.value === 'send' && prop.value.type === 'boolean') { - send = prop.value.value; - } else if (prop.key.value === 'showContinueOn' && prop.value.type === 'boolean') { - showContinueOn = prop.value.value; - } else if (prop.key.value === 'model' && prop.value.type === 'string') { + } else if (prop.key.value === 'send' && prop.value.type === 'scalar') { + send = parseBoolean(prop.value); + } else if (prop.key.value === 'showContinueOn' && prop.value.type === 'scalar') { + showContinueOn = parseBoolean(prop.value); + } else if (prop.key.value === 'model' && prop.value.type === 'scalar') { model = prop.value.value; } } @@ -291,10 +285,10 @@ export class PromptHeader { if (!attribute) { return undefined; } - if (attribute.value.type === 'array') { + if (attribute.value.type === 'sequence') { const result: string[] = []; for (const item of attribute.value.items) { - if (item.type === 'string' && item.value) { + if (item.type === 'scalar' && item.value) { result.push(item.value); } } @@ -308,13 +302,13 @@ export class PromptHeader { if (!attribute) { return undefined; } - if (attribute.value.type === 'string') { + if (attribute.value.type === 'scalar') { return [attribute.value.value]; } - if (attribute.value.type === 'array') { + if (attribute.value.type === 'sequence') { const result: string[] = []; for (const item of attribute.value.items) { - if (item.type === 'string') { + if (item.type === 'scalar') { result.push(item.value); } } @@ -327,8 +321,9 @@ export class PromptHeader { return this.getStringArrayAttribute(PromptHeaderAttributes.agents); } - public get userInvokable(): boolean | undefined { - return this.getBooleanAttribute(PromptHeaderAttributes.userInvokable); + public get userInvocable(): boolean | undefined { + // TODO: user-invokable is deprecated, remove later and only keep user-invocable + return this.getBooleanAttribute(PromptHeaderAttributes.userInvocable) ?? this.getBooleanAttribute(PromptHeaderAttributes.userInvokable); } public get disableModelInvocation(): boolean | undefined { @@ -337,13 +332,22 @@ export class PromptHeader { private getBooleanAttribute(key: string): boolean | undefined { const attribute = this._parsedHeader.attributes.find(attr => attr.key === key); - if (attribute?.value.type === 'boolean') { - return attribute.value.value; + if (attribute?.value.type === 'scalar') { + return parseBoolean(attribute.value); } return undefined; } } +function parseBoolean(stringValue: IScalarValue): boolean | undefined { + if (stringValue.value === 'true') { + return true; + } else if (stringValue.value === 'false') { + return false; + } + return undefined; +} + export interface IHandOff { readonly agent: string; readonly label: string; @@ -359,24 +363,26 @@ export interface IHeaderAttribute { readonly value: IValue; } -export interface IStringValue { readonly type: 'string'; readonly value: string; readonly range: Range } -export interface INumberValue { readonly type: 'number'; readonly value: number; readonly range: Range } -export interface INullValue { readonly type: 'null'; readonly value: null; readonly range: Range } -export interface IBooleanValue { readonly type: 'boolean'; readonly value: boolean; readonly range: Range } +export interface IScalarValue { + readonly type: 'scalar'; + readonly value: string; + readonly range: Range; + readonly format: 'single' | 'double' | 'none' | 'literal' | 'folded'; +} -export interface IArrayValue { - readonly type: 'array'; +export interface ISequenceValue { + readonly type: 'sequence'; readonly items: readonly IValue[]; readonly range: Range; } -export interface IObjectValue { - readonly type: 'object'; - readonly properties: { key: IStringValue; value: IValue }[]; +export interface IMapValue { + readonly type: 'map'; + readonly properties: { key: IScalarValue; value: IValue }[]; readonly range: Range; } -export type IValue = IStringValue | INumberValue | IBooleanValue | IArrayValue | IObjectValue | INullValue; +export type IValue = IScalarValue | ISequenceValue | IMapValue; interface ParsedBody { @@ -490,10 +496,10 @@ export interface IBodyVariableReference { * Values can be unquoted or quoted (single or double quotes). * * @param input A string containing comma-separated values - * @returns An IArrayValue containing the parsed values and their ranges + * @returns An ISequenceValue containing the parsed values and their ranges */ -export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue { - const result: IStringValue[] = []; +export function parseCommaSeparatedList(stringValue: IScalarValue): ISequenceValue { + const result: IScalarValue[] = []; const input = stringValue.value; const positionOffset = stringValue.range.getStartPosition(); let pos = 0; @@ -512,6 +518,7 @@ export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue const startPos = pos; let value = ''; let endPos: number; + let quoteStyle: 'single' | 'double' | 'none'; const char = input[pos]; if (char === '"' || char === `'`) { @@ -528,7 +535,7 @@ export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue if (pos < input.length) { pos++; } - + quoteStyle = quote === '"' ? 'double' : 'single'; } else { // Unquoted string - read until comma or end const startPos = pos; @@ -538,9 +545,10 @@ export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue } value = value.trimEnd(); endPos = startPos + value.length; + quoteStyle = 'none'; } - result.push({ type: 'string', value: value, range: new Range(positionOffset.lineNumber, positionOffset.column + startPos, positionOffset.lineNumber, positionOffset.column + endPos) }); + result.push({ type: 'scalar', value: value, range: new Range(positionOffset.lineNumber, positionOffset.column + startPos, positionOffset.lineNumber, positionOffset.column + endPos), format: quoteStyle }); // Skip whitespace after value while (pos < input.length && isWhitespace(input[pos])) { @@ -553,7 +561,7 @@ export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue } } - return { type: 'array', items: result, range: stringValue.range }; + return { type: 'sequence', items: result, range: stringValue.range }; } 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 f59d436d4fd2b..c94c7a1069657 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -125,16 +125,16 @@ export type IAgentSource = { * - 'hidden': neither in picker nor usable as subagent */ export type ICustomAgentVisibility = { - readonly userInvokable: boolean; - readonly agentInvokable: boolean; + readonly userInvocable: boolean; + readonly agentInvocable: boolean; }; export function isCustomAgentVisibility(obj: unknown): obj is ICustomAgentVisibility { if (typeof obj !== 'object' || obj === null) { return false; } - const v = obj as { userInvokable?: unknown; agentInvokable?: unknown }; - return typeof v.userInvokable === 'boolean' && typeof v.agentInvokable === 'boolean'; + const v = obj as { userInvocable?: unknown; agentInvocable?: unknown }; + return typeof v.userInvocable === 'boolean' && typeof v.agentInvocable === 'boolean'; } export enum Target { @@ -181,7 +181,7 @@ export interface ICustomAgent { readonly target: Target; /** - * What visibility the agent has (user invokable, subagent invokable). + * What visibility the agent has (user invocable, subagent invocable). */ readonly visibility: ICustomAgentVisibility; @@ -235,7 +235,7 @@ export interface IAgentSkill { * If false, the skill is hidden from the / menu. * Use for background knowledge users shouldn't invoke directly. */ - readonly userInvokable: boolean; + readonly userInvocable: boolean; } /** @@ -274,7 +274,8 @@ export type PromptFileSkipReason = | 'duplicate-name' | 'parse-error' | 'disabled' - | 'all-hooks-disabled'; + | 'all-hooks-disabled' + | 'claude-hooks-disabled'; /** * Result of discovering a single prompt file. @@ -291,8 +292,8 @@ export interface IPromptFileDiscoveryResult { readonly duplicateOf?: URI; /** Extension ID if from extension */ readonly extensionId?: string; - /** If true, the skill is hidden from the / menu (user-invokable: false) */ - readonly userInvokable?: boolean; + /** Whether the skill is user-invocable in the / menu (set user-invocable: false to hide it) */ + readonly userInvocable?: boolean; /** If true, the skill won't be automatically loaded by the agent (disable-model-invocation: true) */ readonly disableModelInvocation?: boolean; } @@ -305,6 +306,11 @@ export interface IPromptDiscoveryInfo { readonly files: readonly IPromptFileDiscoveryResult[]; } +export interface IConfiguredHooksInfo { + readonly hooks: IChatRequestHooks; + readonly hasDisabledClaudeHooks: boolean; +} + /** * Provides prompt services. */ @@ -445,5 +451,5 @@ export interface IPromptsService extends IDisposable { * Gets all hooks collected from hooks.json files. * The result is cached and invalidated when hook files change. */ - getHooks(token: CancellationToken): Promise; + getHooks(token: CancellationToken): Promise; } 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 257cb5fdc57b6..912a103fe5845 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -32,11 +32,11 @@ 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, 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, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target } from './promptsService.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, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; -import { parseHooksFromFile } from '../hookCompatibility.js'; +import { HookSourceFormat, getHookSourceFormat, parseHooksFromFile } from '../hookCompatibility.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders/promptValidator.js'; @@ -96,7 +96,7 @@ export class PromptsService extends Disposable implements IPromptsService { /** * Cached hooks. Invalidated when hook files change. */ - private readonly cachedHooks: CachedPromise; + private readonly cachedHooks: CachedPromise; /** * Cached skills. Caching only happens if the `onDidChangeSkills` event is used. @@ -179,7 +179,7 @@ export class PromptsService extends Disposable implements IPromptsService { (token) => this.computeHooks(token), () => Event.any( this.getFileLocatorEvent(PromptsType.hook), - Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CHAT_HOOKS)), + Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CHAT_HOOKS) || e.affectsConfiguration(PromptsConfig.USE_CLAUDE_HOOKS)), ) )); @@ -548,10 +548,10 @@ export class PromptsService extends Disposable implements IPromptsService { let metadata: any | undefined; if (ast.header) { const advanced = ast.header.getAttribute(PromptHeaderAttributes.advancedOptions); - if (advanced && advanced.value.type === 'object') { + if (advanced && advanced.value.type === 'map') { metadata = {}; for (const [key, value] of Object.entries(advanced.value)) { - if (['string', 'number', 'boolean'].includes(value.type)) { + if (value.type === 'scalar') { metadata[key] = value; } } @@ -579,11 +579,11 @@ export class PromptsService extends Disposable implements IPromptsService { const source: IAgentSource = IAgentSource.fromPromptPath(promptPath); if (!ast.header) { - return { uri, name, agentInstructions, source, target, visibility: { userInvokable: true, agentInvokable: true } }; + return { uri, name, agentInstructions, source, target, visibility: { userInvocable: true, agentInvocable: true } }; } const visibility = { - userInvokable: ast.header.userInvokable !== false, - agentInvokable: ast.header.infer === true || ast.header.disableModelInvocation !== true, + userInvocable: ast.header.userInvocable !== false, + agentInvocable: ast.header.infer === true || ast.header.disableModelInvocation !== true, } satisfies ICustomAgentVisibility; let model = ast.header.model; @@ -916,7 +916,7 @@ export class PromptsService extends Disposable implements IPromptsService { name: file.name, description: sanitizedDescription, disableModelInvocation: file.disableModelInvocation ?? false, - userInvokable: file.userInvokable ?? true + userInvocable: file.userInvocable ?? true }); } } @@ -1002,16 +1002,17 @@ export class PromptsService extends Disposable implements IPromptsService { return result; } - public getHooks(token: CancellationToken): Promise { + public async getHooks(token: CancellationToken): Promise { return this.cachedHooks.get(token); } - private async computeHooks(token: CancellationToken): Promise { + private async computeHooks(token: CancellationToken): Promise { const useChatHooks = this.configurationService.getValue(PromptsConfig.USE_CHAT_HOOKS); if (!useChatHooks) { return undefined; } + const useClaudeHooks = this.configurationService.getValue(PromptsConfig.USE_CLAUDE_HOOKS); const hookFiles = await this.listPromptFiles(PromptsType.hook, token); if (hookFiles.length === 0) { @@ -1025,10 +1026,7 @@ export class PromptsService extends Disposable implements IPromptsService { const userHomeUri = await this.pathService.userHome(); const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - // Get workspace root for resolving relative cwd paths - const workspaceFolder = this.workspaceService.getWorkspace().folders[0]; - const workspaceRootUri = workspaceFolder?.uri; - + let hasDisabledClaudeHooks = false; const collectedHooks: Record = { [HookType.SessionStart]: [], [HookType.UserPromptSubmit]: [], @@ -1040,11 +1038,18 @@ export class PromptsService extends Disposable implements IPromptsService { [HookType.Stop]: [], }; + const defaultFolder = this.workspaceService.getWorkspace().folders[0]; + for (const hookFile of hookFiles) { try { const content = await this.fileService.readFile(hookFile.uri); const json = parseJSONC(content.value.toString()); + // Resolve the workspace folder that contains this hook file for cwd resolution, + // falling back to the first workspace folder for user-level hooks outside the workspace + const hookWorkspaceFolder = this.workspaceService.getWorkspaceFolder(hookFile.uri) ?? defaultFolder; + const workspaceRootUri = hookWorkspaceFolder?.uri; + // Use format-aware parsing that handles Copilot and Claude formats const { format, hooks, disabledAllHooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); @@ -1054,6 +1059,16 @@ export class PromptsService extends Disposable implements IPromptsService { continue; } + if (format === HookSourceFormat.Claude && useClaudeHooks === false) { + const hasAnyCommands = [...hooks.values()].some(({ hooks: cmds }) => cmds.length > 0); + if (hasAnyCommands) { + hasDisabledClaudeHooks = true; + } + + this.logger.trace(`[PromptsService] Skipping Claude hook file (disabled via setting): ${hookFile.uri}`); + continue; + } + for (const [hookType, { hooks: commands }] of hooks) { for (const command of commands) { collectedHooks[hookType].push(command); @@ -1078,7 +1093,7 @@ export class PromptsService extends Disposable implements IPromptsService { ) as IChatRequestHooks; this.logger.trace(`[PromptsService] Collected hooks: ${JSON.stringify(Object.keys(result))}`); - return result; + return { hooks: result, hasDisabledClaudeHooks }; } public async getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken): Promise { @@ -1124,10 +1139,10 @@ export class PromptsService extends Disposable implements IPromptsService { * Returns the discovery results and a map of skill counts by source type for telemetry. */ private async computeSkillDiscoveryInfo(token: CancellationToken): Promise<{ - files: (IPromptFileDiscoveryResult & { description?: string; source?: PromptFileSource; disableModelInvocation?: boolean; userInvokable?: boolean })[]; + files: (IPromptFileDiscoveryResult & { description?: string; source?: PromptFileSource; disableModelInvocation?: boolean; userInvocable?: boolean })[]; skillsBySource: Map; }> { - const files: (IPromptFileDiscoveryResult & { description?: string; source?: PromptFileSource; disableModelInvocation?: boolean; userInvokable?: boolean })[] = []; + const files: (IPromptFileDiscoveryResult & { description?: string; source?: PromptFileSource; disableModelInvocation?: boolean; userInvocable?: boolean })[] = []; const skillsBySource = new Map(); const seenNames = new Set(); const nameToUri = new Map(); @@ -1206,8 +1221,8 @@ export class PromptsService extends Disposable implements IPromptsService { seenNames.add(sanitizedName); nameToUri.set(sanitizedName, uri); const disableModelInvocation = parsedFile.header?.disableModelInvocation === true; - const userInvokable = parsedFile.header?.userInvokable !== false; - files.push({ uri, storage, status: 'loaded', name: sanitizedName, description, extensionId, source, disableModelInvocation, userInvokable }); + const userInvocable = parsedFile.header?.userInvocable !== false; + files.push({ uri, storage, status: 'loaded', name: sanitizedName, description, extensionId, source, disableModelInvocation, userInvocable }); // Track skill type skillsBySource.set(source, (skillsBySource.get(source) || 0) + 1); @@ -1327,10 +1342,7 @@ export class PromptsService extends Disposable implements IPromptsService { const userHomeUri = await this.pathService.userHome(); const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - // Get workspace root for resolving relative cwd paths - const workspaceFolder = this.workspaceService.getWorkspace().folders[0]; - const workspaceRootUri = workspaceFolder?.uri; - + const useClaudeHooks = this.configurationService.getValue(PromptsConfig.USE_CLAUDE_HOOKS); const hookFiles = await this.listPromptFiles(PromptsType.hook, token); for (const promptPath of hookFiles) { const uri = promptPath.uri; @@ -1338,6 +1350,19 @@ export class PromptsService extends Disposable implements IPromptsService { const extensionId = promptPath.extension?.identifier?.value; const name = basename(uri); + // Skip Claude hooks when the setting is disabled + if (getHookSourceFormat(uri) === HookSourceFormat.Claude && useClaudeHooks === false) { + files.push({ + uri, + storage, + status: 'skipped', + skipReason: 'claude-hooks-disabled', + name, + extensionId + }); + continue; + } + try { // Try to parse the JSON to validate it (supports JSONC with comments) const content = await this.fileService.readFile(uri); @@ -1357,6 +1382,11 @@ export class PromptsService extends Disposable implements IPromptsService { continue; } + // Resolve the workspace folder that contains this hook file for cwd resolution, + // falling back to the first workspace folder for user-level hooks outside the workspace + const hookWorkspaceFolder = this.workspaceService.getWorkspaceFolder(uri) ?? this.workspaceService.getWorkspace().folders[0]; + const workspaceRootUri = hookWorkspaceFolder?.uri; + // Use format-aware parsing to check for disabledAllHooks const { disabledAllHooks } = parseHooksFromFile(uri, json, workspaceRootUri, userHome); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts index ba398b6754a30..152288f11987b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ITextModel } from '../../../../../../editor/common/model.js'; -import { IArrayValue } from '../promptFileParser.js'; +import { ISequenceValue } from '../promptFileParser.js'; const isSimpleNameRegex = /^[\w\/\.-]+$/; @@ -20,8 +20,8 @@ export function formatArrayValue(name: string, quotePreference?: QuotePreference export type QuotePreference = '\'' | '\"' | ''; -export function getQuotePreference(arrayValue: IArrayValue, model: ITextModel): QuotePreference { - const firstStringItem = arrayValue.items.find(item => item.type === 'string' && isSimpleNameRegex.test(item.value)); +export function getQuotePreference(arrayValue: ISequenceValue, model: ITextModel): QuotePreference { + const firstStringItem = arrayValue.items.find(item => item.type === 'scalar' && isSimpleNameRegex.test(item.value)); const firstChar = firstStringItem ? model.getValueInRange(firstStringItem.range).charAt(0) : undefined; if (firstChar === `'` || firstChar === `"`) { return firstChar; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 50ca5a8dc0dbd..2b7485a0b05ba 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -194,6 +194,14 @@ export class PromptFilesLocator { eventEmitter.fire(); } })); + disposables.add(this.onDidChangeWorkspaceFolders()(() => { + parentFolders = this.getLocalParentFolders(type); + this.pathService.userHome().then(userHome => { + allSourceFolders = [...this.getSourceFoldersSync(type, userHome)]; + updateExternalFolderWatchers(); + }); + eventEmitter.fire(); + })); disposables.add(this.fileService.onDidFilesChange(e => { if (e.affects(userDataFolder)) { eventEmitter.fire(); diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts index fa07524a29e1a..9994bcffbcb8a 100644 --- a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts @@ -9,7 +9,7 @@ import { Range } from '../../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { IChatVariablesService, IDynamicVariable } from '../attachments/chatVariables.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; -import { IChatAgentData, IChatAgentService } from '../participants/chatAgents.js'; +import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../participants/chatAgents.js'; import { IChatSlashCommandService } from '../participants/chatSlashCommands.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { IToolData, IToolSet, isToolSet } from '../tools/languageModelToolsService.js'; @@ -25,6 +25,7 @@ export interface IChatParserContext { mode?: ChatModeKind; /** Parse as this agent, even when it does not appear in the query text */ forcedAgent?: IChatAgentData; + attachmentCapabilities?: IChatAgentAttachmentCapabilities; } export class ChatRequestParser { @@ -215,7 +216,9 @@ export class ChatRequestParser { // Valid agent subcommand return new ChatRequestAgentSubcommandPart(slashRange, slashEditorRange, subCommand); } - } else { + } + + if (!usedAgent || context?.attachmentCapabilities?.supportsPromptAttachments) { const slashCommands = this.slashCommandService.getCommands(location, context?.mode ?? ChatModeKind.Ask); const slashCommand = slashCommands.find(c => c.command === command); if (slashCommand) { @@ -231,7 +234,7 @@ export class ChatRequestParser { } } - // if there's no agent, asume it is a prompt slash command + // if there's no agent or attachments are supported, asume it is a prompt slash command const isPromptCommand = this.promptsService.isValidSlashCommandName(command); if (isPromptCommand) { return new ChatRequestSlashPromptPart(slashRange, slashEditorRange, command); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index d8784ef6dd740..4e9eacf9ae365 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -250,7 +250,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // Collect hooks from hook .json files let collectedHooks: IChatRequestHooks | undefined; try { - collectedHooks = await this.promptsService.getHooks(token); + const info = await this.promptsService.getHooks(token); + collectedHooks = info?.hooks; } catch (error) { this.logService.warn('[ChatService] Failed to collect hooks:', error); } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts index 96fb13cb66942..a17eb174f3886 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts @@ -327,6 +327,7 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri const tools: IToolData[] = []; const toolSets: IToolSet[] = []; + const missingToolNames: string[] = []; for (const toolName of toolSet.tools) { const toolObj = languageModelToolsService.getToolByName(toolName); @@ -339,7 +340,7 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri toolSets.push(toolSetObj); continue; } - extension.collector.warn(`Tool set '${toolSet.name}' CANNOT find tool or tool set by name: ${toolName}`); + missingToolNames.push(toolName); } if (toolSets.length === 0 && tools.length === 0) { @@ -373,6 +374,30 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri toolSets.forEach(toolSet => store.add(obj.addToolSet(toolSet, tx))); }); + // Listen for late-registered tools that weren't available at contribution time + if (missingToolNames.length > 0) { + const pending = new Set(missingToolNames); + const listener = store.add(languageModelToolsService.onDidChangeTools(() => { + for (const toolName of pending) { + const toolObj = languageModelToolsService.getToolByName(toolName); + if (toolObj) { + store.add(obj.addTool(toolObj)); + pending.delete(toolName); + } else { + const toolSetObj = languageModelToolsService.getToolSetByName(toolName); + if (toolSetObj) { + store.add(obj.addToolSet(toolSetObj)); + pending.delete(toolName); + } + } + } + if (pending.size === 0) { + // done + store.delete(listener); + } + })); + } + this._registrationDisposables.set(toToolSetKey(extension.description.identifier, toolSet.name), store); } } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 9153ccc9facfb..3316f148d3186 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -199,14 +199,12 @@ export interface IToolInvocation { } export interface IToolInvocationContext { - /** @deprecated Use {@link sessionResource} instead */ - readonly sessionId: string; readonly sessionResource: URI; } // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isToolInvocationContext(obj: any): obj is IToolInvocationContext { - return typeof obj === 'object' && typeof obj.sessionId === 'string' && URI.isUri(obj.sessionResource); + return obj !== null && typeof obj === 'object' && URI.isUri(obj.sessionResource); } export interface IToolInvocationPreparationContext { @@ -214,8 +212,6 @@ export interface IToolInvocationPreparationContext { parameters: any; toolCallId: string; chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - chatSessionId?: string; chatSessionResource: URI | undefined; chatInteractionId?: string; modelId?: string; diff --git a/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts b/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts index 20de711820c65..53617f61395cd 100644 --- a/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts +++ b/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts @@ -7,9 +7,12 @@ * This is a subset of the types export from jsonTypes.d.ts in @vscode/prompt-tsx. * It's just the types needed to stringify prompt-tsx tool results. * It should be kept in sync with the types in that file. + * + * Note: do NOT use `declare` with const enums, esbuild doesn't inline them. + * See https://github.com/evanw/esbuild/issues/4394 */ -export declare const enum PromptNodeType { +export const enum PromptNodeType { Piece = 1, Text = 2 } @@ -23,7 +26,7 @@ export interface TextJSON { * less descriptive than the actual constructor, as we only care to preserve * the element data that the renderer cares about. */ -export declare const enum PieceCtorKind { +export const enum PieceCtorKind { BaseChatMessage = 1, Other = 2, ImageChatMessage = 3 diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts index e16e8af4d8b2f..7de3b5ea61e8d 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts @@ -44,7 +44,7 @@ export function registerChatExportZipAction() { const fileService = accessor.get(IFileService); const configurationService = accessor.get(IConfigurationService); - const repoInfoEnabled = configurationService.getValue(ChatConfiguration.RepoInfoEnabled) ?? true; + const repoInfoEnabled = configurationService.getValue(ChatConfiguration.RepoInfoEnabled) ?? false; const widget = widgetService.lastFocusedWidget; if (!widget || !widget.viewModel) { diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index 4e8021f5899c1..16559339c6c43 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -2,17 +2,15 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from '../../../../../base/common/buffer.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize2 } from '../../../../../nls.js'; import { Action2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { INativeEnvironmentService } from '../../../../../platform/environment/common/environment.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; import { INativeHostService } from '../../../../../platform/native/common/native.js'; import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js'; import { ProductQualityContext } from '../../../../../platform/contextkey/common/contextkeys.js'; +import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; export class OpenSessionsWindowAction extends Action2 { constructor() { @@ -20,28 +18,13 @@ export class OpenSessionsWindowAction extends Action2 { id: 'workbench.action.openSessionsWindow', title: localize2('openSessionsWindow', "Open Sessions Window"), category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(ProductQualityContext.notEqualsTo('stable'), ChatEntitlementContextKeys.Setup.hidden.negate()), + precondition: ContextKeyExpr.and(ProductQualityContext.notEqualsTo('stable'), ChatEntitlementContextKeys.Setup.hidden.negate(), IsSessionsWindowContext.negate()), f1: true, }); } async run(accessor: ServicesAccessor) { - const environmentService = accessor.get(INativeEnvironmentService); const nativeHostService = accessor.get(INativeHostService); - const fileService = accessor.get(IFileService); - - // Create workspace file if it doesn't exist - const workspaceUri = environmentService.agentSessionsWorkspace; - if (!workspaceUri) { - throw new Error('Agent Sessions workspace is not configured'); - } - - const workspaceExists = await fileService.exists(workspaceUri); - if (!workspaceExists) { - const emptyWorkspaceContent = JSON.stringify({ folders: [] }, null, '\t'); - await fileService.writeFile(workspaceUri, VSBuffer.fromString(emptyWorkspaceContent)); - } - - await nativeHostService.openWindow([{ workspaceUri }], { forceNewWindow: true }); + await nativeHostService.openSessionsWindow(); } } diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 8ea0d40a484f3..ae0c91cc93444 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -143,7 +143,9 @@ class ChatLifecycleHandler extends Disposable { private hasNonCloudSessionInProgress(): boolean { return this.agentSessionsService.model.sessions.some(session => - isSessionInProgressStatus(session.status) && session.providerType !== AgentSessionProviders.Cloud + isSessionInProgressStatus(session.status) && + session.providerType !== AgentSessionProviders.Cloud && + !session.isArchived() ); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index f7fb12e4c80c1..5c75ce87559fc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -2100,13 +2100,8 @@ suite('AgentSessions', () => { suite('AgentSessionsViewModel - getAgentCanContinueIn', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('should return false when contribution.isReadOnly is true', () => { - const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: true }); - assert.strictEqual(result, false); - }); - - test('should return true for Cloud when contribution is not read-only', () => { - const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: false }); + test('should return true for Cloud provider', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Cloud); assert.strictEqual(result, true); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 4ad52ff646c0a..0439386a34154 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Emitter } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatSelector, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel, ILanguageModelProviderDescriptor } from '../../../common/languageModels.js'; +import { ICuratedModels, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatSelector, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel, ILanguageModelProviderDescriptor } from '../../../common/languageModels.js'; import { ChatModelGroup, ChatModelsViewModel, ILanguageModelEntry, ILanguageModelProviderEntry, isLanguageModelProviderEntry, isLanguageModelGroupEntry, ILanguageModelGroupEntry } from '../../../browser/chatManagement/chatModelsViewModel.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IStringDictionary } from '../../../../../../base/common/collections.js'; @@ -134,6 +135,14 @@ class MockLanguageModelsService implements ILanguageModelsService { } async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } + + getRecentlyUsedModelIds(_maxCount?: number): string[] { return []; } + recordModelUsage(_modelIdentifier: string): void { } + getCuratedModels(): ICuratedModels { return { free: [], paid: [] }; } + getNewModelIds(): string[] { return []; } + onDidChangeNewModelIds = Event.None; + markNewModelsAsSeen(): void { } + restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } suite('ChatModelsViewModel', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSetup/chatSetupGrowthSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSetup/chatSetupGrowthSession.test.ts new file mode 100644 index 0000000000000..d9da6ade3e0f9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatSetup/chatSetupGrowthSession.test.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter } from '../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; +import { TestLifecycleService, workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { ChatSessionStatus } from '../../../common/chatSessionsService.js'; +import { AgentSessionProviders } from '../../../browser/agentSessions/agentSessions.js'; +import { IAgentSession } from '../../../browser/agentSessions/agentSessionsModel.js'; +import { GrowthSessionController, GrowthSessionOpenerParticipant } from '../../../browser/chatSetup/chatSetupGrowthSession.js'; +import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; +import { MockChatWidgetService } from '../widget/mockChatWidget.js'; +import { ServicesAccessor } from '../../../../../../editor/browser/editorExtensions.js'; + +class TestMockChatWidgetService extends MockChatWidgetService { + + private readonly _onDidAddWidget = new Emitter(); + override readonly onDidAddWidget = this._onDidAddWidget.event; + + fireDidAddWidget(): void { + this._onDidAddWidget.fire(undefined!); + } + + dispose(): void { + this._onDidAddWidget.dispose(); + } +} + +suite('GrowthSessionController', () => { + + const disposables = new DisposableStore(); + let instantiationService: TestInstantiationService; + let mockWidgetService: TestMockChatWidgetService; + + setup(() => { + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + mockWidgetService = new TestMockChatWidgetService(); + disposables.add({ dispose: () => mockWidgetService.dispose() }); + const mockLifecycleService = disposables.add(new TestLifecycleService()); + instantiationService.stub(IChatWidgetService, mockWidgetService); + instantiationService.stub(ILifecycleService, mockLifecycleService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should return a single NeedsInput session item', () => { + const controller = disposables.add(instantiationService.createInstance(GrowthSessionController)); + + const items = controller.items; + assert.strictEqual(items.length, 1); + assert.strictEqual(items[0].status, ChatSessionStatus.NeedsInput); + assert.strictEqual(items[0].label, 'Try Copilot'); + assert.ok(items[0].resource.scheme === AgentSessionProviders.Growth); + }); + + test('should return empty items after dismiss', async () => { + const controller = disposables.add(instantiationService.createInstance(GrowthSessionController)); + assert.strictEqual(controller.items.length, 1); + + // Allow the lifecycle.when() promise to resolve and register the listener + await new Promise(r => setTimeout(r, 0)); + + // Fire widget add — should dismiss + mockWidgetService.fireDidAddWidget(); + assert.strictEqual(controller.items.length, 0); + }); + + test('should fire onDidChangeChatSessionItems on dismiss', async () => { + const controller = disposables.add(instantiationService.createInstance(GrowthSessionController)); + + let fired = false; + disposables.add(controller.onDidChangeChatSessionItems(() => { + fired = true; + })); + + await new Promise(r => setTimeout(r, 0)); + + mockWidgetService.fireDidAddWidget(); + assert.strictEqual(fired, true); + }); + + test('should not fire onDidChangeChatSessionItems twice', async () => { + const controller = disposables.add(instantiationService.createInstance(GrowthSessionController)); + + let fireCount = 0; + disposables.add(controller.onDidChangeChatSessionItems(() => { + fireCount++; + })); + + await new Promise(r => setTimeout(r, 0)); + + mockWidgetService.fireDidAddWidget(); + mockWidgetService.fireDidAddWidget(); + assert.strictEqual(fireCount, 1); + }); + + test('refresh is a no-op', async () => { + const controller = disposables.add(instantiationService.createInstance(GrowthSessionController)); + await controller.refresh(); + assert.strictEqual(controller.items.length, 1); + }); +}); + +suite('GrowthSessionOpenerParticipant', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should return false for non-Growth sessions', async () => { + const participant = new GrowthSessionOpenerParticipant(); + const session: IAgentSession = { + providerType: AgentSessionProviders.Local, + providerLabel: 'Local', + resource: URI.parse('local://session-1'), + status: ChatSessionStatus.Completed, + label: 'Test Session', + icon: Codicon.vm, + timing: { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }, + isArchived: () => false, + setArchived: () => { }, + isRead: () => true, + setRead: () => { }, + }; + + // The participant checks providerType before touching the accessor, + // so a stub accessor is sufficient for this test path. + const stubAccessor = { get: () => undefined } as unknown as ServicesAccessor; + const result = await participant.handleOpenSession(stubAccessor, session); + assert.strictEqual(result, false); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 873bf370b9219..5ee2250a37324 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -7,21 +7,21 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ICommandEvent, ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IStorageService, InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ChatTipService, ITipDefinition, TipEligibilityTracker } from '../../browser/chatTipService.js'; import { AgentFileType, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { ChatModeKind } from '../../common/constants.js'; +import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { @@ -30,6 +30,19 @@ class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { } } +class TrackingConfigurationService extends TestConfigurationService { + public lastUpdateTarget: ConfigurationTarget | undefined; + public lastUpdateKey: string | undefined; + public lastUpdateValue: unknown; + + override updateValue(key: string, value: unknown, arg3?: unknown): Promise { + this.lastUpdateKey = key; + this.lastUpdateValue = value; + this.lastUpdateTarget = arg3 as ConfigurationTarget | undefined; + return Promise.resolve(undefined); + } +} + suite('ChatTipService', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -78,49 +91,89 @@ suite('ChatTipService', () => { instantiationService.stub(ILanguageModelToolsService, testDisposables.add(new MockLanguageModelToolsService())); }); - test('returns a tip for new requests with timestamp after service creation', () => { + test('returns a welcome tip', () => { const service = createService(); - const now = Date.now(); - // Request created after service initialization - const tip = service.getNextTip('request-1', now + 1000, contextKeyService); - assert.ok(tip, 'Should return a tip for requests created after service instantiation'); + const tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip, 'Should return a welcome tip'); assert.ok(tip.id.startsWith('tip.'), 'Tip should have a valid ID'); assert.ok(tip.content.value.length > 0, 'Tip should have content'); }); - test('returns undefined for old requests with timestamp before service creation', () => { + test('returns Auto switch tip when current model is gpt-4.1', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'gpt-4.1'); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.switchToAuto'); + }); + + test('does not return Auto switch tip when current model is not gpt-4.1', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.notStrictEqual(tip.id, 'tip.switchToAuto'); + }); + + test('does not return Auto switch tip when current model context key is empty and no fallback is available', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, ''); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.notStrictEqual(tip.id, 'tip.switchToAuto'); + }); + + test('returns Auto switch tip when current model is persisted and context key is empty', () => { + storageService.store('chat.currentLanguageModel.panel', 'copilot/gpt-4.1-2025-04-14', StorageScope.APPLICATION, StorageTarget.USER); + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, ''); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.switchToAuto'); + }); + + test('returns Auto switch tip when current model is versioned gpt-4.1', () => { const service = createService(); - const now = Date.now(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'gpt-4.1-2025-04-14'); + + const tip = service.getWelcomeTip(contextKeyService); - // Request created before service initialization (simulating restored chat) - const tip = service.getNextTip('old-request', now - 10000, contextKeyService); - assert.strictEqual(tip, undefined, 'Should not return a tip for requests created before service instantiation'); + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.switchToAuto'); }); - test('only shows one tip per session', () => { + test('switching models advances away from gpt-4.1 tip', () => { const service = createService(); - const now = Date.now(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'gpt-4.1'); + + const firstTip = service.getWelcomeTip(contextKeyService); + assert.ok(firstTip); + assert.strictEqual(firstTip.id, 'tip.switchToAuto'); - // First request gets a tip - const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); - assert.ok(tip1, 'First request should get a tip'); + const switchedContextKeyService = new MockContextKeyServiceWithRulesMatching(); + switchedContextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + const nextTip = service.getWelcomeTip(switchedContextKeyService); - // Second request does not get a tip - const tip2 = service.getNextTip('request-2', now + 2000, contextKeyService); - assert.strictEqual(tip2, undefined, 'Second request should not get a tip'); + assert.ok(nextTip); + assert.notStrictEqual(nextTip.id, 'tip.switchToAuto'); }); - test('returns same tip on rerender of same request', () => { + test('returns same welcome tip on rerender', () => { const service = createService(); - const now = Date.now(); - // First call gets a tip - const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip1 = service.getWelcomeTip(contextKeyService); assert.ok(tip1); - // Same request ID gets the same tip on rerender - const tip2 = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip2 = service.getWelcomeTip(contextKeyService); assert.ok(tip2); assert.strictEqual(tip1.id, tip2.id, 'Should return same tip for stable rerender'); assert.strictEqual(tip1.content.value, tip2.content.value); @@ -128,71 +181,56 @@ suite('ChatTipService', () => { test('returns undefined when Copilot is not enabled', () => { const service = createService(/* hasCopilot */ false); - const now = Date.now(); - const tip = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip = service.getWelcomeTip(contextKeyService); assert.strictEqual(tip, undefined, 'Should not return a tip when Copilot is not enabled'); }); test('returns undefined when tips setting is disabled', () => { const service = createService(/* hasCopilot */ true, /* tipsEnabled */ false); - const now = Date.now(); - const tip = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip = service.getWelcomeTip(contextKeyService); assert.strictEqual(tip, undefined, 'Should not return a tip when tips setting is disabled'); }); - test('old requests do not consume the session tip allowance', () => { + test('returns undefined when location is terminal', () => { const service = createService(); - const now = Date.now(); - // Old request should not consume the tip allowance - const oldTip = service.getNextTip('old-request', now - 10000, contextKeyService); - assert.strictEqual(oldTip, undefined); + const terminalContextKeyService = new MockContextKeyServiceWithRulesMatching(); + terminalContextKeyService.createKey(ChatContextKeys.location.key, ChatAgentLocation.Terminal); - // New request should still be able to get a tip - const newTip = service.getNextTip('new-request', now + 1000, contextKeyService); - assert.ok(newTip, 'New request should get a tip after old request was skipped'); + const tip = service.getWelcomeTip(terminalContextKeyService); + assert.strictEqual(tip, undefined, 'Should not return a tip in terminal inline chat'); }); - test('multiple old requests do not affect new request tip', () => { + test('returns undefined when location is editor inline', () => { const service = createService(); - const now = Date.now(); - // Simulate multiple restored requests being rendered - service.getNextTip('old-1', now - 30000, contextKeyService); - service.getNextTip('old-2', now - 20000, contextKeyService); - service.getNextTip('old-3', now - 10000, contextKeyService); + const editorContextKeyService = new MockContextKeyServiceWithRulesMatching(); + editorContextKeyService.createKey(ChatContextKeys.location.key, ChatAgentLocation.EditorInline); - // New request should still get a tip - const tip = service.getNextTip('new-request', now + 1000, contextKeyService); - assert.ok(tip, 'New request should get a tip after multiple old requests'); + const tip = service.getWelcomeTip(editorContextKeyService); + assert.strictEqual(tip, undefined, 'Should not return a tip in editor inline chat'); }); test('dismissTip excludes the dismissed tip and allows a new one', () => { const service = createService(); - const now = Date.now(); - // Get a tip - const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip1 = service.getWelcomeTip(contextKeyService); assert.ok(tip1); - // Dismiss it service.dismissTip(); - // Next call should return a different tip (since the dismissed one is excluded) - const tip2 = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip2 = service.getWelcomeTip(contextKeyService); if (tip2) { assert.notStrictEqual(tip1.id, tip2.id, 'Dismissed tip should not be shown again'); } - // tip2 may be undefined if it was the only eligible tip — that's also valid }); test('dismissTip fires onDidDismissTip event', () => { const service = createService(); - const now = Date.now(); - service.getNextTip('request-1', now + 1000, contextKeyService); + service.getWelcomeTip(contextKeyService); let fired = false; testDisposables.add(service.onDidDismissTip(() => { fired = true; })); @@ -203,9 +241,8 @@ suite('ChatTipService', () => { test('disableTips fires onDidDisableTips event', async () => { const service = createService(); - const now = Date.now(); - service.getNextTip('request-1', now + 1000, contextKeyService); + service.getWelcomeTip(contextKeyService); let fired = false; testDisposables.add(service.onDidDisableTips(() => { fired = true; })); @@ -214,22 +251,31 @@ suite('ChatTipService', () => { assert.ok(fired, 'onDidDisableTips should fire'); }); + test('disableTips writes to application settings target', async () => { + const trackingConfigurationService = new TrackingConfigurationService(); + configurationService = trackingConfigurationService; + instantiationService.stub(IConfigurationService, configurationService); + + const service = createService(); + + await service.disableTips(); + + assert.strictEqual(trackingConfigurationService.lastUpdateKey, 'chat.tips.enabled'); + assert.strictEqual(trackingConfigurationService.lastUpdateValue, false); + assert.strictEqual(trackingConfigurationService.lastUpdateTarget, ConfigurationTarget.APPLICATION); + }); + test('disableTips resets state so re-enabling works', async () => { const service = createService(); - const now = Date.now(); - // Show a tip - const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + const tip1 = service.getWelcomeTip(contextKeyService); assert.ok(tip1); - // Disable tips await service.disableTips(); - // Re-enable tips configurationService.setUserConfiguration('chat.tips.enabled', true); - // Should be able to get a tip again on a new request - const tip2 = service.getNextTip('request-2', now + 2000, contextKeyService); + const tip2 = service.getWelcomeTip(contextKeyService); assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); }); @@ -479,37 +525,16 @@ suite('ChatTipService', () => { assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted mode exclusion from workspace storage'); }); - test('resetSession allows tips in a new conversation', () => { + test('resetSession allows a new welcome tip', () => { const service = createService(); - const now = Date.now(); - - // Show a tip in the first conversation - const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); - assert.ok(tip1, 'First request should get a tip'); - // Second request — no tip (one per session) - const tip2 = service.getNextTip('request-2', now + 2000, contextKeyService); - assert.strictEqual(tip2, undefined, 'Second request should not get a tip'); + const tip1 = service.getWelcomeTip(contextKeyService); + assert.ok(tip1, 'Should get a welcome tip'); - // Start a new conversation service.resetSession(); - // New request after reset should get a tip - const tip3 = service.getNextTip('request-3', Date.now() + 1000, contextKeyService); - assert.ok(tip3, 'First request after resetSession should get a tip'); - }); - - test('chatResponse tip shows regardless of welcome tip', () => { - const service = createService(); - const now = Date.now(); - - // Show a welcome tip (simulating the getting-started view) - const welcomeTip = service.getWelcomeTip(contextKeyService); - assert.ok(welcomeTip, 'Welcome tip should be shown'); - - // First new request should still get a chatResponse tip - const tip = service.getNextTip('request-1', now + 1000, contextKeyService); - assert.ok(tip, 'ChatResponse tip should show even when welcome tip was shown'); + const tip2 = service.getWelcomeTip(contextKeyService); + assert.ok(tip2, 'Should get a welcome tip after resetSession'); }); test('excludes tip when tracked tool has been invoked', () => { @@ -613,74 +638,6 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); }); - test('excludes tip when requiresAnyToolSetRegistered tool sets are not registered', () => { - const tip: ITipDefinition = { - id: 'tip.githubRepo', - message: 'test', - requiresAnyToolSetRegistered: ['github', 'github-pull-request'], - }; - - const tracker = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService() as IPromptsService, - createMockToolsService(), - new NullLogService(), - )); - - assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when no required tool sets are registered'); - }); - - test('excludes tip when a tool belonging to a monitored tool set has been invoked', () => { - const mockToolsService = createMockToolsService(); - const toolInSet: IToolData = { id: 'mcp_github_get_me', source: ToolDataSource.Internal, displayName: 'Get Me', modelDescription: 'Get Me' }; - mockToolsService.addRegisteredToolSetName('github', [toolInSet]); - - const tip: ITipDefinition = { - id: 'tip.githubRepo', - message: 'test', - excludeWhenAnyToolSetToolInvoked: ['github'], - }; - - const tracker = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService() as IPromptsService, - mockToolsService, - new NullLogService(), - )); - - assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before any tool set tool is invoked'); - - mockToolsService.fireOnDidInvokeTool({ toolId: 'mcp_github_get_me', sessionResource: undefined, requestId: undefined, subagentInvocationId: undefined }); - - assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after a tool from the monitored tool set is invoked'); - }); - - test('does not exclude tip when at least one requiresAnyToolSetRegistered tool set is registered', () => { - const mockToolsService = createMockToolsService(); - mockToolsService.addRegisteredToolSetName('github'); - - const tip: ITipDefinition = { - id: 'tip.githubRepo', - message: 'test', - requiresAnyToolSetRegistered: ['github', 'github-pull-request'], - }; - - const tracker = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService() as IPromptsService, - mockToolsService, - new NullLogService(), - )); - - assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when at least one required tool set is registered'); - }); - test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { const agentChangeEmitter = testDisposables.add(new Emitter()); let agentFiles: IPromptPath[] = []; diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts index 36a6634e5642e..fe90e7a434e45 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts @@ -56,6 +56,7 @@ suite('PromptHeaderAutocompletion', () => { { id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata, { id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata, { id: 'gpt-4', name: 'GPT 4', vendor: 'openai', version: '1.0', family: 'gpt', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: false, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata, + { id: 'bg-agent-model', name: 'BG Agent Model', vendor: 'copilot', version: '1.0', family: 'bg', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true }, targetChatSessionType: 'background' } satisfies ILanguageModelChatMetadata, ]; instaService.stub(ILanguageModelsService, { @@ -76,7 +77,7 @@ suite('PromptHeaderAutocompletion', () => { uri: URI.parse('myFs://.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; const parser = new PromptFileParser(); @@ -145,7 +146,7 @@ suite('PromptHeaderAutocompletion', () => { { label: 'name', result: 'name: $0' }, { label: 'target', result: 'target: ${0:vscode}' }, { label: 'tools', result: 'tools: ${0:[]}' }, - { label: 'user-invokable', result: 'user-invokable: ${0:true}' }, + { label: 'user-invocable', result: 'user-invocable: ${0:true}' }, ].sort(sortByLabel)); }); @@ -332,18 +333,18 @@ suite('PromptHeaderAutocompletion', () => { ].sort(sortByLabel)); }); - test('complete user-invokable attribute value', async () => { + test('complete user-invocable attribute value', async () => { const content = [ '---', 'description: "Test"', - 'user-invokable: |', + 'user-invocable: |', '---', ].join('\n'); const actual = await getCompletions(content, PromptsType.agent); assert.deepStrictEqual(actual.sort(sortByLabel), [ - { label: 'false', result: 'user-invokable: false' }, - { label: 'true', result: 'user-invokable: true' }, + { label: 'false', result: 'user-invocable: false' }, + { label: 'true', result: 'user-invocable: true' }, ].sort(sortByLabel)); }); @@ -361,6 +362,33 @@ suite('PromptHeaderAutocompletion', () => { { label: 'true', result: 'disable-model-invocation: true' }, ].sort(sortByLabel)); }); + + test('exclude models with targetChatSessionType from agent model completions', async () => { + const content = [ + '---', + 'description: "Test"', + 'model: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + // BG Agent Model has targetChatSessionType set, so it should be excluded + assert.ok(!labels.includes('BG Agent Model (copilot)'), 'Models with targetChatSessionType should be excluded from agent model completions'); + }); + + test('exclude models with targetChatSessionType from agent model array completions', async () => { + const content = [ + '---', + 'description: "Test"', + 'model: [|]', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(!labels.includes('BG Agent Model (copilot)'), 'Models with targetChatSessionType should be excluded from agent model array completions'); + }); }); suite('claude agent header completions', () => { @@ -546,5 +574,18 @@ suite('PromptHeaderAutocompletion', () => { { label: 'GPT 4 (openai)', result: 'model: GPT 4 (openai)' }, ].sort(sortByLabel)); }); + + test('exclude models with targetChatSessionType from prompt model completions', async () => { + const content = [ + '---', + 'description: "Test"', + 'model: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.prompt); + const labels = actual.map(a => a.label); + assert.ok(!labels.includes('BG Agent Model (copilot)'), 'Models with targetChatSessionType should be excluded from prompt model completions'); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts index 476b8011ad05a..f8042fe069e53 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts @@ -79,7 +79,7 @@ suite('PromptHoverProvider', () => { agentInstructions: { content: 'Beast mode instructions', toolReferences: [] }, source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }); instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] })); @@ -323,7 +323,7 @@ suite('PromptHoverProvider', () => { '---', ].join('\n'); const hover = await getHover(content, 4, 1, PromptsType.agent); - assert.strictEqual(hover, 'Controls visibility of the agent.\n\nDeprecated: Use `user-invokable` and `disable-model-invocation` instead.'); + assert.strictEqual(hover, 'Controls visibility of the agent.\n\nDeprecated: Use `user-invocable` and `disable-model-invocation` instead.'); }); test('hover on agents attribute shows description', async () => { @@ -338,12 +338,12 @@ suite('PromptHoverProvider', () => { assert.strictEqual(hover, 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.'); }); - test('hover on user-invokable attribute shows description', async () => { + test('hover on user-invocable attribute shows description', async () => { const content = [ '---', 'name: "Test Agent"', 'description: "Test agent"', - 'user-invokable: true', + 'user-invocable: true', '---', ].join('\n'); const hover = await getHover(content, 4, 1, PromptsType.agent); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 071f767b5d142..775ad671cec74 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -136,7 +136,7 @@ suite('PromptValidator', () => { agentInstructions: { content: 'Beast mode instructions', toolReferences: [] }, source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }); instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] })); @@ -156,7 +156,7 @@ suite('PromptValidator', () => { agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([customMode]); instaService.stub(IPromptsService, promptsService); @@ -273,7 +273,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test with invalid model array"', - `model: ['MAE 4 (olama)', 123]`, + `model: ['MAE 4 (olama)', []]`, '---', ].join('\n'); const markers = await validate(content, PromptsType.agent); @@ -299,7 +299,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test with invalid model type"', - `model: 123`, + `model: {}`, '---', ].join('\n'); const markers = await validate(content, PromptsType.agent); @@ -312,7 +312,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test"', - `tools: ['tool1', 2]`, + `tools: ['tool1', {}]`, '---', ].join('\n'); const markers = await validate(content, PromptsType.agent); @@ -511,7 +511,7 @@ suite('PromptValidator', () => { assert.deepStrictEqual( markers.map(m => ({ severity: m.severity, message: m.message })), [ - { severity: MarkerSeverity.Warning, message: `Attribute 'applyTo' is not supported in VS Code agent files. Supported: agents, argument-hint, description, disable-model-invocation, handoffs, model, name, target, tools, user-invokable.` }, + { severity: MarkerSeverity.Warning, message: `Attribute 'applyTo' is not supported in VS Code agent files. Supported: agents, argument-hint, description, disable-model-invocation, handoffs, model, name, target, tools, user-invocable.` }, ] ); }); @@ -773,7 +773,7 @@ suite('PromptValidator', () => { { const content = [ '---', - 'name: 123', + 'name: []', 'description: "Test agent"', 'target: vscode', '---', @@ -846,7 +846,7 @@ suite('PromptValidator', () => { }); test('infer attribute validation', async () => { - const deprecationMessage = `The 'infer' attribute is deprecated in favour of 'user-invokable' and 'disable-model-invocation'.`; + const deprecationMessage = `The 'infer' attribute is deprecated in favour of 'user-invocable' and 'disable-model-invocation'.`; // Valid infer: true (maps to 'all') - shows deprecation warning { @@ -1005,7 +1005,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test"', - `agents: ['agent', 123]`, + `agents: ['agent', {}]`, `tools: ['agent']`, '---', ].join('\n'); @@ -1085,68 +1085,83 @@ suite('PromptValidator', () => { assert.deepStrictEqual(markers, [], 'Empty array should not require agent tool'); }); - test('user-invokable attribute validation', async () => { - // Valid user-invokable: true + test('user-invocable attribute validation', async () => { + // Valid user-invocable: true { const content = [ '---', 'name: "TestAgent"', 'description: "Test agent"', - 'user-invokable: true', + 'user-invocable: true', '---', 'Body', ].join('\n'); const markers = await validate(content, PromptsType.agent); - assert.deepStrictEqual(markers, [], 'Valid user-invokable: true should not produce errors'); + assert.deepStrictEqual(markers, [], 'Valid user-invocable: true should not produce errors'); } - // Valid user-invokable: false + // Valid user-invocable: false { const content = [ '---', 'name: "TestAgent"', 'description: "Test agent"', - 'user-invokable: false', + 'user-invocable: false', '---', 'Body', ].join('\n'); const markers = await validate(content, PromptsType.agent); - assert.deepStrictEqual(markers, [], 'Valid user-invokable: false should not produce errors'); + assert.deepStrictEqual(markers, [], 'Valid user-invocable: false should not produce errors'); } - // Invalid user-invokable: string value + // Invalid user-invocable: string value { const content = [ '---', 'name: "TestAgent"', 'description: "Test agent"', - 'user-invokable: "yes"', + 'user-invocable: "yes"', '---', 'Body', ].join('\n'); const markers = await validate(content, PromptsType.agent); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invokable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be 'true' or 'false'.`); } - // Invalid user-invokable: number value + // Invalid user-invocable: number value { const content = [ '---', 'name: "TestAgent"', 'description: "Test agent"', - 'user-invokable: 1', + 'user-invocable: 1', '---', 'Body', ].join('\n'); const markers = await validate(content, PromptsType.agent); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invokable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be 'true' or 'false'.`); } }); + test('deprecated user-invokable attribute shows warning', async () => { + const content = [ + '---', + 'name: "TestAgent"', + 'description: "Test agent"', + 'user-invokable: true', + '---', + 'Body', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, `The 'user-invokable' attribute is deprecated. Use 'user-invocable' instead.`); + }); + test('disable-model-invocation attribute validation', async () => { // Valid disable-model-invocation: true { @@ -1189,7 +1204,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.agent); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`); } // Invalid disable-model-invocation: number value @@ -1205,7 +1220,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.agent); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`); } }); }); @@ -1227,7 +1242,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Instr"', - 'applyTo: 5', + 'applyTo: []', '---', ].join('\n'); const markers = await validate(content, PromptsType.instructions); @@ -1631,7 +1646,7 @@ suite('PromptValidator', () => { test('skill with non-string name type does not validate folder match', async () => { const content = [ '---', - 'name: 123', + 'name: []', 'description: Test Skill', '---', 'This is a skill.' @@ -1674,47 +1689,47 @@ suite('PromptValidator', () => { assert.ok(markers.every(m => m.message.includes('Supported: '))); }); - test('skill with user-invokable: false is valid', async () => { + test('skill with user-invocable: false is valid', async () => { const content = [ '---', 'name: my-skill', 'description: Background knowledge skill', - 'user-invokable: false', + 'user-invocable: false', '---', 'This skill provides background context.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); - assert.deepStrictEqual(markers, [], 'user-invokable: false should be valid for skills'); + assert.deepStrictEqual(markers, [], 'user-invocable: false should be valid for skills'); }); - test('skill with user-invokable: true is valid', async () => { + test('skill with user-invocable: true is valid', async () => { const content = [ '---', 'name: my-skill', 'description: User-accessible skill', - 'user-invokable: true', + 'user-invocable: true', '---', 'This skill can be invoked by users.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); - assert.deepStrictEqual(markers, [], 'user-invokable: true should be valid for skills'); + assert.deepStrictEqual(markers, [], 'user-invocable: true should be valid for skills'); }); - test('skill with invalid user-invokable value shows error', async () => { + test('skill with invalid user-invocable value shows error', async () => { // String value instead of boolean { const content = [ '---', 'name: my-skill', 'description: Test Skill', - 'user-invokable: "false"', + 'user-invocable: "false"', '---', 'Body' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invokable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be 'true' or 'false'.`); } // Number value instead of boolean @@ -1723,14 +1738,14 @@ suite('PromptValidator', () => { '---', 'name: my-skill', 'description: Test Skill', - 'user-invokable: 0', + 'user-invocable: 0', '---', 'Body' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invokable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be 'true' or 'false'.`); } }); @@ -1774,7 +1789,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`); } // Number value instead of boolean @@ -1790,7 +1805,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`); } }); @@ -1827,7 +1842,7 @@ suite('PromptValidator', () => { '---', 'name: my-skill', 'description: Test Skill', - 'argument-hint: 123', + 'argument-hint: []', '---', 'Body' ].join('\n'); @@ -1842,7 +1857,7 @@ suite('PromptValidator', () => { '---', 'name: my-skill', 'description: Complex visibility skill', - 'user-invokable: false', + 'user-invocable: false', 'disable-model-invocation: true', 'argument-hint: "[optional-arg]"', '---', @@ -1895,32 +1910,6 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `The 'paths' attribute must be an array of glob patterns.`); }); - test('claude rules paths entries must be strings', async () => { - const content = [ - '---', - 'description: "Rules"', - `paths: [123, '**/*.ts']`, - '---', - ].join('\n'); - const markers = await validate(content, PromptsType.instructions, claudeRulesUri); - assert.strictEqual(markers.length, 1); - assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `Each entry in the 'paths' attribute must be a string.`); - }); - - test('claude rules paths entries must be non-empty', async () => { - const content = [ - '---', - 'description: "Rules"', - `paths: ['']`, - '---', - ].join('\n'); - const markers = await validate(content, PromptsType.instructions, claudeRulesUri); - assert.strictEqual(markers.length, 1); - assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `Path entries must be non-empty glob patterns.`); - }); - test('claude rules with unknown attribute shows warning', async () => { const content = [ '---', @@ -1947,7 +1936,6 @@ suite('PromptValidator', () => { [ { severity: MarkerSeverity.Error, message: `The 'description' attribute should not be empty.` }, { severity: MarkerSeverity.Error, message: `Path entries must be non-empty glob patterns.` }, - { severity: MarkerSeverity.Error, message: `Each entry in the 'paths' attribute must be a string.` }, ] ); }); @@ -2032,7 +2020,7 @@ suite('PromptValidator', () => { '---', 'name: test-agent', 'description: Test', - 'model: 123', + 'model: []', '---', ].join('\n'); const markers = await validate(content, PromptsType.agent, claudeAgentUri); @@ -2070,21 +2058,6 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `Unknown value 'allowAll', valid: default, acceptEdits, plan, delegate, dontAsk, bypassPermissions.`); }); - test('Claude agent with non-string permissionMode value', async () => { - const content = [ - '---', - 'name: test-agent', - 'description: Test', - 'model: sonnet', - 'permissionMode: true', - '---', - ].join('\n'); - const markers = await validate(content, PromptsType.agent, claudeAgentUri); - assert.strictEqual(markers.length, 1); - assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'permissionMode' attribute must be a string.`); - }); - test('Claude agent with valid memory values', async () => { for (const mem of ['user', 'project', 'local']) { const content = [ diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 75d099cd9b797..06845cf18a152 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -77,7 +77,6 @@ function registerToolForTest(service: LanguageModelToolsService, store: any, id: tokenBudget: 100, parameters, context: context ? { - sessionId: context.sessionId, sessionResource: LocalChatSessionUri.forSession(context.sessionId), } : undefined, }), @@ -2902,7 +2901,6 @@ suite('LanguageModelToolsService', () => { tokenBudget: 100, parameters: { test: 1 }, context: { - sessionId, sessionResource: LocalChatSessionUri.forSession(sessionId), }, chatStreamToolCallId: 'stream-call-id', // This should correlate diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts new file mode 100644 index 0000000000000..42226127e20d6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts @@ -0,0 +1,327 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { RenameProvider, WorkspaceEdit, Rejection } from '../../../../../../editor/common/languages.js'; +import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; +import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IBulkEditService, IBulkEditResult } from '../../../../../../editor/browser/services/bulkEditService.js'; +import { RenameTool, RenameToolId } from '../../../browser/tools/renameTool.js'; +import { IChatService } from '../../../common/chatService/chatService.js'; +import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; + +function getTextContent(result: IToolResult): string { + const part = result.content.find((p): p is IToolResultTextPart => p.kind === 'text'); + return part?.value ?? ''; +} + +suite('RenameTool', () => { + + const disposables = new DisposableStore(); + let langFeatures: LanguageFeaturesService; + + const testUri = URI.parse('file:///test/file.ts'); + const testContent = [ + 'import { MyClass } from "./myClass";', + '', + 'function doSomething() {', + '\tconst instance = new MyClass();', + '\tinstance.run();', + '}', + ].join('\n'); + + function makeEdit(resource: URI, range: Range, text: string) { + return { resource, versionId: undefined, textEdit: { range, text } }; + } + + function createMockTextModelService(model: unknown): ITextModelService { + return { + _serviceBrand: undefined, + createModelReference: async () => ({ + object: { textEditorModel: model }, + dispose: () => { }, + }), + registerTextModelContentProvider: () => ({ dispose: () => { } }), + canHandleResource: () => false, + } as unknown as ITextModelService; + } + + function createMockWorkspaceService(): IWorkspaceContextService { + const folderUri = URI.parse('file:///test'); + const folder = { + uri: folderUri, + toResource: (relativePath: string) => URI.parse(`file:///test/${relativePath}`), + } as unknown as IWorkspaceFolder; + return { + _serviceBrand: undefined, + getWorkspace: () => ({ folders: [folder] }), + getWorkspaceFolder: (uri: URI) => { + if (uri.toString().startsWith(folderUri.toString())) { + return folder; + } + return null; + }, + } as unknown as IWorkspaceContextService; + } + + function createMockChatService(): IChatService { + return { + _serviceBrand: undefined, + getSession: () => undefined, + } as unknown as IChatService; + } + + function createMockBulkEditService(): IBulkEditService & { appliedEdits: WorkspaceEdit[] } { + const appliedEdits: WorkspaceEdit[] = []; + return { + _serviceBrand: undefined, + apply: async (edit: WorkspaceEdit): Promise => { + appliedEdits.push(edit); + return { ariaSummary: '', isApplied: true }; + }, + appliedEdits, + } as unknown as IBulkEditService & { appliedEdits: WorkspaceEdit[] }; + } + + function createInvocation(parameters: Record): IToolInvocation { + return { parameters } as unknown as IToolInvocation; + } + + const noopCountTokens = async () => 0; + const noopProgress: ToolProgress = { report() { } }; + + function createTool(textModelService: ITextModelService, options?: { bulkEditService?: IBulkEditService }): RenameTool { + return new RenameTool( + langFeatures, + textModelService, + createMockWorkspaceService(), + createMockChatService(), + options?.bulkEditService ?? createMockBulkEditService(), + ); + } + + setup(() => { + langFeatures = new LanguageFeaturesService(); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getToolData', () => { + + test('reports no providers when none registered', () => { + const tool = disposables.add(createTool(createMockTextModelService(null!))); + const data = tool.getToolData(); + assert.strictEqual(data.id, RenameToolId); + assert.ok(data.modelDescription.includes('No languages currently have rename providers')); + }); + + test('lists registered language ids', () => { + const model = disposables.add(createTextModel('', 'typescript', undefined, testUri)); + const tool = disposables.add(createTool(createMockTextModelService(model))); + disposables.add(langFeatures.renameProvider.register('typescript', { + provideRenameEdits: () => ({ edits: [] }), + })); + const data = tool.getToolData(); + assert.ok(data.modelDescription.includes('typescript')); + }); + + test('reports all languages for wildcard', () => { + const tool = disposables.add(createTool(createMockTextModelService(null!))); + disposables.add(langFeatures.renameProvider.register('*', { + provideRenameEdits: () => ({ edits: [] }), + })); + const data = tool.getToolData(); + assert.ok(data.modelDescription.includes('all languages')); + }); + }); + + suite('invoke', () => { + + test('returns error when no uri or filePath provided', async () => { + const tool = disposables.add(createTool(createMockTextModelService(null!))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', lineContent: 'MyClass' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Provide either')); + }); + + test('returns error when no rename provider available', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const tool = disposables.add(createTool(createMockTextModelService(model))); + // No rename provider registered + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('No rename provider')); + }); + + test('returns error when line content not found', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.renameProvider.register('typescript', { + provideRenameEdits: () => ({ edits: [] }), + })); + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'nonexistent line' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Could not find line content')); + }); + + test('returns error when symbol not found in line', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.renameProvider.register('typescript', { + provideRenameEdits: () => ({ edits: [] }), + })); + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'NotHere', newName: 'Something', uri: testUri.toString(), lineContent: 'function doSomething' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Could not find symbol')); + }); + + test('returns error when rename is rejected', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ + edits: [], + rejectReason: 'Cannot rename this symbol', + }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Rename rejected')); + assert.ok(getTextContent(result).includes('Cannot rename this symbol')); + }); + + test('returns error when rename produces no edits', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ + edits: [], + }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('no edits')); + }); + + test('successful rename applies edits via bulk edit and reports result', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const otherUri = URI.parse('file:///test/other.ts'); + const edits = [ + makeEdit(testUri, new Range(1, 10, 1, 17), 'MyNewClass'), + makeEdit(testUri, new Range(4, 23, 4, 30), 'MyNewClass'), + makeEdit(otherUri, new Range(5, 14, 5, 21), 'MyNewClass'), + ]; + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ edits }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + + const bulkEditService = createMockBulkEditService(); + const tool = disposables.add(createTool(createMockTextModelService(model), { bulkEditService })); + + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + const text = getTextContent(result); + assert.ok(text.includes('Renamed')); + assert.ok(text.includes('MyClass')); + assert.ok(text.includes('MyNewClass')); + assert.ok(text.includes('3 edits')); + assert.ok(text.includes('2 files')); + assert.strictEqual(bulkEditService.appliedEdits.length, 1); + assert.strictEqual(bulkEditService.appliedEdits[0].edits.length, 3); + }); + + test('successful rename with single edit reports singular message', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const edits = [ + makeEdit(testUri, new Range(1, 10, 1, 17), 'MyNewClass'), + ]; + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ edits }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + const text = getTextContent(result); + assert.ok(text.includes('1 edit')); + assert.ok(text.includes('1 file')); + }); + + test('resolves filePath via workspace folders', async () => { + const fileUri = URI.parse('file:///test/src/file.ts'); + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, fileUri)); + const edits = [ + makeEdit(fileUri, new Range(1, 10, 1, 17), 'MyNewClass'), + ]; + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ edits }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', filePath: 'src/file.ts', lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('Renamed')); + }); + + test('result includes toolResultMessage', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const edits = [ + makeEdit(testUri, new Range(1, 10, 1, 17), 'MyNewClass'), + ]; + const provider: RenameProvider = { + provideRenameEdits: (): WorkspaceEdit & Rejection => ({ edits }), + }; + disposables.add(langFeatures.renameProvider.register('typescript', provider)); + + const tool = disposables.add(createTool(createMockTextModelService(model))); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', newName: 'MyNewClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(result.toolResultMessage); + const msg = result.toolResultMessage as IMarkdownString; + assert.ok(msg.value.includes('Renamed')); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts new file mode 100644 index 0000000000000..b54cf20029b97 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../../base/common/uri.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { resolveToolUri, findLineNumber, findSymbolColumn, errorResult } from '../../../browser/tools/toolHelpers.js'; + +suite('Tool Helpers', () => { + + const disposables = new DisposableStore(); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createMockWorkspaceService(folderUri?: URI): IWorkspaceContextService { + const uri = folderUri ?? URI.parse('file:///workspace'); + const folder = { + uri, + toResource: (relativePath: string) => URI.joinPath(uri, relativePath), + } as unknown as IWorkspaceFolder; + return { + _serviceBrand: undefined, + getWorkspace: () => ({ folders: [folder] }), + getWorkspaceFolder: (u: URI) => { + if (u.toString().startsWith(uri.toString())) { + return folder; + } + return null; + }, + } as unknown as IWorkspaceContextService; + } + + suite('resolveToolUri', () => { + + test('resolves full URI string', () => { + const ws = createMockWorkspaceService(); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x', uri: 'file:///test/file.ts' }, ws); + assert.strictEqual(result?.toString(), 'file:///test/file.ts'); + }); + + test('resolves workspace-relative filePath', () => { + const ws = createMockWorkspaceService(URI.parse('file:///project')); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x', filePath: 'src/index.ts' }, ws); + assert.strictEqual(result?.toString(), 'file:///project/src/index.ts'); + }); + + test('prefers uri over filePath', () => { + const ws = createMockWorkspaceService(); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x', uri: 'file:///explicit.ts', filePath: 'other.ts' }, ws); + assert.strictEqual(result?.toString(), 'file:///explicit.ts'); + }); + + test('returns undefined when neither provided', () => { + const ws = createMockWorkspaceService(); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x' }, ws); + assert.strictEqual(result, undefined); + }); + }); + + suite('findLineNumber', () => { + + test('finds exact match', () => { + const model = disposables.add(createTextModel('line one\nline two\nline three')); + assert.strictEqual(findLineNumber(model, 'line two'), 2); + }); + + test('handles whitespace normalization', () => { + const model = disposables.add(createTextModel('function doSomething(x: number) {}')); + assert.strictEqual(findLineNumber(model, 'function doSomething(x: number)'), 1); + }); + + test('returns undefined when not found', () => { + const model = disposables.add(createTextModel('hello world')); + assert.strictEqual(findLineNumber(model, 'not here'), undefined); + }); + + test('handles regex special characters in content', () => { + const model = disposables.add(createTextModel('const arr = [1, 2, 3];')); + assert.strictEqual(findLineNumber(model, '[1, 2, 3]'), 1); + }); + + test('finds partial line match', () => { + const model = disposables.add(createTextModel('import { MyClass } from "./myModule";')); + assert.strictEqual(findLineNumber(model, 'MyClass'), 1); + }); + + test('trims leading and trailing whitespace from input', () => { + const model = disposables.add(createTextModel('const x = 42;')); + assert.strictEqual(findLineNumber(model, ' const x = 42; '), 1); + }); + }); + + suite('findSymbolColumn', () => { + + test('finds symbol with word boundaries', () => { + assert.strictEqual(findSymbolColumn('const myVar = 42;', 'myVar'), 7); + }); + + test('returns 1-based column', () => { + assert.strictEqual(findSymbolColumn('x = 1', 'x'), 1); + }); + + test('does not match partial words', () => { + assert.strictEqual(findSymbolColumn('const myVariable = 42;', 'myVar'), undefined); + }); + + test('returns undefined when not found', () => { + assert.strictEqual(findSymbolColumn('hello world', 'missing'), undefined); + }); + + test('handles regex special characters in symbol name', () => { + assert.strictEqual(findSymbolColumn('arr[0] = 1', 'arr'), 1); + }); + + test('finds first occurrence', () => { + assert.strictEqual(findSymbolColumn('foo + foo', 'foo'), 1); + }); + }); + + suite('errorResult', () => { + + test('creates result with text content', () => { + const result = errorResult('something went wrong'); + const textPart = result.content.find(p => p.kind === 'text'); + assert.ok(textPart); + assert.strictEqual((textPart as { kind: 'text'; value: string }).value, 'something went wrong'); + }); + + test('sets toolResultMessage', () => { + const result = errorResult('error message'); + assert.ok(result.toolResultMessage); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts new file mode 100644 index 0000000000000..e0e20ec03f629 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { DefinitionProvider, ImplementationProvider, Location, ReferenceProvider } from '../../../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../../../editor/common/model.js'; +import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { FileMatch, ISearchComplete, ISearchService, ITextQuery, OneLineRange, TextSearchMatch } from '../../../../../services/search/common/search.js'; +import { UsagesTool, UsagesToolId } from '../../../browser/tools/usagesTool.js'; +import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; + +function getTextContent(result: IToolResult): string { + const part = result.content.find((p): p is IToolResultTextPart => p.kind === 'text'); + return part?.value ?? ''; +} + +suite('UsagesTool', () => { + + const disposables = new DisposableStore(); + let langFeatures: LanguageFeaturesService; + + const testUri = URI.parse('file:///test/file.ts'); + const testContent = [ + 'import { MyClass } from "./myClass";', + '', + 'function doSomething() {', + '\tconst instance = new MyClass();', + '\tinstance.run();', + '}', + ].join('\n'); + + function createMockModelService(models?: ITextModel[]): IModelService { + return { + _serviceBrand: undefined, + getModel: (uri: URI) => models?.find(m => m.uri.toString() === uri.toString()) ?? null, + } as unknown as IModelService; + } + + function createMockSearchService(searchImpl?: (query: ITextQuery) => ISearchComplete): ISearchService { + return { + _serviceBrand: undefined, + textSearch: async (query: ITextQuery) => searchImpl?.(query) ?? { results: [], messages: [] }, + } as unknown as ISearchService; + } + + function createMockTextModelService(model: ITextModel): ITextModelService { + return { + _serviceBrand: undefined, + createModelReference: async () => ({ + object: { textEditorModel: model }, + dispose: () => { }, + }), + registerTextModelContentProvider: () => ({ dispose: () => { } }), + canHandleResource: () => false, + } as unknown as ITextModelService; + } + + function createMockWorkspaceService(): IWorkspaceContextService { + const folderUri = URI.parse('file:///test'); + const folder = { + uri: folderUri, + toResource: (relativePath: string) => URI.parse(`file:///test/${relativePath}`), + } as unknown as IWorkspaceFolder; + return { + _serviceBrand: undefined, + getWorkspace: () => ({ folders: [folder] }), + getWorkspaceFolder: (uri: URI) => { + if (uri.toString().startsWith(folderUri.toString())) { + return folder; + } + return null; + }, + } as unknown as IWorkspaceContextService; + } + + function createInvocation(parameters: Record): IToolInvocation { + return { parameters } as unknown as IToolInvocation; + } + + const noopCountTokens = async () => 0; + const noopProgress: ToolProgress = { report() { } }; + + function createTool(textModelService: ITextModelService, workspaceService: IWorkspaceContextService, options?: { modelService?: IModelService; searchService?: ISearchService }): UsagesTool { + return new UsagesTool(langFeatures, options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService); + } + + setup(() => { + langFeatures = new LanguageFeaturesService(); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getToolData', () => { + + test('reports no providers when none registered', () => { + const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); + const data = tool.getToolData(); + assert.strictEqual(data.id, UsagesToolId); + assert.ok(data.modelDescription.includes('No languages currently have reference providers')); + }); + + test('lists registered language ids', () => { + const model = disposables.add(createTextModel('', 'typescript', undefined, testUri)); + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); + const data = tool.getToolData(); + assert.ok(data.modelDescription.includes('typescript')); + }); + + test('reports all languages for wildcard', () => { + const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); + disposables.add(langFeatures.referenceProvider.register('*', { provideReferences: () => [] })); + const data = tool.getToolData(); + assert.ok(data.modelDescription.includes('all languages')); + }); + }); + + suite('invoke', () => { + + test('returns error when no uri or filePath provided', async () => { + const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', lineContent: 'MyClass' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Provide either')); + }); + + test('returns error when line content not found', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'nonexistent line' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Could not find line content')); + }); + + test('returns error when symbol not found in line', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'NotHere', uri: testUri.toString(), lineContent: 'function doSomething' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Could not find symbol')); + }); + + test('finds references and classifies them with usage tags', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const otherUri = URI.parse('file:///test/other.ts'); + + const refProvider: ReferenceProvider = { + provideReferences: (_model: ITextModel): Location[] => [ + { uri: testUri, range: new Range(1, 10, 1, 17) }, + { uri: testUri, range: new Range(4, 23, 4, 30) }, + { uri: otherUri, range: new Range(5, 1, 5, 8) }, + ] + }; + const defProvider: DefinitionProvider = { + provideDefinition: () => [{ uri: testUri, range: new Range(1, 10, 1, 17) }] + }; + const implProvider: ImplementationProvider = { + provideImplementation: () => [{ uri: otherUri, range: new Range(5, 1, 5, 8) }] + }; + + disposables.add(langFeatures.referenceProvider.register('typescript', refProvider)); + disposables.add(langFeatures.definitionProvider.register('typescript', defProvider)); + disposables.add(langFeatures.implementationProvider.register('typescript', implProvider)); + + // Model is open for testUri so IModelService returns it; otherUri needs search + const searchCalled: ITextQuery[] = []; + const searchService = createMockSearchService(query => { + searchCalled.push(query); + const fileMatch = new FileMatch(otherUri); + fileMatch.results = [new TextSearchMatch( + 'export class MyClass implements IMyClass {', + new OneLineRange(4, 0, 7) // 0-based line 4 = 1-based line 5 + )]; + return { results: [fileMatch], messages: [] }; + }); + const modelService = createMockModelService([model]); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { modelService, searchService })); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + const text = getTextContent(result); + + // Check overall structure + assert.ok(text.includes('3 usages of `MyClass`')); + + // Check usage tag format + assert.ok(text.includes(``)); + assert.ok(text.includes(``)); + assert.ok(text.includes(``)); + + // Check that previews from open model are included (testUri lines) + assert.ok(text.includes('import { MyClass } from "./myClass"')); + assert.ok(text.includes('const instance = new MyClass()')); + + // Check that preview from search service is included (otherUri) + assert.ok(text.includes('export class MyClass implements IMyClass {')); + + // Check closing tags + assert.ok(text.includes('')); + + // Verify search service was called for the non-open file + assert.strictEqual(searchCalled.length, 1); + assert.ok(searchCalled[0].contentPattern.pattern.includes('MyClass')); + assert.ok(searchCalled[0].contentPattern.isWordMatch); + }); + + test('uses self-closing tag when no preview available', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const otherUri = URI.parse('file:///test/other.ts'); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: otherUri, range: new Range(10, 5, 10, 12) }, + ] + })); + + // Search returns no results for this file (symbol renamed/aliased) + const searchService = createMockSearchService(() => ({ results: [], messages: [] })); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { searchService })); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + const text = getTextContent(result); + assert.ok(text.includes(``)); + }); + + test('does not call search service for files already open in model service', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: testUri, range: new Range(1, 10, 1, 17) }, + ] + })); + + let searchCalled = false; + const searchService = createMockSearchService(() => { + searchCalled = true; + return { results: [], messages: [] }; + }); + const modelService = createMockModelService([model]); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { modelService, searchService })); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('1 usages')); + assert.strictEqual(searchCalled, false, 'search service should not be called when all files are open'); + }); + + test('handles whitespace normalization in lineContent', async () => { + const content = 'function doSomething(x: number) {}'; + const model = disposables.add(createTextModel(content, 'typescript', undefined, testUri)); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: testUri, range: new Range(1, 12, 1, 23) }, + ] + })); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'doSomething', uri: testUri.toString(), lineContent: 'function doSomething(x: number)' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('1 usages')); + }); + + test('resolves filePath via workspace folders', async () => { + const fileUri = URI.parse('file:///test/src/file.ts'); + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, fileUri)); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: fileUri, range: new Range(1, 10, 1, 17) }, + ] + })); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', filePath: 'src/file.ts', lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('1 usages')); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 4cffb9f84e7df..2d91102c1e1b0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../../../../browser/widget/chatContentParts/chatQuestionCarouselPart.js'; @@ -85,6 +86,25 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(title?.textContent?.includes('Fallback title text')); }); + test('renders markdown in question message', () => { + const carousel = createMockCarousel([ + { + id: 'q1', + type: 'text', + title: 'Question', + message: new MarkdownString('Please review **details** in [docs](https://example.com)') + } + ]); + createWidget(carousel); + + const title = widget.domNode.querySelector('.chat-question-title'); + assert.ok(title, 'title element should exist'); + assert.ok(title?.querySelector('.rendered-markdown'), 'markdown content should be rendered'); + assert.strictEqual(title?.textContent?.includes('**details**'), false, 'markdown syntax should not be shown as raw text'); + const link = title?.querySelector('a') as HTMLAnchorElement | null; + assert.ok(link, 'markdown link should render as anchor'); + }); + test('renders progress indicator correctly', () => { const carousel = createMockCarousel([ { id: 'q1', type: 'text', title: 'Question 1', message: 'Question 1' }, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 393726f0c607b..1f3400c6fc029 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -526,7 +526,7 @@ suite('ChatSubagentContentPart', () => { const button = getCollapseButton(part); assert.ok(button, 'Should have collapse button'); const loadingIcon = getCollapseButtonIcon(button); - assert.ok(loadingIcon?.classList.contains('codicon-loading'), 'Should have loading spinner while streaming'); + assert.ok(loadingIcon?.classList.contains('codicon-circle-filled'), 'Should have circle-filled icon while streaming'); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index 81ff8e020e991..eeaac1a46c705 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -247,7 +247,7 @@ suite('ChatThinkingContentPart', () => { }); test('should start expanded when streaming (not complete)', () => { - const content = createThinkingPart('**Analyzing**'); + const content = createThinkingPart('**Analyzing**\nSome detailed reasoning about the code structure'); const context = createMockRenderContext(false); const part = store.add(instantiationService.createInstance( @@ -495,7 +495,7 @@ suite('ChatThinkingContentPart', () => { }); test('appendItem should render immediately when expanded', () => { - const content = createThinkingPart('**Working**'); + const content = createThinkingPart('**Working**\nSome detailed analysis of the problem'); const context = createMockRenderContext(false); const part = store.add(instantiationService.createInstance( @@ -984,7 +984,7 @@ suite('ChatThinkingContentPart', () => { }); test('collapseContent should collapse the part', () => { - const content = createThinkingPart('**Content**'); + const content = createThinkingPart('**Content**\nSome detailed reasoning that differs from the title'); const context = createMockRenderContext(false); // Use CollapsedPreview to start expanded @@ -1130,7 +1130,7 @@ suite('ChatThinkingContentPart', () => { }); test('should have proper aria-expanded attribute', () => { - const content = createThinkingPart('**Content**'); + const content = createThinkingPart('**Content**\nSome detailed reasoning that differs from the title'); const context = createMockRenderContext(false); const part = store.add(instantiationService.createInstance( @@ -1169,9 +1169,9 @@ suite('ChatThinkingContentPart', () => { mainWindow.document.body.appendChild(part.domNode); disposables.add(toDisposable(() => part.domNode.remove())); - // Should have loading spinner icon - const loadingIcon = part.domNode.querySelector('.codicon-loading'); - assert.ok(loadingIcon, 'Should have loading spinner while streaming'); + // Should have circle-filled icon (not loading spinner) while streaming + const circleIcon = part.domNode.querySelector('.codicon-circle-filled'); + assert.ok(circleIcon, 'Should have circle-filled icon while streaming'); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts deleted file mode 100644 index 82a8283135d9d..0000000000000 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { URI } from '../../../../../../../base/common/uri.js'; -import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { ContextKeyService } from '../../../../../../../platform/contextkey/browser/contextKeyService.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js'; -import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; -import { IChatCollapsibleListItem } from '../../../../browser/widget/chatContentParts/chatReferencesContentPart.js'; -import { buildEditsList, buildEditsTree, ChatEditsListWidget, ChatEditsTreeIdentityProvider, IChatEditsFolderElement } from '../../../../browser/widget/input/chatEditsTree.js'; -import { CHAT_EDITS_VIEW_MODE_STORAGE_KEY } from '../../../../browser/chatEditing/chatEditingActions.js'; -import { ModifiedFileEntryState, IChatEditingSession } from '../../../../common/editing/chatEditingService.js'; -import { Event } from '../../../../../../../base/common/event.js'; - -function makeFileItem(path: string, added = 0, removed = 0): IChatCollapsibleListItem { - return { - reference: URI.file(path), - state: ModifiedFileEntryState.Modified, - kind: 'reference', - options: { - status: undefined, - diffMeta: { added, removed }, - } - }; -} - -suite('ChatEditsTree', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - suite('buildEditsList', () => { - test('wraps items as flat tree elements', () => { - const items = [ - makeFileItem('/src/a.ts'), - makeFileItem('/src/b.ts'), - ]; - const result = buildEditsList(items); - assert.strictEqual(result.length, 2); - assert.strictEqual(result[0].children, undefined); - assert.strictEqual(result[1].children, undefined); - }); - - test('returns empty array for empty input', () => { - assert.deepStrictEqual(buildEditsList([]), []); - }); - }); - - suite('buildEditsTree', () => { - test('groups files by directory', () => { - const items = [ - makeFileItem('/project/src/a.ts'), - makeFileItem('/project/src/b.ts'), - makeFileItem('/project/lib/c.ts'), - ]; - const result = buildEditsTree(items); - - // Should have 2 folder elements - assert.strictEqual(result.length, 2); - - const folders = result.map(r => r.element).filter((e): e is IChatEditsFolderElement => e.kind === 'folder'); - assert.strictEqual(folders.length, 2); - - // Each folder should have children - for (const r of result) { - assert.ok(r.children); - assert.ok(r.collapsible); - } - }); - - test('skips folder grouping for single file in single folder', () => { - const items = [makeFileItem('/project/src/a.ts')]; - const result = buildEditsTree(items); - - // Single file should not be wrapped in a folder - assert.strictEqual(result.length, 1); - assert.notStrictEqual(result[0].element.kind, 'folder'); - }); - - test('still groups when there are multiple folders even with single files', () => { - const items = [ - makeFileItem('/project/src/a.ts'), - makeFileItem('/project/lib/b.ts'), - ]; - const result = buildEditsTree(items); - - assert.strictEqual(result.length, 2); - const folders = result.map(r => r.element).filter((e): e is IChatEditsFolderElement => e.kind === 'folder'); - assert.strictEqual(folders.length, 2); - }); - - test('handles items without URIs as top-level elements', () => { - const warning: IChatCollapsibleListItem = { - kind: 'warning', - content: { value: 'Something went wrong' }, - }; - const items: IChatCollapsibleListItem[] = [ - warning, - makeFileItem('/src/a.ts'), - ]; - const result = buildEditsTree(items); - - // Warning at top level + single file at root (common ancestor is /src/) - assert.strictEqual(result.length, 2); - assert.strictEqual(result[0].element.kind, 'warning'); - assert.strictEqual(result[1].element.kind, 'reference'); - }); - - test('flattens files at common ancestor and shows subfolders', () => { - const items = [ - makeFileItem('/project/root/hello.py'), - makeFileItem('/project/root/README.md'), - makeFileItem('/project/root/test.py'), - makeFileItem('/project/root/js/test2.js'), - ]; - const result = buildEditsTree(items); - - // Common ancestor is /project/root/ — files there go to root level, - // js/ becomes a folder node - const rootFiles = result.filter(r => r.element.kind === 'reference'); - const folders = result.filter(r => r.element.kind === 'folder'); - assert.strictEqual(rootFiles.length, 3, 'three files at root level'); - assert.strictEqual(folders.length, 1, 'one subfolder'); - assert.strictEqual((folders[0].element as IChatEditsFolderElement).children.length, 1); - - // Folders should come before files (like search) - const firstFolderIndex = result.findIndex(r => r.element.kind === 'folder'); - const firstFileIndex = result.findIndex(r => r.element.kind === 'reference'); - assert.ok(firstFolderIndex < firstFileIndex, 'folders should appear before files'); - }); - - test('all files in same directory produces no folder row', () => { - const items = [ - makeFileItem('/project/src/a.ts'), - makeFileItem('/project/src/b.ts'), - makeFileItem('/project/src/c.ts'), - ]; - const result = buildEditsTree(items); - - // All files in the same directory — common ancestor is /project/src/ - // No folder row needed - assert.strictEqual(result.length, 3); - assert.ok(result.every(r => r.element.kind === 'reference')); - }); - }); - - suite('ChatEditsTreeIdentityProvider', () => { - test('provides stable IDs for folders', () => { - const provider = new ChatEditsTreeIdentityProvider(); - const folder: IChatEditsFolderElement = { - kind: 'folder', - uri: URI.file('/src'), - children: [], - }; - const id = provider.getId(folder); - assert.strictEqual(id, `folder:${URI.file('/src').toString()}`); - }); - - test('provides stable IDs for file references', () => { - const provider = new ChatEditsTreeIdentityProvider(); - const item = makeFileItem('/src/a.ts'); - const id = provider.getId(item); - assert.strictEqual(id, `file:${URI.file('/src/a.ts').toString()}`); - }); - - test('same element produces same ID', () => { - const provider = new ChatEditsTreeIdentityProvider(); - const item1 = makeFileItem('/src/a.ts'); - const item2 = makeFileItem('/src/a.ts'); - assert.strictEqual(provider.getId(item1), provider.getId(item2)); - }); - - test('different elements produce different IDs', () => { - const provider = new ChatEditsTreeIdentityProvider(); - const item1 = makeFileItem('/src/a.ts'); - const item2 = makeFileItem('/src/b.ts'); - assert.notStrictEqual(provider.getId(item1), provider.getId(item2)); - }); - }); - - suite('ChatEditsListWidget lifecycle', () => { - let store: DisposableStore; - let storageService: IStorageService; - let widget: ChatEditsListWidget; - - setup(() => { - store = new DisposableStore(); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(new TestConfigurationService)), - }, store); - store.add(instaService); - - storageService = instaService.get(IStorageService); - widget = store.add(instaService.createInstance(ChatEditsListWidget, Event.None)); - }); - - teardown(() => { - store.dispose(); - }); - - test.skip('storage listener fires after clear', () => { - // Stub create to avoid DOM/widget side effects in tests - let createCallCount = 0; - const origCreate = widget.create.bind(widget); - widget.create = (c, s) => { - createCallCount++; - // Update stored refs without actually building widgets - (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; - (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; - }; - - const container = document.createElement('div'); - widget.create(container, null); - assert.strictEqual(createCallCount, 1); - - // Simulate session switch - widget.clear(); - - // Toggle view mode — storage listener must still fire after clear() - createCallCount = 0; - storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); - assert.strictEqual(createCallCount, 1, 'storage listener should trigger create after clear()'); - - widget.create = origCreate; - }); - - test.skip('currentSession is updated on rebuild', () => { - // Stub create - widget.create = (c, s) => { - (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; - (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; - }; - - const container = document.createElement('div'); - widget.create(container, null); - assert.strictEqual(widget.currentSession, null); - - const mockSession = {} as IChatEditingSession; - widget.rebuild(container, mockSession); - assert.strictEqual(widget.currentSession, mockSession); - }); - - test.skip('setEntries replays after view mode toggle', () => { - // Stub create and track setEntries calls - widget.create = (c, s) => { - (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; - (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; - }; - - const container = document.createElement('div'); - widget.create(container, null); - - const entries = [makeFileItem('/src/a.ts'), makeFileItem('/src/b.ts')]; - widget.setEntries(entries); - - const setEntriesCalls: readonly IChatCollapsibleListItem[][] = []; - const origSetEntries = widget.setEntries.bind(widget); - widget.setEntries = (e) => { - (setEntriesCalls as IChatCollapsibleListItem[][]).push([...e]); - origSetEntries(e); - }; - - // Toggle to tree mode — should replay entries - storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); - assert.strictEqual(setEntriesCalls.length, 1, 'setEntries should have been replayed'); - assert.strictEqual(setEntriesCalls[0].length, 2, 'should have replayed the 2 entries'); - - widget.setEntries = origSetEntries; - }); - }); -}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatStatusWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatStatusWidget.test.ts new file mode 100644 index 0000000000000..bc8dbabb0d4ff --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatStatusWidget.test.ts @@ -0,0 +1,240 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../../../../../platform/contextkey/browser/contextKeyService.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { ChatEntitlement, IChatEntitlementService, IChatSentiment } from '../../../../../../services/chat/common/chatEntitlementService.js'; +import { IChatDetail, IChatService, ResponseModelState } from '../../../../common/chatService/chatService.js'; +import { MockChatService } from '../../../common/chatService/mockChatService.js'; +import { ChatStatusWidget } from '../../../../browser/widget/input/chatStatusWidget.js'; +import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { timeout } from '../../../../../../../base/common/async.js'; +import { runWithFakedTimers } from '../../../../../../../base/test/common/timeTravelScheduler.js'; +import { URI } from '../../../../../../../base/common/uri.js'; + +class MockChatEntitlementService implements IChatEntitlementService { + _serviceBrand: undefined; + + readonly onDidChangeEntitlement = Event.None; + readonly onDidChangeQuotaExceeded = Event.None; + readonly onDidChangeQuotaRemaining = Event.None; + readonly onDidChangeSentiment = Event.None; + readonly onDidChangeAnonymous = Event.None; + + entitlement = ChatEntitlement.Unknown; + entitlementObs = observableValue(this, ChatEntitlement.Unknown); + previewFeaturesDisabled = false; + organisations: string[] | undefined; + isInternal = false; + sku: string | undefined; + copilotTrackingId: string | undefined; + anonymous = false; + anonymousObs = observableValue(this, false); + sentiment: IChatSentiment = {}; + sentimentObs = observableValue(this, {}); + quotas: IChatEntitlementService['quotas'] = {}; + + async update(_token: CancellationToken): Promise { } + markAnonymousRateLimited(): void { } +} + +class TestChatService extends MockChatService { + private _history: Awaited> = []; + + setHistory(items: Awaited>): void { + this._history = items; + } + + override async getHistorySessionItems(): Promise>> { + return this._history; + } +} + +suite('ChatStatusWidget', () => { + + let store: DisposableStore; + let entitlementService: MockChatEntitlementService; + let chatService: TestChatService; + let configService: TestConfigurationService; + + setup(() => { + store = new DisposableStore(); + entitlementService = new MockChatEntitlementService(); + chatService = new TestChatService(); + configService = new TestConfigurationService(); + }); + + teardown(() => { + store.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createWidget(): ChatStatusWidget { + const instaService = workbenchInstantiationService({ + contextKeyService: () => store.add(new ContextKeyService(configService)), + }, store); + instaService.stub(IChatEntitlementService, entitlementService); + instaService.stub(IChatService, chatService); + instaService.stub(IConfigurationService, configService); + store.add(instaService); + return store.add(instaService.createInstance(ChatStatusWidget)); + } + + test('hidden by default when no conditions are met', () => { + const widget = createWidget(); + assert.strictEqual(widget.domNode.style.display, 'none'); + }); + + test('shows free quota-exceeded banner for free tier users', () => { + entitlementService.entitlement = ChatEntitlement.Free; + entitlementService.quotas = { chat: { total: 50, remaining: 0, percentRemaining: 0, overageEnabled: false, overageCount: 0, unlimited: false } }; + + const widget = createWidget(); + assert.strictEqual(widget.domNode.style.display, ''); + assert.ok(widget.domNode.querySelector('.chat-status-button')); + }); + + test('shows anonymous quota-exceeded banner when experiment enabled', () => { + entitlementService.anonymous = true; + entitlementService.quotas = { chat: { total: 50, remaining: 0, percentRemaining: 0, overageEnabled: false, overageCount: 0, unlimited: false } }; + configService.setUserConfiguration('chat.statusWidget.anonymous', true); + + const widget = createWidget(); + assert.strictEqual(widget.domNode.style.display, ''); + assert.ok(widget.domNode.querySelector('.chat-status-button')); + }); + + test('does not show quota banner when quota not exceeded', () => { + entitlementService.entitlement = ChatEntitlement.Free; + entitlementService.quotas = { chat: { total: 50, remaining: 25, percentRemaining: 50, overageEnabled: false, overageCount: 0, unlimited: false } }; + + const widget = createWidget(); + assert.strictEqual(widget.domNode.style.display, 'none'); + }); + + test('shows welcome banner for anonymous user with empty history', () => { + return runWithFakedTimers({}, async () => { + entitlementService.anonymous = true; + chatService.setHistory([]); + configService.setUserConfiguration('chat.noAuthWidget.enabled', true); + + const widget = createWidget(); + await timeout(0); + + assert.strictEqual(widget.domNode.style.display, ''); + assert.ok(widget.domNode.querySelector('.chat-status-icon')); + assert.ok(widget.domNode.querySelector('.chat-status-dismiss')); + }); + }); + + test('does not show welcome banner when user has history', () => { + return runWithFakedTimers({}, async () => { + entitlementService.anonymous = true; + chatService.setHistory([{ + sessionResource: URI.parse('test://session'), + title: 'test', + lastMessageDate: Date.now(), + timing: { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }, + isActive: false, + lastResponseState: ResponseModelState.Complete, + } satisfies IChatDetail]); + configService.setUserConfiguration('chat.noAuthWidget.enabled', true); + + const widget = createWidget(); + await timeout(0); + + assert.strictEqual(widget.domNode.style.display, 'none'); + }); + }); + + test('does not show welcome banner when previously dismissed', () => { + return runWithFakedTimers({}, async () => { + entitlementService.anonymous = true; + chatService.setHistory([]); + configService.setUserConfiguration('chat.noAuthWidget.enabled', true); + + const instaService = workbenchInstantiationService({ + contextKeyService: () => store.add(new ContextKeyService(configService)), + }, store); + instaService.stub(IChatEntitlementService, entitlementService); + instaService.stub(IChatService, chatService); + instaService.stub(IConfigurationService, configService); + store.add(instaService); + + // Pre-set dismissed in storage + instaService.get(IStorageService).store('chat.noAuthWidget.dismissed', true, StorageScope.PROFILE, StorageTarget.USER); + + const widget = store.add(instaService.createInstance(ChatStatusWidget)); + await timeout(0); + + assert.strictEqual(widget.domNode.style.display, 'none'); + }); + }); + + test('dismiss button persists dismissal to storage', () => { + return runWithFakedTimers({}, async () => { + entitlementService.anonymous = true; + chatService.setHistory([]); + configService.setUserConfiguration('chat.noAuthWidget.enabled', true); + + const instaService = workbenchInstantiationService({ + contextKeyService: () => store.add(new ContextKeyService(configService)), + }, store); + instaService.stub(IChatEntitlementService, entitlementService); + instaService.stub(IChatService, chatService); + instaService.stub(IConfigurationService, configService); + store.add(instaService); + + const widget = store.add(instaService.createInstance(ChatStatusWidget)); + await timeout(0); + + const dismissButton = widget.domNode.querySelector('.chat-status-dismiss') as HTMLElement; + assert.ok(dismissButton); + dismissButton.click(); + + assert.strictEqual(widget.domNode.style.display, 'none'); + assert.strictEqual( + instaService.get(IStorageService).getBoolean('chat.noAuthWidget.dismissed', StorageScope.PROFILE, false), + true + ); + }); + }); + + test('does not show welcome banner when experiment is disabled', () => { + return runWithFakedTimers({}, async () => { + entitlementService.anonymous = true; + chatService.setHistory([]); + configService.setUserConfiguration('chat.noAuthWidget.enabled', false); + + const widget = createWidget(); + await timeout(0); + + assert.strictEqual(widget.domNode.style.display, 'none'); + }); + }); + + test('does not show welcome banner for non-anonymous users', () => { + return runWithFakedTimers({}, async () => { + entitlementService.anonymous = false; + entitlementService.entitlement = ChatEntitlement.Pro; + chatService.setHistory([]); + configService.setUserConfiguration('chat.noAuthWidget.enabled', true); + + const widget = createWidget(); + await timeout(0); + + assert.strictEqual(widget.domNode.style.display, 'none'); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/mockChatWidget.ts b/src/vs/workbench/contrib/chat/test/browser/widget/mockChatWidget.ts index 7e7d3336a17a1..edd6275ee86bf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/mockChatWidget.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/mockChatWidget.ts @@ -13,6 +13,7 @@ export class MockChatWidgetService implements IChatWidgetService { readonly onDidAddWidget: Event = Event.None; readonly onDidBackgroundSession: Event = Event.None; readonly onDidChangeFocusedWidget: Event = Event.None; + readonly onDidChangeFocusedSession: Event = Event.None; readonly _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index 75f22e0c69f2b..f7745a0b6fa9f 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -120,7 +120,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([customMode]); @@ -158,7 +158,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([customMode]); @@ -178,7 +178,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Findable mode body', toolReferences: [] }, source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([customMode]); @@ -204,7 +204,7 @@ suite('ChatModeService', () => { model: ['gpt-4'], source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([initialMode]); @@ -249,7 +249,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Mode 1 body', toolReferences: [] }, source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; const mode2: ICustomAgent = { @@ -260,7 +260,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Mode 2 body', toolReferences: [] }, source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; // Add both modes diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 601e138974197..15a5add31964f 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -33,7 +33,8 @@ import { IExtensionService, nullExtensionDescription } from '../../../../../serv import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; -import { InMemoryTestFileService, mock, TestContextService, TestExtensionService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { InMemoryTestFileService, mock, TestChatEntitlementService, TestContextService, TestExtensionService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -163,6 +164,7 @@ suite('ChatService', () => { [IPromptsService, new MockPromptsService()], ))); instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); + instantiationService.stub(IChatEntitlementService, new TestChatEntitlementService()); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IUserDataProfilesService, { defaultProfile: toUserDataProfile('default', 'Default', URI.file('/test/userdata'), URI.file('/test/cache')) }); instantiationService.stub(ITelemetryService, NullTelemetryService); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 48e2b40d1f17f..ddac0e86cc3dc 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -22,6 +22,8 @@ import { ContextKeyExpression } from '../../../../../platform/contextkey/common/ import { ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IRequestService } from '../../../../../platform/request/common/request.js'; suite('LanguageModels', function () { @@ -50,6 +52,8 @@ suite('LanguageModels', function () { }, new class extends mock() { }, new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, ); languageModels.deltaLanguageModelChatProviderDescriptors([ @@ -251,6 +255,8 @@ suite('LanguageModels - When Clause', function () { }, new class extends mock() { }, new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, ); languageModelsWithWhen.deltaLanguageModelChatProviderDescriptors([ @@ -312,6 +318,8 @@ suite('LanguageModels - Model Picker Preferences Storage', function () { }, new class extends mock() { }, new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, ); // Register vendor1 used in most tests @@ -548,6 +556,8 @@ suite('LanguageModels - Model Change Events', function () { }, new class extends mock() { }, new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, ); // Register the vendor first @@ -895,6 +905,8 @@ suite('LanguageModels - Vendor Change Events', function () { }, new class extends mock() { }, new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, ); }); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index 35b2595109d49..ee3375bdef594 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -7,8 +7,9 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IStringDictionary } from '../../../../../base/common/collections.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { IChatMessage, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelProviderDescriptor, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; +import { IChatMessage, ICuratedModels, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelProviderDescriptor, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; export class NullLanguageModelsService implements ILanguageModelsService { @@ -85,4 +86,24 @@ export class NullLanguageModelsService implements ILanguageModelsService { } async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } + + getRecentlyUsedModelIds(_maxCount?: number): string[] { + return []; + } + + recordModelUsage(_modelIdentifier: string): void { } + + getCuratedModels(): ICuratedModels { + return { free: [], paid: [] }; + } + + getNewModelIds(): string[] { + return []; + } + + onDidChangeNewModelIds = Event.None; + + markNewModelsAsSeen(): void { } + + restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 097866e2e4d61..932cbb036b06b 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -205,6 +205,10 @@ export class MockChatSessionsService implements IChatSessionsService { return this.contributions.find(c => c.type === chatSessionType)?.customAgentTarget ?? Target.Undefined; } + requiresCustomModelsForSessionType(chatSessionType: string): boolean { + return this.contributions.find(c => c.type === chatSessionType)?.requiresCustomModels ?? false; + } + getContentProviderSchemes(): string[] { return Array.from(this.contentProviders.keys()); } diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index 934d86152e6b7..c0800d4b46d91 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -167,6 +167,51 @@ suite('ChatModel', () => { assert.strictEqual(request1.response!.shouldBeRemovedOnSend, undefined); }); + test('deserialization marks unused question carousels as used', async () => { + const serializableData: ISerializableChatData3 = { + version: 3, + sessionId: 'test-session', + creationDate: Date.now(), + customTitle: undefined, + initialLocation: ChatAgentLocation.Chat, + requests: [{ + requestId: 'req1', + message: { text: 'hello', parts: [] }, + variableData: { variables: [] }, + response: [ + { value: 'some text', isTrusted: false }, + { + kind: 'questionCarousel' as const, + questions: [{ id: 'q1', title: 'Question 1', type: 'text' as const }], + allowSkip: true, + resolveId: 'resolve1', + isUsed: false, + }, + ], + modelState: { value: 2 /* ResponseModelState.Cancelled */, completedAt: Date.now() }, + }], + responderUsername: 'bot', + }; + + const model = testDisposables.add(instantiationService.createInstance( + ChatModel, + { value: serializableData, serializer: undefined! }, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + + const requests = model.getRequests(); + assert.strictEqual(requests.length, 1); + const response = requests[0].response!; + + // The question carousel should be marked as used after deserialization + const carouselPart = response.response.value.find(p => p.kind === 'questionCarousel'); + assert.ok(carouselPart); + assert.strictEqual(carouselPart.isUsed, true); + + // The response should be complete (not stuck in NeedsInput) + assert.strictEqual(response.isComplete, true); + }); + test('inputModel.toJSON filters extension-contributed contexts', async function () { const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index f21450267d46b..9401936b62eae 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { IChatChangeEvent, IChatModel, IChatPendingRequest, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; -import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; +import { IChatSessionContext, IChatSessionTiming } from '../../../common/chatService/chatService.js'; export class MockChatModel extends Disposable implements IChatModel { readonly onDidDispose = this._register(new Emitter()).event; @@ -49,6 +49,10 @@ export class MockChatModel extends Disposable implements IChatModel { this.lastRequestObs = observableValue('lastRequest', undefined); } + setContributedChatSession(session: IChatSessionContext | undefined): void { + throw new Error('Method not implemented.'); + } + readonly hasRequests = false; readonly lastRequest: IChatRequestModel | undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 5733ddff8275a..3cb6fe2ad58fe 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -1150,7 +1150,7 @@ suite('ComputeAutomaticInstructions', () => { contents: [ '---', 'description: \'Test agent 1\'', - 'user-invokable: true', + 'user-invocable: true', 'disable-model-invocation: false', '---', 'Test agent content', @@ -1161,7 +1161,7 @@ suite('ComputeAutomaticInstructions', () => { contents: [ '---', 'description: \'Test agent 2\'', - 'user-invokable: true', + 'user-invocable: true', 'disable-model-invocation: true', '---', 'Test agent content', @@ -1172,7 +1172,7 @@ suite('ComputeAutomaticInstructions', () => { contents: [ '---', 'description: \'Test agent 3\'', - 'user-invokable: false', + 'user-invocable: false', 'disable-model-invocation: false', '---', 'Test agent content', @@ -1183,7 +1183,7 @@ suite('ComputeAutomaticInstructions', () => { contents: [ '---', 'description: \'Test agent 4\'', - 'user-invokable: false', + 'user-invocable: false', 'disable-model-invocation: true', '---', 'Test agent content', diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts index 0d09d3047d1ad..728349a04bfd0 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts @@ -323,7 +323,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', '', '[](./some-other-folder/non-existing-folder)', @@ -360,7 +360,7 @@ suite('PromptFileReference', function () { name: 'another-file.prompt.md', contents: [ '---', - 'tools: [\'my-tool3\', false, "my-tool2" ]', + 'tools: [\'my-tool3\', "my-tool2" ]', '---', `[](${rootFolder}/folder1/some-other-folder)`, 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', @@ -441,7 +441,7 @@ suite('PromptFileReference', function () { contents: [ '---', 'applyTo: \'**/*\'', - 'tools: [ false, \'my-tool12\' , ]', + 'tools: [ \'my-tool12\' , ]', 'description: \'Description of my prompt.\'', '---', '## Files', @@ -457,7 +457,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', ' some more\t content', ], @@ -547,7 +547,7 @@ suite('PromptFileReference', function () { contents: [ '---', 'applyTo: \'**/*\'', - 'tools: [ false, \'my-tool12\' , ]', + 'tools: [ \'my-tool12\' , ]', 'description: \'Description of my instructions file.\'', '---', '## Files', @@ -563,7 +563,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', ' some more\t content', ], @@ -668,7 +668,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', 'agent: \'agent\'\t', '---', ' some more\t content', @@ -771,7 +771,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', ' some more\t content', ], @@ -874,7 +874,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', ' some more\t content', ], @@ -961,7 +961,7 @@ suite('PromptFileReference', function () { name: 'file2.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool12\' , ]', + 'tools: [ \'my-tool12\' , ]', 'description: \'Description of the prompt file.\'', '---', '## Files', @@ -977,7 +977,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', ' some more\t content', ], diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts index 156e28227d776..ed6e2f47948eb 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts @@ -8,7 +8,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; import { URI } from '../../../../../../../base/common/uri.js'; -import { IStringValue, parseCommaSeparatedList, PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; +import { IScalarValue, parseCommaSeparatedList, PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; suite('PromptFileParser', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -30,12 +30,12 @@ suite('PromptFileParser', () => { assert.ok(result.body); assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 5, endColumn: 1 }); assert.deepEqual(result.header.attributes, [ - { key: 'description', range: new Range(2, 1, 2, 26), value: { type: 'string', value: 'Agent test', range: new Range(2, 14, 2, 26) } }, - { key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(3, 8, 3, 15) } }, + { key: 'description', range: new Range(2, 1, 2, 26), value: { type: 'scalar', value: 'Agent test', range: new Range(2, 14, 2, 26), format: 'double' } }, + { key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'scalar', value: 'GPT 4.1', range: new Range(3, 8, 3, 15), format: 'none' } }, { key: 'tools', range: new Range(4, 1, 4, 26), value: { - type: 'array', - items: [{ type: 'string', value: 'tool1', range: new Range(4, 9, 4, 16) }, { type: 'string', value: 'tool2', range: new Range(4, 18, 4, 25) }], + type: 'sequence', + items: [{ type: 'scalar', value: 'tool1', range: new Range(4, 9, 4, 16), format: 'single' }, { type: 'scalar', value: 'tool2', range: new Range(4, 18, 4, 25), format: 'single' }], range: new Range(4, 8, 4, 26) } }, @@ -80,29 +80,29 @@ suite('PromptFileParser', () => { assert.ok(result.header); assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 13, endColumn: 1 }); assert.deepEqual(result.header.attributes, [ - { key: 'description', range: new Range(2, 1, 2, 26), value: { type: 'string', value: 'Agent test', range: new Range(2, 14, 2, 26) } }, - { key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(3, 8, 3, 15) } }, + { key: 'description', range: new Range(2, 1, 2, 26), value: { type: 'scalar', value: 'Agent test', range: new Range(2, 14, 2, 26), format: 'double' } }, + { key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'scalar', value: 'GPT 4.1', range: new Range(3, 8, 3, 15), format: 'none' } }, { key: 'handoffs', range: new Range(4, 1, 12, 15), value: { - type: 'array', - range: new Range(5, 3, 12, 15), + type: 'sequence', + range: new Range(5, 1, 12, 15), items: [ { - type: 'object', range: new Range(5, 5, 8, 16), + type: 'map', range: new Range(5, 5, 8, 16), properties: [ - { key: { type: 'string', value: 'label', range: new Range(5, 5, 5, 10) }, value: { type: 'string', value: 'Implement', range: new Range(5, 12, 5, 23) } }, - { key: { type: 'string', value: 'agent', range: new Range(6, 5, 6, 10) }, value: { type: 'string', value: 'Default', range: new Range(6, 12, 6, 19) } }, - { key: { type: 'string', value: 'prompt', range: new Range(7, 5, 7, 11) }, value: { type: 'string', value: 'Implement the plan', range: new Range(7, 13, 7, 33) } }, - { key: { type: 'string', value: 'send', range: new Range(8, 5, 8, 9) }, value: { type: 'boolean', value: false, range: new Range(8, 11, 8, 16) } }, + { key: { type: 'scalar', value: 'label', range: new Range(5, 5, 5, 10), format: 'none' }, value: { type: 'scalar', value: 'Implement', range: new Range(5, 12, 5, 23), format: 'double' } }, + { key: { type: 'scalar', value: 'agent', range: new Range(6, 5, 6, 10), format: 'none' }, value: { type: 'scalar', value: 'Default', range: new Range(6, 12, 6, 19), format: 'none' } }, + { key: { type: 'scalar', value: 'prompt', range: new Range(7, 5, 7, 11), format: 'none' }, value: { type: 'scalar', value: 'Implement the plan', range: new Range(7, 13, 7, 33), format: 'double' } }, + { key: { type: 'scalar', value: 'send', range: new Range(8, 5, 8, 9), format: 'none' }, value: { type: 'scalar', value: 'false', range: new Range(8, 11, 8, 16), format: 'none' } }, ] }, { - type: 'object', range: new Range(9, 5, 12, 15), + type: 'map', range: new Range(9, 5, 12, 15), properties: [ - { key: { type: 'string', value: 'label', range: new Range(9, 5, 9, 10) }, value: { type: 'string', value: 'Save', range: new Range(9, 12, 9, 18) } }, - { key: { type: 'string', value: 'agent', range: new Range(10, 5, 10, 10) }, value: { type: 'string', value: 'Default', range: new Range(10, 12, 10, 19) } }, - { key: { type: 'string', value: 'prompt', range: new Range(11, 5, 11, 11) }, value: { type: 'string', value: 'Save the plan to a file', range: new Range(11, 13, 11, 38) } }, - { key: { type: 'string', value: 'send', range: new Range(12, 5, 12, 9) }, value: { type: 'boolean', value: true, range: new Range(12, 11, 12, 15) } }, + { key: { type: 'scalar', value: 'label', range: new Range(9, 5, 9, 10), format: 'none' }, value: { type: 'scalar', value: 'Save', range: new Range(9, 12, 9, 18), format: 'double' } }, + { key: { type: 'scalar', value: 'agent', range: new Range(10, 5, 10, 10), format: 'none' }, value: { type: 'scalar', value: 'Default', range: new Range(10, 12, 10, 19), format: 'none' } }, + { key: { type: 'scalar', value: 'prompt', range: new Range(11, 5, 11, 11), format: 'none' }, value: { type: 'scalar', value: 'Save the plan to a file', range: new Range(11, 13, 11, 38), format: 'double' } }, + { key: { type: 'scalar', value: 'send', range: new Range(12, 5, 12, 9), format: 'none' }, value: { type: 'scalar', value: 'true', range: new Range(12, 11, 12, 15), format: 'none' } }, ] }, ] @@ -180,8 +180,8 @@ suite('PromptFileParser', () => { assert.ok(result.body); assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 4, endColumn: 1 }); assert.deepEqual(result.header.attributes, [ - { key: 'description', range: new Range(2, 1, 2, 54), value: { type: 'string', value: 'Code style instructions for TypeScript', range: new Range(2, 14, 2, 54) } }, - { key: 'applyTo', range: new Range(3, 1, 3, 14), value: { type: 'string', value: '*.ts', range: new Range(3, 10, 3, 14) } }, + { key: 'description', range: new Range(2, 1, 2, 54), value: { type: 'scalar', value: 'Code style instructions for TypeScript', range: new Range(2, 14, 2, 54), format: 'double' } }, + { key: 'applyTo', range: new Range(3, 1, 3, 14), value: { type: 'scalar', value: '*.ts', range: new Range(3, 10, 3, 14), format: 'none' } }, ]); assert.deepEqual(result.body.range, { startLineNumber: 5, startColumn: 1, endLineNumber: 6, endColumn: 1 }); assert.equal(result.body.offset, 76); @@ -212,13 +212,13 @@ suite('PromptFileParser', () => { assert.ok(result.body); assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 6, endColumn: 1 }); assert.deepEqual(result.header.attributes, [ - { key: 'description', range: new Range(2, 1, 2, 48), value: { type: 'string', value: 'General purpose coding assistant', range: new Range(2, 14, 2, 48) } }, - { key: 'agent', range: new Range(3, 1, 3, 13), value: { type: 'string', value: 'agent', range: new Range(3, 8, 3, 13) } }, - { key: 'model', range: new Range(4, 1, 4, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(4, 8, 4, 15) } }, + { key: 'description', range: new Range(2, 1, 2, 48), value: { type: 'scalar', value: 'General purpose coding assistant', range: new Range(2, 14, 2, 48), format: 'double' } }, + { key: 'agent', range: new Range(3, 1, 3, 13), value: { type: 'scalar', value: 'agent', range: new Range(3, 8, 3, 13), format: 'none' } }, + { key: 'model', range: new Range(4, 1, 4, 15), value: { type: 'scalar', value: 'GPT 4.1', range: new Range(4, 8, 4, 15), format: 'none' } }, { key: 'tools', range: new Range(5, 1, 5, 30), value: { - type: 'array', - items: [{ type: 'string', value: 'search', range: new Range(5, 9, 5, 17) }, { type: 'string', value: 'terminal', range: new Range(5, 19, 5, 29) }], + type: 'sequence', + items: [{ type: 'scalar', value: 'search', range: new Range(5, 9, 5, 17), format: 'single' }, { type: 'scalar', value: 'terminal', range: new Range(5, 19, 5, 29), format: 'single' }], range: new Range(5, 8, 5, 30) } }, @@ -306,53 +306,53 @@ suite('PromptFileParser', () => { suite('parseCommaSeparatedList', () => { - function assertCommaSeparatedList(input: string, expected: IStringValue[]): void { - const actual = parseCommaSeparatedList({ type: 'string', value: input, range: new Range(1, 1, 1, input.length + 1) }); + function assertCommaSeparatedList(input: string, expected: IScalarValue[]): void { + const actual = parseCommaSeparatedList({ type: 'scalar', value: input, range: new Range(1, 1, 1, input.length + 1), format: 'none' }); assert.deepStrictEqual(actual.items, expected); } test('simple unquoted values', () => { assertCommaSeparatedList('a, b, c', [ - { type: 'string', value: 'a', range: new Range(1, 1, 1, 2) }, - { type: 'string', value: 'b', range: new Range(1, 4, 1, 5) }, - { type: 'string', value: 'c', range: new Range(1, 7, 1, 8) } + { type: 'scalar', value: 'a', range: new Range(1, 1, 1, 2), format: 'none' }, + { type: 'scalar', value: 'b', range: new Range(1, 4, 1, 5), format: 'none' }, + { type: 'scalar', value: 'c', range: new Range(1, 7, 1, 8), format: 'none' } ]); }); test('unquoted values without spaces', () => { assertCommaSeparatedList('foo,bar,baz', [ - { type: 'string', value: 'foo', range: new Range(1, 1, 1, 4) }, - { type: 'string', value: 'bar', range: new Range(1, 5, 1, 8) }, - { type: 'string', value: 'baz', range: new Range(1, 9, 1, 12) } + { type: 'scalar', value: 'foo', range: new Range(1, 1, 1, 4), format: 'none' }, + { type: 'scalar', value: 'bar', range: new Range(1, 5, 1, 8), format: 'none' }, + { type: 'scalar', value: 'baz', range: new Range(1, 9, 1, 12), format: 'none' } ]); }); test('double quoted values', () => { assertCommaSeparatedList('"hello", "world"', [ - { type: 'string', value: 'hello', range: new Range(1, 1, 1, 8) }, - { type: 'string', value: 'world', range: new Range(1, 10, 1, 17) } + { type: 'scalar', value: 'hello', range: new Range(1, 1, 1, 8), format: 'double' }, + { type: 'scalar', value: 'world', range: new Range(1, 10, 1, 17), format: 'double' } ]); }); test('single quoted values', () => { assertCommaSeparatedList(`'one', 'two'`, [ - { type: 'string', value: 'one', range: new Range(1, 1, 1, 6) }, - { type: 'string', value: 'two', range: new Range(1, 8, 1, 13) } + { type: 'scalar', value: 'one', range: new Range(1, 1, 1, 6), format: 'single' }, + { type: 'scalar', value: 'two', range: new Range(1, 8, 1, 13), format: 'single' } ]); }); test('mixed quoted and unquoted values', () => { assertCommaSeparatedList('unquoted, "double", \'single\'', [ - { type: 'string', value: 'unquoted', range: new Range(1, 1, 1, 9) }, - { type: 'string', value: 'double', range: new Range(1, 11, 1, 19) }, - { type: 'string', value: 'single', range: new Range(1, 21, 1, 29) } + { type: 'scalar', value: 'unquoted', range: new Range(1, 1, 1, 9), format: 'none' }, + { type: 'scalar', value: 'double', range: new Range(1, 11, 1, 19), format: 'double' }, + { type: 'scalar', value: 'single', range: new Range(1, 21, 1, 29), format: 'single' } ]); }); test('quoted values with commas inside', () => { assertCommaSeparatedList('"a,b", "c,d"', [ - { type: 'string', value: 'a,b', range: new Range(1, 1, 1, 6) }, - { type: 'string', value: 'c,d', range: new Range(1, 8, 1, 13) } + { type: 'scalar', value: 'a,b', range: new Range(1, 1, 1, 6), format: 'double' }, + { type: 'scalar', value: 'c,d', range: new Range(1, 8, 1, 13), format: 'double' } ]); }); @@ -362,49 +362,93 @@ suite('PromptFileParser', () => { test('single value', () => { assertCommaSeparatedList('single', [ - { type: 'string', value: 'single', range: new Range(1, 1, 1, 7) } + { type: 'scalar', value: 'single', range: new Range(1, 1, 1, 7), format: 'none' } ]); }); test('values with extra whitespace', () => { assertCommaSeparatedList(' a , b , c ', [ - { type: 'string', value: 'a', range: new Range(1, 3, 1, 4) }, - { type: 'string', value: 'b', range: new Range(1, 9, 1, 10) }, - { type: 'string', value: 'c', range: new Range(1, 15, 1, 16) } + { type: 'scalar', value: 'a', range: new Range(1, 3, 1, 4), format: 'none' }, + { type: 'scalar', value: 'b', range: new Range(1, 9, 1, 10), format: 'none' }, + { type: 'scalar', value: 'c', range: new Range(1, 15, 1, 16), format: 'none' } ]); }); test('quoted value with spaces', () => { assertCommaSeparatedList('"hello world", "foo bar"', [ - { type: 'string', value: 'hello world', range: new Range(1, 1, 1, 14) }, - { type: 'string', value: 'foo bar', range: new Range(1, 16, 1, 25) } + { type: 'scalar', value: 'hello world', range: new Range(1, 1, 1, 14), format: 'double' }, + { type: 'scalar', value: 'foo bar', range: new Range(1, 16, 1, 25), format: 'double' } ]); }); test('with position offset', () => { // Simulate parsing a list that starts at line 5, character 10 - const result = parseCommaSeparatedList({ type: 'string', value: 'a, b, c', range: new Range(6, 11, 6, 18) }); + const result = parseCommaSeparatedList({ type: 'scalar', value: 'a, b, c', range: new Range(6, 11, 6, 18), format: 'none' }); assert.deepStrictEqual(result.items, [ - { type: 'string', value: 'a', range: new Range(6, 11, 6, 12) }, - { type: 'string', value: 'b', range: new Range(6, 14, 6, 15) }, - { type: 'string', value: 'c', range: new Range(6, 17, 6, 18) } + { type: 'scalar', value: 'a', range: new Range(6, 11, 6, 12), format: 'none' }, + { type: 'scalar', value: 'b', range: new Range(6, 14, 6, 15), format: 'none' }, + { type: 'scalar', value: 'c', range: new Range(6, 17, 6, 18), format: 'none' } ]); }); test('entire input wrapped in double quotes', () => { // When the entire input is wrapped in quotes, it should be treated as a single quoted value assertCommaSeparatedList('"a, b, c"', [ - { type: 'string', value: 'a, b, c', range: new Range(1, 1, 1, 10) } + { type: 'scalar', value: 'a, b, c', range: new Range(1, 1, 1, 10), format: 'double' } ]); }); test('entire input wrapped in single quotes', () => { // When the entire input is wrapped in single quotes, it should be treated as a single quoted value assertCommaSeparatedList(`'a, b, c'`, [ - { type: 'string', value: 'a, b, c', range: new Range(1, 1, 1, 10) } + { type: 'scalar', value: 'a, b, c', range: new Range(1, 1, 1, 10), format: 'single' } ]); }); }); + test('userInvocable getter falls back to deprecated user-invokable', async () => { + const uri = URI.parse('file:///test/test.agent.md'); + + // user-invocable (new spelling) takes precedence + const content1 = [ + '---', + 'description: "Test"', + 'user-invocable: true', + '---', + ].join('\n'); + const result1 = new PromptFileParser().parse(uri, content1); + assert.strictEqual(result1.header?.userInvocable, true); + + // deprecated user-invokable still works as fallback + const content2 = [ + '---', + 'description: "Test"', + 'user-invokable: false', + '---', + ].join('\n'); + const result2 = new PromptFileParser().parse(uri, content2); + assert.strictEqual(result2.header?.userInvocable, false); + + // user-invocable takes precedence over deprecated user-invokable + const content3 = [ + '---', + 'description: "Test"', + 'user-invocable: true', + 'user-invokable: false', + '---', + ].join('\n'); + const result3 = new PromptFileParser().parse(uri, content3); + assert.strictEqual(result3.header?.userInvocable, true); + + // neither set returns undefined + const content4 = [ + '---', + 'description: "Test"', + '---', + ].join('\n'); + const result4 = new PromptFileParser().parse(uri, content4); + assert.strictEqual(result4.header?.userInvocable, undefined); + }); + }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index f4e54cac7f152..05eabacb54dec 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -38,7 +38,7 @@ import { TestContextService, TestUserDataProfileService } from '../../../../../. import { ChatRequestVariableSet, isPromptFileVariableEntry, toFileVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; -import { AGENTS_SOURCE_FOLDER, CLAUDE_CONFIG_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_CONFIG_FOLDER, HOOKS_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; @@ -48,6 +48,7 @@ import { IPathService } from '../../../../../../services/path/common/pathService import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js'; import { ChatModeKind } from '../../../../common/constants.js'; +import { HookType } from '../../../../common/promptSyntax/hookSchema.js'; suite('PromptsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -183,7 +184,7 @@ suite('PromptsService', () => { contents: [ '---', 'description: \'Root prompt description.\'', - 'tools: [\'my-tool1\', , true]', + 'tools: [\'my-tool1\', , tool]', 'agent: "agent" ', '---', '## Files', @@ -238,7 +239,7 @@ suite('PromptsService', () => { contents: [ '---', 'description: "Another file description."', - 'tools: [\'my-tool3\', false, "my-tool2" ]', + 'tools: [\'my-tool3\', "my-tool2" ]', 'applyTo: "**/*.tsx"', '---', `[](${rootFolder}/folder1/some-other-folder)`, @@ -262,7 +263,7 @@ suite('PromptsService', () => { const result1 = await service.parseNew(rootFileUri, CancellationToken.None); assert.deepEqual(result1.uri, rootFileUri); assert.deepEqual(result1.header?.description, 'Root prompt description.'); - assert.deepEqual(result1.header?.tools, ['my-tool1']); + assert.deepEqual(result1.header?.tools, ['my-tool1', 'tool']); assert.deepEqual(result1.header?.agent, 'agent'); assert.ok(result1.body); assert.deepEqual( @@ -769,7 +770,7 @@ suite('PromptsService', () => { argumentHint: undefined, tools: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local } @@ -825,7 +826,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local }, @@ -843,7 +844,7 @@ suite('PromptsService', () => { uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } } ]; @@ -900,7 +901,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local } @@ -918,7 +919,7 @@ suite('PromptsService', () => { model: undefined, tools: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local } @@ -988,7 +989,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, argumentHint: undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/github-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1006,7 +1007,7 @@ suite('PromptsService', () => { handOffs: undefined, argumentHint: undefined, tools: undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/vscode-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1024,7 +1025,7 @@ suite('PromptsService', () => { argumentHint: undefined, tools: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/generic-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1101,7 +1102,7 @@ suite('PromptsService', () => { }, handOffs: undefined, argumentHint: undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/copilot-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1121,7 +1122,7 @@ suite('PromptsService', () => { }, handOffs: undefined, argumentHint: undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent.md'), source: { storage: PromptsStorage.local } @@ -1140,7 +1141,7 @@ suite('PromptsService', () => { }, handOffs: undefined, argumentHint: undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent2.md'), source: { storage: PromptsStorage.local } @@ -1195,7 +1196,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/demonstrate.md'), source: { storage: PromptsStorage.local } @@ -1266,7 +1267,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/restricted-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1284,7 +1285,7 @@ suite('PromptsService', () => { argumentHint: undefined, tools: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/no-access-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1302,7 +1303,7 @@ suite('PromptsService', () => { argumentHint: undefined, tools: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/full-access-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -3101,22 +3102,22 @@ suite('PromptsService', () => { }); }); - suite('getPromptSlashCommands - userInvokable filtering', () => { + suite('getPromptSlashCommands - userInvocable filtering', () => { teardown(() => { sinon.restore(); }); - test('should return correct userInvokable value for skills with user-invokable: false', async () => { + test('should return correct userInvocable value for skills with user-invocable: false', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - const rootFolderName = 'user-invokable-false'; + const rootFolderName = 'user-invocable-false'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Create a skill with user-invokable: false (should be hidden from / menu) + // Create a skill with user-invocable: false (should be hidden from / menu) await mockFiles(fileService, [ { path: `${rootFolder}/.github/skills/hidden-skill/SKILL.md`, @@ -3124,7 +3125,7 @@ suite('PromptsService', () => { '---', 'name: "hidden-skill"', 'description: "A skill hidden from the / menu"', - 'user-invokable: false', + 'user-invocable: false', '---', 'Hidden skill content', ], @@ -3135,27 +3136,27 @@ suite('PromptsService', () => { const hiddenSkillCommand = slashCommands.find(cmd => cmd.name === 'hidden-skill'); assert.ok(hiddenSkillCommand, 'Should find hidden skill in slash commands'); - assert.strictEqual(hiddenSkillCommand.parsedPromptFile?.header?.userInvokable, false, - 'Should have userInvokable=false in parsed header'); + assert.strictEqual(hiddenSkillCommand.parsedPromptFile?.header?.userInvocable, false, + 'Should have userInvocable=false in parsed header'); // Verify the filtering logic would correctly exclude this skill - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); const hiddenSkillInFiltered = filteredCommands.find(cmd => cmd.name === 'hidden-skill'); assert.strictEqual(hiddenSkillInFiltered, undefined, - 'Hidden skill should be filtered out when applying userInvokable filter'); + 'Hidden skill should be filtered out when applying userInvocable filter'); }); - test('should return correct userInvokable value for skills with user-invokable: true', async () => { + test('should return correct userInvocable value for skills with user-invocable: true', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - const rootFolderName = 'user-invokable-true'; + const rootFolderName = 'user-invocable-true'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Create a skill with explicit user-invokable: true + // Create a skill with explicit user-invocable: true await mockFiles(fileService, [ { path: `${rootFolder}/.github/skills/visible-skill/SKILL.md`, @@ -3163,7 +3164,7 @@ suite('PromptsService', () => { '---', 'name: "visible-skill"', 'description: "A skill visible in the / menu"', - 'user-invokable: true', + 'user-invocable: true', '---', 'Visible skill content', ], @@ -3174,34 +3175,34 @@ suite('PromptsService', () => { const visibleSkillCommand = slashCommands.find(cmd => cmd.name === 'visible-skill'); assert.ok(visibleSkillCommand, 'Should find visible skill in slash commands'); - assert.strictEqual(visibleSkillCommand.parsedPromptFile?.header?.userInvokable, true, - 'Should have userInvokable=true in parsed header'); + assert.strictEqual(visibleSkillCommand.parsedPromptFile?.header?.userInvocable, true, + 'Should have userInvocable=true in parsed header'); // Verify the filtering logic would correctly include this skill - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); const visibleSkillInFiltered = filteredCommands.find(cmd => cmd.name === 'visible-skill'); assert.ok(visibleSkillInFiltered, - 'Visible skill should be included when applying userInvokable filter'); + 'Visible skill should be included when applying userInvocable filter'); }); - test('should default to true for skills without user-invokable attribute', async () => { + test('should default to true for skills without user-invocable attribute', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - const rootFolderName = 'user-invokable-undefined'; + const rootFolderName = 'user-invocable-undefined'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Create a skill without user-invokable attribute (should default to true) + // Create a skill without user-invocable attribute (should default to true) await mockFiles(fileService, [ { path: `${rootFolder}/.github/skills/default-skill/SKILL.md`, contents: [ '---', 'name: "default-skill"', - 'description: "A skill without explicit user-invokable"', + 'description: "A skill without explicit user-invocable"', '---', 'Default skill content', ], @@ -3212,24 +3213,24 @@ suite('PromptsService', () => { const defaultSkillCommand = slashCommands.find(cmd => cmd.name === 'default-skill'); assert.ok(defaultSkillCommand, 'Should find default skill in slash commands'); - assert.strictEqual(defaultSkillCommand.parsedPromptFile?.header?.userInvokable, undefined, - 'Should have userInvokable=undefined when attribute is not specified'); + assert.strictEqual(defaultSkillCommand.parsedPromptFile?.header?.userInvocable, undefined, + 'Should have userInvocable=undefined when attribute is not specified'); // Verify the filtering logic would correctly include this skill (undefined !== false is true) - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); const defaultSkillInFiltered = filteredCommands.find(cmd => cmd.name === 'default-skill'); assert.ok(defaultSkillInFiltered, - 'Skill without user-invokable attribute should be included when applying userInvokable filter'); + 'Skill without user-invocable attribute should be included when applying userInvocable filter'); }); - test('should handle prompts with user-invokable: false', async () => { - const rootFolderName = 'prompt-user-invokable-false'; + test('should handle prompts with user-invocable: false', async () => { + const rootFolderName = 'prompt-user-invocable-false'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Create a prompt with user-invokable: false + // Create a prompt with user-invocable: false await mockFiles(fileService, [ { path: `${rootFolder}/.github/prompts/hidden-prompt.prompt.md`, @@ -3237,7 +3238,7 @@ suite('PromptsService', () => { '---', 'name: "hidden-prompt"', 'description: "A prompt hidden from the / menu"', - 'user-invokable: false', + 'user-invocable: false', '---', 'Hidden prompt content', ], @@ -3248,27 +3249,27 @@ suite('PromptsService', () => { const hiddenPromptCommand = slashCommands.find(cmd => cmd.name === 'hidden-prompt'); assert.ok(hiddenPromptCommand, 'Should find hidden prompt in slash commands'); - assert.strictEqual(hiddenPromptCommand.parsedPromptFile?.header?.userInvokable, false, - 'Should have userInvokable=false in parsed header'); + assert.strictEqual(hiddenPromptCommand.parsedPromptFile?.header?.userInvocable, false, + 'Should have userInvocable=false in parsed header'); // Verify the filtering logic would correctly exclude this prompt - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); const hiddenPromptInFiltered = filteredCommands.find(cmd => cmd.name === 'hidden-prompt'); assert.strictEqual(hiddenPromptInFiltered, undefined, - 'Hidden prompt should be filtered out when applying userInvokable filter'); + 'Hidden prompt should be filtered out when applying userInvocable filter'); }); - test('should correctly filter mixed user-invokable values', async () => { + test('should correctly filter mixed user-invocable values', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - const rootFolderName = 'mixed-user-invokable'; + const rootFolderName = 'mixed-user-invocable'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Create a mix of skills and prompts with different user-invokable values + // Create a mix of skills and prompts with different user-invocable values await mockFiles(fileService, [ { path: `${rootFolder}/.github/prompts/visible-prompt.prompt.md`, @@ -3286,7 +3287,7 @@ suite('PromptsService', () => { '---', 'name: "hidden-prompt"', 'description: "A hidden prompt"', - 'user-invokable: false', + 'user-invocable: false', '---', 'Hidden prompt content', ], @@ -3297,7 +3298,7 @@ suite('PromptsService', () => { '---', 'name: "visible-skill"', 'description: "A visible skill"', - 'user-invokable: true', + 'user-invocable: true', '---', 'Visible skill content', ], @@ -3308,7 +3309,7 @@ suite('PromptsService', () => { '---', 'name: "hidden-skill"', 'description: "A hidden skill"', - 'user-invokable: false', + 'user-invocable: false', '---', 'Hidden skill content', ], @@ -3321,7 +3322,7 @@ suite('PromptsService', () => { assert.strictEqual(slashCommands.length, 4, 'Should find all 4 commands'); // Apply the same filtering logic as chatInputCompletions.ts - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); assert.strictEqual(filteredCommands.length, 2, 'Should have 2 commands after filtering'); assert.ok(filteredCommands.find(c => c.name === 'visible-prompt'), 'visible-prompt should be included'); @@ -3361,13 +3362,66 @@ suite('PromptsService', () => { 'Should have undefined header'); // Verify the filtering logic handles missing header correctly - // parsedPromptFile?.header?.userInvokable !== false + // parsedPromptFile?.header?.userInvocable !== false // When header is undefined: undefined !== false is true, so skill is included - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); const noHeaderSkillInFiltered = filteredCommands.find(cmd => cmd.promptPath.uri.path.includes('no-header-skill')); assert.ok(noHeaderSkillInFiltered, - 'Skill without header should be included when applying userInvokable filter (defaults to true)'); + 'Skill without header should be included when applying userInvocable filter (defaults to true)'); + }); + }); + + suite('hooks', () => { + test('multi-root workspace resolves cwd to per-hook-file workspace folder', async function () { + const folder1Uri = URI.file('/workspace-a'); + const folder2Uri = URI.file('/workspace-b'); + + workspaceContextService.setWorkspace(testWorkspace(folder1Uri, folder2Uri)); + testConfigService.setUserConfiguration(PromptsConfig.USE_CHAT_HOOKS, true); + testConfigService.setUserConfiguration(PromptsConfig.HOOKS_LOCATION_KEY, { [HOOKS_SOURCE_FOLDER]: true }); + + await mockFiles(fileService, [ + { + path: '/workspace-a/.github/hooks/my-hook.json', + contents: [ + JSON.stringify({ + hooks: { + [HookType.PreToolUse]: [ + { type: 'command', command: 'echo folder-a' }, + ], + }, + }), + ], + }, + { + path: '/workspace-b/.github/hooks/my-hook.json', + contents: [ + JSON.stringify({ + hooks: { + [HookType.PreToolUse]: [ + { type: 'command', command: 'echo folder-b' }, + ], + }, + }), + ], + }, + ]); + + const result = await service.getHooks(CancellationToken.None); + assert.ok(result, 'Expected hooks result'); + + const preToolUseHooks = result.hooks[HookType.PreToolUse]; + assert.ok(preToolUseHooks, 'Expected PreToolUse hooks'); + assert.strictEqual(preToolUseHooks.length, 2, 'Expected two PreToolUse hooks'); + + const hookA = preToolUseHooks.find(h => h.command === 'echo folder-a'); + const hookB = preToolUseHooks.find(h => h.command === 'echo folder-b'); + assert.ok(hookA, 'Expected hook from folder-a'); + assert.ok(hookB, 'Expected hook from folder-b'); + + assert.strictEqual(hookA.cwd?.path, folder1Uri.path, 'Hook from folder-a should have cwd pointing to workspace-a'); + assert.strictEqual(hookB.cwd?.path, folder2Uri.path, 'Hook from folder-b should have cwd pointing to workspace-b'); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_subcommand_still_takes_priority_with_supportsPromptAttachments.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_subcommand_still_takes_priority_with_supportsPromptAttachments.0.snap new file mode 100644 index 0000000000000..932d8aaea2dc3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_subcommand_still_takes_priority_with_supportsPromptAttachments.0.snap @@ -0,0 +1,85 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + agent: { + id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + extensionVersion: undefined, + publisherDisplayName: "", + extensionDisplayName: "", + extensionPublisherId: "", + locations: [ "panel" ], + modes: [ "ask" ], + metadata: { }, + slashCommands: [ + { + name: "subCommand", + description: "" + } + ], + disambiguation: [ ] + }, + kind: "agent" + }, + { + range: { + start: 6, + endExclusive: 7 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 8 + }, + text: " ", + kind: "text" + }, + { + range: { + start: 7, + endExclusive: 18 + }, + editorRange: { + startLineNumber: 1, + startColumn: 8, + endLineNumber: 1, + endColumn: 19 + }, + command: { + name: "subCommand", + description: "" + }, + kind: "subcommand" + }, + { + range: { + start: 18, + endExclusive: 31 + }, + editorRange: { + startLineNumber: 1, + startColumn: 19, + endLineNumber: 1, + endColumn: 32 + }, + text: " do something", + kind: "text" + } + ], + text: "@agent /subCommand do something" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_and_supportsPromptAttachments.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_and_supportsPromptAttachments.0.snap new file mode 100644 index 0000000000000..2ae3f526313cb --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_and_supportsPromptAttachments.0.snap @@ -0,0 +1,82 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + agent: { + id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + extensionVersion: undefined, + publisherDisplayName: "", + extensionDisplayName: "", + extensionPublisherId: "", + locations: [ "panel" ], + modes: [ "ask" ], + metadata: { }, + slashCommands: [ + { + name: "subCommand", + description: "" + } + ], + disambiguation: [ ] + }, + kind: "agent" + }, + { + range: { + start: 6, + endExclusive: 7 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 8 + }, + text: " ", + kind: "text" + }, + { + range: { + start: 7, + endExclusive: 16 + }, + editorRange: { + startLineNumber: 1, + startColumn: 8, + endLineNumber: 1, + endColumn: 17 + }, + name: "myPrompt", + kind: "prompt" + }, + { + range: { + start: 16, + endExclusive: 29 + }, + editorRange: { + startLineNumber: 1, + startColumn: 17, + endLineNumber: 1, + endColumn: 30 + }, + text: " do something", + kind: "text" + } + ], + text: "@agent /myPrompt do something" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_but_no_supportsPromptAttachments.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_but_no_supportsPromptAttachments.0.snap new file mode 100644 index 0000000000000..736584ac2f41f --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_but_no_supportsPromptAttachments.0.snap @@ -0,0 +1,54 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + agent: { + id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + extensionVersion: undefined, + publisherDisplayName: "", + extensionDisplayName: "", + extensionPublisherId: "", + locations: [ "panel" ], + modes: [ "ask" ], + metadata: { }, + slashCommands: [ + { + name: "subCommand", + description: "" + } + ], + disambiguation: [ ] + }, + kind: "agent" + }, + { + range: { + start: 6, + endExclusive: 29 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 30 + }, + text: " /myPrompt do something", + kind: "text" + } + ], + text: "@agent /myPrompt do something" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_slash_command_with_agent_and_supportsPromptAttachments.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_slash_command_with_agent_and_supportsPromptAttachments.0.snap new file mode 100644 index 0000000000000..b7e770cf3f7c9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_slash_command_with_agent_and_supportsPromptAttachments.0.snap @@ -0,0 +1,82 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + agent: { + id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + extensionVersion: undefined, + publisherDisplayName: "", + extensionDisplayName: "", + extensionPublisherId: "", + locations: [ "panel" ], + modes: [ "ask" ], + metadata: { }, + slashCommands: [ + { + name: "subCommand", + description: "" + } + ], + disambiguation: [ ] + }, + kind: "agent" + }, + { + range: { + start: 6, + endExclusive: 7 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 8 + }, + text: " ", + kind: "text" + }, + { + range: { + start: 7, + endExclusive: 11 + }, + editorRange: { + startLineNumber: 1, + startColumn: 8, + endLineNumber: 1, + endColumn: 12 + }, + slashCommand: { command: "fix" }, + kind: "slash" + }, + { + range: { + start: 11, + endExclusive: 16 + }, + editorRange: { + startLineNumber: 1, + startColumn: 12, + endLineNumber: 1, + endColumn: 17 + }, + text: " this", + kind: "text" + } + ], + text: "@agent /fix this" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts index 1ec95eed15f7d..96e754d368887 100644 --- a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts @@ -347,4 +347,97 @@ suite('ChatRequestParser', () => { const result = parser.parseChatRequest(testSessionUri, '@agent Please \ndo /subCommand with #selection\nand #debugConsole'); await assertSnapshot(result); }); + + test('prompt slash command with agent and supportsPromptAttachments', async () => { + const agentsService = mockObject()({}); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatAgentService, agentsService as any); + + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + const promptSlashCommandService = mockObject()({}); + promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { + return !!command.match(/^[\w_\-\.]+$/); + }); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IPromptsService, promptSlashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequest(testSessionUri, '@agent /myPrompt do something', undefined, { + attachmentCapabilities: { supportsPromptAttachments: true } + }); + await assertSnapshot(result); + }); + + test('prompt slash command with agent but no supportsPromptAttachments', async () => { + const agentsService = mockObject()({}); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatAgentService, agentsService as any); + + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + const promptSlashCommandService = mockObject()({}); + promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { + return !!command.match(/^[\w_\-\.]+$/); + }); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IPromptsService, promptSlashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequest(testSessionUri, '@agent /myPrompt do something', undefined, { + attachmentCapabilities: { supportsPromptAttachments: false } + }); + await assertSnapshot(result); + }); + + test('agent subcommand still takes priority with supportsPromptAttachments', async () => { + const agentsService = mockObject()({}); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatAgentService, agentsService as any); + + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + const promptSlashCommandService = mockObject()({}); + promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { + return !!command.match(/^[\w_\-\.]+$/); + }); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IPromptsService, promptSlashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequest(testSessionUri, '@agent /subCommand do something', undefined, { + attachmentCapabilities: { supportsPromptAttachments: true } + }); + await assertSnapshot(result); + }); + + test('slash command with agent and supportsPromptAttachments', async () => { + const agentsService = mockObject()({}); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatAgentService, agentsService as any); + + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([{ command: 'fix' }]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequest(testSessionUri, '@agent /fix this', undefined, { + attachmentCapabilities: { supportsPromptAttachments: true } + }); + await assertSnapshot(result); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 32809f2ebdd94..df306a3ef3ad0 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -56,7 +56,7 @@ suite('RunSubagentTool', () => { agentInstructions: { content: 'Custom agent body', toolReferences: [] }, source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([customMode]); @@ -276,7 +276,7 @@ suite('RunSubagentTool', () => { agentInstructions: { content: 'test', toolReferences: [] }, source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; } diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts b/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts index 3a54c6236d8df..00bc6005901b1 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts @@ -152,7 +152,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget @IDialogService private readonly dialogService: IDialogService ) { super(editor, { keepEditorSelection: true, isAccessible: true, showArrow: !!_commentThread.range }); - this._contextKeyService = contextKeyService.createScoped(this.domNode); + this._contextKeyService = this._globalToDispose.add(contextKeyService.createScoped(this.domNode)); this._scopedInstantiationService = this._globalToDispose.add(instantiationService.createChild(new ServiceCollection( [IContextKeyService, this._contextKeyService] diff --git a/src/vs/workbench/contrib/debug/browser/media/repl.css b/src/vs/workbench/contrib/debug/browser/media/repl.css index 59696ad50041a..6aad626c79f0d 100644 --- a/src/vs/workbench/contrib/debug/browser/media/repl.css +++ b/src/vs/workbench/contrib/debug/browser/media/repl.css @@ -11,9 +11,13 @@ overflow: hidden; } -.monaco-workbench .repl .repl-tree .monaco-tl-contents { +.monaco-workbench .repl .repl-tree .monaco-tl-contents, +.monaco-workbench .repl .repl-tree .monaco-tl-twistie { user-select: text; -webkit-user-select: text; +} + +.monaco-workbench .repl .repl-tree .monaco-tl-contents { white-space: pre; } @@ -44,7 +48,7 @@ } .monaco-workbench .repl .repl-tree .monaco-tl-twistie.collapsible + .monaco-tl-contents, -.monaco-workbench .repl .repl-tree .monaco-tl-twistie { +.monaco-workbench .repl .repl-tree .monaco-tl-twistie.collapsible { cursor: pointer; } @@ -69,6 +73,7 @@ .monaco-workbench .repl .repl-tree .monaco-tl-contents .arrow { position:absolute; left: 2px; + pointer-events: none; } .monaco-workbench .repl .repl-tree .output.expression.value-and-source .source, diff --git a/src/vs/workbench/contrib/debug/common/debugger.ts b/src/vs/workbench/contrib/debug/common/debugger.ts index c734119ad85a5..2b3e423f8223c 100644 --- a/src/vs/workbench/contrib/debug/common/debugger.ts +++ b/src/vs/workbench/contrib/debug/common/debugger.ts @@ -118,7 +118,7 @@ export class Debugger implements IDebugger, IDebuggerMetadata { throw new Error(nls.localize('cannot.find.da', "Cannot find debug adapter for type '{0}'.", this.type)); } - async substituteVariables(folder: IWorkspaceFolder | undefined, config: IConfig): Promise { + async substituteVariables(folder: IWorkspaceFolder | undefined, config: IConfig): Promise { const substitutedConfig = await this.adapterManager.substituteVariables(this.type, folder, config); return await this.configurationResolverService.resolveWithInteractionReplace(folder, substitutedConfig, 'launch', this.variables, substitutedConfig.__configurationTarget); } diff --git a/src/vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts b/src/vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts index ce109ad4f2941..263cc326515ff 100644 --- a/src/vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts +++ b/src/vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts @@ -48,12 +48,12 @@ export class EditSessionsWorkbenchService extends Disposable implements IEditSes return this.existingSessionId !== undefined; } - private _didSignIn = new Emitter(); + private _didSignIn = this._register(new Emitter()); get onDidSignIn() { return this._didSignIn.event; } - private _didSignOut = new Emitter(); + private _didSignOut = this._register(new Emitter()); get onDidSignOut() { return this._didSignOut.event; } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts index dd9ede29d6875..2842ef3b7d2bd 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts @@ -94,8 +94,8 @@ import { fromNow } from '../../../../base/common/date.js'; class NavBar extends Disposable { - private _onChange = this._register(new Emitter<{ id: string | null; focus: boolean }>()); - get onChange(): Event<{ id: string | null; focus: boolean }> { return this._onChange.event; } + private readonly _onChange = this._register(new Emitter<{ id: string | null; focus: boolean }>()); + readonly onChange = this._onChange.event; private _currentId: string | null = null; get currentId(): string | null { return this._currentId; } @@ -142,6 +142,11 @@ class NavBar extends Disposable { this._onChange.fire({ id, focus: !!focus }); this.actions.forEach(a => a.checked = a.id === id); } + + override dispose(): void { + this.clear(); + super.dispose(); + } } interface ILayoutParticipant { diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 6956d7918b3b1..1d28ae56418f0 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -265,6 +265,15 @@ Registry.as(ConfigurationExtensions.Configuration) description: localize('extensionsInQuickAccess', "When enabled, extensions can be searched for via Quick Access and report issues from there."), default: true }, + 'extensions.allowOpenInModalEditor': { + type: 'boolean', + description: localize('extensions.allowOpenInModalEditor', "Controls whether extensions and MCP servers open in a modal editor overlay."), + default: product.quality !== 'stable', // TODO@bpasero figure out the default for stable and retire this setting + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, [VerifyExtensionSignatureConfigKey]: { type: 'boolean', description: localize('extensions.verifySignature', "When enabled, extensions are verified to be signed before getting installed."), diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts index 282f5e496dde9..418cb12e5f66c 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts @@ -5,7 +5,7 @@ import * as dom from '../../../../base/browser/dom.js'; import { localize } from '../../../../nls.js'; -import { IDisposable, dispose, Disposable, DisposableStore, toDisposable, isDisposable } from '../../../../base/common/lifecycle.js'; +import { IDisposable, dispose, Disposable, DisposableStore, toDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { Action, ActionRunner, IAction, Separator } from '../../../../base/common/actions.js'; import { IExtensionsWorkbenchService, IExtension, IExtensionsViewState } from '../common/extensions.js'; import { Event } from '../../../../base/common/event.js'; @@ -16,7 +16,8 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { registerThemingParticipant, IColorTheme, ICssStyleCollector } from '../../../../platform/theme/common/themeService.js'; import { IAsyncDataSource, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js'; import { isNonEmptyArray } from '../../../../base/common/arrays.js'; import { Delegate, Renderer } from './extensionsList.js'; import { listFocusForeground, listFocusBackground, foreground, editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; @@ -35,6 +36,8 @@ import { INotificationService } from '../../../../platform/notification/common/n import { getLocationBasedViewColors } from '../../../browser/parts/views/viewPane.js'; import { DelayedPagedModel, IPagedModel } from '../../../../base/common/paging.js'; import { ExtensionIconWidget } from './extensionsWidgets.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; function getAriaLabelForExtension(extension: IExtension | null): string { if (!extension) { @@ -51,6 +54,8 @@ export class ExtensionsList extends Disposable { readonly list: WorkbenchPagedList; private readonly contextMenuActionRunner = this._register(new ActionRunner()); + private readonly modalNavigationDisposable = this._register(new MutableDisposable()); + constructor( parent: HTMLElement, viewId: string, @@ -63,6 +68,7 @@ export class ExtensionsList extends Disposable { @IContextMenuService private readonly contextMenuService: IContextMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILogService private readonly logService: ILogService ) { super(); this._register(this.contextMenuActionRunner.onDidRun(({ error }) => error && notificationService.error(error))); @@ -115,7 +121,17 @@ export class ExtensionsList extends Disposable { private openExtension(extension: IExtension, options: { sideByside?: boolean; preserveFocus?: boolean; pinned?: boolean }): void { extension = this.extensionsWorkbenchService.local.filter(e => areSameExtensions(e.identifier, extension.identifier))[0] || extension; - this.extensionsWorkbenchService.open(extension, options); + this.extensionsWorkbenchService.open(extension, { + ...options, + modal: options.sideByside ? undefined : buildModalNavigationForPagedList( + extension, + () => this.list.model, + (extA, extB) => areSameExtensions(extA.identifier, extB.identifier), + (ext, modal) => this.extensionsWorkbenchService.open(ext, { pinned: false, modal }), + this.modalNavigationDisposable, + this.logService + ), + }); } private async onContextMenu(e: IListContextMenuEvent): Promise { @@ -453,6 +469,80 @@ export async function getExtensions(extensions: string[], extensionsWorkbenchSer return result; } +/** + * Builds modal navigation options for navigating items in a paged list model. + */ +export function buildModalNavigationForPagedList( + openedItem: T, + getModel: () => IPagedModel | undefined, + isSame: (a: T, b: T) => boolean, + openItem: (item: T, modal: IModalEditorPartOptions) => void, + cancellationStore: MutableDisposable, + logService: ILogService +): IModalEditorPartOptions | undefined { + const model = getModel(); + if (!model) { + return undefined; + } + + const total = model.length; + if (total <= 1) { + return undefined; + } + + // Find the index of the opened item in the list + let current = -1; + for (let i = 0; i < total; i++) { + if (model.isResolved(i) && isSame(model.get(i), openedItem)) { + current = i; + break; + } + } + + if (current === -1) { + return undefined; + } + + const openAtIndex = (index: number, item: T) => { + const currentTotal = getModel()?.length ?? 0; + openItem(item, { navigation: { total: currentTotal, current: index, navigate } }); + }; + + let cts: CancellationTokenSource | undefined; + const navigate = (index: number) => { + cts?.cancel(); + cts = cancellationStore.value = new CancellationTokenSource(); + const token = cts.token; + + const currentModel = getModel(); + if (!currentModel || index < 0 || index >= currentModel.length) { + return; + } + + // Fast path: item already resolved + if (currentModel.isResolved(index)) { + openAtIndex(index, currentModel.get(index)); + } + + // Slow path: resolve the item first + else { + currentModel.resolve(index, token).then(item => { + if (token.isCancellationRequested) { + return; + } + + openAtIndex(index, item); + }, error => { + if (!isCancellationError(error)) { + logService.error(`Error while resolving item at index ${index} for modal navigation`, error); + } + }); + } + }; + + return { navigation: { total, current, navigate } }; +} + registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { const focusBackground = theme.getColor(listFocusBackground); if (focusBackground) { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 3773741743fab..6735f7f09fb30 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -35,7 +35,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHostService } from '../../../services/host/browser/host.js'; import { URI } from '../../../../base/common/uri.js'; import { IExtension, ExtensionState, IExtensionsWorkbenchService, AutoUpdateConfigurationKey, AutoCheckUpdatesConfigurationKey, HasOutdatedExtensionsContext, AutoUpdateConfigurationValue, InstallExtensionOptions, ExtensionRuntimeState, ExtensionRuntimeActionType, AutoRestartConfigurationKey, VIEWLET_ID, IExtensionsViewPaneContainer, IExtensionsNotification } from '../common/extensions.js'; -import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; +import { ACTIVE_GROUP, IEditorService, MODAL_GROUP, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { IURLService, IURLHandler, IOpenURLOptions } from '../../../../platform/url/common/url.js'; import { ExtensionsInput, IExtensionEditorOptions } from '../common/extensionsInput.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -1577,7 +1577,8 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension if (!extension) { throw new Error(`Extension not found. ${extension}`); } - await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, options?.sideByside ? SIDE_GROUP : ACTIVE_GROUP); + const useModal = this.configurationService.getValue('extensions.allowOpenInModalEditor'); + await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, options?.sideByside ? SIDE_GROUP : useModal ? MODAL_GROUP : ACTIVE_GROUP); } async openSearch(searchValue: string, preserveFoucs?: boolean): Promise { diff --git a/src/vs/workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.ts b/src/vs/workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.ts index 1edaaa8ada121..832c960a20715 100644 --- a/src/vs/workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.ts +++ b/src/vs/workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.ts @@ -5,6 +5,7 @@ import * as nls from '../../../../nls.js'; import * as paths from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; import { DEFAULT_TERMINAL_OSX, IExternalTerminalSettings } from '../../../../platform/externalTerminal/common/externalTerminal.js'; import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; @@ -19,6 +20,9 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { TerminalContextKeys } from '../../terminal/common/terminalContextKey.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; const OPEN_NATIVE_CONSOLE_COMMAND_ID = 'workbench.action.terminal.openNativeConsole'; KeybindingsRegistry.registerCommandAndKeybindingRule({ @@ -32,9 +36,30 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ const terminalService = accessor.get(IExternalTerminalService); const configurationService = accessor.get(IConfigurationService); const remoteAuthorityResolverService = accessor.get(IRemoteAuthorityResolverService); - const root = historyService.getLastActiveWorkspaceRoot(); + const workspaceContextService = accessor.get(IWorkspaceContextService); + const quickInputService = accessor.get(IQuickInputService); + const labelService = accessor.get(ILabelService); const config = configurationService.getValue('terminal.external'); + // When there are multiple workspace folders, let the user pick one + const folders = workspaceContextService.getWorkspace().folders; + let root: URI | undefined; + if (folders.length > 1) { + const folderPicks: IQuickPickItem[] = folders.map(folder => ({ + label: folder.name, + description: labelService.getUriLabel(folder.uri, { relative: true }) + })); + const pick = await quickInputService.pick(folderPicks, { + placeHolder: nls.localize('selectWorkspace', "Select workspace folder") + }); + if (!pick) { + return; + } + root = folders[folderPicks.indexOf(pick)].uri; + } else { + root = historyService.getLastActiveWorkspaceRoot(); + } + // It's a local workspace, open the root if (root?.scheme === Schemas.file) { terminalService.openTerminal(config, root.fsPath); diff --git a/src/vs/workbench/contrib/externalTerminal/test/electron-browser/externalTerminal.contribution.test.ts b/src/vs/workbench/contrib/externalTerminal/test/electron-browser/externalTerminal.contribution.test.ts new file mode 100644 index 0000000000000..243b1268dc17c --- /dev/null +++ b/src/vs/workbench/contrib/externalTerminal/test/electron-browser/externalTerminal.contribution.test.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IHistoryService } from '../../../../services/history/common/history.js'; +import { IExternalTerminalService } from '../../../../../platform/externalTerminal/electron-browser/externalTerminalService.js'; +import { IExternalTerminalSettings } from '../../../../../platform/externalTerminal/common/externalTerminal.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IRemoteAuthorityResolverService } from '../../../../../platform/remote/common/remoteAuthorityResolver.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import '../../electron-browser/externalTerminal.contribution.js'; + +suite('ExternalTerminal contribution', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let openTerminalCalls: { cwd: string | undefined }[]; + let pickCalls: IQuickPickItem[][]; + + function createWorkspaceFolder(uri: URI, name: string, index: number): IWorkspaceFolder { + return { + uri, + name, + index, + toResource: (relativePath: string) => URI.joinPath(uri, relativePath) + }; + } + + function setupServices(options: { + folders: IWorkspaceFolder[]; + lastActiveRoot?: URI; + lastActiveFile?: URI; + pickedFolder?: IWorkspaceFolder | undefined; + }) { + instantiationService = store.add(new TestInstantiationService()); + + openTerminalCalls = []; + pickCalls = []; + + instantiationService.stub(IHistoryService, new class extends mock() { + override getLastActiveWorkspaceRoot() { + return options.lastActiveRoot; + } + override getLastActiveFile(_schemeFilter: string) { + return options.lastActiveFile; + } + }); + + instantiationService.stub(IExternalTerminalService, new class extends mock() { + override async openTerminal(_config: IExternalTerminalSettings, cwd: string | undefined) { + openTerminalCalls.push({ cwd }); + } + }); + + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + terminal: { external: { linuxExec: 'xterm', osxExec: 'Terminal.app', windowsExec: 'cmd' } } + })); + + instantiationService.stub(IRemoteAuthorityResolverService, new class extends mock() { + }); + + instantiationService.stub(IWorkspaceContextService, new class extends mock() { + override getWorkspace(): IWorkspace { + return { + id: 'test-workspace', + folders: options.folders, + }; + } + }); + + instantiationService.stub(IQuickInputService, new class extends mock() { + override async pick(picks: T[]): Promise { + pickCalls.push(picks); + if (options.pickedFolder) { + const index = options.folders.indexOf(options.pickedFolder); + return picks[index]; + } + return undefined; + } + }); + + instantiationService.stub(ILabelService, new class extends mock() { + override getUriLabel(uri: URI) { + return uri.fsPath; + } + }); + } + + test('single folder - uses last active workspace root', async () => { + const folderUri = URI.file('/workspace/project'); + const folder = createWorkspaceFolder(folderUri, 'project', 0); + + setupServices({ + folders: [folder], + lastActiveRoot: folderUri, + }); + + const handler = CommandsRegistry.getCommand('workbench.action.terminal.openNativeConsole')!.handler; + await instantiationService.invokeFunction(handler); + + assert.deepStrictEqual(openTerminalCalls, [{ cwd: folderUri.fsPath }]); + assert.deepStrictEqual(pickCalls, []); + }); + + test('multiple folders - shows picker and opens selected folder', async () => { + const folder1Uri = URI.file('/workspace/project1'); + const folder2Uri = URI.file('/workspace/project2'); + const folder1 = createWorkspaceFolder(folder1Uri, 'project1', 0); + const folder2 = createWorkspaceFolder(folder2Uri, 'project2', 1); + + setupServices({ + folders: [folder1, folder2], + pickedFolder: folder2, + }); + + const handler = CommandsRegistry.getCommand('workbench.action.terminal.openNativeConsole')!.handler; + await instantiationService.invokeFunction(handler); + + assert.strictEqual(pickCalls.length, 1); + assert.deepStrictEqual(openTerminalCalls, [{ cwd: folder2Uri.fsPath }]); + }); + + test('multiple folders - picker cancelled does not open terminal', async () => { + const folder1Uri = URI.file('/workspace/project1'); + const folder2Uri = URI.file('/workspace/project2'); + const folder1 = createWorkspaceFolder(folder1Uri, 'project1', 0); + const folder2 = createWorkspaceFolder(folder2Uri, 'project2', 1); + + setupServices({ + folders: [folder1, folder2], + pickedFolder: undefined, + }); + + const handler = CommandsRegistry.getCommand('workbench.action.terminal.openNativeConsole')!.handler; + await instantiationService.invokeFunction(handler); + + assert.strictEqual(pickCalls.length, 1); + assert.deepStrictEqual(openTerminalCalls, []); + }); + + test('no workspace root - falls back to active file directory', async () => { + const fileUri = URI.file('/workspace/project/src/file.ts'); + const expectedDir = URI.file('/workspace/project/src').fsPath; + + setupServices({ + folders: [], + lastActiveRoot: undefined, + lastActiveFile: fileUri, + }); + + const handler = CommandsRegistry.getCommand('workbench.action.terminal.openNativeConsole')!.handler; + await instantiationService.invokeFunction(handler); + + assert.deepStrictEqual(openTerminalCalls, [{ cwd: expectedDir }]); + }); + + test('no workspace, no file - opens terminal without cwd', async () => { + setupServices({ + folders: [], + lastActiveRoot: undefined, + lastActiveFile: undefined, + }); + + const handler = CommandsRegistry.getCommand('workbench.action.terminal.openNativeConsole')!.handler; + await instantiationService.invokeFunction(handler); + + assert.deepStrictEqual(openTerminalCalls, [{ cwd: undefined }]); + }); +}); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index 76f69758c245a..c3bb43bc90b68 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -6,10 +6,10 @@ import './inlineChatDefaultModel.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; -import { IMenuItem, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IMenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { InlineChatController } from './inlineChatController.js'; import * as InlineChatActions from './inlineChatActions.js'; -import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_V1_ENABLED, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; +import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_V1_ENABLED, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, MENU_INLINE_CHAT_WIDGET_STATUS, ACTION_START } from '../common/inlineChat.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; @@ -23,6 +23,8 @@ import { localize } from '../../../../nls.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { InlineChatAccessibilityHelp } from './inlineChatAccessibilityHelp.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { Codicon } from '../../../../base/common/codicons.js'; registerEditorContribution(InlineChatController.ID, InlineChatController, EditorContributionInstantiation.Eager); // EAGER because of notebook dispose/create of editors @@ -84,6 +86,19 @@ const cancelActionMenuItem: IMenuItem = { MenuRegistry.appendMenuItem(MENU_INLINE_CHAT_WIDGET_STATUS, cancelActionMenuItem); +// --- InlineChatEditorAffordance menu --- + +MenuRegistry.appendMenuItem(MenuId.InlineChatEditorAffordance, { + group: '0_chat', + order: 1, + command: { + id: ACTION_START, + title: localize('editCode', "Edit Code"), + icon: Codicon.sparkle, + }, + when: EditorContextKeys.hasNonEmptySelection, +}); + // --- actions --- registerAction2(InlineChatActions.StartSessionAction); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 5dd9eab5dfa98..f7dc57db29273 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -79,11 +79,6 @@ export class StartSessionAction extends Action2 { id: MenuId.ChatEditorInlineGutter, group: '1_chat', order: 1, - }, { - id: MenuId.InlineChatEditorAffordance, - group: '1_chat', - order: 1, - when: EditorContextKeys.hasNonEmptySelection }] }); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts index 5d53da9301b8b..58ecc761706fc 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts @@ -20,28 +20,51 @@ import { Selection, SelectionDirection } from '../../../../editor/common/core/se import { assertType } from '../../../../base/common/types.js'; import { CursorChangeReason } from '../../../../editor/common/cursorEvents.js'; import { IInlineChatSessionService } from './inlineChatSessionService.js'; +import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; + +type InlineChatAffordanceEvent = { + mode: string; + id: string; +}; + +type InlineChatAffordanceClassification = { + mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The affordance mode: gutter or editor.' }; + id: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'UUID to correlate shown and selected events.' }; + owner: 'jrieken'; + comment: 'Tracks when the inline chat affordance is shown or selected.'; +}; export class InlineChatAffordance extends Disposable { - private _menuData = observableValue<{ rect: DOMRect; above: boolean; lineNumber: number } | undefined>(this, undefined); + readonly #editor: ICodeEditor; + readonly #inputWidget: InlineChatInputWidget; + readonly #instantiationService: IInstantiationService; + readonly #menuData = observableValue<{ rect: DOMRect; above: boolean; lineNumber: number } | undefined>(this, undefined); constructor( - private readonly _editor: ICodeEditor, - private readonly _inputWidget: InlineChatInputWidget, - @IInstantiationService private readonly _instantiationService: IInstantiationService, + editor: ICodeEditor, + inputWidget: InlineChatInputWidget, + @IInstantiationService instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, @IChatEntitlementService chatEntiteldService: IChatEntitlementService, @IInlineChatSessionService inlineChatSessionService: IInlineChatSessionService, + @ITelemetryService telemetryService: ITelemetryService, ) { super(); + this.#editor = editor; + this.#inputWidget = inputWidget; + this.#instantiationService = instantiationService; - const editorObs = observableCodeEditor(this._editor); + const editorObs = observableCodeEditor(this.#editor); const affordance = observableConfigValue<'off' | 'gutter' | 'editor'>(InlineChatConfigKeys.Affordance, 'off', configurationService); const debouncedSelection = debouncedObservable(editorObs.cursorSelection, 500); const selectionData = observableValue(this, undefined); let explicitSelection = false; + let affordanceId: string | undefined; this._store.add(runOnChange(editorObs.selections, (value, _prev, events) => { explicitSelection = events.every(e => e.reason === CursorChangeReason.Explicit); @@ -52,10 +75,16 @@ export class InlineChatAffordance extends Disposable { this._store.add(autorun(r => { const value = debouncedSelection.read(r); - if (!value || value.isEmpty() || !explicitSelection || _editor.getModel()?.getValueInRange(value).match(/^\s+$/)) { + if (!value || value.isEmpty() || !explicitSelection || this.#editor.getModel()?.getValueInRange(value).match(/^\s+$/)) { selectionData.set(undefined, undefined); + affordanceId = undefined; return; } + affordanceId = generateUuid(); + const mode = affordance.read(undefined); + if (mode === 'gutter' || mode === 'editor') { + telemetryService.publicLog2('inlineChatAffordance/shown', { mode, id: affordanceId }); + } selectionData.set(value, undefined); })); @@ -77,61 +106,79 @@ export class InlineChatAffordance extends Disposable { } })); - this._store.add(this._instantiationService.createInstance( + this._store.add(this.#instantiationService.createInstance( InlineChatGutterAffordance, editorObs, derived(r => affordance.read(r) === 'gutter' ? selectionData.read(r) : undefined), - this._menuData + this.#menuData )); - this._store.add(this._instantiationService.createInstance( + const editorAffordance = this.#instantiationService.createInstance( InlineChatEditorAffordance, - this._editor, + this.#editor, derived(r => affordance.read(r) === 'editor' ? selectionData.read(r) : undefined) - )); + ); + this._store.add(editorAffordance); + this._store.add(editorAffordance.onDidRunAction(() => { + if (affordanceId) { + telemetryService.publicLog2('inlineChatAffordance/selected', { mode: 'editor', id: affordanceId }); + } + })); + + this._store.add(autorun(r => { + const isEditor = affordance.read(r) === 'editor'; + const controller = CodeActionController.get(this.#editor); + if (controller) { + controller.onlyLightBulbWithEmptySelection = isEditor; + } + })); this._store.add(autorun(r => { - const data = this._menuData.read(r); + const data = this.#menuData.read(r); if (!data) { return; } + if (affordanceId) { + telemetryService.publicLog2('inlineChatAffordance/selected', { mode: 'gutter', id: affordanceId }); + } + // Reveal the line in case it's outside the viewport (e.g., when triggered from sticky scroll) - this._editor.revealLineInCenterIfOutsideViewport(data.lineNumber, ScrollType.Immediate); + this.#editor.revealLineInCenterIfOutsideViewport(data.lineNumber, ScrollType.Immediate); - const editorDomNode = this._editor.getDomNode()!; + const editorDomNode = this.#editor.getDomNode()!; const editorRect = editorDomNode.getBoundingClientRect(); const left = data.rect.left - editorRect.left; // Show the overlay widget - this._inputWidget.show(data.lineNumber, left, data.above); + this.#inputWidget.show(data.lineNumber, left, data.above); })); this._store.add(autorun(r => { - const pos = this._inputWidget.position.read(r); + const pos = this.#inputWidget.position.read(r); if (pos === null) { - this._menuData.set(undefined, undefined); + this.#menuData.set(undefined, undefined); } })); } async showMenuAtSelection() { - assertType(this._editor.hasModel()); + assertType(this.#editor.hasModel()); - const direction = this._editor.getSelection().getDirection(); - const position = this._editor.getPosition(); - const editorDomNode = this._editor.getDomNode(); - const scrolledPosition = this._editor.getScrolledVisiblePosition(position); + const direction = this.#editor.getSelection().getDirection(); + const position = this.#editor.getPosition(); + const editorDomNode = this.#editor.getDomNode(); + const scrolledPosition = this.#editor.getScrolledVisiblePosition(position); const editorRect = editorDomNode.getBoundingClientRect(); const x = editorRect.left + scrolledPosition.left; const y = editorRect.top + scrolledPosition.top; - this._menuData.set({ + this.#menuData.set({ rect: new DOMRect(x, y, 0, scrolledPosition.height), above: direction === SelectionDirection.RTL, lineNumber: position.lineNumber }, undefined); - await waitForState(this._inputWidget.position, pos => pos === null); + await waitForState(this.#inputWidget.position, pos => pos === null); } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts index 568fb591e5448..0aacdb125727d 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts @@ -7,9 +7,11 @@ import './media/inlineChatEditorAffordance.css'; import { IDimension } from '../../../../base/browser/dom.js'; import * as dom from '../../../../base/browser/dom.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js'; +import { computeIndentLevel } from '../../../../editor/common/model/utils.js'; import { autorun, IObservable } from '../../../../base/common/observable.js'; import { MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; @@ -26,6 +28,8 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { ACTION_START } from '../common/inlineChat.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; class QuickFixActionViewItem extends MenuEntryActionViewItem { @@ -40,9 +44,30 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem { @IContextKeyService contextKeyService: IContextKeyService, @IThemeService themeService: IThemeService, @IContextMenuService contextMenuService: IContextMenuService, - @IAccessibilityService accessibilityService: IAccessibilityService + @IAccessibilityService accessibilityService: IAccessibilityService, + @ICommandService commandService: ICommandService ) { - super(action, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); + const wrappedAction = new class extends MenuItemAction { + constructor() { + super(action.item, action.alt?.item, {}, action.hideActions, action.menuKeybinding, contextKeyService, commandService); + } + + elementGetter: () => HTMLElement | undefined = () => undefined; + + override async run(...args: unknown[]): Promise { + const controller = CodeActionController.get(_editor); + const info = controller?.lightBulbState.get(); + const element = this.elementGetter(); + if (controller && info && element) { + const { bottom, left } = element.getBoundingClientRect(); + await controller.showCodeActions(info.trigger, info.actions, { x: left, y: bottom }); + } + } + }; + + super(wrappedAction, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); + + wrappedAction.elementGetter = () => this.element; } override render(container: HTMLElement): void { @@ -70,7 +95,7 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem { const icon = info?.icon ?? Codicon.lightBulb; const iconClasses = ThemeIcon.asClassNameArray(icon); this.label.className = ''; - this.label.classList.add('codicon', ...iconClasses); + this.label.classList.add('codicon', 'action-label', ...iconClasses); } // Update tooltip @@ -80,6 +105,35 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem { } } +class InlineChatStartActionViewItem extends MenuEntryActionViewItem { + + private readonly _kbLabel: string | undefined; + + constructor( + action: MenuItemAction, + @IKeybindingService keybindingService: IKeybindingService, + @INotificationService notificationService: INotificationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IThemeService themeService: IThemeService, + @IContextMenuService contextMenuService: IContextMenuService, + @IAccessibilityService accessibilityService: IAccessibilityService + ) { + super(action, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); + this.options.label = true; + this.options.icon = false; + this._kbLabel = keybindingService.lookupKeybinding(action.id)?.getLabel() ?? undefined; + } + + protected override updateLabel(): void { + if (this.label) { + dom.reset(this.label, + this.action.label, + ...(this._kbLabel ? [dom.$('span.inline-chat-keybinding', undefined, this._kbLabel)] : []) + ); + } + } +} + /** * Content widget that shows a small sparkle icon at the cursor position. * When clicked, it shows the overlay widget for inline chat. @@ -93,7 +147,10 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi private _position: IContentWidgetPosition | null = null; private _isVisible = false; - readonly allowEditorOverflow = true; + private readonly _onDidRunAction = this._store.add(new Emitter()); + readonly onDidRunAction: Event = this._onDidRunAction.event; + + readonly allowEditorOverflow = false; readonly suppressMouseDown = false; constructor( @@ -107,18 +164,22 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi this._domNode = dom.$('.inline-chat-content-widget'); // Create toolbar with the inline chat start action - this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._domNode, MenuId.InlineChatEditorAffordance, { + const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._domNode, MenuId.InlineChatEditorAffordance, { telemetrySource: 'inlineChatEditorAffordance', hiddenItemStrategy: HiddenItemStrategy.Ignore, menuOptions: { renderShortTitle: true }, - toolbarOptions: { primaryGroup: () => true }, + toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, actionViewItemProvider: (action: IAction) => { if (action instanceof MenuItemAction && action.id === quickFixCommandId) { return instantiationService.createInstance(QuickFixActionViewItem, action, this._editor); } + if (action instanceof MenuItemAction && action.id === ACTION_START) { + return instantiationService.createInstance(InlineChatStartActionViewItem, action); + } return undefined; } })); + this._store.add(toolbar.actionRunner.onDidRun(() => this._onDidRunAction.fire())); this._store.add(autorun(r => { const sel = selection.read(r); @@ -132,11 +193,24 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi private _show(selection: Selection): void { - // Position at the cursor (active end of selection) + if (selection.isEmpty()) { + this._showAtLineStart(selection.getPosition().lineNumber); + } else { + this._showAtSelection(selection); + } + + if (this._isVisible) { + this._editor.layoutContentWidget(this); + } else { + this._editor.addContentWidget(this); + this._isVisible = true; + } + } + + private _showAtSelection(selection: Selection): void { const cursorPosition = selection.getPosition(); const direction = selection.getDirection(); - // Show above for RTL (selection going up), below for LTR (selection going down) const preference = direction === SelectionDirection.RTL ? ContentWidgetPositionPreference.ABOVE : ContentWidgetPositionPreference.BELOW; @@ -145,13 +219,42 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi position: cursorPosition, preference: [preference], }; + } - if (this._isVisible) { - this._editor.layoutContentWidget(this); - } else { - this._editor.addContentWidget(this); - this._isVisible = true; + private _showAtLineStart(lineNumber: number): void { + const model = this._editor.getModel(); + if (!model) { + return; + } + + const tabSize = model.getOptions().tabSize; + const fontInfo = this._editor.getOptions().get(EditorOption.fontInfo); + const lineContent = model.getLineContent(lineNumber); + const indent = computeIndentLevel(lineContent, tabSize); + const lineHasSpace = indent < 0 ? true : fontInfo.spaceWidth * indent > 22; + + let effectiveLineNumber = lineNumber; + + if (!lineHasSpace) { + const isLineEmptyOrIndented = (ln: number): boolean => { + const content = model.getLineContent(ln); + return /^\s*$|^\s+/.test(content); + }; + + const lineCount = model.getLineCount(); + if (lineNumber > 1 && isLineEmptyOrIndented(lineNumber - 1)) { + effectiveLineNumber = lineNumber - 1; + } else if (lineNumber < lineCount && isLineEmptyOrIndented(lineNumber + 1)) { + effectiveLineNumber = lineNumber + 1; + } } + + const effectiveColumnNumber = /^\S\s*$/.test(model.getLineContent(effectiveLineNumber)) ? 2 : 1; + + this._position = { + position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber }, + preference: [ContentWidgetPositionPreference.EXACT], + }; } private _hide(): void { diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css index 5eaa356dcfa01..14913c7eb451a 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css @@ -16,7 +16,24 @@ line-height: var(--vscode-inline-chat-affordance-height); } -.inline-chat-content-widget .icon.codicon { +.inline-chat-content-widget .action-label.codicon.codicon-light-bulb, +.inline-chat-content-widget .action-label.codicon.codicon-lightbulb-sparkle { margin: 0; color: var(--vscode-editorLightBulb-foreground); } + +.inline-chat-content-widget .action-label.codicon.codicon-lightbulb-autofix, +.inline-chat-content-widget .action-label.codicon.codicon-lightbulb-sparkle-autofix { + margin: 0; + color: var(--vscode-editorLightBulbAutoFix-foreground, var(--vscode-editorLightBulb-foreground)); +} + +.inline-chat-content-widget .action-label.codicon.codicon-sparkle-filled { + margin: 0; + color: var(--vscode-editorLightBulbAi-foreground, var(--vscode-icon-foreground)); +} + +.inline-chat-content-widget .inline-chat-keybinding { + opacity: 0.7; + margin-left: 4px; +} diff --git a/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts b/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts index 2b1b21e5bb0e5..622dc36f19850 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts @@ -19,7 +19,6 @@ import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../plat import { ChatElicitationRequestPart } from '../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatModel } from '../../chat/common/model/chatModel.js'; import { ElicitationState, IChatService } from '../../chat/common/chatService/chatService.js'; -import { LocalChatSessionUri } from '../../chat/common/model/chatUri.js'; import { ElicitationKind, ElicitResult, IFormModeElicitResult, IMcpElicitationService, IMcpServer, IMcpToolCallContext, IUrlModeElicitResult, McpConnectionState, MpcResponseError } from '../common/mcpTypes.js'; import { mcpServerToSourceData } from '../common/mcpTypesUtils.js'; import { MCP } from '../common/modelContextProtocol.js'; @@ -85,7 +84,7 @@ export class McpElicitationService implements IMcpElicitationService { private async _elicitForm(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequestFormParams | Pre20251125ElicitationParams, token: CancellationToken): Promise { const store = new DisposableStore(); const value = await new Promise(resolve => { - const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId)); + const chatModel = context?.chatSessionResource && this._chatService.getSession(context.chatSessionResource); if (chatModel instanceof ChatModel) { const request = chatModel.getRequests().at(-1); if (request) { @@ -152,7 +151,7 @@ export class McpElicitationService implements IMcpElicitationService { const store = new DisposableStore(); const value = await new Promise(resolve => { - const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId)); + const chatModel = context?.chatSessionResource && this._chatService.getSession(context.chatSessionResource); if (chatModel instanceof ChatModel) { const request = chatModel.getRequests().at(-1); if (request) { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts index 7f4dfea45e3d1..9e82f119e8830 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts @@ -9,7 +9,7 @@ import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { createMarkdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js'; -import { combinedDisposable, Disposable, DisposableStore, dispose, IDisposable, isDisposable } from '../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableStore, dispose, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { DelayedPagedModel, IPagedModel, PagedModel, IterativePagedModel } from '../../../../base/common/paging.js'; import { localize, localize2 } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -52,6 +52,8 @@ import { IMcpGalleryManifestService, McpGalleryManifestStatus } from '../../../. import { ProductQualityContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js'; import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { buildModalNavigationForPagedList } from '../../extensions/browser/extensionsViewer.js'; export interface McpServerListViewOptions { showWelcome?: boolean; @@ -81,6 +83,7 @@ export class McpServersListView extends AbstractExtensionsListView); this._register(Event.debounce(Event.filter(this.list.onDidOpen, e => e.element !== null), (_, event) => event, 75, true)(options => { - this.mcpWorkbenchService.open(options.element!, options.editorOptions); + this.mcpWorkbenchService.open(options.element!, { + ...options.editorOptions, + modal: options.sideBySide ? undefined : buildModalNavigationForPagedList( + options.element!, + () => this.list?.model, + (serverA, serverB) => serverA.id === serverB.id, + (server, modal) => this.mcpWorkbenchService.open(server, { pinned: false, modal }), + this.modalNavigationDisposable, + this.logService + ), + }); })); this._register(this.list.onContextMenu(e => this.onContextMenu(e), this)); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index fcfc538cf8070..a87f598400655 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -30,7 +30,7 @@ import { IUserDataProfilesService } from '../../../../platform/userDataProfile/c import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { MCP_CONFIGURATION_KEY, WORKSPACE_STANDALONE_CONFIGURATIONS } from '../../../services/configuration/common/configuration.js'; -import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; +import { ACTIVE_GROUP, IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { DidUninstallWorkbenchMcpServerEvent, IWorkbenchLocalMcpServer, IWorkbenchMcpManagementService, IWorkbenchMcpServerInstallResult, IWorkbencMcpServerInstallOptions, LocalMcpServerScope, REMOTE_USER_CONFIG_ID, USER_CONFIG_ID, WORKSPACE_CONFIG_ID, WORKSPACE_FOLDER_CONFIG_ID_PREFIX } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; @@ -721,7 +721,8 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } async open(extension: IWorkbenchMcpServer, options?: IEditorOptions): Promise { - await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, ACTIVE_GROUP); + const useModal = this.configurationService.getValue('extensions.allowOpenInModalEditor'); + await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, useModal ? MODAL_GROUP : ACTIVE_GROUP); } private getInstallState(extension: McpWorkbenchServer): McpServerInstallState { diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index 25e9080dd4c2d..f5f6687be947d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -251,7 +251,7 @@ class McpToolImplementation implements IToolImpl { content: [] }; - const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionId: invocation.context?.sessionId }, token); + const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionResource: undefined }, token); const details: Mutable = { input: JSON.stringify(invocation.parameters, undefined, 2), output: [], diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 0c55918b43bf9..6452f5d62bd83 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -30,6 +30,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { IOutputService } from '../../../services/output/common/output.js'; +import { chatSessionResourceToId } from '../../chat/common/model/chatUri.js'; import { ToolProgress } from '../../chat/common/tools/languageModelToolsService.js'; import { mcpActivationEvent } from './mcpConfiguration.js'; import { McpDevModeServerAttache } from './mcpDevMode.js'; @@ -1069,8 +1070,8 @@ export class McpTool implements IMcpTool { } const meta: Record = { progressToken }; - if (context?.chatSessionId) { - meta['vscode.conversationId'] = context.chatSessionId; + if (context?.chatSessionResource) { + meta['vscode.conversationId'] = chatSessionResourceToId(context.chatSessionResource); } if (context?.chatRequestId) { meta['vscode.requestId'] = context.chatRequestId; diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 9a38b10c35fff..7d35fa80ac796 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -434,7 +434,7 @@ export const mcpPromptPrefix = (definition: McpDefinitionReference) => export interface IMcpPromptMessage extends MCP.PromptMessage { } export interface IMcpToolCallContext { - chatSessionId?: string; + chatSessionResource: URI | undefined; chatRequestId?: string; } diff --git a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnection.contribution.ts b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnection.contribution.ts index 66e27614968c6..9e8acd82bc36b 100644 --- a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnection.contribution.ts +++ b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnection.contribution.ts @@ -3,9 +3,65 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { METERED_CONNECTION_SETTING_KEY, MeteredConnectionSettingValue } from '../../../../platform/meteredConnection/common/meteredConnection.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { MeteredConnectionStatusContribution } from './meteredConnectionStatus.js'; import '../../../../platform/meteredConnection/common/meteredConnection.config.contribution.js'; registerWorkbenchContribution2(MeteredConnectionStatusContribution.ID, MeteredConnectionStatusContribution, WorkbenchPhase.AfterRestored); + +registerAction2(class ConfigureMeteredConnectionAction extends Action2 { + + static readonly ID = 'workbench.action.configureMeteredConnection'; + + constructor() { + super({ + id: ConfigureMeteredConnectionAction.ID, + title: localize2('configureMeteredConnection', 'Configure Metered Connection'), + f1: true + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const quickInputService = accessor.get(IQuickInputService); + const configurationService = accessor.get(IConfigurationService); + + const currentValue = configurationService.getValue(METERED_CONNECTION_SETTING_KEY); + + const picks: (IQuickPickItem & { value: MeteredConnectionSettingValue })[] = [ + { + value: 'auto', + label: localize('meteredConnection.auto', "Auto"), + description: localize('meteredConnection.auto.description', "Detect metered connections automatically"), + picked: currentValue === 'auto' + }, + { + value: 'on', + label: localize('meteredConnection.on', "On"), + description: localize('meteredConnection.on.description', "Always treat the connection as metered"), + picked: currentValue === 'on' + }, + { + value: 'off', + label: localize('meteredConnection.off', "Off"), + description: localize('meteredConnection.off.description', "Never treat the connection as metered"), + picked: currentValue === 'off' + } + ]; + + const pick = await quickInputService.pick(picks, { + placeHolder: localize('meteredConnection.placeholder', "Select Metered Connection Mode"), + activeItem: picks.find(p => p.picked) + }); + + if (pick) { + await configurationService.updateValue(METERED_CONNECTION_SETTING_KEY, pick.value); + } + } +}); diff --git a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts index 619ecfcf04305..677ddbc62521b 100644 --- a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts +++ b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts @@ -5,7 +5,7 @@ import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; -import { IMeteredConnectionService, METERED_CONNECTION_SETTING_KEY } from '../../../../platform/meteredConnection/common/meteredConnection.js'; +import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from '../../../services/statusbar/browser/statusbar.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; @@ -47,12 +47,11 @@ export class MeteredConnectionStatusContribution extends Disposable implements I return { name: localize('status.meteredConnection', "Metered Connection"), text: '$(radio-tower)', - ariaLabel: localize('status.meteredConnection.ariaLabel', "Metered Connection Detected"), - tooltip: localize('status.meteredConnection.tooltip', "Metered connection detected. Some automatic features like extension updates, Settings Sync, and automatic Git operations are paused to reduce data usage."), + ariaLabel: localize('status.meteredConnection.ariaLabel', "Metered Connection Enabled"), + tooltip: localize('status.meteredConnection.tooltip', "Metered connection enabled. Some automatic features like extension updates, Settings Sync, and automatic Git operations are paused to reduce data usage."), command: { - id: 'workbench.action.openSettings', - title: localize('status.meteredConnection.configure', "Configure"), - arguments: [`@id:${METERED_CONNECTION_SETTING_KEY}`] + id: 'workbench.action.configureMeteredConnection', + title: localize('status.meteredConnection.configure', "Configure") }, showInAllWindows: true }; diff --git a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts index a6b9475f06dca..bb37f7a15d61b 100644 --- a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts +++ b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts @@ -238,8 +238,8 @@ export class WorkspaceChangeExtHostRelauncher extends Disposable implements IWor return; // no restart when in tests: see https://github.com/microsoft/vscode/issues/66936 } - if (contextService.getWorkspace().isAgentSessionsWorkspace) { - return; // no restart for agent sessions workspace + if (environmentService.isSessionsWindow) { + return; // no restart for sessions window } if (environmentService.remoteAuthority) { diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index 9b0241b7a2dd3..366bca29f921c 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -18,7 +18,7 @@ import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../ import { ICommandService, ICommandHandler, CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { Event } from '../../../../base/common/event.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; -import { Disposable, IDisposable, toDisposable, dispose, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { toDisposable, dispose, DisposableStore } from '../../../../base/common/lifecycle.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { IconLabel } from '../../../../base/browser/ui/iconLabel/iconLabel.js'; import { ActionRunner, IAction } from '../../../../base/common/actions.js'; @@ -320,7 +320,8 @@ class PrivacyColumn implements ITableColumn { } interface IActionBarTemplateData { - elementDisposable: IDisposable; + readonly elementDisposables: DisposableStore; + readonly templateDisposables: DisposableStore; container: HTMLElement; label: IconLabel; button?: Button; @@ -338,7 +339,7 @@ interface ActionBarCell { editId: TunnelEditId; } -class ActionBarRenderer extends Disposable implements ITableRenderer { +class ActionBarRenderer implements ITableRenderer { readonly templateId = 'actionbar'; private inputDone?: (success: boolean, finishEditing: boolean) => void; private _actionRunner: ActionRunner | undefined; @@ -353,8 +354,6 @@ class ActionBarRenderer extends Disposable implements ITableRenderer { + templateData.elementDisposables.add(templateData.button.onDidClick(() => { this.commandService.executeCommand(ForwardPortAction.INLINE_ID); })); } @@ -464,10 +467,8 @@ class ActionBarRenderer extends Disposable implements ITableRenderer action.id.toLowerCase().indexOf('label') >= 0); @@ -489,12 +490,13 @@ class ActionBarRenderer extends Disposable implements ITableRenderer { + templateData.elementDisposables.add(toDisposable(() => { done(false, false); - }); + })); } disposeElement(element: ActionBarCell, index: number, templateData: IActionBarTemplateData) { - templateData.elementDisposable.dispose(); + templateData.elementDisposables.clear(); } disposeTemplate(templateData: IActionBarTemplateData): void { - templateData.label.dispose(); - templateData.actionBar.dispose(); - templateData.elementDisposable.dispose(); - templateData.button?.dispose(); + templateData.templateDisposables.dispose(); } } @@ -1817,4 +1816,3 @@ MenuRegistry.appendMenuItem(MenuId.TunnelLocalAddressInline, ({ })); registerColor('ports.iconRunningProcessForeground', STATUS_BAR_REMOTE_ITEM_BACKGROUND, nls.localize('portWithRunningProcess.foreground', "The color of the icon for a port that has an associated running process.")); - diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index c38dc40be0647..8f2ea73835db4 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -49,6 +49,7 @@ import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.j import { SCMHistoryItemContextContribution } from './scmHistoryChatContext.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; +import { SCMInputContextKeys } from './scmInput.js'; import product from '../../../../platform/product/common/product.js'; ModesRegistry.registerLanguage({ @@ -465,7 +466,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ weight: KeybindingWeight.WorkbenchContrib, when: ContextKeyExpr.and( ContextKeyExpr.has('scmRepository'), - ContextKeys.SCMInputHasValidationMessage), + SCMInputContextKeys.SCMInputHasValidationMessage), primary: KeyCode.Escape, handler: async (accessor) => { const scmViewService = accessor.get(ISCMViewService); @@ -480,7 +481,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ ContextKeyExpr.has('scmRepository'), SuggestContext.Visible.toNegated(), InlineCompletionContextKeys.inlineSuggestionVisible.toNegated(), - ContextKeys.SCMInputHasValidationMessage.toNegated(), + SCMInputContextKeys.SCMInputHasValidationMessage.toNegated(), EditorContextKeys.hasNonEmptySelection.toNegated()), primary: KeyCode.Escape, handler: async (accessor) => { diff --git a/src/vs/workbench/contrib/scm/browser/scmInput.ts b/src/vs/workbench/contrib/scm/browser/scmInput.ts new file mode 100644 index 0000000000000..bcc312c6ba2c3 --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/scmInput.ts @@ -0,0 +1,873 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/scm.css'; +import { Event, Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { append, $, Dimension, trackFocus } from '../../../../base/browser/dom.js'; +import { InputValidationType, ISCMInput, IInputValidation, ISCMViewService, SCMInputChangeReason, ISCMInputValueProviderContext } from '../common/scm.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IContextViewService, IContextMenuService, IOpenContextView } from '../../../../platform/contextview/browser/contextView.js'; +import { IContextKeyService, IContextKey, ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { MenuItemAction, IMenuService, registerAction2, MenuId, Action2 } from '../../../../platform/actions/common/actions.js'; +import { IAction, ActionRunner, Action } from '../../../../base/common/actions.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { IConfigurationService, ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; +import { ThrottledDelayer } from '../../../../base/common/async.js'; +import { localize } from '../../../../nls.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; +import { getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../codeEditor/browser/simpleEditorOptions.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; +import { MenuPreventer } from '../../codeEditor/browser/menuPreventer.js'; +import { SelectionClipboardContributionID } from '../../codeEditor/browser/selectionClipboard.js'; +import { EditorDictation } from '../../codeEditor/browser/dictation/editorDictation.js'; +import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; +import * as platform from '../../../../base/common/platform.js'; +import { format } from '../../../../base/common/strings.js'; +import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; +import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { ColorDetector } from '../../../../editor/contrib/colorPicker/browser/colorDetector.js'; +import { LinkDetector } from '../../../../editor/contrib/links/browser/links.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { AnchorAlignment } from '../../../../base/browser/ui/contextview/contextview.js'; +import { Selection } from '../../../../editor/common/core/selection.js'; +import { createActionViewItem, getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { DragAndDropController } from '../../../../editor/contrib/dnd/browser/dnd.js'; +import { CopyPasteController } from '../../../../editor/contrib/dropOrPasteInto/browser/copyPasteController.js'; +import { DropIntoEditorController } from '../../../../editor/contrib/dropOrPasteInto/browser/dropIntoEditorController.js'; +import { MessageController } from '../../../../editor/contrib/message/browser/messageController.js'; +import { InlineCompletionsController } from '../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; +import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; +import { FormatOnType } from '../../../../editor/contrib/format/browser/formatActions.js'; +import { EditorOption, EditorOptions, IEditorOptions } from '../../../../editor/common/config/editorOptions.js'; +import { EditOperation } from '../../../../editor/common/core/editOperation.js'; +import { HiddenItemStrategy, IMenuWorkbenchToolBarOptions, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { DropdownWithPrimaryActionViewItem } from '../../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; +import { clamp } from '../../../../base/common/numbers.js'; +import { ContentHoverController } from '../../../../editor/contrib/hover/browser/contentHoverController.js'; +import { GlyphHoverController } from '../../../../editor/contrib/hover/browser/glyphHoverController.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { autorun, runOnChange } from '../../../../base/common/observable.js'; +import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; +import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; +import product from '../../../../platform/product/common/product.js'; +import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; + +export const SCMInputContextKeys = { + SCMInputHasValidationMessage: new RawContextKey('scmInputHasValidationMessage', false), +}; + +const enum SCMInputWidgetCommandId { + CancelAction = 'scm.input.cancelAction', + SetupAction = 'scm.input.triggerSetup' +} + +const enum SCMInputWidgetStorageKey { + LastActionId = 'scm.input.lastActionId' +} + +class SCMInputWidgetActionRunner extends ActionRunner { + + private readonly _runningActions = new Set(); + public get runningActions(): Set { return this._runningActions; } + + private _cts: CancellationTokenSource | undefined; + + constructor( + private readonly input: ISCMInput, + @IStorageService private readonly storageService: IStorageService + ) { + super(); + } + + protected override async runAction(action: IAction): Promise { + try { + // Cancel previous action + if (this.runningActions.size !== 0) { + this._cts?.cancel(); + + if (action.id === SCMInputWidgetCommandId.CancelAction) { + return; + } + } + + // Create action context + const context: ISCMInputValueProviderContext[] = []; + for (const group of this.input.repository.provider.groups) { + context.push({ + resourceGroupId: group.id, + resources: [...group.resources.map(r => r.sourceUri)] + }); + } + + // Run action + this._runningActions.add(action); + this._cts = new CancellationTokenSource(); + await action.run(...[this.input.repository.provider.rootUri, context, this._cts.token]); + } finally { + this._runningActions.delete(action); + + // Save last action + if (this._runningActions.size === 0) { + const actionId = action.id === SCMInputWidgetCommandId.SetupAction + ? product.defaultChatAgent?.generateCommitMessageCommand ?? action.id + : action.id; + this.storageService.store(SCMInputWidgetStorageKey.LastActionId, actionId, StorageScope.PROFILE, StorageTarget.USER); + } + } + } + +} + +class SCMInputWidgetToolbar extends WorkbenchToolBar { + + private _dropdownActions: IAction[] = []; + get dropdownActions(): IAction[] { return this._dropdownActions; } + + private _dropdownAction: IAction; + get dropdownAction(): IAction { return this._dropdownAction; } + + private _cancelAction: IAction; + + private _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private readonly _disposables = this._register(new MutableDisposable()); + + constructor( + container: HTMLElement, + options: IMenuWorkbenchToolBarOptions | undefined, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IContextMenuService contextMenuService: IContextMenuService, + @ICommandService commandService: ICommandService, + @IKeybindingService keybindingService: IKeybindingService, + @IStorageService private readonly storageService: IStorageService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(container, options, menuService, contextKeyService, contextMenuService, keybindingService, commandService, telemetryService); + + this._dropdownAction = new Action( + 'scmInputMoreActions', + localize('scmInputMoreActions', "More Actions..."), + 'codicon-chevron-down'); + + this._cancelAction = new MenuItemAction({ + id: SCMInputWidgetCommandId.CancelAction, + title: localize('scmInputCancelAction', "Cancel"), + icon: Codicon.stopCircle, + }, undefined, undefined, undefined, undefined, contextKeyService, commandService); + } + + public setInput(input: ISCMInput): void { + this._disposables.value = new DisposableStore(); + + const contextKeyService = this.contextKeyService.createOverlay([ + ['scmProvider', input.repository.provider.providerId], + ['scmProviderRootUri', input.repository.provider.rootUri?.toString()], + ['scmProviderHasRootUri', !!input.repository.provider.rootUri] + ]); + + const menu = this._disposables.value.add(this.menuService.createMenu(MenuId.SCMInputBox, contextKeyService, { emitEventsForSubmenuChanges: true })); + + const isEnabled = (): boolean => { + return input.repository.provider.groups.some(g => g.resources.length > 0); + }; + + const updateToolbar = () => { + const actions = getFlatActionBarActions(menu.getActions({ shouldForwardArgs: true })); + + for (const action of actions) { + action.enabled = isEnabled(); + } + this._dropdownAction.enabled = isEnabled(); + + let primaryAction: IAction | undefined = undefined; + + if ((this.actionRunner as SCMInputWidgetActionRunner).runningActions.size !== 0) { + primaryAction = this._cancelAction; + } else if (actions.length === 1) { + primaryAction = actions[0]; + } else if (actions.length > 1) { + const lastActionId = this.storageService.get(SCMInputWidgetStorageKey.LastActionId, StorageScope.PROFILE, ''); + primaryAction = actions.find(a => a.id === lastActionId) ?? actions[0]; + } + + this._dropdownActions = actions.length === 1 ? [] : actions; + super.setActions(primaryAction ? [primaryAction] : [], []); + + this._onDidChange.fire(); + }; + + this._disposables.value.add(menu.onDidChange(() => updateToolbar())); + this._disposables.value.add(input.repository.provider.onDidChangeResources(() => updateToolbar())); + this._disposables.value.add(this.storageService.onDidChangeValue(StorageScope.PROFILE, SCMInputWidgetStorageKey.LastActionId, this._disposables.value)(() => updateToolbar())); + + this.actionRunner = this._disposables.value.add(new SCMInputWidgetActionRunner(input, this.storageService)); + this._disposables.value.add(this.actionRunner.onWillRun(e => { + if ((this.actionRunner as SCMInputWidgetActionRunner).runningActions.size === 0) { + super.setActions([this._cancelAction], []); + this._onDidChange.fire(); + } + })); + this._disposables.value.add(this.actionRunner.onDidRun(e => { + if ((this.actionRunner as SCMInputWidgetActionRunner).runningActions.size === 0) { + updateToolbar(); + } + })); + + updateToolbar(); + } +} + +class SCMInputWidgetEditorOptions { + + private readonly _onDidChange = new Emitter(); + readonly onDidChange = this._onDidChange.event; + + private readonly defaultInputFontFamily = DEFAULT_FONT_FAMILY; + + private readonly _disposables = new DisposableStore(); + + constructor( + private readonly overflowWidgetsDomNode: HTMLElement, + private readonly configurationService: IConfigurationService) { + + const onDidChangeConfiguration = Event.filter( + this.configurationService.onDidChangeConfiguration, + e => { + return e.affectsConfiguration('editor.accessibilitySupport') || + e.affectsConfiguration('editor.cursorBlinking') || + e.affectsConfiguration('editor.cursorStyle') || + e.affectsConfiguration('editor.cursorWidth') || + e.affectsConfiguration('editor.emptySelectionClipboard') || + e.affectsConfiguration('editor.fontFamily') || + e.affectsConfiguration('editor.rulers') || + e.affectsConfiguration('editor.wordWrap') || + e.affectsConfiguration('editor.wordSegmenterLocales') || + e.affectsConfiguration('scm.inputFontFamily') || + e.affectsConfiguration('scm.inputFontSize'); + }, + this._disposables + ); + + this._disposables.add(onDidChangeConfiguration(() => this._onDidChange.fire())); + } + + getEditorConstructionOptions(): IEditorConstructionOptions { + return { + ...getSimpleEditorOptions(this.configurationService), + ...this.getEditorOptions(), + dragAndDrop: true, + dropIntoEditor: { enabled: true }, + formatOnType: true, + lineDecorationsWidth: 6, + overflowWidgetsDomNode: this.overflowWidgetsDomNode, + padding: { top: 2, bottom: 2 }, + quickSuggestions: false, + renderWhitespace: 'none', + scrollbar: { + alwaysConsumeMouseWheel: false, + vertical: 'hidden' + }, + wrappingIndent: 'none', + wrappingStrategy: 'advanced', + }; + } + + getEditorOptions(): IEditorOptions { + const fontFamily = this._getEditorFontFamily(); + const fontSize = this._getEditorFontSize(); + const lineHeight = this._getEditorLineHeight(fontSize); + const wordSegmenterLocales = this.configurationService.getValue('editor.wordSegmenterLocales'); + const accessibilitySupport = this.configurationService.getValue<'auto' | 'off' | 'on'>('editor.accessibilitySupport'); + const cursorBlinking = this.configurationService.getValue<'blink' | 'smooth' | 'phase' | 'expand' | 'solid'>('editor.cursorBlinking'); + const cursorStyle = this.configurationService.getValue('editor.cursorStyle'); + const cursorWidth = this.configurationService.getValue('editor.cursorWidth') ?? 1; + const emptySelectionClipboard = this.configurationService.getValue('editor.emptySelectionClipboard') === true; + + return { ...this._getEditorLanguageConfiguration(), accessibilitySupport, cursorBlinking, cursorStyle, cursorWidth, fontFamily, fontSize, lineHeight, emptySelectionClipboard, wordSegmenterLocales }; + } + + private _getEditorFontFamily(): string { + const inputFontFamily = this.configurationService.getValue('scm.inputFontFamily').trim(); + + if (inputFontFamily.toLowerCase() === 'editor') { + return this.configurationService.getValue('editor.fontFamily').trim(); + } + + if (inputFontFamily.length !== 0 && inputFontFamily.toLowerCase() !== 'default') { + return inputFontFamily; + } + + return this.defaultInputFontFamily; + } + + private _getEditorFontSize(): number { + return this.configurationService.getValue('scm.inputFontSize'); + } + + private _getEditorLanguageConfiguration(): IEditorOptions { + // editor.rulers + const rulersConfig = this.configurationService.inspect('editor.rulers', { overrideIdentifier: 'scminput' }); + const rulers = rulersConfig.overrideIdentifiers?.includes('scminput') ? EditorOptions.rulers.validate(rulersConfig.value) : []; + + // editor.wordWrap + const wordWrapConfig = this.configurationService.inspect('editor.wordWrap', { overrideIdentifier: 'scminput' }); + const wordWrap = wordWrapConfig.overrideIdentifiers?.includes('scminput') ? EditorOptions.wordWrap.validate(wordWrapConfig.value) : 'on'; + + return { rulers, wordWrap }; + } + + private _getEditorLineHeight(fontSize: number): number { + return Math.round(fontSize * 1.5); + } + + dispose(): void { + this._disposables.dispose(); + this._onDidChange.dispose(); + } + +} + +export class SCMInputWidget { + + private static readonly ValidationTimeouts: { [severity: number]: number } = { + [InputValidationType.Information]: 5000, + [InputValidationType.Warning]: 8000, + [InputValidationType.Error]: 10000 + }; + + private readonly contextKeyService: IContextKeyService; + + private element: HTMLElement; + private editorContainer: HTMLElement; + private readonly inputEditor: CodeEditorWidget; + private readonly inputEditorOptions: SCMInputWidgetEditorOptions; + private toolbarContainer: HTMLElement; + private toolbar: SCMInputWidgetToolbar; + private readonly disposables = new DisposableStore(); + + private model: { readonly input: ISCMInput; readonly textModel: ITextModel } | undefined; + private repositoryIdContextKey: IContextKey; + private validationMessageContextKey: IContextKey; + private readonly repositoryDisposables = new DisposableStore(); + + private validation: IInputValidation | undefined; + private validationContextView: IOpenContextView | undefined; + private validationHasFocus: boolean = false; + private _validationTimer: Timeout | undefined; + + // This is due to "Setup height change listener on next tick" above + // https://github.com/microsoft/vscode/issues/108067 + private lastLayoutWasTrash = false; + private shouldFocusAfterLayout = false; + + readonly onDidChangeContentHeight: Event; + + get input(): ISCMInput | undefined { + return this.model?.input; + } + + set input(input: ISCMInput | undefined) { + if (input === this.input) { + return; + } + + this.clearValidation(); + this.element.classList.remove('synthetic-focus'); + + this.repositoryDisposables.clear(); + this.repositoryIdContextKey.set(input?.repository.id); + + if (!input) { + this.inputEditor.setModel(undefined); + this.model = undefined; + return; + } + + const textModel = input.repository.provider.inputBoxTextModel; + this.inputEditor.setModel(textModel); + + if (this.configurationService.getValue('editor.wordBasedSuggestions', { resource: textModel.uri }) !== 'off') { + this.configurationService.updateValue('editor.wordBasedSuggestions', 'off', { resource: textModel.uri }, ConfigurationTarget.MEMORY); + } + + // Validation + const validationDelayer = new ThrottledDelayer(200); + const validate = async () => { + const position = this.inputEditor.getSelection()?.getStartPosition(); + const offset = position && textModel.getOffsetAt(position); + const value = textModel.getValue(); + + this.setValidation(await input.validateInput(value, offset || 0)); + }; + + const triggerValidation = () => validationDelayer.trigger(validate); + this.repositoryDisposables.add(validationDelayer); + this.repositoryDisposables.add(this.inputEditor.onDidChangeCursorPosition(triggerValidation)); + + // Adaptive indentation rules + const opts = this.modelService.getCreationOptions(textModel.getLanguageId(), textModel.uri, textModel.isForSimpleWidget); + const onEnter = Event.filter(this.inputEditor.onKeyDown, e => e.keyCode === KeyCode.Enter, this.repositoryDisposables); + this.repositoryDisposables.add(onEnter(() => textModel.detectIndentation(opts.insertSpaces, opts.tabSize))); + + // Keep model in sync with API + textModel.setValue(input.value); + this.repositoryDisposables.add(input.onDidChange(({ value, reason }) => { + const currentValue = textModel.getValue(); + if (value === currentValue) { // circuit breaker + return; + } + + textModel.pushStackElement(); + textModel.pushEditOperations(null, [EditOperation.replaceMove(textModel.getFullModelRange(), value)], () => []); + + const position = reason === SCMInputChangeReason.HistoryPrevious + ? textModel.getFullModelRange().getStartPosition() + : textModel.getFullModelRange().getEndPosition(); + this.inputEditor.setPosition(position); + this.inputEditor.revealPositionInCenterIfOutsideViewport(position); + })); + this.repositoryDisposables.add(input.onDidChangeFocus(() => this.focus())); + this.repositoryDisposables.add(input.onDidChangeValidationMessage((e) => this.setValidation(e, { focus: true, timeout: true }))); + this.repositoryDisposables.add(input.onDidChangeValidateInput((e) => triggerValidation())); + this.repositoryDisposables.add(input.onDidClearValidation(() => this.clearValidation())); + + // Keep API in sync with model and validate + this.repositoryDisposables.add(textModel.onDidChangeContent(() => { + input.setValue(textModel.getValue(), true); + triggerValidation(); + })); + + // Aria label & placeholder text + const accessibilityVerbosityConfig = observableConfigValue( + AccessibilityVerbositySettingId.SourceControl, true, this.configurationService); + + const getAriaLabel = (placeholder: string, verbosity?: boolean) => { + verbosity = verbosity ?? accessibilityVerbosityConfig.get(); + + if (!verbosity || !this.accessibilityService.isScreenReaderOptimized()) { + return placeholder; + } + + const kbLabel = this.keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel(); + return kbLabel + ? localize('scmInput.accessibilityHelp', "{0}, Use {1} to open Source Control Accessibility Help.", placeholder, kbLabel) + : localize('scmInput.accessibilityHelpNoKb', "{0}, Run the Open Accessibility Help command for more information.", placeholder); + }; + + const getPlaceholderText = (): string => { + const binding = this.keybindingService.lookupKeybinding('scm.acceptInput'); + const label = binding ? binding.getLabel() : (platform.isMacintosh ? 'Cmd+Enter' : 'Ctrl+Enter'); + return format(input.placeholder, label); + }; + + const updatePlaceholderText = () => { + const placeholder = getPlaceholderText(); + const ariaLabel = getAriaLabel(placeholder); + + this.inputEditor.updateOptions({ ariaLabel, placeholder }); + }; + + this.repositoryDisposables.add(input.onDidChangePlaceholder(updatePlaceholderText)); + this.repositoryDisposables.add(this.keybindingService.onDidUpdateKeybindings(updatePlaceholderText)); + + this.repositoryDisposables.add(runOnChange(accessibilityVerbosityConfig, verbosity => { + const placeholder = getPlaceholderText(); + const ariaLabel = getAriaLabel(placeholder, verbosity); + + this.inputEditor.updateOptions({ ariaLabel }); + })); + + updatePlaceholderText(); + + // Update input template + let commitTemplate = ''; + this.repositoryDisposables.add(autorun(reader => { + if (!input.visible) { + return; + } + + const oldCommitTemplate = commitTemplate; + commitTemplate = input.repository.provider.commitTemplate.read(reader); + + const value = textModel.getValue(); + if (value && value !== oldCommitTemplate) { + return; + } + + textModel.setValue(commitTemplate); + })); + + // Update input enablement + const updateEnablement = (enabled: boolean) => { + this.inputEditor.updateOptions({ readOnly: !enabled }); + }; + this.repositoryDisposables.add(input.onDidChangeEnablement(enabled => updateEnablement(enabled))); + updateEnablement(input.enabled); + + // Toolbar + this.toolbar.setInput(input); + + // Save model + this.model = { input, textModel }; + } + + get selections(): Selection[] | null { + return this.inputEditor.getSelections(); + } + + set selections(selections: Selection[] | null) { + if (selections) { + this.inputEditor.setSelections(selections); + } + } + + private setValidation(validation: IInputValidation | undefined, options?: { focus?: boolean; timeout?: boolean }) { + if (this._validationTimer) { + clearTimeout(this._validationTimer); + this._validationTimer = undefined; + } + + this.validation = validation; + this.renderValidation(); + + if (options?.focus && !this.hasFocus()) { + this.focus(); + } + + if (validation && options?.timeout) { + this._validationTimer = setTimeout(() => this.setValidation(undefined), SCMInputWidget.ValidationTimeouts[validation.type]); + } + } + + constructor( + container: HTMLElement, + overflowWidgetsDomNode: HTMLElement, + @IContextKeyService contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, + @IModelService private modelService: IModelService, + @IKeybindingService private keybindingService: IKeybindingService, + @IConfigurationService private configurationService: IConfigurationService, + @ISCMViewService private readonly scmViewService: ISCMViewService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IOpenerService private readonly openerService: IOpenerService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + ) { + this.element = append(container, $('.scm-editor')); + this.editorContainer = append(this.element, $('.scm-editor-container')); + this.toolbarContainer = append(this.element, $('.scm-editor-toolbar')); + + this.contextKeyService = this.disposables.add(contextKeyService.createScoped(this.element)); + this.repositoryIdContextKey = this.contextKeyService.createKey('scmRepository', undefined); + this.validationMessageContextKey = SCMInputContextKeys.SCMInputHasValidationMessage.bindTo(this.contextKeyService); + + this.inputEditorOptions = new SCMInputWidgetEditorOptions(overflowWidgetsDomNode, this.configurationService); + this.disposables.add(this.inputEditorOptions.onDidChange(this.onDidChangeEditorOptions, this)); + this.disposables.add(this.inputEditorOptions); + + const codeEditorWidgetOptions: ICodeEditorWidgetOptions = { + contributions: EditorExtensionsRegistry.getSomeEditorContributions([ + CodeActionController.ID, + ColorDetector.ID, + ContextMenuController.ID, + CopyPasteController.ID, + DragAndDropController.ID, + DropIntoEditorController.ID, + EditorDictation.ID, + FormatOnType.ID, + ContentHoverController.ID, + GlyphHoverController.ID, + InlineCompletionsController.ID, + LinkDetector.ID, + MenuPreventer.ID, + MessageController.ID, + PlaceholderTextContribution.ID, + SelectionClipboardContributionID, + SnippetController2.ID, + SuggestController.ID + ]), + isSimpleWidget: true + }; + + const services = new ServiceCollection([IContextKeyService, this.contextKeyService]); + const instantiationService2 = instantiationService.createChild(services, this.disposables); + const editorConstructionOptions = this.inputEditorOptions.getEditorConstructionOptions(); + this.inputEditor = instantiationService2.createInstance(CodeEditorWidget, this.editorContainer, editorConstructionOptions, codeEditorWidgetOptions); + this.disposables.add(this.inputEditor); + + this.disposables.add(this.inputEditor.onDidFocusEditorText(() => { + if (this.input?.repository) { + this.scmViewService.focus(this.input.repository); + } + + this.element.classList.add('synthetic-focus'); + this.renderValidation(); + })); + this.disposables.add(this.inputEditor.onDidBlurEditorText(() => { + this.element.classList.remove('synthetic-focus'); + + setTimeout(() => { + if (!this.validation || !this.validationHasFocus) { + this.clearValidation(); + } + }, 0); + })); + + this.disposables.add(this.inputEditor.onDidBlurEditorWidget(() => { + CopyPasteController.get(this.inputEditor)?.clearWidgets(); + DropIntoEditorController.get(this.inputEditor)?.clearWidgets(); + })); + + const firstLineKey = this.contextKeyService.createKey('scmInputIsInFirstPosition', false); + const lastLineKey = this.contextKeyService.createKey('scmInputIsInLastPosition', false); + + this.disposables.add(this.inputEditor.onDidChangeCursorPosition(({ position }) => { + const viewModel = this.inputEditor._getViewModel()!; + const lastLineNumber = viewModel.getLineCount(); + const lastLineCol = viewModel.getLineLength(lastLineNumber) + 1; + const viewPosition = viewModel.coordinatesConverter.convertModelPositionToViewPosition(position); + firstLineKey.set(viewPosition.lineNumber === 1 && viewPosition.column === 1); + lastLineKey.set(viewPosition.lineNumber === lastLineNumber && viewPosition.column === lastLineCol); + })); + this.disposables.add(this.inputEditor.onDidScrollChange(e => { + this.toolbarContainer.classList.toggle('scroll-decoration', e.scrollTop > 0); + })); + + Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('scm.showInputActionButton'))(() => this.layout(), this, this.disposables); + + this.onDidChangeContentHeight = Event.signal(Event.filter(this.inputEditor.onDidContentSizeChange, e => e.contentHeightChanged, this.disposables)); + + // Toolbar + this.toolbar = instantiationService2.createInstance(SCMInputWidgetToolbar, this.toolbarContainer, { + actionViewItemProvider: (action, options) => { + if (action instanceof MenuItemAction && this.toolbar.dropdownActions.length > 1) { + return instantiationService.createInstance(DropdownWithPrimaryActionViewItem, action, this.toolbar.dropdownAction, this.toolbar.dropdownActions, '', { actionRunner: this.toolbar.actionRunner, hoverDelegate: options.hoverDelegate }); + } + + return createActionViewItem(instantiationService, action, options); + }, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + menuOptions: { + shouldForwardArgs: true + } + }); + this.disposables.add(this.toolbar.onDidChange(() => this.layout())); + this.disposables.add(this.toolbar); + } + + getContentHeight(): number { + const lineHeight = this.inputEditor.getOption(EditorOption.lineHeight); + const { top, bottom } = this.inputEditor.getOption(EditorOption.padding); + + const inputMinLinesConfig = this.configurationService.getValue('scm.inputMinLineCount'); + const inputMinLines = typeof inputMinLinesConfig === 'number' ? clamp(inputMinLinesConfig, 1, 50) : 1; + const editorMinHeight = inputMinLines * lineHeight + top + bottom; + + const inputMaxLinesConfig = this.configurationService.getValue('scm.inputMaxLineCount'); + const inputMaxLines = typeof inputMaxLinesConfig === 'number' ? clamp(inputMaxLinesConfig, 1, 50) : 10; + const editorMaxHeight = inputMaxLines * lineHeight + top + bottom; + + return clamp(this.inputEditor.getContentHeight(), editorMinHeight, editorMaxHeight); + } + + layout(): void { + const editorHeight = this.getContentHeight(); + const toolbarWidth = this.getToolbarWidth(); + const dimension = new Dimension(this.element.clientWidth - toolbarWidth, editorHeight); + + if (dimension.width < 0) { + this.lastLayoutWasTrash = true; + return; + } + + this.lastLayoutWasTrash = false; + this.inputEditor.layout(dimension); + this.renderValidation(); + + const showInputActionButton = this.configurationService.getValue('scm.showInputActionButton') === true; + this.toolbarContainer.classList.toggle('hidden', !showInputActionButton || this.toolbar?.isEmpty() === true); + + if (this.shouldFocusAfterLayout) { + this.shouldFocusAfterLayout = false; + this.focus(); + } + } + + focus(): void { + if (this.lastLayoutWasTrash) { + this.lastLayoutWasTrash = false; + this.shouldFocusAfterLayout = true; + return; + } + + this.inputEditor.focus(); + this.element.classList.add('synthetic-focus'); + } + + hasFocus(): boolean { + return this.inputEditor.hasTextFocus(); + } + + private onDidChangeEditorOptions(): void { + this.inputEditor.updateOptions(this.inputEditorOptions.getEditorOptions()); + } + + private renderValidation(): void { + this.clearValidation(); + + this.element.classList.toggle('validation-info', this.validation?.type === InputValidationType.Information); + this.element.classList.toggle('validation-warning', this.validation?.type === InputValidationType.Warning); + this.element.classList.toggle('validation-error', this.validation?.type === InputValidationType.Error); + + if (!this.validation || !this.inputEditor.hasTextFocus()) { + return; + } + + this.validationMessageContextKey.set(true); + const disposables = new DisposableStore(); + + this.validationContextView = this.contextViewService.showContextView({ + getAnchor: () => this.element, + render: container => { + this.element.style.borderBottomLeftRadius = '0'; + this.element.style.borderBottomRightRadius = '0'; + + const validationContainer = append(container, $('.scm-editor-validation-container')); + validationContainer.classList.toggle('validation-info', this.validation!.type === InputValidationType.Information); + validationContainer.classList.toggle('validation-warning', this.validation!.type === InputValidationType.Warning); + validationContainer.classList.toggle('validation-error', this.validation!.type === InputValidationType.Error); + validationContainer.style.width = `${this.element.clientWidth + 2}px`; + const element = append(validationContainer, $('.scm-editor-validation')); + + const message = this.validation!.message; + if (typeof message === 'string') { + element.textContent = message; + } else { + const tracker = trackFocus(element); + disposables.add(tracker); + disposables.add(tracker.onDidFocus(() => (this.validationHasFocus = true))); + disposables.add(tracker.onDidBlur(() => { + this.validationHasFocus = false; + this.element.style.borderBottomLeftRadius = '2px'; + this.element.style.borderBottomRightRadius = '2px'; + this.contextViewService.hideContextView(); + })); + + const renderedMarkdown = this.markdownRendererService.render(message, { + actionHandler: (link, mdStr) => { + openLinkFromMarkdown(this.openerService, link, mdStr.isTrusted); + this.element.style.borderBottomLeftRadius = '2px'; + this.element.style.borderBottomRightRadius = '2px'; + this.contextViewService.hideContextView(); + }, + }); + disposables.add(renderedMarkdown); + element.appendChild(renderedMarkdown.element); + } + const actionsContainer = append(validationContainer, $('.scm-editor-validation-actions')); + const actionbar = new ActionBar(actionsContainer); + const action = new Action('scmInputWidget.validationMessage.close', localize('label.close', "Close"), ThemeIcon.asClassName(Codicon.close), true, () => { + this.contextViewService.hideContextView(); + this.element.style.borderBottomLeftRadius = '2px'; + this.element.style.borderBottomRightRadius = '2px'; + }); + disposables.add(actionbar); + actionbar.push(action, { icon: true, label: false }); + + return Disposable.None; + }, + onHide: () => { + this.validationHasFocus = false; + this.element.style.borderBottomLeftRadius = '2px'; + this.element.style.borderBottomRightRadius = '2px'; + disposables.dispose(); + }, + anchorAlignment: AnchorAlignment.LEFT + }); + } + + private getToolbarWidth(): number { + const showInputActionButton = this.configurationService.getValue('scm.showInputActionButton'); + if (!this.toolbar || !showInputActionButton || this.toolbar?.isEmpty() === true) { + return 0; + } + + return this.toolbar.dropdownActions.length === 0 ? + 26 /* 22px action + 4px margin */ : + 39 /* 35px action + 4px margin */; + } + + clearValidation(): void { + this.validationContextView?.close(); + this.validationContextView = undefined; + this.validationHasFocus = false; + this.validationMessageContextKey.set(false); + } + + dispose(): void { + this.input = undefined; + this.repositoryDisposables.dispose(); + this.clearValidation(); + this.disposables.dispose(); + } +} + +registerAction2(class extends Action2 { + constructor() { + super({ + id: SCMInputWidgetCommandId.SetupAction, + title: localize('scmInputGenerateCommitMessage', "Generate Commit Message"), + icon: Codicon.sparkle, + f1: false, + menu: { + id: MenuId.SCMInputBox, + when: ContextKeyExpr.and( + ChatContextKeys.Setup.hidden.negate(), + ChatContextKeys.Setup.disabled.negate(), + ChatContextKeys.Setup.installed.negate(), + ContextKeyExpr.equals('scmProvider', 'git') + ) + } + }); + } + + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const commandService = accessor.get(ICommandService); + + const result = await commandService.executeCommand(CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID); + if (!result) { + return; + } + + const command = product.defaultChatAgent?.generateCommitMessageCommand; + if (!command) { + return; + } + + await commandService.executeCommand(command, ...args); + } +}); + +setupSimpleEditorSelectionStyling('.scm-view .scm-editor-container'); diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 669dd15c1c9d4..a2887d47f2521 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -8,26 +8,26 @@ import { Event, Emitter } from '../../../../base/common/event.js'; import { basename, dirname } from '../../../../base/common/resources.js'; import { IDisposable, Disposable, DisposableStore, combinedDisposable, dispose, toDisposable, MutableDisposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { ViewPane, IViewPaneOptions, ViewAction } from '../../../browser/parts/views/viewPane.js'; -import { append, $, Dimension, trackFocus, clearNode, isPointerEvent, isActiveElement } from '../../../../base/browser/dom.js'; +import { append, $, clearNode, isPointerEvent, isActiveElement } from '../../../../base/browser/dom.js'; import { asCSSUrl } from '../../../../base/browser/cssValue.js'; import { IListVirtualDelegate, IIdentityProvider } from '../../../../base/browser/ui/list/list.js'; -import { ISCMResourceGroup, ISCMResource, InputValidationType, ISCMRepository, ISCMInput, IInputValidation, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ISCMService, SCMInputChangeReason, VIEW_PANE_ID, ISCMActionButton, ISCMActionButtonDescriptor, ISCMRepositorySortKey, ISCMInputValueProviderContext, ViewMode, ISCMRepositorySelectionMode } from '../common/scm.js'; +import { ISCMResourceGroup, ISCMResource, ISCMRepository, ISCMInput, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ISCMService, VIEW_PANE_ID, ISCMActionButton, ISCMActionButtonDescriptor, ISCMRepositorySortKey, ViewMode, ISCMRepositorySelectionMode } from '../common/scm.js'; import { ResourceLabels, IResourceLabel, IFileLabelOptions } from '../../../browser/labels.js'; import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IContextViewService, IContextMenuService, IOpenContextView } from '../../../../platform/contextview/browser/contextView.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IContextKeyService, IContextKey, ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { MenuItemAction, IMenuService, registerAction2, MenuId, IAction2Options, MenuRegistry, Action2, IMenu } from '../../../../platform/actions/common/actions.js'; -import { IAction, ActionRunner, Action, Separator, IActionRunner, toAction } from '../../../../base/common/actions.js'; -import { ActionBar, IActionViewItemProvider } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { IAction, ActionRunner, Separator, IActionRunner, toAction } from '../../../../base/common/actions.js'; +import { IActionViewItemProvider } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { IThemeService, IFileIconTheme } from '../../../../platform/theme/common/themeService.js'; import { isSCMResource, isSCMResourceGroup, isSCMRepository, isSCMInput, collectContextMenuActions, getActionViewItemProvider, isSCMActionButton, isSCMViewService, isSCMResourceNode, connectPrimaryMenu } from './util.js'; import { WorkbenchCompressibleAsyncDataTree, IOpenEvent } from '../../../../platform/list/browser/listService.js'; -import { IConfigurationService, ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; -import { disposableTimeout, Sequencer, ThrottledDelayer, Throttler } from '../../../../base/common/async.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { disposableTimeout, Sequencer, Throttler } from '../../../../base/common/async.js'; import { ITreeNode, ITreeFilter, ITreeSorter, ITreeContextMenuEvent, ITreeDragAndDrop, ITreeDragOverReaction, IAsyncDataSource } from '../../../../base/browser/ui/tree/tree.js'; import { ResourceTree, IResourceNode } from '../../../../base/common/resourceTree.js'; import { ICompressibleTreeRenderer, ICompressibleKeyboardNavigationLabelProvider } from '../../../../base/browser/ui/tree/objectTree.js'; @@ -41,76 +41,40 @@ import { IViewDescriptorService } from '../../../common/views.js'; import { localize } from '../../../../nls.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../common/editor.js'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; -import { getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../codeEditor/browser/simpleEditorOptions.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; -import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; -import { MenuPreventer } from '../../codeEditor/browser/menuPreventer.js'; -import { SelectionClipboardContributionID } from '../../codeEditor/browser/selectionClipboard.js'; -import { EditorDictation } from '../../codeEditor/browser/dictation/editorDictation.js'; -import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; -import * as platform from '../../../../base/common/platform.js'; -import { compare, format } from '../../../../base/common/strings.js'; -import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; -import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; -import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { ColorDetector } from '../../../../editor/contrib/colorPicker/browser/colorDetector.js'; -import { LinkDetector } from '../../../../editor/contrib/links/browser/links.js'; +import { compare } from '../../../../base/common/strings.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; -import { KeyCode } from '../../../../base/common/keyCodes.js'; -import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { AnchorAlignment } from '../../../../base/browser/ui/contextview/contextview.js'; import { RepositoryActionRunner, RepositoryRenderer } from './scmRepositoryRenderer.js'; import { isDark } from '../../../../platform/theme/common/theme.js'; import { LabelFuzzyScore } from '../../../../base/browser/ui/tree/abstractTree.js'; import { Selection } from '../../../../editor/common/core/selection.js'; import { API_OPEN_DIFF_EDITOR_COMMAND_ID, API_OPEN_EDITOR_COMMAND_ID } from '../../../browser/parts/editor/editorCommands.js'; -import { createActionViewItem, getFlatActionBarActions, getFlatContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { getFlatContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { Button, ButtonWithDescription, ButtonWithDropdown } from '../../../../base/browser/ui/button/button.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { RepositoryContextKeys } from './scmViewService.js'; -import { DragAndDropController } from '../../../../editor/contrib/dnd/browser/dnd.js'; -import { CopyPasteController } from '../../../../editor/contrib/dropOrPasteInto/browser/copyPasteController.js'; -import { DropIntoEditorController } from '../../../../editor/contrib/dropOrPasteInto/browser/dropIntoEditorController.js'; -import { MessageController } from '../../../../editor/contrib/message/browser/messageController.js'; import { defaultButtonStyles, defaultCountBadgeStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { InlineCompletionsController } from '../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; -import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; import { Schemas } from '../../../../base/common/network.js'; import { IDragAndDropData } from '../../../../base/browser/dnd.js'; import { fillEditorsDragData } from '../../../browser/dnd.js'; import { ElementsDragAndDropData, ListViewTargetSector } from '../../../../base/browser/ui/list/listView.js'; import { CodeDataTransfers } from '../../../../platform/dnd/browser/dnd.js'; -import { FormatOnType } from '../../../../editor/contrib/format/browser/formatActions.js'; -import { EditorOption, EditorOptions, IEditorOptions } from '../../../../editor/common/config/editorOptions.js'; import { IAsyncDataTreeViewState, ITreeCompressionDelegate } from '../../../../base/browser/ui/tree/asyncDataTree.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { EditOperation } from '../../../../editor/common/core/editOperation.js'; -import { HiddenItemStrategy, IMenuWorkbenchToolBarOptions, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { DropdownWithPrimaryActionViewItem } from '../../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; -import { clamp, rot } from '../../../../base/common/numbers.js'; +import { WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { rot } from '../../../../base/common/numbers.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { OpenScmGroupAction } from '../../multiDiffEditor/browser/scmMultiDiffSourceResolver.js'; -import { ContentHoverController } from '../../../../editor/contrib/hover/browser/contentHoverController.js'; -import { GlyphHoverController } from '../../../../editor/contrib/hover/browser/glyphHoverController.js'; -import { ITextModel } from '../../../../editor/common/model.js'; -import { autorun, runOnChange } from '../../../../base/common/observable.js'; -import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; +import { autorun } from '../../../../base/common/observable.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; -import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; -import product from '../../../../platform/product/common/product.js'; -import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; +import { SCMInputWidget } from './scmInput.js'; type TreeElement = ISCMRepository | ISCMInput | ISCMActionButton | ISCMResourceGroup | ISCMResource | IResourceNode; @@ -981,7 +945,6 @@ export const ContextKeys = { SCMCurrentHistoryItemRefInFilter: new RawContextKey('scmCurrentHistoryItemRefInFilter', false), RepositoryCount: new RawContextKey('scmRepositoryCount', 0), RepositoryVisibilityCount: new RawContextKey('scmRepositoryVisibleCount', 0), - SCMInputHasValidationMessage: new RawContextKey('scmInputHasValidationMessage', false), RepositoryVisibility(repository: ISCMRepository) { return new RawContextKey(`scmRepositoryVisible:${repository.provider.id}`, false); } @@ -1394,803 +1357,9 @@ class CollapseAllAction extends ViewAction { registerAction2(CollapseAllAction); -const enum SCMInputWidgetCommandId { - CancelAction = 'scm.input.cancelAction', - SetupAction = 'scm.input.triggerSetup' -} - -const enum SCMInputWidgetStorageKey { - LastActionId = 'scm.input.lastActionId' -} - -registerAction2(class extends Action2 { - constructor() { - super({ - id: SCMInputWidgetCommandId.SetupAction, - title: localize('scmInputGenerateCommitMessage', "Generate Commit Message"), - icon: Codicon.sparkle, - f1: false, - menu: { - id: MenuId.SCMInputBox, - when: ContextKeyExpr.and( - ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabled.negate(), - ChatContextKeys.Setup.installed.negate(), - ContextKeyExpr.equals('scmProvider', 'git') - ) - } - }); - } - - override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { - const commandService = accessor.get(ICommandService); - - const result = await commandService.executeCommand(CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID); - if (!result) { - return; - } - - const command = product.defaultChatAgent?.generateCommitMessageCommand; - if (!command) { - return; - } - - await commandService.executeCommand(command, ...args); - } -}); - -class SCMInputWidgetActionRunner extends ActionRunner { - - private readonly _runningActions = new Set(); - public get runningActions(): Set { return this._runningActions; } - - private _cts: CancellationTokenSource | undefined; - - constructor( - private readonly input: ISCMInput, - @IStorageService private readonly storageService: IStorageService - ) { - super(); - } - - protected override async runAction(action: IAction): Promise { - try { - // Cancel previous action - if (this.runningActions.size !== 0) { - this._cts?.cancel(); - - if (action.id === SCMInputWidgetCommandId.CancelAction) { - return; - } - } - - // Create action context - const context: ISCMInputValueProviderContext[] = []; - for (const group of this.input.repository.provider.groups) { - context.push({ - resourceGroupId: group.id, - resources: [...group.resources.map(r => r.sourceUri)] - }); - } - - // Run action - this._runningActions.add(action); - this._cts = new CancellationTokenSource(); - await action.run(...[this.input.repository.provider.rootUri, context, this._cts.token]); - } finally { - this._runningActions.delete(action); - - // Save last action - if (this._runningActions.size === 0) { - const actionId = action.id === SCMInputWidgetCommandId.SetupAction - ? product.defaultChatAgent?.generateCommitMessageCommand ?? action.id - : action.id; - this.storageService.store(SCMInputWidgetStorageKey.LastActionId, actionId, StorageScope.PROFILE, StorageTarget.USER); - } - } - } - -} - -class SCMInputWidgetToolbar extends WorkbenchToolBar { - - private _dropdownActions: IAction[] = []; - get dropdownActions(): IAction[] { return this._dropdownActions; } - - private _dropdownAction: IAction; - get dropdownAction(): IAction { return this._dropdownAction; } - - private _cancelAction: IAction; - - private _onDidChange = new Emitter(); - readonly onDidChange: Event = this._onDidChange.event; - - private readonly _disposables = this._register(new MutableDisposable()); - - constructor( - container: HTMLElement, - options: IMenuWorkbenchToolBarOptions | undefined, - @IMenuService private readonly menuService: IMenuService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IContextMenuService contextMenuService: IContextMenuService, - @ICommandService commandService: ICommandService, - @IKeybindingService keybindingService: IKeybindingService, - @IStorageService private readonly storageService: IStorageService, - @ITelemetryService telemetryService: ITelemetryService, - ) { - super(container, options, menuService, contextKeyService, contextMenuService, keybindingService, commandService, telemetryService); - - this._dropdownAction = new Action( - 'scmInputMoreActions', - localize('scmInputMoreActions', "More Actions..."), - 'codicon-chevron-down'); - - this._cancelAction = new MenuItemAction({ - id: SCMInputWidgetCommandId.CancelAction, - title: localize('scmInputCancelAction', "Cancel"), - icon: Codicon.stopCircle, - }, undefined, undefined, undefined, undefined, contextKeyService, commandService); - } - - public setInput(input: ISCMInput): void { - this._disposables.value = new DisposableStore(); - - const contextKeyService = this.contextKeyService.createOverlay([ - ['scmProvider', input.repository.provider.providerId], - ['scmProviderRootUri', input.repository.provider.rootUri?.toString()], - ['scmProviderHasRootUri', !!input.repository.provider.rootUri] - ]); - - const menu = this._disposables.value.add(this.menuService.createMenu(MenuId.SCMInputBox, contextKeyService, { emitEventsForSubmenuChanges: true })); - - const isEnabled = (): boolean => { - return input.repository.provider.groups.some(g => g.resources.length > 0); - }; - - const updateToolbar = () => { - const actions = getFlatActionBarActions(menu.getActions({ shouldForwardArgs: true })); - - for (const action of actions) { - action.enabled = isEnabled(); - } - this._dropdownAction.enabled = isEnabled(); - - let primaryAction: IAction | undefined = undefined; - - if ((this.actionRunner as SCMInputWidgetActionRunner).runningActions.size !== 0) { - primaryAction = this._cancelAction; - } else if (actions.length === 1) { - primaryAction = actions[0]; - } else if (actions.length > 1) { - const lastActionId = this.storageService.get(SCMInputWidgetStorageKey.LastActionId, StorageScope.PROFILE, ''); - primaryAction = actions.find(a => a.id === lastActionId) ?? actions[0]; - } - - this._dropdownActions = actions.length === 1 ? [] : actions; - super.setActions(primaryAction ? [primaryAction] : [], []); - - this._onDidChange.fire(); - }; - - this._disposables.value.add(menu.onDidChange(() => updateToolbar())); - this._disposables.value.add(input.repository.provider.onDidChangeResources(() => updateToolbar())); - this._disposables.value.add(this.storageService.onDidChangeValue(StorageScope.PROFILE, SCMInputWidgetStorageKey.LastActionId, this._disposables.value)(() => updateToolbar())); - - this.actionRunner = this._disposables.value.add(new SCMInputWidgetActionRunner(input, this.storageService)); - this._disposables.value.add(this.actionRunner.onWillRun(e => { - if ((this.actionRunner as SCMInputWidgetActionRunner).runningActions.size === 0) { - super.setActions([this._cancelAction], []); - this._onDidChange.fire(); - } - })); - this._disposables.value.add(this.actionRunner.onDidRun(e => { - if ((this.actionRunner as SCMInputWidgetActionRunner).runningActions.size === 0) { - updateToolbar(); - } - })); - - updateToolbar(); - } -} - -class SCMInputWidgetEditorOptions { - - private readonly _onDidChange = new Emitter(); - readonly onDidChange = this._onDidChange.event; - - private readonly defaultInputFontFamily = DEFAULT_FONT_FAMILY; - - private readonly _disposables = new DisposableStore(); - - constructor( - private readonly overflowWidgetsDomNode: HTMLElement, - private readonly configurationService: IConfigurationService) { - - const onDidChangeConfiguration = Event.filter( - this.configurationService.onDidChangeConfiguration, - e => { - return e.affectsConfiguration('editor.accessibilitySupport') || - e.affectsConfiguration('editor.cursorBlinking') || - e.affectsConfiguration('editor.cursorStyle') || - e.affectsConfiguration('editor.cursorWidth') || - e.affectsConfiguration('editor.emptySelectionClipboard') || - e.affectsConfiguration('editor.fontFamily') || - e.affectsConfiguration('editor.rulers') || - e.affectsConfiguration('editor.wordWrap') || - e.affectsConfiguration('editor.wordSegmenterLocales') || - e.affectsConfiguration('scm.inputFontFamily') || - e.affectsConfiguration('scm.inputFontSize'); - }, - this._disposables - ); - - this._disposables.add(onDidChangeConfiguration(() => this._onDidChange.fire())); - } - - getEditorConstructionOptions(): IEditorConstructionOptions { - return { - ...getSimpleEditorOptions(this.configurationService), - ...this.getEditorOptions(), - dragAndDrop: true, - dropIntoEditor: { enabled: true }, - formatOnType: true, - lineDecorationsWidth: 6, - overflowWidgetsDomNode: this.overflowWidgetsDomNode, - padding: { top: 2, bottom: 2 }, - quickSuggestions: false, - renderWhitespace: 'none', - scrollbar: { - alwaysConsumeMouseWheel: false, - vertical: 'hidden' - }, - wrappingIndent: 'none', - wrappingStrategy: 'advanced', - }; - } - - getEditorOptions(): IEditorOptions { - const fontFamily = this._getEditorFontFamily(); - const fontSize = this._getEditorFontSize(); - const lineHeight = this._getEditorLineHeight(fontSize); - const wordSegmenterLocales = this.configurationService.getValue('editor.wordSegmenterLocales'); - const accessibilitySupport = this.configurationService.getValue<'auto' | 'off' | 'on'>('editor.accessibilitySupport'); - const cursorBlinking = this.configurationService.getValue<'blink' | 'smooth' | 'phase' | 'expand' | 'solid'>('editor.cursorBlinking'); - const cursorStyle = this.configurationService.getValue('editor.cursorStyle'); - const cursorWidth = this.configurationService.getValue('editor.cursorWidth') ?? 1; - const emptySelectionClipboard = this.configurationService.getValue('editor.emptySelectionClipboard') === true; - - return { ...this._getEditorLanguageConfiguration(), accessibilitySupport, cursorBlinking, cursorStyle, cursorWidth, fontFamily, fontSize, lineHeight, emptySelectionClipboard, wordSegmenterLocales }; - } - - private _getEditorFontFamily(): string { - const inputFontFamily = this.configurationService.getValue('scm.inputFontFamily').trim(); - - if (inputFontFamily.toLowerCase() === 'editor') { - return this.configurationService.getValue('editor.fontFamily').trim(); - } - - if (inputFontFamily.length !== 0 && inputFontFamily.toLowerCase() !== 'default') { - return inputFontFamily; - } - - return this.defaultInputFontFamily; - } - - private _getEditorFontSize(): number { - return this.configurationService.getValue('scm.inputFontSize'); - } - - private _getEditorLanguageConfiguration(): IEditorOptions { - // editor.rulers - const rulersConfig = this.configurationService.inspect('editor.rulers', { overrideIdentifier: 'scminput' }); - const rulers = rulersConfig.overrideIdentifiers?.includes('scminput') ? EditorOptions.rulers.validate(rulersConfig.value) : []; - - // editor.wordWrap - const wordWrapConfig = this.configurationService.inspect('editor.wordWrap', { overrideIdentifier: 'scminput' }); - const wordWrap = wordWrapConfig.overrideIdentifiers?.includes('scminput') ? EditorOptions.wordWrap.validate(wordWrapConfig.value) : 'on'; - - return { rulers, wordWrap }; - } - - private _getEditorLineHeight(fontSize: number): number { - return Math.round(fontSize * 1.5); - } - - dispose(): void { - this._disposables.dispose(); - this._onDidChange.dispose(); - } - -} - -class SCMInputWidget { - - private static readonly ValidationTimeouts: { [severity: number]: number } = { - [InputValidationType.Information]: 5000, - [InputValidationType.Warning]: 8000, - [InputValidationType.Error]: 10000 - }; - - private readonly contextKeyService: IContextKeyService; - - private element: HTMLElement; - private editorContainer: HTMLElement; - private readonly inputEditor: CodeEditorWidget; - private readonly inputEditorOptions: SCMInputWidgetEditorOptions; - private toolbarContainer: HTMLElement; - private toolbar: SCMInputWidgetToolbar; - private readonly disposables = new DisposableStore(); - - private model: { readonly input: ISCMInput; readonly textModel: ITextModel } | undefined; - private repositoryIdContextKey: IContextKey; - private validationMessageContextKey: IContextKey; - private readonly repositoryDisposables = new DisposableStore(); - - private validation: IInputValidation | undefined; - private validationContextView: IOpenContextView | undefined; - private validationHasFocus: boolean = false; - private _validationTimer: Timeout | undefined; - - // This is due to "Setup height change listener on next tick" above - // https://github.com/microsoft/vscode/issues/108067 - private lastLayoutWasTrash = false; - private shouldFocusAfterLayout = false; - - readonly onDidChangeContentHeight: Event; - - get input(): ISCMInput | undefined { - return this.model?.input; - } - - set input(input: ISCMInput | undefined) { - if (input === this.input) { - return; - } - - this.clearValidation(); - this.element.classList.remove('synthetic-focus'); - - this.repositoryDisposables.clear(); - this.repositoryIdContextKey.set(input?.repository.id); - - if (!input) { - this.inputEditor.setModel(undefined); - this.model = undefined; - return; - } - - const textModel = input.repository.provider.inputBoxTextModel; - this.inputEditor.setModel(textModel); - - if (this.configurationService.getValue('editor.wordBasedSuggestions', { resource: textModel.uri }) !== 'off') { - this.configurationService.updateValue('editor.wordBasedSuggestions', 'off', { resource: textModel.uri }, ConfigurationTarget.MEMORY); - } - - // Validation - const validationDelayer = new ThrottledDelayer(200); - const validate = async () => { - const position = this.inputEditor.getSelection()?.getStartPosition(); - const offset = position && textModel.getOffsetAt(position); - const value = textModel.getValue(); - - this.setValidation(await input.validateInput(value, offset || 0)); - }; - - const triggerValidation = () => validationDelayer.trigger(validate); - this.repositoryDisposables.add(validationDelayer); - this.repositoryDisposables.add(this.inputEditor.onDidChangeCursorPosition(triggerValidation)); - - // Adaptive indentation rules - const opts = this.modelService.getCreationOptions(textModel.getLanguageId(), textModel.uri, textModel.isForSimpleWidget); - const onEnter = Event.filter(this.inputEditor.onKeyDown, e => e.keyCode === KeyCode.Enter, this.repositoryDisposables); - this.repositoryDisposables.add(onEnter(() => textModel.detectIndentation(opts.insertSpaces, opts.tabSize))); - - // Keep model in sync with API - textModel.setValue(input.value); - this.repositoryDisposables.add(input.onDidChange(({ value, reason }) => { - const currentValue = textModel.getValue(); - if (value === currentValue) { // circuit breaker - return; - } - - textModel.pushStackElement(); - textModel.pushEditOperations(null, [EditOperation.replaceMove(textModel.getFullModelRange(), value)], () => []); - - const position = reason === SCMInputChangeReason.HistoryPrevious - ? textModel.getFullModelRange().getStartPosition() - : textModel.getFullModelRange().getEndPosition(); - this.inputEditor.setPosition(position); - this.inputEditor.revealPositionInCenterIfOutsideViewport(position); - })); - this.repositoryDisposables.add(input.onDidChangeFocus(() => this.focus())); - this.repositoryDisposables.add(input.onDidChangeValidationMessage((e) => this.setValidation(e, { focus: true, timeout: true }))); - this.repositoryDisposables.add(input.onDidChangeValidateInput((e) => triggerValidation())); - this.repositoryDisposables.add(input.onDidClearValidation(() => this.clearValidation())); - - // Keep API in sync with model and validate - this.repositoryDisposables.add(textModel.onDidChangeContent(() => { - input.setValue(textModel.getValue(), true); - triggerValidation(); - })); - - // Aria label & placeholder text - const accessibilityVerbosityConfig = observableConfigValue( - AccessibilityVerbositySettingId.SourceControl, true, this.configurationService); - - const getAriaLabel = (placeholder: string, verbosity?: boolean) => { - verbosity = verbosity ?? accessibilityVerbosityConfig.get(); - - if (!verbosity || !this.accessibilityService.isScreenReaderOptimized()) { - return placeholder; - } - - const kbLabel = this.keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel(); - return kbLabel - ? localize('scmInput.accessibilityHelp', "{0}, Use {1} to open Source Control Accessibility Help.", placeholder, kbLabel) - : localize('scmInput.accessibilityHelpNoKb', "{0}, Run the Open Accessibility Help command for more information.", placeholder); - }; - - const getPlaceholderText = (): string => { - const binding = this.keybindingService.lookupKeybinding('scm.acceptInput'); - const label = binding ? binding.getLabel() : (platform.isMacintosh ? 'Cmd+Enter' : 'Ctrl+Enter'); - return format(input.placeholder, label); - }; - - const updatePlaceholderText = () => { - const placeholder = getPlaceholderText(); - const ariaLabel = getAriaLabel(placeholder); - - this.inputEditor.updateOptions({ ariaLabel, placeholder }); - }; - - this.repositoryDisposables.add(input.onDidChangePlaceholder(updatePlaceholderText)); - this.repositoryDisposables.add(this.keybindingService.onDidUpdateKeybindings(updatePlaceholderText)); - - this.repositoryDisposables.add(runOnChange(accessibilityVerbosityConfig, verbosity => { - const placeholder = getPlaceholderText(); - const ariaLabel = getAriaLabel(placeholder, verbosity); - - this.inputEditor.updateOptions({ ariaLabel }); - })); - - updatePlaceholderText(); - - // Update input template - let commitTemplate = ''; - this.repositoryDisposables.add(autorun(reader => { - if (!input.visible) { - return; - } - - const oldCommitTemplate = commitTemplate; - commitTemplate = input.repository.provider.commitTemplate.read(reader); - - const value = textModel.getValue(); - if (value && value !== oldCommitTemplate) { - return; - } - - textModel.setValue(commitTemplate); - })); - - // Update input enablement - const updateEnablement = (enabled: boolean) => { - this.inputEditor.updateOptions({ readOnly: !enabled }); - }; - this.repositoryDisposables.add(input.onDidChangeEnablement(enabled => updateEnablement(enabled))); - updateEnablement(input.enabled); - - // Toolbar - this.toolbar.setInput(input); - - // Save model - this.model = { input, textModel }; - } - - get selections(): Selection[] | null { - return this.inputEditor.getSelections(); - } - - set selections(selections: Selection[] | null) { - if (selections) { - this.inputEditor.setSelections(selections); - } - } - - private setValidation(validation: IInputValidation | undefined, options?: { focus?: boolean; timeout?: boolean }) { - if (this._validationTimer) { - clearTimeout(this._validationTimer); - this._validationTimer = undefined; - } - - this.validation = validation; - this.renderValidation(); - - if (options?.focus && !this.hasFocus()) { - this.focus(); - } - - if (validation && options?.timeout) { - this._validationTimer = setTimeout(() => this.setValidation(undefined), SCMInputWidget.ValidationTimeouts[validation.type]); - } - } - - constructor( - container: HTMLElement, - overflowWidgetsDomNode: HTMLElement, - @IContextKeyService contextKeyService: IContextKeyService, - @IInstantiationService instantiationService: IInstantiationService, - @IModelService private modelService: IModelService, - @IKeybindingService private keybindingService: IKeybindingService, - @IConfigurationService private configurationService: IConfigurationService, - @ISCMViewService private readonly scmViewService: ISCMViewService, - @IContextViewService private readonly contextViewService: IContextViewService, - @IOpenerService private readonly openerService: IOpenerService, - @IAccessibilityService private readonly accessibilityService: IAccessibilityService, - @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, - ) { - this.element = append(container, $('.scm-editor')); - this.editorContainer = append(this.element, $('.scm-editor-container')); - this.toolbarContainer = append(this.element, $('.scm-editor-toolbar')); - - this.contextKeyService = contextKeyService.createScoped(this.element); - this.repositoryIdContextKey = this.contextKeyService.createKey('scmRepository', undefined); - this.validationMessageContextKey = ContextKeys.SCMInputHasValidationMessage.bindTo(this.contextKeyService); - - this.inputEditorOptions = new SCMInputWidgetEditorOptions(overflowWidgetsDomNode, this.configurationService); - this.disposables.add(this.inputEditorOptions.onDidChange(this.onDidChangeEditorOptions, this)); - this.disposables.add(this.inputEditorOptions); - - const codeEditorWidgetOptions: ICodeEditorWidgetOptions = { - contributions: EditorExtensionsRegistry.getSomeEditorContributions([ - CodeActionController.ID, - ColorDetector.ID, - ContextMenuController.ID, - CopyPasteController.ID, - DragAndDropController.ID, - DropIntoEditorController.ID, - EditorDictation.ID, - FormatOnType.ID, - ContentHoverController.ID, - GlyphHoverController.ID, - InlineCompletionsController.ID, - LinkDetector.ID, - MenuPreventer.ID, - MessageController.ID, - PlaceholderTextContribution.ID, - SelectionClipboardContributionID, - SnippetController2.ID, - SuggestController.ID - ]), - isSimpleWidget: true - }; - - const services = new ServiceCollection([IContextKeyService, this.contextKeyService]); - const instantiationService2 = instantiationService.createChild(services, this.disposables); - const editorConstructionOptions = this.inputEditorOptions.getEditorConstructionOptions(); - this.inputEditor = instantiationService2.createInstance(CodeEditorWidget, this.editorContainer, editorConstructionOptions, codeEditorWidgetOptions); - this.disposables.add(this.inputEditor); - - this.disposables.add(this.inputEditor.onDidFocusEditorText(() => { - if (this.input?.repository) { - this.scmViewService.focus(this.input.repository); - } - - this.element.classList.add('synthetic-focus'); - this.renderValidation(); - })); - this.disposables.add(this.inputEditor.onDidBlurEditorText(() => { - this.element.classList.remove('synthetic-focus'); - - setTimeout(() => { - if (!this.validation || !this.validationHasFocus) { - this.clearValidation(); - } - }, 0); - })); - - this.disposables.add(this.inputEditor.onDidBlurEditorWidget(() => { - CopyPasteController.get(this.inputEditor)?.clearWidgets(); - DropIntoEditorController.get(this.inputEditor)?.clearWidgets(); - })); - - const firstLineKey = this.contextKeyService.createKey('scmInputIsInFirstPosition', false); - const lastLineKey = this.contextKeyService.createKey('scmInputIsInLastPosition', false); - - this.disposables.add(this.inputEditor.onDidChangeCursorPosition(({ position }) => { - const viewModel = this.inputEditor._getViewModel()!; - const lastLineNumber = viewModel.getLineCount(); - const lastLineCol = viewModel.getLineLength(lastLineNumber) + 1; - const viewPosition = viewModel.coordinatesConverter.convertModelPositionToViewPosition(position); - firstLineKey.set(viewPosition.lineNumber === 1 && viewPosition.column === 1); - lastLineKey.set(viewPosition.lineNumber === lastLineNumber && viewPosition.column === lastLineCol); - })); - this.disposables.add(this.inputEditor.onDidScrollChange(e => { - this.toolbarContainer.classList.toggle('scroll-decoration', e.scrollTop > 0); - })); - - Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('scm.showInputActionButton'))(() => this.layout(), this, this.disposables); - - this.onDidChangeContentHeight = Event.signal(Event.filter(this.inputEditor.onDidContentSizeChange, e => e.contentHeightChanged, this.disposables)); - - // Toolbar - this.toolbar = instantiationService2.createInstance(SCMInputWidgetToolbar, this.toolbarContainer, { - actionViewItemProvider: (action, options) => { - if (action instanceof MenuItemAction && this.toolbar.dropdownActions.length > 1) { - return instantiationService.createInstance(DropdownWithPrimaryActionViewItem, action, this.toolbar.dropdownAction, this.toolbar.dropdownActions, '', { actionRunner: this.toolbar.actionRunner, hoverDelegate: options.hoverDelegate }); - } - - return createActionViewItem(instantiationService, action, options); - }, - hiddenItemStrategy: HiddenItemStrategy.NoHide, - menuOptions: { - shouldForwardArgs: true - } - }); - this.disposables.add(this.toolbar.onDidChange(() => this.layout())); - this.disposables.add(this.toolbar); - } - - getContentHeight(): number { - const lineHeight = this.inputEditor.getOption(EditorOption.lineHeight); - const { top, bottom } = this.inputEditor.getOption(EditorOption.padding); - - const inputMinLinesConfig = this.configurationService.getValue('scm.inputMinLineCount'); - const inputMinLines = typeof inputMinLinesConfig === 'number' ? clamp(inputMinLinesConfig, 1, 50) : 1; - const editorMinHeight = inputMinLines * lineHeight + top + bottom; - - const inputMaxLinesConfig = this.configurationService.getValue('scm.inputMaxLineCount'); - const inputMaxLines = typeof inputMaxLinesConfig === 'number' ? clamp(inputMaxLinesConfig, 1, 50) : 10; - const editorMaxHeight = inputMaxLines * lineHeight + top + bottom; - - return clamp(this.inputEditor.getContentHeight(), editorMinHeight, editorMaxHeight); - } - - layout(): void { - const editorHeight = this.getContentHeight(); - const toolbarWidth = this.getToolbarWidth(); - const dimension = new Dimension(this.element.clientWidth - toolbarWidth, editorHeight); - - if (dimension.width < 0) { - this.lastLayoutWasTrash = true; - return; - } - - this.lastLayoutWasTrash = false; - this.inputEditor.layout(dimension); - this.renderValidation(); - - const showInputActionButton = this.configurationService.getValue('scm.showInputActionButton') === true; - this.toolbarContainer.classList.toggle('hidden', !showInputActionButton || this.toolbar?.isEmpty() === true); - - if (this.shouldFocusAfterLayout) { - this.shouldFocusAfterLayout = false; - this.focus(); - } - } - - focus(): void { - if (this.lastLayoutWasTrash) { - this.lastLayoutWasTrash = false; - this.shouldFocusAfterLayout = true; - return; - } - - this.inputEditor.focus(); - this.element.classList.add('synthetic-focus'); - } - - hasFocus(): boolean { - return this.inputEditor.hasTextFocus(); - } - - private onDidChangeEditorOptions(): void { - this.inputEditor.updateOptions(this.inputEditorOptions.getEditorOptions()); - } - - private renderValidation(): void { - this.clearValidation(); - - this.element.classList.toggle('validation-info', this.validation?.type === InputValidationType.Information); - this.element.classList.toggle('validation-warning', this.validation?.type === InputValidationType.Warning); - this.element.classList.toggle('validation-error', this.validation?.type === InputValidationType.Error); - - if (!this.validation || !this.inputEditor.hasTextFocus()) { - return; - } - - this.validationMessageContextKey.set(true); - const disposables = new DisposableStore(); - - this.validationContextView = this.contextViewService.showContextView({ - getAnchor: () => this.element, - render: container => { - this.element.style.borderBottomLeftRadius = '0'; - this.element.style.borderBottomRightRadius = '0'; - - const validationContainer = append(container, $('.scm-editor-validation-container')); - validationContainer.classList.toggle('validation-info', this.validation!.type === InputValidationType.Information); - validationContainer.classList.toggle('validation-warning', this.validation!.type === InputValidationType.Warning); - validationContainer.classList.toggle('validation-error', this.validation!.type === InputValidationType.Error); - validationContainer.style.width = `${this.element.clientWidth + 2}px`; - const element = append(validationContainer, $('.scm-editor-validation')); - - const message = this.validation!.message; - if (typeof message === 'string') { - element.textContent = message; - } else { - const tracker = trackFocus(element); - disposables.add(tracker); - disposables.add(tracker.onDidFocus(() => (this.validationHasFocus = true))); - disposables.add(tracker.onDidBlur(() => { - this.validationHasFocus = false; - this.element.style.borderBottomLeftRadius = '2px'; - this.element.style.borderBottomRightRadius = '2px'; - this.contextViewService.hideContextView(); - })); - - const renderedMarkdown = this.markdownRendererService.render(message, { - actionHandler: (link, mdStr) => { - openLinkFromMarkdown(this.openerService, link, mdStr.isTrusted); - this.element.style.borderBottomLeftRadius = '2px'; - this.element.style.borderBottomRightRadius = '2px'; - this.contextViewService.hideContextView(); - }, - }); - disposables.add(renderedMarkdown); - element.appendChild(renderedMarkdown.element); - } - const actionsContainer = append(validationContainer, $('.scm-editor-validation-actions')); - const actionbar = new ActionBar(actionsContainer); - const action = new Action('scmInputWidget.validationMessage.close', localize('label.close', "Close"), ThemeIcon.asClassName(Codicon.close), true, () => { - this.contextViewService.hideContextView(); - this.element.style.borderBottomLeftRadius = '2px'; - this.element.style.borderBottomRightRadius = '2px'; - }); - disposables.add(actionbar); - actionbar.push(action, { icon: true, label: false }); - - return Disposable.None; - }, - onHide: () => { - this.validationHasFocus = false; - this.element.style.borderBottomLeftRadius = '2px'; - this.element.style.borderBottomRightRadius = '2px'; - disposables.dispose(); - }, - anchorAlignment: AnchorAlignment.LEFT - }); - } - - private getToolbarWidth(): number { - const showInputActionButton = this.configurationService.getValue('scm.showInputActionButton'); - if (!this.toolbar || !showInputActionButton || this.toolbar?.isEmpty() === true) { - return 0; - } - - return this.toolbar.dropdownActions.length === 0 ? - 26 /* 22px action + 4px margin */ : - 39 /* 35px action + 4px margin */; - } - - clearValidation(): void { - this.validationContextView?.close(); - this.validationContextView = undefined; - this.validationHasFocus = false; - this.validationMessageContextKey.set(false); - } - - dispose(): void { - this.input = undefined; - this.repositoryDisposables.dispose(); - this.clearValidation(); - this.disposables.dispose(); - } -} - export class SCMViewPane extends ViewPane { - private _onDidLayout: Emitter; + private readonly _onDidLayout: Emitter; private layoutCache: ISCMLayout; private treeScrollTop: number | undefined; @@ -2222,7 +1391,7 @@ export class SCMViewPane extends ViewPane { this.storageService.store(`scm.viewMode`, mode, StorageScope.WORKSPACE, StorageTarget.USER); } - private readonly _onDidChangeViewMode = new Emitter(); + private readonly _onDidChangeViewMode = this._register(new Emitter()); readonly onDidChangeViewMode = this._onDidChangeViewMode.event; private _viewSortKey: ViewSortKey; @@ -2243,7 +1412,7 @@ export class SCMViewPane extends ViewPane { } } - private readonly _onDidChangeViewSortKey = new Emitter(); + private readonly _onDidChangeViewSortKey = this._register(new Emitter()); readonly onDidChangeViewSortKey = this._onDidChangeViewSortKey.event; private readonly items = new DisposableMap(); @@ -2300,7 +1469,7 @@ export class SCMViewPane extends ViewPane { this.scmProviderRootUriContextKey = ContextKeys.SCMProviderRootUri.bindTo(contextKeyService); this.scmProviderHasRootUriContextKey = ContextKeys.SCMProviderHasRootUri.bindTo(contextKeyService); - this._onDidLayout = new Emitter(); + this._onDidLayout = this._register(new Emitter()); this.layoutCache = { height: undefined, width: undefined, onDidChange: this._onDidLayout.event }; this.storageService.onDidChangeValue(StorageScope.WORKSPACE, undefined, this.disposables)(e => { @@ -3239,5 +2408,3 @@ export class SCMActionButton implements IDisposable { } } } - -setupSimpleEditorSelectionStyling('.scm-view .scm-editor-container'); diff --git a/src/vs/workbench/contrib/terminal/browser/remotePty.ts b/src/vs/workbench/contrib/terminal/browser/remotePty.ts index e27137d903c48..68d16e842f824 100644 --- a/src/vs/workbench/contrib/terminal/browser/remotePty.ts +++ b/src/vs/workbench/contrib/terminal/browser/remotePty.ts @@ -80,14 +80,14 @@ export class RemotePty extends BasePty implements ITerminalChildProcess { return this._remoteTerminalChannel.processBinary(this.id, e); } - resize(cols: number, rows: number): void { + resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void { if (this._inReplay || this._lastDimensions.cols === cols && this._lastDimensions.rows === rows) { return; } this._startBarrier.wait().then(_ => { this._lastDimensions.cols = cols; this._lastDimensions.rows = rows; - this._remoteTerminalChannel.resize(this.id, cols, rows); + this._remoteTerminalChannel.resize(this.id, cols, rows, pixelWidth, pixelHeight); }); } diff --git a/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts b/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts index e2abc0fe925f0..40335e7d4c778 100644 --- a/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts +++ b/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts @@ -28,6 +28,7 @@ import { ICompleteTerminalConfiguration, ITerminalConfiguration, TERMINAL_CONFIG import { TerminalStorageKeys } from '../common/terminalStorageKeys.js'; import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; import { IHistoryService } from '../../../services/history/common/history.js'; +import { getWorkspaceForTerminal } from '../common/terminalEnvironment.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { IStatusbarService } from '../../../services/statusbar/browser/statusbar.js'; @@ -193,7 +194,7 @@ class RemoteTerminalBackend extends BaseTerminalBackend implements ITerminalBack tabActions: shellLaunchConfig.tabActions, shellIntegrationEnvironmentReporting: shellLaunchConfig.shellIntegrationEnvironmentReporting, }; - const activeWorkspaceRootUri = this._historyService.getLastActiveWorkspaceRoot(); + const activeWorkspaceRootUri = getWorkspaceForTerminal(shellLaunchConfig.cwd, this._workspaceContextService, this._historyService)?.uri; const result = await this._remoteTerminalChannel.createProcess( shellLaunchConfigDto, diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts index 40b580ef0f0fc..71745671670df 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts @@ -19,7 +19,7 @@ import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/edit import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; -import { IViewContainersRegistry, IViewsRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation } from '../../../common/views.js'; +import { IViewContainersRegistry, IViewsRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, WindowVisibility } from '../../../common/views.js'; import { ITerminalProfileService, TERMINAL_VIEW_ID, TerminalCommandId } from '../common/terminal.js'; import { TerminalEditingService } from './terminalEditingService.js'; import { registerColors } from '../common/terminalColorRegistry.js'; @@ -111,6 +111,7 @@ const VIEW_CONTAINER = Registry.as(ViewContainerExtensi storageId: TERMINAL_VIEW_ID, hideIfEmpty: true, order: 3, + windowVisibility: WindowVisibility.Both }, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true, isDefault: true }); Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([{ id: TERMINAL_VIEW_ID, @@ -119,6 +120,7 @@ Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews canToggleVisibility: true, canMoveView: true, ctorDescriptor: new SyncDescriptor(TerminalViewPane), + windowVisibility: WindowVisibility.Both, openCommandActionDescriptor: { id: TerminalCommandId.Toggle, mnemonicTitle: nls.localize({ key: 'miToggleIntegratedTerminal', comment: ['&& denotes a mnemonic'] }, "&&Terminal"), diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index c4bcfd5b70bd3..b13ee6aded3f4 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -834,6 +834,7 @@ export interface ITerminalInstance extends IBaseTerminalInstance { readonly fixedCols?: number; readonly fixedRows?: number; readonly domElement: HTMLElement; + readonly isVisible: boolean; readonly icon?: TerminalIcon; readonly color?: string; readonly reconnectionProperties?: IReconnectionProperties; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts b/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts index da3402de126b1..fbd8325319e16 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts @@ -573,6 +573,8 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { return this._layoutService.getSideBarPosition(); case ViewContainerLocation.AuxiliaryBar: return this._layoutService.getSideBarPosition() === Position.LEFT ? Position.RIGHT : Position.LEFT; + default: + return this._panelPosition; } } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 3ec8c50c4fd7e..a9fd028211a58 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -150,7 +150,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { private _latestXtermParseData: number = 0; private _isExiting: boolean; private _hadFocusOnExit: boolean; - private _isVisible: boolean; private _exitCode: number | undefined; private _exitReason: TerminalExitReason | undefined; private _skipTerminalCommands: string[]; @@ -218,6 +217,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._shellLaunchConfig.waitOnExit = value; } + private _isVisible: boolean; + get isVisible(): boolean { return this._isVisible; } + private _targetRef: ImmortalReference = new ImmortalReference(undefined); get targetRef(): IReference { return this._targetRef; } @@ -2004,7 +2006,11 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } private async _updatePtyDimensions(rawXterm: XTermTerminal): Promise { - await this._processManager.setDimensions(rawXterm.cols, rawXterm.rows); + const pixelWidth = rawXterm.dimensions?.css.canvas.width; + const pixelHeight = rawXterm.dimensions?.css.canvas.height; + const roundedPixelWidth = pixelWidth ? Math.round(pixelWidth) : undefined; + const roundedPixelHeight = pixelHeight ? Math.round(pixelHeight) : undefined; + await this._processManager.setDimensions(rawXterm.cols, rawXterm.rows, undefined, roundedPixelWidth, roundedPixelHeight); } setShellType(shellType: TerminalShellType | undefined) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index 251a94fd3e65c..2cc3d43b763cc 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -581,16 +581,16 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce return os; } - setDimensions(cols: number, rows: number): Promise; - setDimensions(cols: number, rows: number, sync: false): Promise; - setDimensions(cols: number, rows: number, sync: true): void; - setDimensions(cols: number, rows: number, sync?: boolean): MaybePromise { + setDimensions(cols: number, rows: number, sync?: undefined, pixelWidth?: number, pixelHeight?: number): Promise; + setDimensions(cols: number, rows: number, sync: false, pixelWidth?: number, pixelHeight?: number): Promise; + setDimensions(cols: number, rows: number, sync: true, pixelWidth?: number, pixelHeight?: number): void; + setDimensions(cols: number, rows: number, sync?: boolean, pixelWidth?: number, pixelHeight?: number): MaybePromise { if (sync) { - this._resize(cols, rows); + this._resize(cols, rows, pixelWidth, pixelHeight); return; } - return this.ptyProcessReady.then(() => this._resize(cols, rows)); + return this.ptyProcessReady.then(() => this._resize(cols, rows, pixelWidth, pixelHeight)); } async setUnicodeVersion(version: '6' | '11'): Promise { @@ -606,13 +606,13 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce await this._terminalService.setNextCommandId(process.id, commandLine, commandId); } - private _resize(cols: number, rows: number) { + private _resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number) { if (!this._process) { return; } // The child process could already be terminated try { - this._process.resize(cols, rows); + this._process.resize(cols, rows, pixelWidth, pixelHeight); } catch (error) { // We tried to write to a closed pipe / channel. if (error.code !== 'EPIPE' && error.code !== 'ERR_IPC_CHANNEL_CLOSED') { diff --git a/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts b/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts index b728f908338de..6b6e849e3444d 100644 --- a/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts +++ b/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts @@ -231,8 +231,8 @@ export class RemoteTerminalChannelClient implements IPtyHostController { shutdown(id: number, immediate: boolean): Promise { return this._channel.call(RemoteTerminalChannelRequest.Shutdown, [id, immediate]); } - resize(id: number, cols: number, rows: number): Promise { - return this._channel.call(RemoteTerminalChannelRequest.Resize, [id, cols, rows]); + resize(id: number, cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): Promise { + return this._channel.call(RemoteTerminalChannelRequest.Resize, [id, cols, rows, pixelWidth, pixelHeight]); } clearBuffer(id: number): Promise { return this._channel.call(RemoteTerminalChannelRequest.ClearBuffer, [id]); diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 19a1d971cdbd5..12fa676c314ed 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -300,9 +300,9 @@ export interface ITerminalProcessManager extends IDisposable, ITerminalProcessIn relaunch(shellLaunchConfig: IShellLaunchConfig, cols: number, rows: number, reset: boolean): Promise; write(data: string): Promise; sendSignal(signal: string): Promise; - setDimensions(cols: number, rows: number): Promise; - setDimensions(cols: number, rows: number, sync: false): Promise; - setDimensions(cols: number, rows: number, sync: true): void; + setDimensions(cols: number, rows: number, sync?: undefined, pixelWidth?: number, pixelHeight?: number): Promise; + setDimensions(cols: number, rows: number, sync: false, pixelWidth?: number, pixelHeight?: number): Promise; + setDimensions(cols: number, rows: number, sync: true, pixelWidth?: number, pixelHeight?: number): void; clearBuffer(): Promise; setUnicodeVersion(version: '6' | '11'): Promise; setNextCommandId(commandLine: string, commandId: string): Promise; diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index e97facbaa3541..a99f9628499c4 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -342,7 +342,7 @@ const terminalConfiguration: IStringDictionary = { }, [TerminalSettingId.TerminalTitle]: { 'type': 'string', - 'default': '${sequence}', + 'default': '${process}', 'markdownDescription': terminalTitle }, [TerminalSettingId.TerminalDescription]: { diff --git a/src/vs/workbench/contrib/terminal/electron-browser/localPty.ts b/src/vs/workbench/contrib/terminal/electron-browser/localPty.ts index 6405af520543b..eed9c7fa8e109 100644 --- a/src/vs/workbench/contrib/terminal/electron-browser/localPty.ts +++ b/src/vs/workbench/contrib/terminal/electron-browser/localPty.ts @@ -52,13 +52,13 @@ export class LocalPty extends BasePty implements ITerminalChildProcess { this._proxy.sendSignal(this.id, signal); } - resize(cols: number, rows: number): void { + resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void { if (this._inReplay || this._lastDimensions.cols === cols && this._lastDimensions.rows === rows) { return; } this._lastDimensions.cols = cols; this._lastDimensions.rows = rows; - this._proxy.resize(this.id, cols, rows); + this._proxy.resize(this.id, cols, rows, pixelWidth, pixelHeight); } async clearBuffer(): Promise { diff --git a/src/vs/workbench/contrib/terminal/terminal.all.ts b/src/vs/workbench/contrib/terminal/terminal.all.ts index 6f08c6293479b..c586b200ef6cf 100644 --- a/src/vs/workbench/contrib/terminal/terminal.all.ts +++ b/src/vs/workbench/contrib/terminal/terminal.all.ts @@ -24,6 +24,7 @@ import '../terminalContrib/commandGuide/browser/terminal.commandGuide.contributi import '../terminalContrib/history/browser/terminal.history.contribution.js'; import '../terminalContrib/inlineHint/browser/terminal.initialHint.contribution.js'; import '../terminalContrib/links/browser/terminal.links.contribution.js'; +import '../terminalContrib/notification/browser/terminal.notification.contribution.js'; import '../terminalContrib/zoom/browser/terminal.zoom.contribution.js'; import '../terminalContrib/stickyScroll/browser/terminal.stickyScroll.contribution.js'; import '../terminalContrib/quickAccess/browser/terminal.quickAccess.contribution.js'; diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index 5692332ebc9eb..a24b204a899d5 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -14,6 +14,7 @@ import { terminalCommandGuideConfiguration } from '../terminalContrib/commandGui import { TerminalDeveloperCommandId } from '../terminalContrib/developer/common/terminal.developer.js'; import { defaultTerminalFindCommandToSkipShell } from '../terminalContrib/find/common/terminal.find.js'; import { defaultTerminalHistoryCommandsToSkipShell, terminalHistoryConfiguration } from '../terminalContrib/history/common/terminal.history.js'; +import { terminalOscNotificationsConfiguration } from '../terminalContrib/notification/common/terminalNotificationConfiguration.js'; import { TerminalStickyScrollSettingId, terminalStickyScrollConfiguration } from '../terminalContrib/stickyScroll/common/terminalStickyScrollConfiguration.js'; import { defaultTerminalSuggestCommandsToSkipShell } from '../terminalContrib/suggest/common/terminal.suggest.js'; import { TerminalSuggestSettingId, terminalSuggestConfiguration } from '../terminalContrib/suggest/common/terminalSuggestConfiguration.js'; @@ -65,6 +66,7 @@ export const terminalContribConfiguration: IConfigurationNode['properties'] = { ...terminalInitialHintConfiguration, ...terminalCommandGuideConfiguration, ...terminalHistoryConfiguration, + ...terminalOscNotificationsConfiguration, ...terminalStickyScrollConfiguration, ...terminalSuggestConfiguration, ...terminalTypeAheadConfiguration, diff --git a/src/vs/workbench/contrib/terminal/test/common/terminalEnvironment.test.ts b/src/vs/workbench/contrib/terminal/test/common/terminalEnvironment.test.ts index dad6191105932..f06292f5e1d84 100644 --- a/src/vs/workbench/contrib/terminal/test/common/terminalEnvironment.test.ts +++ b/src/vs/workbench/contrib/terminal/test/common/terminalEnvironment.test.ts @@ -7,9 +7,11 @@ import { deepStrictEqual, strictEqual } from 'assert'; import { IStringDictionary } from '../../../../../base/common/collections.js'; import { isWindows, OperatingSystem } from '../../../../../base/common/platform.js'; import { URI as Uri } from '../../../../../base/common/uri.js'; -import { addTerminalEnvironmentKeys, createTerminalEnvironment, getUriLabelForShell, getCwd, getLangEnvVariable, mergeEnvironments, preparePathForShell, shouldSetLangEnvVariable } from '../../common/terminalEnvironment.js'; +import { addTerminalEnvironmentKeys, createTerminalEnvironment, getUriLabelForShell, getCwd, getLangEnvVariable, getWorkspaceForTerminal, mergeEnvironments, preparePathForShell, shouldSetLangEnvVariable } from '../../common/terminalEnvironment.js'; import { GeneralShellType, PosixShellType, WindowsShellType } from '../../../../../platform/terminal/common/terminal.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestContextService, TestHistoryService } from '../../../../test/common/workbenchTestServices.js'; +import { testWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; const wslPathBackend = { getWslPath: async (original: string, direction: 'unix-to-win' | 'win-to-unix') => { @@ -320,6 +322,38 @@ suite('Workbench - TerminalEnvironment', () => { ); }); }); + suite('getWorkspaceForTerminal', () => { + test('should resolve workspace folder from cwd, not last active workspace', () => { + const folderA = Uri.file('/workspace/proj1'); + const folderB = Uri.file('/workspace/proj2'); + const contextService = new TestContextService(testWorkspace(folderA, folderB)); + const historyService = new TestHistoryService(folderA); + const result = getWorkspaceForTerminal(folderB, contextService, historyService); + strictEqual(result?.uri.fsPath, folderB.fsPath); + }); + + test('should fall back to last active workspace when cwd is not in any workspace folder', () => { + const folderA = Uri.file('/workspace/proj1'); + const contextService = new TestContextService(testWorkspace(folderA)); + const historyService = new TestHistoryService(folderA); + const result = getWorkspaceForTerminal(Uri.file('/other/path'), contextService, historyService); + strictEqual(result?.uri.fsPath, folderA.fsPath); + }); + + test('should fall back to last active workspace when cwd is undefined', () => { + const folderA = Uri.file('/workspace/proj1'); + const contextService = new TestContextService(testWorkspace(folderA)); + const historyService = new TestHistoryService(folderA); + strictEqual(getWorkspaceForTerminal(undefined, contextService, historyService)?.uri.fsPath, folderA.fsPath); + }); + + test('should return undefined when cwd and history are both unavailable', () => { + const contextService = new TestContextService(testWorkspace(Uri.file('/workspace/proj1'))); + const historyService = new TestHistoryService(undefined); + strictEqual(getWorkspaceForTerminal(undefined, contextService, historyService), undefined); + }); + }); + suite('formatUriForShellDisplay', () => { test('Wsl', async () => { strictEqual(await getUriLabelForShell('c:\\foo\\bar', wslPathBackend, WindowsShellType.Wsl, OperatingSystem.Windows, true), '/mnt/c/foo/bar'); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts index 90743dfac7fc7..89dc39f4b9637 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts @@ -478,7 +478,6 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ id: TerminalChatCommandId.FocusMostRecentChatTerminal, weight: KeybindingWeight.WorkbenchContrib, when: ChatContextKeys.inChatSession, - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyMod.Alt | KeyCode.KeyT, handler: async (accessor: ServicesAccessor) => { const terminalChatService = accessor.get(ITerminalChatService); const part = terminalChatService.getMostRecentProgressPart(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts index 9786f71a25c7a..eda700886bdc8 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts @@ -258,7 +258,8 @@ export async function collectTerminalResults( } } - const outputMonitor = disposableStore.add(instantiationService.createInstance(OutputMonitor, execution, taskProblemPollFn, invocationContext, token, task._label)); + const hasProblemMatchers = terminalTask.configurationProperties.problemMatchers && terminalTask.configurationProperties.problemMatchers.length > 0; + const outputMonitor = disposableStore.add(instantiationService.createInstance(OutputMonitor, execution, hasProblemMatchers ? taskProblemPollFn : undefined, invocationContext, token, task._label)); await Promise.race([ Event.toPromise(outputMonitor.onDidFinishCommand), Event.toPromise(token.onCancellationRequested as Event) 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 e624c80821c00..085c43ed463e9 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 @@ -355,7 +355,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const custom = await this._pollFn?.(this._execution, token, this._taskService); this._logService.trace(`OutputMonitor: Custom poller result: ${custom ? 'provided' : 'none'}`); const resources = custom?.resources; - const modelOutputEvalResponse = await this._assessOutputForErrors(this._execution.getOutput(), token); + const modelOutputEvalResponse = this._pollFn ? undefined : await this._assessOutputForErrors(this._execution.getOutput(), token); return { resources, modelOutputEvalResponse, shouldContinuePollling: false, output: custom?.output ?? output }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts index 9432fe208d714..65a0a56506888 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts @@ -5,7 +5,6 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { RunInTerminalTool } from './runInTerminalTool.js'; @@ -60,18 +59,6 @@ export const ConfirmTerminalCommandToolData: IToolData = { export class ConfirmTerminalCommandTool extends RunInTerminalTool { override async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { - // Safe-guard: If session is the chat provider specific id - // then convert it to the session id understood by chat service - try { - const sessionUri = context.chatSessionId ? URI.parse(context.chatSessionId) : undefined; - const sessionId = sessionUri ? this._chatService.getSession(sessionUri)?.sessionId : undefined; - if (sessionId) { - context.chatSessionId = sessionId; - } - } - catch { - // Ignore parse errors or session lookup failures; fallback to using the original chatSessionId. - } const preparedInvocation = await super.prepareToolInvocation(context, token); if (preparedInvocation) { preparedInvocation.presentation = ToolInvocationPresentation.HiddenAfterComplete; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 8fad759ebe12e..e9b28acf901f6 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -429,7 +429,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { const args = context.parameters as IRunInTerminalInputParams; - const chatSessionResource = context.chatSessionResource ?? (context.chatSessionId ? LocalChatSessionUri.forSession(context.chatSessionId) : undefined); + const chatSessionResource = context.chatSessionResource; let instance: ITerminalInstance | undefined; if (chatSessionResource) { const toolTerminal = this._sessionTerminalAssociations.get(chatSessionResource); @@ -658,6 +658,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (!toolSpecificData) { throw new Error('toolSpecificData must be provided for this tool'); } + if (!invocation.context) { + throw new Error('Invocation context must be provided for this tool'); + } + const commandId = toolSpecificData.terminalCommandId; if (toolSpecificData.alternativeRecommendation) { return { @@ -672,8 +676,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._logService.debug(`RunInTerminalTool: Invoking with options ${JSON.stringify(args)}`); let toolResultMessage: string | IMarkdownString | undefined; - const chatSessionResource = invocation.context?.sessionResource ?? LocalChatSessionUri.forSession(invocation.context?.sessionId ?? 'no-chat-session'); - const chatSessionId = chatSessionResourceToId(chatSessionResource); + const chatSessionResource = invocation.context.sessionResource; const command = toolSpecificData.commandLine.userEdited ?? toolSpecificData.commandLine.toolEdited ?? toolSpecificData.commandLine.original; const didUserEditCommand = ( toolSpecificData.commandLine.userEdited !== undefined && @@ -699,7 +702,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const store = new DisposableStore(); // Unified terminal initialization - this._logService.debug(`RunInTerminalTool: Creating ${args.isBackground ? 'background' : 'foreground'} terminal. termId=${termId}, chatSessionId=${chatSessionId}`); + this._logService.debug(`RunInTerminalTool: Creating ${args.isBackground ? 'background' : 'foreground'} terminal. termId=${termId}, chatSessionResource=${chatSessionResource}`); const toolTerminal = await this._initTerminal(chatSessionResource, termId, terminalToolSessionId, args.isBackground, token); this._handleTerminalVisibility(toolTerminal, chatSessionResource); @@ -775,7 +778,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Create unified ActiveTerminalExecution (creates and owns the strategy) const execution = this._instantiationService.createInstance( ActiveTerminalExecution, - chatSessionId, + chatSessionResource, termId, toolTerminal, commandDetection!, @@ -1229,7 +1232,7 @@ class ActiveTerminalExecution extends Disposable implements IActiveTerminalExecu } constructor( - readonly sessionId: string, + readonly sessionResource: URI, readonly termId: string, toolTerminal: IToolTerminal, commandDetection: ICommandDetectionCapability, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 5903834e459cd..059b63a92c5e5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -206,6 +206,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary` and `--no-pager` immediately after `git` '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+status\\b/': true, '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b/': true, + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b.*\\s--output(=|\\s|$)/': false, '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+show\\b/': true, '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+diff\\b/': true, '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+ls-files\\b/': true, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index c10004a8417f5..9b1da127a3529 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -160,7 +160,11 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const allowedDomainsSet = new Set(networkSetting.allowedDomains ?? []); if (networkSetting.allowTrustedDomains) { for (const domain of this._trustedDomainService.trustedDomains) { - allowedDomainsSet.add(domain); + // Filter out sole wildcard '*' as sandbox runtime doesn't allow it + // Wildcards like '*.github.com' are OK + if (domain !== '*') { + allowedDomainsSet.add(domain); + } } } const allowedDomains = Array.from(allowedDomainsSet); 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 e7eeceda2e794..cb4aa2c1dc081 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 @@ -362,5 +362,5 @@ suite('OutputMonitor', () => { }); function createTestContext(id: string): IToolInvocationContext { - return { sessionId: id, sessionResource: LocalChatSessionUri.forSession(id) }; + return { sessionResource: LocalChatSessionUri.forSession(id) }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts new file mode 100644 index 0000000000000..55901336b328d --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -0,0 +1,257 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { strictEqual, ok } from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TerminalSandboxService } from '../../common/terminalSandboxService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; +import { ITrustedDomainService } from '../../../../url/common/trustedDomainService.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; +import { Event, Emitter } from '../../../../../../base/common/event.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { IRemoteAgentEnvironment } from '../../../../../../platform/remote/common/remoteAgentEnvironment.js'; + +suite('TerminalSandboxService - allowTrustedDomains', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let trustedDomainService: MockTrustedDomainService; + let fileService: MockFileService; + let createdFiles: Map; + + class MockTrustedDomainService implements ITrustedDomainService { + _serviceBrand: undefined; + private _onDidChangeTrustedDomains = new Emitter(); + readonly onDidChangeTrustedDomains: Event = this._onDidChangeTrustedDomains.event; + trustedDomains: string[] = []; + isValid(_resource: URI): boolean { + return true; + } + } + + class MockFileService { + async createFile(uri: URI, content: VSBuffer): Promise { + const contentString = content.toString(); + createdFiles.set(uri.path, contentString); + return {}; + } + } + + class MockRemoteAgentService { + async getEnvironment(): Promise { + // Return a Linux environment to ensure tests pass on Windows + // (sandbox is not supported on Windows) + return { + os: OperatingSystem.Linux, + tmpDir: URI.file('/tmp'), + appRoot: URI.file('/app'), + pid: 1234, + connectionToken: 'test-token', + settingsPath: URI.file('/settings'), + mcpResource: URI.file('/mcp'), + logsPath: URI.file('/logs'), + extensionHostLogsPath: URI.file('/ext-logs'), + globalStorageHome: URI.file('/global'), + workspaceStorageHome: URI.file('/workspace'), + localHistoryHome: URI.file('/history'), + userHome: URI.file('/home/user'), + arch: 'x64', + marks: [], + useHostProxy: false, + profiles: { + all: [], + home: URI.file('/profiles') + }, + isUnsupportedGlibc: false + }; + } + } + + setup(() => { + createdFiles = new Map(); + instantiationService = workbenchInstantiationService({}, store); + configurationService = new TestConfigurationService(); + trustedDomainService = new MockTrustedDomainService(); + fileService = new MockFileService(); + + // Setup default configuration + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, true); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: [], + deniedDomains: [], + allowTrustedDomains: false + }); + + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IFileService, fileService); + instantiationService.stub(IEnvironmentService, { + _serviceBrand: undefined, + tmpDir: URI.file('/tmp'), + execPath: '/usr/bin/node' + }); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRemoteAgentService, new MockRemoteAgentService()); + instantiationService.stub(ITrustedDomainService, trustedDomainService); + }); + + test('should filter out sole wildcard (*) from trusted domains', async () => { + // Setup: Enable allowTrustedDomains and add * to trusted domains + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: [], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = ['*']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 0, 'Sole wildcard * should be filtered out'); + }); + + test('should allow wildcards with domains like *.github.com', async () => { + // Setup: Enable allowTrustedDomains and add *.github.com + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: [], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = ['*.github.com']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 1, 'Wildcard domain should be included'); + strictEqual(config.network.allowedDomains[0], '*.github.com', 'Wildcard domain should match'); + }); + + test('should combine trusted domains with configured allowedDomains, filtering out *', async () => { + // Setup: Enable allowTrustedDomains with multiple domains including * + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: ['example.com'], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = ['*', '*.github.com', 'microsoft.com']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 3, 'Should have 3 domains (excluding *)'); + ok(config.network.allowedDomains.includes('example.com'), 'Should include configured domain'); + ok(config.network.allowedDomains.includes('*.github.com'), 'Should include wildcard domain'); + ok(config.network.allowedDomains.includes('microsoft.com'), 'Should include microsoft.com'); + ok(!config.network.allowedDomains.includes('*'), 'Should not include sole wildcard'); + }); + + test('should not include trusted domains when allowTrustedDomains is false', async () => { + // Setup: Disable allowTrustedDomains + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: ['example.com'], + deniedDomains: [], + allowTrustedDomains: false + }); + trustedDomainService.trustedDomains = ['*', '*.github.com']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 1, 'Should only have configured domain'); + strictEqual(config.network.allowedDomains[0], 'example.com', 'Should only include example.com'); + }); + + test('should deduplicate domains when combining sources', async () => { + // Setup: Same domain in both sources + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: ['github.com', '*.github.com'], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = ['*.github.com', 'github.com']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 2, 'Should have 2 unique domains'); + ok(config.network.allowedDomains.includes('github.com'), 'Should include github.com'); + ok(config.network.allowedDomains.includes('*.github.com'), 'Should include *.github.com'); + }); + + test('should handle empty trusted domains list', async () => { + // Setup: Empty trusted domains + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: ['example.com'], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = []; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 1, 'Should have only configured domain'); + strictEqual(config.network.allowedDomains[0], 'example.com', 'Should only include example.com'); + }); + + test('should handle only * in trusted domains', async () => { + // Setup: Only * in trusted domains (edge case) + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: [], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = ['*']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 0, 'Should have no domains (* filtered out)'); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 896b2d3ebca3e..e362782b44b24 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -319,6 +319,9 @@ suite('RunInTerminalTool', () => { 'docker compose events', ]; const confirmationRequiredTestCases = [ + // git log file output + 'git log --output=log.txt', + // Dangerous file operations 'rm README.md', 'rmdir folder', diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLink.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLink.ts index 228d047f4b254..aef7d87a3cd19 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLink.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLink.ts @@ -22,7 +22,7 @@ export class TerminalLink extends Disposable implements ILink { private readonly _tooltipScheduler: MutableDisposable = this._register(new MutableDisposable()); private readonly _hoverListeners = this._register(new MutableDisposable()); - private readonly _onInvalidated = new Emitter(); + private readonly _onInvalidated = this._register(new Emitter()); get onInvalidated(): Event { return this._onInvalidated.event; } get type(): TerminalLinkType { return this._type; } diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts new file mode 100644 index 0000000000000..85e3b84aa0a10 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Terminal as RawXtermTerminal } from '@xterm/xterm'; +import * as dom from '../../../../../base/browser/dom.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { ITerminalLogService } from '../../../../../platform/terminal/common/terminal.js'; +import type { ITerminalContribution, ITerminalInstance, IXtermTerminal } from '../../../terminal/browser/terminal.js'; +import { registerTerminalContribution, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js'; +import { TerminalOscNotificationsSettingId } from '../common/terminalNotificationConfiguration.js'; +import { TerminalNotificationHandler } from './terminalNotificationHandler.js'; + + +class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { + static readonly ID = 'terminal.oscNotifications'; + + private readonly _handler: TerminalNotificationHandler; + + constructor( + private readonly _ctx: ITerminalContributionContext, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @INotificationService private readonly _notificationService: INotificationService, + @ITerminalLogService private readonly _logService: ITerminalLogService, + ) { + super(); + this._handler = this._register(new TerminalNotificationHandler({ + isEnabled: () => this._configurationService.getValue(TerminalOscNotificationsSettingId.EnableNotifications) === true, + isWindowFocused: () => dom.getActiveWindow().document.hasFocus(), + isTerminalVisible: () => this._ctx.instance.isVisible, + focusTerminal: () => this._ctx.instance.focus(true), + notify: notification => this._notificationService.notify(notification), + updateEnableNotifications: value => this._configurationService.updateValue(TerminalOscNotificationsSettingId.EnableNotifications, value), + logWarn: message => this._logService.warn(message), + writeToProcess: data => { void this._ctx.instance.sendText(data, false); } + })); + } + + xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { + this._register(xterm.raw.parser.registerOscHandler(99, data => this._handler.handleSequence(data))); + } +} + +registerTerminalContribution(TerminalOscNotificationsContribution.ID, TerminalOscNotificationsContribution); + +export function getTerminalOscNotifications(instance: ITerminalInstance): TerminalOscNotificationsContribution | null { + return instance.getContribution(TerminalOscNotificationsContribution.ID); +} diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts new file mode 100644 index 0000000000000..74c18acb5b24a --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts @@ -0,0 +1,528 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action, IAction } from '../../../../../base/common/actions.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { decodeBase64 } from '../../../../../base/common/buffer.js'; +import { Disposable, DisposableStore, type IDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { NotificationPriority, Severity, type INotification, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; + +const enum Osc99PayloadType { + Title = 'title', + Body = 'body', + Buttons = 'buttons', + Close = 'close', + Query = '?', + Alive = 'alive' +} + +type Osc99Occasion = 'always' | 'unfocused' | 'invisible'; +type Osc99CloseReason = 'button' | 'secondary' | 'auto' | 'protocol'; + +interface IOsc99NotificationState { + id: string | undefined; + title: string; + body: string; + buttonsPayload: string; + focusOnActivate: boolean; + reportOnActivate: boolean; + reportOnClose: boolean; + urgency: number | undefined; + autoCloseMs: number | undefined; + occasion: Osc99Occasion | undefined; +} + +interface IOsc99ActiveNotification { + id: string | undefined; + handle: INotificationHandle; + actionStore: DisposableStore; + autoCloseDisposable: IDisposable | undefined; + reportOnActivate: boolean; + reportOnClose: boolean; + focusOnActivate: boolean; + closeReason: Osc99CloseReason | undefined; +} + +export interface IOsc99NotificationHost { + isEnabled(): boolean; + isWindowFocused(): boolean; + isTerminalVisible(): boolean; + focusTerminal(): void; + notify(notification: INotification): INotificationHandle; + updateEnableNotifications(value: boolean): Promise; + logWarn(message: string): void; + writeToProcess(data: string): void; +} + +export class TerminalNotificationHandler extends Disposable { + private readonly _osc99PendingNotifications = new Map(); + private _osc99PendingAnonymous: IOsc99NotificationState | undefined; + private readonly _osc99ActiveNotifications = new Map(); + + constructor( + private readonly _host: IOsc99NotificationHost + ) { + super(); + } + + handleSequence(data: string): boolean { + const { metadata, payload } = this._splitOsc99Data(data); + const metadataEntries = this._parseOsc99Metadata(metadata); + const payloadTypes = metadataEntries.get('p'); + const rawPayloadType = payloadTypes && payloadTypes.length > 0 ? payloadTypes[payloadTypes.length - 1] : undefined; + const payloadType = rawPayloadType && rawPayloadType.length > 0 ? rawPayloadType : Osc99PayloadType.Title; + const id = this._sanitizeOsc99Id(metadataEntries.get('i')?.[0]); + + if (!this._host.isEnabled()) { + return true; + } + + switch (payloadType) { + case Osc99PayloadType.Query: + this._sendOsc99QueryResponse(id); + return true; + case Osc99PayloadType.Alive: + this._sendOsc99AliveResponse(id); + return true; + case Osc99PayloadType.Close: + this._closeOsc99Notification(id); + return true; + } + + const state = this._getOrCreateOsc99State(id); + this._updateOsc99StateFromMetadata(state, metadataEntries); + + const isEncoded = metadataEntries.get('e')?.[0] === '1'; + const payloadText = this._decodeOsc99Payload(payload, isEncoded); + const isDone = metadataEntries.get('d')?.[0] !== '0'; + + switch (payloadType) { + case Osc99PayloadType.Title: + state.title += payloadText; + break; + case Osc99PayloadType.Body: + state.body += payloadText; + break; + case Osc99PayloadType.Buttons: + state.buttonsPayload += payloadText; + break; + default: + return true; + } + + if (!isDone) { + return true; + } + if (!this._shouldHonorOsc99Occasion(state.occasion)) { + this._clearOsc99PendingState(id); + return true; + } + + if (this._showOsc99Notification(state)) { + this._clearOsc99PendingState(id); + } + return true; + } + + private _splitOsc99Data(data: string): { metadata: string; payload: string } { + const separatorIndex = data.indexOf(';'); + if (separatorIndex === -1) { + return { metadata: data, payload: '' }; + } + return { + metadata: data.substring(0, separatorIndex), + payload: data.substring(separatorIndex + 1) + }; + } + + private _parseOsc99Metadata(metadata: string): Map { + const result = new Map(); + if (!metadata) { + return result; + } + for (const entry of metadata.split(':')) { + if (!entry) { + continue; + } + const separatorIndex = entry.indexOf('='); + if (separatorIndex === -1) { + continue; + } + const key = entry.substring(0, separatorIndex); + const value = entry.substring(separatorIndex + 1); + if (!key) { + continue; + } + let values = result.get(key); + if (!values) { + values = []; + result.set(key, values); + } + values.push(value); + } + return result; + } + + private _decodeOsc99Payload(payload: string, isEncoded: boolean): string { + if (!isEncoded) { + return payload; + } + try { + return decodeBase64(payload).toString(); + } catch { + this._host.logWarn('Failed to decode OSC 99 payload'); + return ''; + } + } + + private _sanitizeOsc99Id(rawId: string | undefined): string | undefined { + if (!rawId) { + return undefined; + } + const sanitized = rawId.replace(/[^a-zA-Z0-9_\-+.]/g, ''); + return sanitized.length > 0 ? sanitized : undefined; + } + + private _sanitizeOsc99MessageText(text: string): string { + return text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + } + + private _getOrCreateOsc99State(id: string | undefined): IOsc99NotificationState { + if (!id) { + if (!this._osc99PendingAnonymous) { + this._osc99PendingAnonymous = this._createOsc99State(undefined); + } + return this._osc99PendingAnonymous; + } + let state = this._osc99PendingNotifications.get(id); + if (!state) { + state = this._createOsc99State(id); + this._osc99PendingNotifications.set(id, state); + } + return state; + } + + private _createOsc99State(id: string | undefined): IOsc99NotificationState { + return { + id, + title: '', + body: '', + buttonsPayload: '', + focusOnActivate: true, + reportOnActivate: false, + reportOnClose: false, + urgency: undefined, + autoCloseMs: undefined, + occasion: undefined + }; + } + + private _clearOsc99PendingState(id: string | undefined): void { + if (!id) { + this._osc99PendingAnonymous = undefined; + return; + } + this._osc99PendingNotifications.delete(id); + } + + private _updateOsc99StateFromMetadata(state: IOsc99NotificationState, metadataEntries: Map): void { + const actionValues = metadataEntries.get('a'); + const actionValue = actionValues && actionValues.length > 0 ? actionValues[actionValues.length - 1] : undefined; + if (actionValue !== undefined) { + const actions = this._parseOsc99Actions(actionValue); + state.focusOnActivate = actions.focusOnActivate; + state.reportOnActivate = actions.reportOnActivate; + } + const closeValues = metadataEntries.get('c'); + const closeValue = closeValues && closeValues.length > 0 ? closeValues[closeValues.length - 1] : undefined; + if (closeValue !== undefined) { + state.reportOnClose = closeValue === '1'; + } + const urgencyValues = metadataEntries.get('u'); + const urgencyValue = urgencyValues && urgencyValues.length > 0 ? urgencyValues[urgencyValues.length - 1] : undefined; + if (urgencyValue !== undefined) { + const urgency = Number.parseInt(urgencyValue, 10); + if (!Number.isNaN(urgency)) { + state.urgency = urgency; + } + } + const autoCloseValues = metadataEntries.get('w'); + const autoCloseValue = autoCloseValues && autoCloseValues.length > 0 ? autoCloseValues[autoCloseValues.length - 1] : undefined; + if (autoCloseValue !== undefined) { + const autoClose = Number.parseInt(autoCloseValue, 10); + if (!Number.isNaN(autoClose)) { + state.autoCloseMs = autoClose; + } + } + const occasionValues = metadataEntries.get('o'); + const occasionValue = occasionValues && occasionValues.length > 0 ? occasionValues[occasionValues.length - 1] : undefined; + if (occasionValue === 'always' || occasionValue === 'unfocused' || occasionValue === 'invisible') { + state.occasion = occasionValue; + } + } + + private _parseOsc99Actions(value: string): { focusOnActivate: boolean; reportOnActivate: boolean } { + let focusOnActivate = true; + let reportOnActivate = false; + for (const token of value.split(',')) { + switch (token) { + case 'focus': + focusOnActivate = true; + break; + case '-focus': + focusOnActivate = false; + break; + case 'report': + reportOnActivate = true; + break; + case '-report': + reportOnActivate = false; + break; + } + } + return { focusOnActivate, reportOnActivate }; + } + + private _shouldHonorOsc99Occasion(occasion: Osc99Occasion | undefined): boolean { + if (!occasion || occasion === 'always') { + return true; + } + const windowFocused = this._host.isWindowFocused(); + switch (occasion) { + case 'unfocused': + return !windowFocused; + case 'invisible': + return !windowFocused && !this._host.isTerminalVisible(); + default: + return true; + } + } + + private _showOsc99Notification(state: IOsc99NotificationState): boolean { + const message = this._getOsc99NotificationMessage(state); + if (!message) { + return false; + } + + const severity = state.urgency === 2 ? Severity.Warning : Severity.Info; + const priority = this._getOsc99NotificationPriority(state.urgency); + const source = { + id: 'terminal', + label: localize('terminalNotificationSource', 'Terminal') + }; + const buttons = state.buttonsPayload.length > 0 ? state.buttonsPayload.split('\u2028') : []; + const actionStore = this._register(new DisposableStore()); + + const handleRef: { current: INotificationHandle | undefined } = { current: undefined }; + const activeRef: { current: IOsc99ActiveNotification | undefined } = { current: undefined }; + const reportActivation = (buttonIndex?: number, forceFocus?: boolean) => { + if (forceFocus || state.focusOnActivate) { + this._host.focusTerminal(); + } + if (state.reportOnActivate) { + this._sendOsc99ActivationReport(state.id, buttonIndex); + } + }; + + const primaryActions: IAction[] = []; + for (let i = 0; i < buttons.length; i++) { + const label = buttons[i]; + if (!label) { + continue; + } + const action = actionStore.add(new Action(`terminal.osc99.button.${i}`, label, undefined, true, () => { + if (activeRef.current) { + activeRef.current.closeReason = 'button'; + } + reportActivation(i + 1); + handleRef.current?.close(); + })); + primaryActions.push(action); + } + + const secondaryActions: IAction[] = []; + secondaryActions.push(actionStore.add(new Action( + 'terminal.osc99.dismiss', + localize('terminalNotificationDismiss', 'Dismiss'), + undefined, + true, + () => { + if (activeRef.current) { + activeRef.current.closeReason = 'secondary'; + } + handleRef.current?.close(); + } + ))); + secondaryActions.push(actionStore.add(new Action( + 'terminal.osc99.disable', + localize('terminalNotificationDisable', 'Disable Terminal Notifications'), + undefined, + true, + async () => { + await this._host.updateEnableNotifications(false); + if (activeRef.current) { + activeRef.current.closeReason = 'secondary'; + } + handleRef.current?.close(); + } + ))); + + const actions = { primary: primaryActions, secondary: secondaryActions }; + + if (state.id) { + const existing = this._osc99ActiveNotifications.get(state.id); + if (existing) { + activeRef.current = existing; + handleRef.current = existing.handle; + existing.handle.updateMessage(message); + existing.handle.updateSeverity(severity); + existing.handle.updateActions(actions); + existing.actionStore.dispose(); + existing.actionStore = actionStore; + existing.focusOnActivate = state.focusOnActivate; + existing.reportOnActivate = state.reportOnActivate; + existing.reportOnClose = state.reportOnClose; + existing.autoCloseDisposable?.dispose(); + existing.autoCloseDisposable = this._scheduleOsc99AutoClose(existing, state.autoCloseMs); + return true; + } + } + + const handle = this._host.notify({ + id: state.id ? `terminal.osc99.${state.id}` : undefined, + severity, + message, + source, + actions, + priority + }); + handleRef.current = handle; + + const active: IOsc99ActiveNotification = { + id: state.id, + handle, + actionStore, + autoCloseDisposable: undefined, + reportOnActivate: state.reportOnActivate, + reportOnClose: state.reportOnClose, + focusOnActivate: state.focusOnActivate, + closeReason: undefined + }; + activeRef.current = active; + active.autoCloseDisposable = this._scheduleOsc99AutoClose(active, state.autoCloseMs); + this._register(handle.onDidClose(() => { + if (active.reportOnActivate && active.closeReason === undefined) { + if (active.focusOnActivate) { + this._host.focusTerminal(); + } + this._sendOsc99ActivationReport(active.id); + } + if (active.reportOnClose) { + this._sendOsc99CloseReport(active.id); + } + active.actionStore.dispose(); + active.autoCloseDisposable?.dispose(); + if (active.id) { + this._osc99ActiveNotifications.delete(active.id); + } + })); + + if (active.id) { + this._osc99ActiveNotifications.set(active.id, active); + } + return true; + } + + private _getOsc99NotificationMessage(state: IOsc99NotificationState): string | undefined { + const title = this._sanitizeOsc99MessageText(state.title); + const body = this._sanitizeOsc99MessageText(state.body); + const hasTitle = title.trim().length > 0; + const hasBody = body.trim().length > 0; + if (hasTitle && hasBody) { + return `${title}: ${body}`; + } + if (hasTitle) { + return title; + } + if (hasBody) { + return body; + } + return undefined; + } + + private _getOsc99NotificationPriority(urgency: number | undefined): NotificationPriority | undefined { + switch (urgency) { + case 0: + return NotificationPriority.SILENT; + case 1: + return NotificationPriority.DEFAULT; + case 2: + return NotificationPriority.URGENT; + default: + return undefined; + } + } + + private _scheduleOsc99AutoClose(active: IOsc99ActiveNotification, autoCloseMs: number | undefined): IDisposable | undefined { + if (autoCloseMs === undefined || autoCloseMs <= 0) { + return undefined; + } + return disposableTimeout(() => { + active.closeReason = 'auto'; + active.handle.close(); + }, autoCloseMs, this._store); + } + + private _closeOsc99Notification(id: string | undefined): void { + if (!id) { + return; + } + const active = this._osc99ActiveNotifications.get(id); + if (active) { + active.closeReason = 'protocol'; + active.handle.close(); + } + this._osc99PendingNotifications.delete(id); + } + + private _sendOsc99QueryResponse(id: string | undefined): void { + const requestId = id ?? '0'; + this._sendOsc99Response([ + `i=${requestId}`, + 'p=?', + 'a=report,focus', + 'c=1', + 'o=always,unfocused,invisible', + 'p=title,body,buttons,close,alive,?', + 'u=0,1,2', + 'w=1' + ]); + } + + private _sendOsc99AliveResponse(id: string | undefined): void { + const requestId = id ?? '0'; + const aliveIds = Array.from(this._osc99ActiveNotifications.keys()).join(','); + this._sendOsc99Response([ + `i=${requestId}`, + 'p=alive' + ], aliveIds); + } + + private _sendOsc99ActivationReport(id: string | undefined, buttonIndex?: number): void { + const reportId = id ?? '0'; + this._sendOsc99Response([`i=${reportId}`], buttonIndex !== undefined ? String(buttonIndex) : ''); + } + + private _sendOsc99CloseReport(id: string | undefined): void { + const reportId = id ?? '0'; + this._sendOsc99Response([`i=${reportId}`, 'p=close']); + } + + private _sendOsc99Response(metadataParts: string[], payload: string = ''): void { + const metadata = metadataParts.join(':'); + this._host.writeToProcess(`\x1b]99;${metadata};${payload}\x1b\\`); + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts new file mode 100644 index 0000000000000..f4e1e8dc3c20c --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IStringDictionary } from '../../../../../base/common/collections.js'; +import { localize } from '../../../../../nls.js'; +import type { IConfigurationPropertySchema } from '../../../../../platform/configuration/common/configurationRegistry.js'; + +export const enum TerminalOscNotificationsSettingId { + EnableNotifications = 'terminal.integrated.enableNotifications', +} + +export const terminalOscNotificationsConfiguration: IStringDictionary = { + [TerminalOscNotificationsSettingId.EnableNotifications]: { + description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown. This uses notifications inside the product instead of desktop notifications. Sounds, icons and filtering are not supported."), + type: 'boolean', + default: true + }, +}; diff --git a/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts new file mode 100644 index 0000000000000..a44395c5e94a7 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts @@ -0,0 +1,262 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { strictEqual } from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { NotificationPriority, Severity, type INotification, type INotificationActions, type INotificationHandle, type INotificationProgress, type NotificationMessage } from '../../../../../../platform/notification/common/notification.js'; +import { TerminalNotificationHandler, type IOsc99NotificationHost } from '../../browser/terminalNotificationHandler.js'; + +class TestNotificationProgress implements INotificationProgress { + infinite(): void { } + total(_value: number): void { } + worked(_value: number): void { } + done(): void { } +} + +class TestNotificationHandle implements INotificationHandle { + private readonly _onDidClose = new Emitter(); + readonly onDidClose = this._onDidClose.event; + readonly onDidChangeVisibility = Event.None; + readonly progress = new TestNotificationProgress(); + closed = false; + message: NotificationMessage; + severity: Severity; + actions?: INotificationActions; + priority?: NotificationPriority; + source?: string | { id: string; label: string }; + + constructor(notification: INotification) { + this.message = notification.message; + this.severity = notification.severity; + this.actions = notification.actions; + this.priority = notification.priority; + this.source = notification.source; + } + + updateSeverity(severity: Severity): void { + this.severity = severity; + } + + updateMessage(message: NotificationMessage): void { + this.message = message; + } + + updateActions(actions?: INotificationActions): void { + this._disposeActions(this.actions); + this.actions = actions; + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this._disposeActions(this.actions); + this._onDidClose.fire(); + } + + private _disposeActions(actions: INotificationActions | undefined): void { + for (const action of actions?.primary ?? []) { + const disposable = action as { dispose?: () => void }; + if (typeof disposable.dispose === 'function') { + disposable.dispose(); + } + } + for (const action of actions?.secondary ?? []) { + const disposable = action as { dispose?: () => void }; + if (typeof disposable.dispose === 'function') { + disposable.dispose(); + } + } + } +} + +class TestOsc99Host implements IOsc99NotificationHost { + enabled = true; + windowFocused = false; + terminalVisible = false; + writes: string[] = []; + notifications: TestNotificationHandle[] = []; + focusCalls = 0; + updatedEnableNotifications: boolean[] = []; + logMessages: string[] = []; + + isEnabled(): boolean { + return this.enabled; + } + + isWindowFocused(): boolean { + return this.windowFocused; + } + + isTerminalVisible(): boolean { + return this.terminalVisible; + } + + focusTerminal(): void { + this.focusCalls++; + } + + notify(notification: INotification): INotificationHandle { + const handle = new TestNotificationHandle(notification); + this.notifications.push(handle); + return handle; + } + + async updateEnableNotifications(value: boolean): Promise { + this.enabled = value; + this.updatedEnableNotifications.push(value); + } + + logWarn(message: string): void { + this.logMessages.push(message); + } + + writeToProcess(data: string): void { + this.writes.push(data); + } +} + +suite('Terminal OSC 99 notifications', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let host: TestOsc99Host; + let handler: TerminalNotificationHandler; + + setup(() => { + host = new TestOsc99Host(); + handler = store.add(new TerminalNotificationHandler(host)); + }); + + teardown(() => { + for (const notification of host.notifications) { + notification.close(); + } + }); + + test('ignores notifications when disabled', () => { + host.enabled = false; + + handler.handleSequence(';Hello'); + strictEqual(host.notifications.length, 0); + strictEqual(host.writes.length, 0); + }); + + test('creates notification for title and body and updates', () => { + handler.handleSequence('i=1:d=0:p=title;Hello'); + strictEqual(host.notifications.length, 0); + + handler.handleSequence('i=1:p=body;World'); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Hello: World'); + }); + + test('decodes base64 payloads', () => { + handler.handleSequence('e=1:p=title;SGVsbG8='); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Hello'); + }); + + test('sanitizes markdown links in payloads', () => { + handler.handleSequence('i=link:d=0:p=title;Click [run](command:workbench.action.reloadWindow)'); + handler.handleSequence('i=link:p=body;See [docs](https://example.com)'); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Click run: See docs'); + }); + + test('defers display until done', () => { + handler.handleSequence('i=chunk:d=0:p=title;Hello '); + strictEqual(host.notifications.length, 0); + + handler.handleSequence('i=chunk:d=1:p=title;World'); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Hello World'); + }); + + test('reports activation on button click', async () => { + handler.handleSequence('i=btn:d=0:a=report:p=title;Hi'); + handler.handleSequence('i=btn:p=buttons;Yes'); + + const actions = host.notifications[0].actions; + if (!actions?.primary || actions.primary.length === 0) { + throw new Error('Expected primary actions'); + } + await actions.primary[0].run(); + strictEqual(host.writes[0], '\x1b]99;i=btn;1\x1b\\'); + }); + + test('supports buttons before title and reports body activation', async () => { + handler.handleSequence('i=btn:p=buttons;One\u2028Two'); + handler.handleSequence('i=btn:a=report;Buttons test'); + + strictEqual(host.notifications.length, 1); + const actions = host.notifications[0].actions; + if (!actions?.primary || actions.primary.length !== 2) { + throw new Error('Expected two primary actions'); + } + strictEqual(actions.primary[0].label, 'One'); + strictEqual(actions.primary[1].label, 'Two'); + + await actions.primary[1].run(); + strictEqual(host.writes[0], '\x1b]99;i=btn;2\x1b\\'); + }); + + test('reports activation when notification closes without button action', () => { + handler.handleSequence('i=btn:p=buttons;One\u2028Two'); + handler.handleSequence('i=btn:a=report;Buttons test'); + + host.notifications[0].close(); + strictEqual(host.writes[0], '\x1b]99;i=btn;\x1b\\'); + }); + + test('sends close report when requested', () => { + handler.handleSequence('i=close:c=1:p=title;Bye'); + strictEqual(host.notifications.length, 1); + host.notifications[0].close(); + strictEqual(host.writes[0], '\x1b]99;i=close:p=close;\x1b\\'); + }); + + test('responds to query and alive', () => { + handler.handleSequence('i=a:p=title;A'); + handler.handleSequence('i=b:p=title;B'); + handler.handleSequence('i=q:p=?;'); + handler.handleSequence('i=q:p=alive;'); + + strictEqual(host.writes[0], '\x1b]99;i=q:p=?:a=report,focus:c=1:o=always,unfocused,invisible:p=title,body,buttons,close,alive,?:u=0,1,2:w=1;\x1b\\'); + strictEqual(host.writes[1], '\x1b]99;i=q:p=alive;a,b\x1b\\'); + }); + + test('honors occasion for visibility and focus', () => { + host.windowFocused = true; + host.terminalVisible = true; + handler.handleSequence('o=unfocused:p=title;Hidden'); + strictEqual(host.notifications.length, 0); + + host.windowFocused = false; + host.terminalVisible = true; + handler.handleSequence('o=invisible:p=title;Hidden'); + strictEqual(host.notifications.length, 0); + + host.terminalVisible = false; + handler.handleSequence('o=invisible:p=title;Shown'); + strictEqual(host.notifications.length, 1); + }); + + test('closes notifications via close payload', () => { + handler.handleSequence('i=closeme:p=title;Close'); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].closed, false); + + handler.handleSequence('i=closeme:p=close;'); + strictEqual(host.notifications[0].closed, true); + }); + + test('maps urgency to severity and priority', () => { + handler.handleSequence('u=2:p=title;Urgent'); + strictEqual(host.notifications[0].severity, Severity.Warning); + strictEqual(host.notifications[0].priority, NotificationPriority.URGENT); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts b/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts index 66b343c557439..b7d264ee0c84b 100644 --- a/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts @@ -715,7 +715,7 @@ export class PredictionStats extends Disposable { } } -export class PredictionTimeline { +export class PredictionTimeline extends Disposable { /** * Expected queue of events. Only predictions for the lowest are * written into the terminal. @@ -760,11 +760,11 @@ export class PredictionTimeline { */ private _lookBehind?: IPrediction; - private readonly _addedEmitter = new Emitter(); + private readonly _addedEmitter = this._register(new Emitter()); readonly onPredictionAdded = this._addedEmitter.event; - private readonly _failedEmitter = new Emitter(); + private readonly _failedEmitter = this._register(new Emitter()); readonly onPredictionFailed = this._failedEmitter.event; - private readonly _succeededEmitter = new Emitter(); + private readonly _succeededEmitter = this._register(new Emitter()); readonly onPredictionSucceeded = this._succeededEmitter.event; private get _currentGenerationPredictions() { @@ -779,7 +779,7 @@ export class PredictionTimeline { return this._expected.length; } - constructor(readonly terminal: Terminal, private readonly _style: TypeAheadStyle) { } + constructor(readonly terminal: Terminal, private readonly _style: TypeAheadStyle) { super(); } setShowPredictions(show: boolean) { if (show === this._showPredictions) { @@ -1321,7 +1321,7 @@ export class TypeAheadAddon extends Disposable implements ITerminalAddon { activate(terminal: Terminal): void { const style = this._typeaheadStyle = this._register(new TypeAheadStyle(this._configurationService.getValue(TERMINAL_CONFIG_SECTION).localEchoStyle, terminal)); - const timeline = this._timeline = new PredictionTimeline(terminal, this._typeaheadStyle); + const timeline = this._timeline = this._register(new PredictionTimeline(terminal, this._typeaheadStyle)); const stats = this.stats = this._register(new PredictionStats(this._timeline)); timeline.setShowPredictions(this._typeaheadThreshold === 0); diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts index 970d72c5a4fe1..230dd0ba2de13 100644 --- a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -77,6 +77,7 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor return; } + const productIcon = this.productService.quality === 'insider' ? '$(vscode-insiders)' : '$(vscode)'; switch (state.type) { case StateType.Uninitialized: case StateType.Idle: @@ -90,15 +91,16 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor text: nls.localize('updateStatus.checkingForUpdates', "$(sync~spin) Checking for updates..."), ariaLabel: nls.localize('updateStatus.checkingForUpdatesAria', "Checking for updates"), tooltip: this.getCheckingTooltip(), - command: ShowTooltipCommand + command: ShowTooltipCommand, }); break; case StateType.AvailableForDownload: this.updateStatusBarEntry({ + kind: 'prominent', name: UpdateStatusBarEntryContribution.NAME, - text: nls.localize('updateStatus.updateAvailableStatus', "$(cloud-download) Update is available. Click here to download."), - ariaLabel: nls.localize('updateStatus.updateAvailableAria', "Update available. Click here to download."), + text: nls.localize('updateStatus.updateAvailableStatus', "{0} Update available, click to download.", productIcon), + ariaLabel: nls.localize('updateStatus.updateAvailableAria', "Update available, click to download."), tooltip: this.getAvailableTooltip(state.update), command: 'update.downloadNow' }); @@ -116,9 +118,10 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor case StateType.Downloaded: this.updateStatusBarEntry({ + kind: 'prominent', name: UpdateStatusBarEntryContribution.NAME, - text: nls.localize('updateStatus.updateReadyStatus', "$(package) Downloaded update. Click here to install."), - ariaLabel: nls.localize('updateStatus.updateReadyAria', "Downloaded update. Click here to install."), + text: nls.localize('updateStatus.updateReadyStatus', "{0} Update downloaded, click to install.", productIcon), + ariaLabel: nls.localize('updateStatus.updateReadyAria', "Update downloaded, click to install."), tooltip: this.getReadyToInstallTooltip(state.update), command: 'update.install' }); @@ -134,15 +137,18 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor }); break; - case StateType.Ready: + case StateType.Ready: { + this.updateStatusBarEntry({ + kind: 'prominent', name: UpdateStatusBarEntryContribution.NAME, - text: nls.localize('updateStatus.restartToUpdateStatus', "$(debug-restart) Update is ready. Click here to restart."), - ariaLabel: nls.localize('updateStatus.restartToUpdateAria', "Update is ready. Click here to restart."), + text: nls.localize('updateStatus.restartToUpdateStatus', "{0} Update is ready, click to restart.", productIcon), + ariaLabel: nls.localize('updateStatus.restartToUpdateAria', "Update is ready, click to restart."), tooltip: this.getRestartToUpdateTooltip(state.update), command: 'update.restart' }); break; + } case StateType.Overwriting: this.updateStatusBarEntry({ diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index 42293c78211f7..00fc64dbb82f6 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Dimension, getWindowById } from '../../../../base/browser/dom.js'; +import { Dimension, getWindowById, isHTMLElement } from '../../../../base/browser/dom.js'; import { FastDomNode } from '../../../../base/browser/fastDomNode.js'; import { IMouseWheelEvent } from '../../../../base/browser/mouseEvent.js'; import { CodeWindow } from '../../../../base/browser/window.js'; @@ -15,6 +15,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { IContextKey, IContextKeyService, IScopedContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { IOverlayWebview, IWebview, IWebviewElement, IWebviewService, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_ENABLED, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_VISIBLE, WebviewContentOptions, WebviewExtensionDescription, WebviewInitInfo, WebviewMessageReceivedEvent, WebviewOptions } from './webview.js'; /** @@ -23,7 +24,7 @@ import { IOverlayWebview, IWebview, IWebviewElement, IWebviewService, KEYBINDING export class OverlayWebview extends Disposable implements IOverlayWebview { private _isFirstLoad = true; - private readonly _firstLoadPendingMessages = new Set<{ readonly message: any; readonly transfer?: readonly ArrayBuffer[]; readonly resolve: (value: boolean) => void }>(); + private readonly _firstLoadPendingMessages = new Set<{ readonly message: unknown; readonly transfer?: readonly ArrayBuffer[]; readonly resolve: (value: boolean) => void }>(); private readonly _webview = this._register(new MutableDisposable()); private readonly _webviewEvents = this._register(new DisposableStore()); @@ -36,7 +37,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { private _contentOptions: WebviewContentOptions; private _options: WebviewOptions; - private _owner: any = undefined; + private _owner: unknown = undefined; private _windowId: number | undefined = undefined; private get window() { return getWindowById(this._windowId, true).window; } @@ -56,7 +57,8 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { initInfo: WebviewInitInfo, @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @IWebviewService private readonly _webviewService: IWebviewService, - @IContextKeyService private readonly _baseContextKeyService: IContextKeyService + @IContextKeyService private readonly _baseContextKeyService: IContextKeyService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, ) { super(); @@ -107,14 +109,21 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { this._container.setVisibility('hidden'); // Webviews cannot be reparented in the dom as it will destroy their contents. - // Mount them to a high level node to avoid this. - this._layoutService.getContainer(this.window).appendChild(node); + // Mount them to a high level node to avoid this depending on the active container. + const modalEditorContainer = this._editorGroupsService.activeModalEditorPart?.modalElement; + let root: HTMLElement; + if (isHTMLElement(modalEditorContainer)) { + root = modalEditorContainer; + } else { + root = this._layoutService.getContainer(this.window); + } + root.appendChild(node); } return this._container.domNode; } - public claim(owner: any, targetWindow: CodeWindow, scopedContextKeyService: IContextKeyService | undefined) { + public claim(owner: unknown, targetWindow: CodeWindow, scopedContextKeyService: IContextKeyService | undefined) { if (this._isDisposed) { return; } @@ -156,7 +165,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { } } - public release(owner: any) { + public release(owner: unknown) { if (this._owner !== owner) { return; } @@ -361,7 +370,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { public readonly intrinsicContentSize = observableValue<{ readonly width: number; readonly height: number } | undefined>('WebviewIntrinsicContentSize', undefined); - public async postMessage(message: any, transfer?: readonly ArrayBuffer[]): Promise { + public async postMessage(message: unknown, transfer?: readonly ArrayBuffer[]): Promise { if (this._webview.value) { return this._webview.value.postMessage(message, transfer); } diff --git a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts index 5a7de862a3593..b6bdf6eef7393 100644 --- a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts +++ b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts @@ -25,6 +25,7 @@ import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/com import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; +import { isHTMLElement } from '../../../../base/browser/dom.js'; /** * Tracks the id of the actively focused webview. @@ -194,8 +195,14 @@ export class WebviewEditor extends EditorPane { return; } - const rootContainer = this._workbenchLayoutService.getContainer(this.window, Parts.EDITOR_PART); - webview.layoutWebviewOverElement(this._element.parentElement!, dimension, rootContainer); + const modalEditorContainer = this._editorGroupsService.activeModalEditorPart?.modalElement; + let clippingContainer: HTMLElement | undefined; + if (isHTMLElement(modalEditorContainer)) { + clippingContainer = modalEditorContainer; + } else { + clippingContainer = this._workbenchLayoutService.getContainer(this.window, Parts.EDITOR_PART); + } + webview.layoutWebviewOverElement(this._element.parentElement!, dimension, clippingContainer); } private trackFocus(webview: IOverlayWebview): IDisposable { diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts index 2ae37f9fd8f51..ac89ae8e93179 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts @@ -121,13 +121,13 @@ const NEW_WALKTHROUGH_TIME = 7 * DAYS; export class WalkthroughsService extends Disposable implements IWalkthroughsService { declare readonly _serviceBrand: undefined; - private readonly _onDidAddWalkthrough = new Emitter(); + private readonly _onDidAddWalkthrough = this._register(new Emitter()); readonly onDidAddWalkthrough: Event = this._onDidAddWalkthrough.event; - private readonly _onDidRemoveWalkthrough = new Emitter(); + private readonly _onDidRemoveWalkthrough = this._register(new Emitter()); readonly onDidRemoveWalkthrough: Event = this._onDidRemoveWalkthrough.event; - private readonly _onDidChangeWalkthrough = new Emitter(); + private readonly _onDidChangeWalkthrough = this._register(new Emitter()); readonly onDidChangeWalkthrough: Event = this._onDidChangeWalkthrough.event; - private readonly _onDidProgressStep = new Emitter(); + private readonly _onDidProgressStep = this._register(new Emitter()); readonly onDidProgressStep: Event = this._onDidProgressStep.event; private memento: Memento>; diff --git a/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts b/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts index 729fc617379e3..e21db81253e1c 100644 --- a/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts +++ b/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts @@ -6,8 +6,6 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDialogHandler, IDialogResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; @@ -19,10 +17,8 @@ import { DialogService } from '../../../services/dialogs/common/dialogService.js import { Disposable } from '../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { createNativeAboutDialogDetails } from '../../../../platform/dialogs/electron-browser/dialog.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; -import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; export class DialogHandlerContribution extends Disposable implements IWorkbenchContribution { @@ -38,19 +34,15 @@ export class DialogHandlerContribution extends Disposable implements IWorkbenchC @IConfigurationService private configurationService: IConfigurationService, @IDialogService private dialogService: IDialogService, @ILogService logService: ILogService, - @ILayoutService layoutService: ILayoutService, - @IKeybindingService keybindingService: IKeybindingService, @IInstantiationService instantiationService: IInstantiationService, @IProductService private productService: IProductService, @IClipboardService clipboardService: IClipboardService, @INativeHostService private nativeHostService: INativeHostService, @IWorkbenchEnvironmentService private environmentService: IWorkbenchEnvironmentService, - @IOpenerService openerService: IOpenerService, - @IMarkdownRendererService markdownRendererService: IMarkdownRendererService, ) { super(); - this.browserImpl = new Lazy(() => new BrowserDialogHandler(logService, layoutService, keybindingService, instantiationService, clipboardService, openerService, markdownRendererService)); + this.browserImpl = new Lazy(() => instantiationService.createInstance(BrowserDialogHandler)); this.nativeImpl = new Lazy(() => new NativeDialogHandler(logService, nativeHostService, clipboardService)); this.model = (this.dialogService as DialogService).model; diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 9f6ab53f4b30a..3fba2439fa500 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -29,6 +29,7 @@ import { equals } from '../../../../base/common/objects.js'; import { IDefaultChatAgent } from '../../../../base/common/product.js'; import { IRequestContext } from '../../../../base/parts/request/common/request.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; interface IDefaultAccountConfig { readonly preferredExtensions: string[]; @@ -177,6 +178,11 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount return this.defaultAccountProvider?.signIn(options) ?? null; } + async signOut(): Promise { + await this.initBarrier.wait(); + await this.defaultAccountProvider?.signOut(); + } + private setDefaultAccount(account: IDefaultAccount | null): void { if (equals(this.defaultAccount, account)) { return; @@ -244,6 +250,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid @IContextKeyService contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, @IHostService private readonly hostService: IHostService, + @ICommandService private readonly commandService: ICommandService, ) { super(); this.accountStatusContext = CONTEXT_DEFAULT_ACCOUNT_STATE.bindTo(contextKeyService); @@ -822,6 +829,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return this.defaultAccount; } + async signOut(): Promise { + if (!this.defaultAccount) { + return; + } + this.commandService.executeCommand('_signOutOfAccount', { providerId: this.defaultAccount.authenticationProvider.id, accountLabel: this.defaultAccount.accountName }); + } + } class DefaultAccountProviderContribution extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/workbench/services/assignment/common/assignmentService.ts b/src/vs/workbench/services/assignment/common/assignmentService.ts index 9f3b69e0232a9..f7594b944a2d3 100644 --- a/src/vs/workbench/services/assignment/common/assignmentService.ts +++ b/src/vs/workbench/services/assignment/common/assignmentService.ts @@ -178,6 +178,11 @@ export class WorkbenchAssignmentService extends Disposable implements IAssignmen this.telemetry = this._register(new WorkbenchAssignmentServiceTelemetry(telemetryService, productService)); this._register(this.telemetry.onDidUpdateAssignmentContext(() => this._onDidRefetchAssignments.fire())); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('experiments.override')) { + this._onDidRefetchAssignments.fire(); + } + })); this.keyValueStorage = new MementoKeyValueStorage(new Memento>('experiment.service.memento', storageService)); diff --git a/extensions/emmet/extension-browser.webpack.config.js b/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts similarity index 56% rename from extensions/emmet/extension-browser.webpack.config.js rename to src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts index ce7ea8d197b90..f50672fd91398 100644 --- a/extensions/emmet/extension-browser.webpack.config.js +++ b/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts @@ -2,16 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/browser/emmetBrowserMain.ts' - }, - output: { - filename: 'emmetBrowserMain.js' - } -}); +import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; +import { registerSharedProcessRemoteService } from '../../../../platform/ipc/electron-browser/services.js'; +registerSharedProcessRemoteService(IPlaywrightService, 'playwright'); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index adca06c9c5aa8..919d79dc291ee 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -164,6 +164,8 @@ export interface IChatEntitlementService { readonly anonymous: boolean; readonly anonymousObs: IObservable; + markAnonymousRateLimited(): void; + update(token: CancellationToken): Promise; } @@ -511,6 +513,15 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme //#endregion + markAnonymousRateLimited(): void { + if (!this.anonymous) { + return; + } + + this.chatQuotaExceededContextKey.set(true); + this._onDidChangeQuotaExceeded.fire(); + } + async update(token: CancellationToken): Promise { await this.requests?.value.forceResolveEntitlement(token); } diff --git a/src/vs/workbench/services/configuration/browser/configurationService.ts b/src/vs/workbench/services/configuration/browser/configurationService.ts index 7da80d02749db..d095b024d40a2 100644 --- a/src/vs/workbench/services/configuration/browser/configurationService.ts +++ b/src/vs/workbench/services/configuration/browser/configurationService.ts @@ -109,7 +109,7 @@ export class WorkspaceService extends Disposable implements IWorkbenchConfigurat constructor( { remoteAuthority, configurationCache }: { remoteAuthority?: string; configurationCache: IConfigurationCache }, - private readonly environmentService: IBrowserWorkbenchEnvironmentService, + environmentService: IBrowserWorkbenchEnvironmentService, private readonly userDataProfileService: IUserDataProfileService, private readonly userDataProfilesService: IUserDataProfilesService, private readonly fileService: IFileService, @@ -519,8 +519,7 @@ export class WorkspaceService extends Disposable implements IWorkbenchConfigurat const workspaceConfigPath = workspaceIdentifier.configPath; const workspaceFolders = toWorkspaceFolders(this.workspaceConfiguration.getFolders(), workspaceConfigPath, this.uriIdentityService.extUri); const workspaceId = workspaceIdentifier.id; - const isAgentSessionsWorkspace = this.uriIdentityService.extUri.isEqual(workspaceConfigPath, this.environmentService.agentSessionsWorkspace); - const workspace = new Workspace(workspaceId, workspaceFolders, this.workspaceConfiguration.isTransient(), workspaceConfigPath, uri => this.uriIdentityService.extUri.ignorePathCasing(uri), isAgentSessionsWorkspace); + const workspace = new Workspace(workspaceId, workspaceFolders, this.workspaceConfiguration.isTransient(), workspaceConfigPath, uri => this.uriIdentityService.extUri.ignorePathCasing(uri)); workspace.initialized = this.workspaceConfiguration.initialized; return workspace; } diff --git a/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts index 9277723a59537..2b7e6c69b2ab3 100644 --- a/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts +++ b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts @@ -145,7 +145,12 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR override async resolveWithInteractionReplace(folder: IWorkspaceFolderData | undefined, config: unknown, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise { const parsed = ConfigurationResolverExpression.parse(config); - await this.resolveWithInteraction(folder, parsed, section, variables, target); + const resolved = await this.resolveWithInteraction(folder, parsed, section, variables, target); + + // Skip if input variable was canceled + if (resolved === undefined) { + return undefined; + } return parsed.toObject(); } diff --git a/src/vs/workbench/services/configurationResolver/test/electron-browser/configurationResolverService.test.ts b/src/vs/workbench/services/configurationResolver/test/electron-browser/configurationResolverService.test.ts index 9d33b6e6d5e4f..76db126970219 100644 --- a/src/vs/workbench/services/configurationResolver/test/electron-browser/configurationResolverService.test.ts +++ b/src/vs/workbench/services/configurationResolver/test/electron-browser/configurationResolverService.test.ts @@ -752,6 +752,23 @@ suite('Configuration Resolver Service', () => { assert.strictEqual(0, mockCommandService.callCount); }); }); + + test('canceled input', async () => { + stub(quickInputService, 'input').resolves(undefined); + + const configuration = { + 'name': 'Attach to Process', + 'type': 'node', + 'request': 'attach', + 'processId': '${input:input1}', + 'port': 5858, + 'sourceMaps': false, + 'outDir': null + }; + + const result = await configurationResolverService!.resolveWithInteractionReplace(workspace, configuration, 'tasks'); + assert.strictEqual(result, undefined); + }); }); diff --git a/src/vs/workbench/services/editor/common/editorGroupFinder.ts b/src/vs/workbench/services/editor/common/editorGroupFinder.ts index 825061a3663c8..f46747726b722 100644 --- a/src/vs/workbench/services/editor/common/editorGroupFinder.ts +++ b/src/vs/workbench/services/editor/common/editorGroupFinder.ts @@ -119,16 +119,13 @@ function doFindGroup(input: EditorInputWithOptions | IUntypedEditorInput, prefer // Group: Aux Window else if (preferredGroup === AUX_WINDOW_GROUP) { - group = editorGroupService.createAuxiliaryEditorPart({ - bounds: options?.auxiliary?.bounds, - compact: options?.auxiliary?.compact, - alwaysOnTop: options?.auxiliary?.alwaysOnTop - }).then(group => group.activeGroup); + group = editorGroupService.createAuxiliaryEditorPart(options?.auxiliary) + .then(group => group.activeGroup); } // Group: Modal (gated behind a setting) else if (preferredGroup === MODAL_GROUP && configurationService.getValue('workbench.editor.allowOpenInModalEditor')) { - group = editorGroupService.createModalEditorPart() + group = editorGroupService.createModalEditorPart(options?.modal) .then(part => part.activeGroup); } diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index d782d8df7baf0..9fcff97208a66 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -7,7 +7,7 @@ import { Event } from '../../../../base/common/event.js'; import { IInstantiationService, createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IEditorPane, GroupIdentifier, EditorInputWithOptions, CloseDirection, IEditorPartOptions, IEditorPartOptionsChangeEvent, EditorsOrder, IVisibleEditorPane, IEditorCloseEvent, IUntypedEditorInput, isEditorInput, IEditorWillMoveEvent, IMatchEditorOptions, IActiveEditorChangeEvent, IFindEditorOptions, IToolbarActions } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; -import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { IEditorOptions, IModalEditorNavigation, IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDimension } from '../../../../editor/common/core/2d/dimension.js'; import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; @@ -524,9 +524,9 @@ export interface IAuxiliaryEditorPart extends IEditorPart { export interface IModalEditorPart extends IEditorPart { /** - * Fired when this modal editor part is about to close. + * Modal container of the editor part. */ - readonly onWillClose: Event; + readonly modalElement: unknown /* HTMLElement */; /** * Whether the modal editor part is currently maximized. @@ -543,6 +543,21 @@ export interface IModalEditorPart extends IEditorPart { */ toggleMaximized(): void; + /** + * The current navigation context, if any. + */ + readonly navigation: IModalEditorNavigation | undefined; + + /** + * Update options for the modal editor part. + */ + updateOptions(options?: IModalEditorPartOptions): void; + + /** + * Fired when this modal editor part is about to close. + */ + readonly onWillClose: Event; + /** * Close this modal editor part after moving all * editors of all groups back to the main editor part @@ -626,7 +641,7 @@ export interface IEditorGroupsService extends IEditorGroupsContainer { * If a modal part already exists, it will be returned * instead of creating a new one. */ - createModalEditorPart(): Promise; + createModalEditorPart(options?: IModalEditorPartOptions): Promise; /** * The currently active modal editor part, if any. diff --git a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts index 3a42f865c9630..22d994be10d0b 100644 --- a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts @@ -476,6 +476,11 @@ suite('EditorGroupsService', () => { assert.strictEqual(part.partOptions.showTabs, 'single'); assert.strictEqual(newOptions.showTabs, 'single'); assert.strictEqual(oldOptions, currentOptions); + + const enforced = part.enforcePartOptions({ allowDropIntoGroup: false }); + assert.strictEqual(part.partOptions.allowDropIntoGroup, false); + enforced.dispose(); + assert.strictEqual(part.partOptions.allowDropIntoGroup, true); }); test('editor basics', async function () { diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index ec1a621d29e78..d0bc952a87478 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -254,6 +254,9 @@ export class BrowserWorkbenchEnvironmentService implements IBrowserWorkbenchEnvi @memoize get disableWorkspaceTrust(): boolean { return !this.options.enableWorkspaceTrust; } + @memoize + get isSessionsWindow(): boolean { return false; } + @memoize get profile(): string | undefined { return this.payload?.get('profile'); } diff --git a/src/vs/workbench/services/environment/common/environmentService.ts b/src/vs/workbench/services/environment/common/environmentService.ts index 5312892fe6f1d..3ad7edaa43405 100644 --- a/src/vs/workbench/services/environment/common/environmentService.ts +++ b/src/vs/workbench/services/environment/common/environmentService.ts @@ -35,6 +35,7 @@ export interface IWorkbenchEnvironmentService extends IEnvironmentService { readonly skipReleaseNotes: boolean; readonly skipWelcome: boolean; readonly disableWorkspaceTrust: boolean; + readonly isSessionsWindow: boolean; readonly webviewExternalEndpoint: string; // --- Development diff --git a/src/vs/workbench/services/environment/electron-browser/environmentService.ts b/src/vs/workbench/services/environment/electron-browser/environmentService.ts index 563753dfedc65..9d08b14460784 100644 --- a/src/vs/workbench/services/environment/electron-browser/environmentService.ts +++ b/src/vs/workbench/services/environment/electron-browser/environmentService.ts @@ -151,6 +151,9 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment @memoize get filesToWait(): IPathsToWaitFor | undefined { return this.configuration.filesToWait; } + @memoize + get isSessionsWindow(): boolean { return !!this.configuration.isSessionsWindow; } + constructor( private readonly configuration: INativeWindowConfiguration, productService: IProductService diff --git a/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts b/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts index a95e4ae1b5074..3d0f306ac45f9 100644 --- a/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts +++ b/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts @@ -318,14 +318,14 @@ export class WebWorkerExtensionHost extends Disposable implements IExtensionHost extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI, globalStorageHome: this._userDataProfilesService.defaultProfile.globalStorageHome, workspaceStorageHome: this._environmentService.workspaceStorageHome, - extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions + extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions, + isSessionsWindow: this._environmentService.isSessionsWindow }, workspace: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY ? undefined : { configuration: workspace.configuration || undefined, id: workspace.id, name: this._labelService.getWorkspaceLabel(workspace), - transient: workspace.transient, - isAgentSessionsWorkspace: workspace.isAgentSessionsWorkspace + transient: workspace.transient }, consoleForward: { includeStack: false, diff --git a/src/vs/workbench/services/extensions/common/extensionHostProtocol.ts b/src/vs/workbench/services/extensions/common/extensionHostProtocol.ts index 54b8c4f9e78c3..4f7f8ff9fe889 100644 --- a/src/vs/workbench/services/extensions/common/extensionHostProtocol.ts +++ b/src/vs/workbench/services/extensions/common/extensionHostProtocol.ts @@ -75,6 +75,7 @@ export interface IEnvironment { useHostProxy?: boolean; skipWorkspaceStorageLock?: boolean; extensionLogLevel?: [string, LogLevel][]; + isSessionsWindow?: boolean; } export interface IStaticWorkspaceData { @@ -83,7 +84,6 @@ export interface IStaticWorkspaceData { transient?: boolean; configuration?: UriComponents | null; isUntitled?: boolean | null; - isAgentSessionsWorkspace?: boolean; } export interface MessagePortLike { diff --git a/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts b/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts index 9a3b997f2a6a7..cc716aa469f62 100644 --- a/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts +++ b/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts @@ -225,7 +225,8 @@ export class RemoteExtensionHost extends Disposable implements IExtensionHost { extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI, globalStorageHome: remoteInitData.globalStorageHome, workspaceStorageHome: remoteInitData.workspaceStorageHome, - extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions + extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions, + isSessionsWindow: this._environmentService.isSessionsWindow }, workspace: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY ? null : { configuration: workspace.configuration, diff --git a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts index 6676c97550bad..af93d8558dea5 100644 --- a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts +++ b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts @@ -249,8 +249,8 @@ export class NativeLocalProcessExtensionHost extends Disposable implements IExte // Catch all output coming from the extension host process type Output = { data: string; format: string[] }; - const onStdout = this._handleProcessOutputStream(this._extensionHostProcess.onStdout); - const onStderr = this._handleProcessOutputStream(this._extensionHostProcess.onStderr); + const onStdout = this._register(this._handleProcessOutputStream(this._extensionHostProcess.onStdout)); + const onStderr = this._register(this._handleProcessOutputStream(this._extensionHostProcess.onStderr)); const onOutput = Event.any( Event.map(onStdout.event, o => ({ data: `%c${o}`, format: [''] })), Event.map(onStderr.event, o => ({ data: `%c${o}`, format: ['color: red'] })) @@ -488,15 +488,15 @@ export class NativeLocalProcessExtensionHost extends Disposable implements IExte extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI, globalStorageHome: this._userDataProfilesService.defaultProfile.globalStorageHome, workspaceStorageHome: this._environmentService.workspaceStorageHome, - extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions + extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions, + isSessionsWindow: this._environmentService.isSessionsWindow }, workspace: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY ? undefined : { configuration: workspace.configuration ?? undefined, id: workspace.id, name: this._labelService.getWorkspaceLabel(workspace), isUntitled: workspace.configuration ? isUntitledWorkspace(workspace.configuration, this._environmentService) : false, - transient: workspace.transient, - isAgentSessionsWorkspace: workspace.isAgentSessionsWorkspace + transient: workspace.transient }, remote: { authority: this._environmentService.remoteAuthority, diff --git a/src/vs/workbench/services/host/browser/browserHostService.ts b/src/vs/workbench/services/host/browser/browserHostService.ts index de8fda44c33eb..6038ad8ac9411 100644 --- a/src/vs/workbench/services/host/browser/browserHostService.ts +++ b/src/vs/workbench/services/host/browser/browserHostService.ts @@ -577,6 +577,10 @@ export class BrowserHostService extends Disposable implements IHostService { // There seems to be no API to bring a window to front in browsers } + async setWindowDimmed(_targetWindow: Window, _dimmed: boolean): Promise { + // not supported in browser + } + async getCursorScreenPoint(): Promise { return undefined; } diff --git a/src/vs/workbench/services/host/browser/host.ts b/src/vs/workbench/services/host/browser/host.ts index 402d97d4634e4..c7d300bf3c7ea 100644 --- a/src/vs/workbench/services/host/browser/host.ts +++ b/src/vs/workbench/services/host/browser/host.ts @@ -105,6 +105,12 @@ export interface IHostService { */ moveTop(targetWindow: Window): Promise; + /** + * Toggle dimming of window control overlays (e.g. when showing + * a modal dialog or modal editor part). + */ + setWindowDimmed(targetWindow: Window, dimmed: boolean): Promise; + /** * Get the location of the mouse cursor and its display bounds or `undefined` if unavailable. */ diff --git a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts index 16defe36affd4..3838e3bb4e530 100644 --- a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts +++ b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts @@ -173,6 +173,10 @@ class WorkbenchHostService extends Disposable implements IHostService { return this.nativeHostService.moveWindowTop(isAuxiliaryWindow(targetWindow) ? { targetWindowId: targetWindow.vscodeWindowId } : undefined); } + async setWindowDimmed(targetWindow: Window, dimmed: boolean): Promise { + return this.nativeHostService.updateWindowControls({ dimmed, targetWindowId: getWindowId(targetWindow) }); + } + getCursorScreenPoint(): Promise<{ readonly point: IPoint; readonly display: IRectangle }> { return this.nativeHostService.getCursorScreenPoint(); } diff --git a/src/vs/workbench/services/label/common/labelService.ts b/src/vs/workbench/services/label/common/labelService.ts index de859bba7ddda..47b509ff20f17 100644 --- a/src/vs/workbench/services/label/common/labelService.ts +++ b/src/vs/workbench/services/label/common/labelService.ts @@ -308,10 +308,6 @@ export class LabelService extends Disposable implements ILabelService { getWorkspaceLabel(workspace: IWorkspace | IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | URI, options?: { verbose: Verbosity }): string { if (isWorkspace(workspace)) { - if (workspace.isAgentSessionsWorkspace) { - return localize('agentSessionsWorkspace', "Agent Sessions"); - } - const identifier = toWorkspaceIdentifier(workspace); if (isSingleFolderWorkspaceIdentifier(identifier) || isWorkspaceIdentifier(identifier)) { return this.getWorkspaceLabel(identifier, options); diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index 9e05d8b0687c8..aa67ec08d6ad0 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -25,6 +25,7 @@ export const enum Parts { SIDEBAR_PART = 'workbench.parts.sidebar', PANEL_PART = 'workbench.parts.panel', AUXILIARYBAR_PART = 'workbench.parts.auxiliarybar', + CHATBAR_PART = 'workbench.parts.chatbar', EDITOR_PART = 'workbench.parts.editor', STATUSBAR_PART = 'workbench.parts.statusbar' } diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index cb06ab105a116..db6c6bc4aa5ed 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -49,6 +49,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider { async signIn(): Promise { return null; } + + async signOut(): Promise { } } suite('AccountPolicyService', () => { diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index eb0e740f798a3..512d8d9111084 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -56,6 +56,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider { async signIn(): Promise { return null; } + + async signOut(): Promise { } } suite('MultiplexPolicyService', () => { diff --git a/src/vs/workbench/services/progress/browser/progressService.ts b/src/vs/workbench/services/progress/browser/progressService.ts index daf34ce8ee19d..a03c869f95878 100644 --- a/src/vs/workbench/services/progress/browser/progressService.ts +++ b/src/vs/workbench/services/progress/browser/progressService.ts @@ -23,7 +23,8 @@ import { IViewsService } from '../../views/common/viewsService.js'; import { IPaneCompositePartService } from '../../panecomposite/browser/panecomposite.js'; import { stripIcons } from '../../../../base/common/iconLabels.js'; import { IUserActivityService } from '../../userActivity/common/userActivityService.js'; -import { createWorkbenchDialogOptions } from '../../../../platform/dialogs/browser/dialog.js'; +import { createWorkbenchDialogOptions } from '../../../browser/parts/dialogs/dialog.js'; +import { IHostService } from '../../host/browser/host.js'; export class ProgressService extends Disposable implements IProgressService { @@ -39,6 +40,7 @@ export class ProgressService extends Disposable implements IProgressService { @ILayoutService private readonly layoutService: ILayoutService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IUserActivityService private readonly userActivityService: IUserActivityService, + @IHostService private readonly hostService: IHostService, ) { super(); } @@ -567,7 +569,7 @@ export class ProgressService extends Disposable implements IProgressService { cancelId: buttons.length - 1, disableCloseAction: options.sticky, disableDefaultAction: options.sticky - }, this.keybindingService, this.layoutService) + }, this.keybindingService, this.layoutService, this.hostService) ); disposables.add(dialog); diff --git a/src/vs/workbench/services/remote/common/abstractRemoteAgentService.ts b/src/vs/workbench/services/remote/common/abstractRemoteAgentService.ts index 1d2a47b2bcb29..c9071cc3c19e0 100644 --- a/src/vs/workbench/services/remote/common/abstractRemoteAgentService.ts +++ b/src/vs/workbench/services/remote/common/abstractRemoteAgentService.ts @@ -246,9 +246,9 @@ class RemoteAgentConnection extends Disposable implements IRemoteAgentConnection this._initialConnectionMs = Date.now() - start; } - connection.protocol.onDidDispose(() => { + this._register(connection.protocol.onDidDispose(() => { connection.dispose(); - }); + })); this.end = () => { connection.protocol.sendDisconnect(); return connection.protocol.drain(); diff --git a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts index 8884c7958f87f..1a4648762e31f 100644 --- a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts @@ -118,20 +118,20 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme this.colorThemeRegistry = this._register(new ThemeRegistry(colorThemesExtPoint, ColorThemeData.fromExtensionTheme)); this.colorThemeWatcher = this._register(new ThemeFileWatcher(fileService, environmentService, this.reloadCurrentColorTheme.bind(this))); - this.onColorThemeChange = new Emitter({ leakWarningThreshold: 400 }); + this.onColorThemeChange = this._register(new Emitter({ leakWarningThreshold: 400 })); this.currentColorTheme = ColorThemeData.createUnloadedTheme(''); this.colorThemeSequencer = new Sequencer(); this.fileIconThemeWatcher = this._register(new ThemeFileWatcher(fileService, environmentService, this.reloadCurrentFileIconTheme.bind(this))); this.fileIconThemeRegistry = this._register(new ThemeRegistry(fileIconThemesExtPoint, FileIconThemeData.fromExtensionTheme, true, FileIconThemeData.noIconTheme)); this.fileIconThemeLoader = new FileIconThemeLoader(extensionResourceLoaderService, languageService); - this.onFileIconThemeChange = new Emitter({ leakWarningThreshold: 400 }); + this.onFileIconThemeChange = this._register(new Emitter({ leakWarningThreshold: 400 })); this.currentFileIconTheme = FileIconThemeData.createUnloadedTheme(''); this.fileIconThemeSequencer = new Sequencer(); this.productIconThemeWatcher = this._register(new ThemeFileWatcher(fileService, environmentService, this.reloadCurrentProductIconTheme.bind(this))); this.productIconThemeRegistry = this._register(new ThemeRegistry(productIconThemesExtPoint, ProductIconThemeData.fromExtensionTheme, true, ProductIconThemeData.defaultTheme)); - this.onProductIconThemeChange = new Emitter(); + this.onProductIconThemeChange = this._register(new Emitter()); this.currentProductIconTheme = ProductIconThemeData.createUnloadedTheme(''); this.productIconThemeSequencer = new Sequencer(); diff --git a/src/vs/workbench/services/views/browser/viewDescriptorService.ts b/src/vs/workbench/services/views/browser/viewDescriptorService.ts index 62cf852a83b0a..79ff9dd03ccd1 100644 --- a/src/vs/workbench/services/views/browser/viewDescriptorService.ts +++ b/src/vs/workbench/services/views/browser/viewDescriptorService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ViewContainerLocation, IViewDescriptorService, ViewContainer, IViewsRegistry, IViewContainersRegistry, IViewDescriptor, Extensions as ViewExtensions, ViewVisibilityState, defaultViewIcon, ViewContainerLocationToString, VIEWS_LOG_ID, VIEWS_LOG_NAME } from '../../../common/views.js'; +import { ViewContainerLocation, IViewDescriptorService, ViewContainer, IViewsRegistry, IViewContainersRegistry, IViewDescriptor, Extensions as ViewExtensions, ViewVisibilityState, defaultViewIcon, ViewContainerLocationToString, VIEWS_LOG_ID, VIEWS_LOG_NAME, WindowVisibility } from '../../../common/views.js'; import { IContextKey, RawContextKey, IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; @@ -24,6 +24,7 @@ import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js' import { Lazy } from '../../../../base/common/lazy.js'; import { IViewsService } from '../common/viewsService.js'; import { windowLogGroup } from '../../log/common/logConstants.js'; +import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; interface IViewsCustomizations { viewContainerLocations: IStringDictionary; @@ -69,6 +70,7 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor get viewContainers(): ReadonlyArray { return this.viewContainersRegistry.all; } private readonly logger: Lazy; + private readonly isSessionsWindow: boolean; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -77,10 +79,12 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor @IExtensionService private readonly extensionService: IExtensionService, @ITelemetryService private readonly telemetryService: ITelemetryService, @ILoggerService loggerService: ILoggerService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, ) { super(); this.logger = new Lazy(() => loggerService.createLogger(VIEWS_LOG_ID, { name: VIEWS_LOG_NAME, group: windowLogGroup })); + this.isSessionsWindow = environmentService.isSessionsWindow; this.activeViewContextKeys = new Map>(); this.movableViewContextKeys = new Map>(); @@ -263,7 +267,11 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } getViewDescriptorById(viewId: string): IViewDescriptor | null { - return this.viewsRegistry.getView(viewId); + const view = this.viewsRegistry.getView(viewId); + if (view && !this.isViewVisible(view)) { + return null; + } + return view; } getViewLocationById(viewId: string): ViewContainerLocation | null { @@ -276,6 +284,12 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } getViewContainerByViewId(viewId: string): ViewContainer | null { + // Check if the view itself should be visible in current workspace + const view = this.viewsRegistry.getView(viewId); + if (view && !this.isViewVisible(view)) { + return null; + } + const containerId = this.viewDescriptorsCustomLocations.get(viewId); return containerId ? @@ -284,11 +298,21 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } getViewContainerLocation(viewContainer: ViewContainer): ViewContainerLocation { - return this.viewContainersCustomLocations.get(viewContainer.id) ?? this.getDefaultViewContainerLocation(viewContainer); + const location = this.viewContainersCustomLocations.get(viewContainer.id) ?? this.getDefaultViewContainerLocation(viewContainer); + return this.getEffectiveViewContainerLocation(location); } getDefaultViewContainerLocation(viewContainer: ViewContainer): ViewContainerLocation { - return this.viewContainersRegistry.getViewContainerLocation(viewContainer); + return this.getEffectiveViewContainerLocation(this.viewContainersRegistry.getViewContainerLocation(viewContainer)); + } + + private getEffectiveViewContainerLocation(location: ViewContainerLocation): ViewContainerLocation { + // When not in agent sessions workspace, view containers contributed to ChatBar + // should be registered at the AuxiliaryBar location instead + if (!this.isSessionsWindow && location === ViewContainerLocation.ChatBar) { + return ViewContainerLocation.AuxiliaryBar; + } + return location; } getDefaultContainerById(viewId: string): ViewContainer | null { @@ -300,19 +324,40 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } getViewContainerById(id: string): ViewContainer | null { - return this.viewContainersRegistry.get(id) || null; + const viewContainer = this.viewContainersRegistry.get(id) || null; + if (viewContainer && !this.isViewContainerVisible(viewContainer)) { + return null; + } + return viewContainer; } getViewContainersByLocation(location: ViewContainerLocation): ViewContainer[] { - return this.viewContainers.filter(v => this.getViewContainerLocation(v) === location); + return this.viewContainers.filter(v => this.getViewContainerLocation(v) === location && this.isViewContainerVisible(v)); + } + + private isViewContainerVisible(viewContainer: ViewContainer): boolean { + const layoutVisibility = viewContainer.windowVisibility; + if (this.isSessionsWindow) { + return layoutVisibility === WindowVisibility.Sessions || layoutVisibility === WindowVisibility.Both; + } + return !layoutVisibility || layoutVisibility === WindowVisibility.Editor || layoutVisibility === WindowVisibility.Both; + } + + private isViewVisible(view: IViewDescriptor): boolean { + const layoutVisibility = view.windowVisibility; + if (this.isSessionsWindow) { + return layoutVisibility === WindowVisibility.Sessions || layoutVisibility === WindowVisibility.Both; + } + return !layoutVisibility || layoutVisibility === WindowVisibility.Editor || layoutVisibility === WindowVisibility.Both; } getDefaultViewContainer(location: ViewContainerLocation): ViewContainer | undefined { - return this.viewContainersRegistry.getDefaultViewContainers(location)[0]; + const viewContainers = this.viewContainersRegistry.getDefaultViewContainers(location); + return viewContainers.find(viewContainer => this.isViewContainerVisible(viewContainer)); } canMoveViews(): boolean { - return true; + return !this.isSessionsWindow; } moveViewContainerToLocation(viewContainer: ViewContainer, location: ViewContainerLocation, requestedIndex?: number, reason?: string): void { @@ -673,10 +718,16 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } private getStoredViewCustomizationsValue(): string { + if (this.isSessionsWindow) { + return '{}'; + } return this.storageService.get(ViewDescriptorService.VIEWS_CUSTOMIZATIONS, StorageScope.PROFILE, '{}'); } private setStoredViewCustomizationsValue(value: string): void { + if (this.isSessionsWindow) { + return; + } this.storageService.store(ViewDescriptorService.VIEWS_CUSTOMIZATIONS, value, StorageScope.PROFILE, StorageTarget.USER); } diff --git a/src/vs/workbench/test/browser/parts/editor/modalEditorNavigation.test.ts b/src/vs/workbench/test/browser/parts/editor/modalEditorNavigation.test.ts new file mode 100644 index 0000000000000..fa1d60c8537d0 --- /dev/null +++ b/src/vs/workbench/test/browser/parts/editor/modalEditorNavigation.test.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IModalEditorNavigation, IModalEditorPartOptions } from '../../../../../platform/editor/common/editor.js'; + +/** + * Simple test harness that mimics the ModalEditorPartImpl navigation behavior + * without requiring the full editor part infrastructure. + */ +class TestModalEditorNavigationHost { + + private readonly _onDidChangeNavigation = new Emitter(); + readonly onDidChangeNavigation = this._onDidChangeNavigation.event; + + private _navigation: IModalEditorNavigation | undefined; + get navigation(): IModalEditorNavigation | undefined { return this._navigation; } + + updateOptions(options: IModalEditorPartOptions): void { + this._navigation = options.navigation; + this._onDidChangeNavigation.fire(options.navigation); + } + + dispose(): void { + this._onDidChangeNavigation.dispose(); + } +} + +suite('Modal Editor Navigation', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('updateOptions sets navigation and fires event', () => { + const host = new TestModalEditorNavigationHost(); + disposables.add({ dispose: () => host.dispose() }); + + const events: (IModalEditorNavigation | undefined)[] = []; + disposables.add(host.onDidChangeNavigation(ctx => events.push(ctx))); + + const nav: IModalEditorNavigation = { + total: 10, + current: 3, + navigate: () => { } + }; + + host.updateOptions({ navigation: nav }); + + assert.strictEqual(host.navigation, nav); + assert.deepStrictEqual(events, [nav]); + }); + + test('updateOptions with undefined navigation clears navigation', () => { + const host = new TestModalEditorNavigationHost(); + disposables.add({ dispose: () => host.dispose() }); + + const events: (IModalEditorNavigation | undefined)[] = []; + disposables.add(host.onDidChangeNavigation(ctx => events.push(ctx))); + + const nav: IModalEditorNavigation = { + total: 5, + current: 0, + navigate: () => { } + }; + + host.updateOptions({ navigation: nav }); + host.updateOptions({ navigation: undefined }); + + assert.strictEqual(host.navigation, undefined); + assert.deepStrictEqual(events, [nav, undefined]); + }); + + test('navigate callback updates context', () => { + const host = new TestModalEditorNavigationHost(); + disposables.add({ dispose: () => host.dispose() }); + + const navigatedIndices: number[] = []; + + const navigate = (index: number) => { + navigatedIndices.push(index); + // Simulates what real navigation does: update the context with new index + host.updateOptions({ navigation: { total: 10, current: index, navigate } }); + }; + + host.updateOptions({ navigation: { total: 10, current: 0, navigate } }); + + // Navigate forward + host.navigation!.navigate(1); + assert.strictEqual(host.navigation!.current, 1); + + host.navigation!.navigate(5); + assert.strictEqual(host.navigation!.current, 5); + + assert.deepStrictEqual(navigatedIndices, [1, 5]); + }); + + test('navigation boundary conditions', () => { + const host = new TestModalEditorNavigationHost(); + disposables.add({ dispose: () => host.dispose() }); + + const navigate = (index: number) => { + if (index >= 0 && index < 3) { + host.updateOptions({ navigation: { total: 3, current: index, navigate } }); + } + }; + + host.updateOptions({ navigation: { total: 3, current: 0, navigate } }); + + // At first item + assert.strictEqual(host.navigation!.current, 0); + assert.ok(host.navigation!.current <= 0); // previous disabled + + // Navigate to last + host.navigation!.navigate(2); + assert.strictEqual(host.navigation!.current, 2); + assert.ok(host.navigation!.current >= host.navigation!.total - 1); // next disabled + + // Navigate back to middle + host.navigation!.navigate(1); + assert.strictEqual(host.navigation!.current, 1); + }); + + test('navigation context fires multiple events', () => { + const host = new TestModalEditorNavigationHost(); + disposables.add({ dispose: () => host.dispose() }); + + let eventCount = 0; + disposables.add(host.onDidChangeNavigation(() => eventCount++)); + + const navigate = (index: number) => { + host.updateOptions({ navigation: { total: 5, current: index, navigate } }); + }; + + host.updateOptions({ navigation: { total: 5, current: 0, navigate } }); + host.navigation!.navigate(1); + host.navigation!.navigate(2); + host.updateOptions({ navigation: undefined }); + + assert.strictEqual(eventCount, 4); // initial + 2 navigates + clear + }); +}); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 0684952209a18..529c27d720d98 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -1379,6 +1379,8 @@ export class TestHostService implements IHostService { async showToast(_options: IToastOptions, token: CancellationToken): Promise { return { supported: false, clicked: false }; } + async setWindowDimmed(_targetWindow: Window, _dimmed: boolean): Promise { } + readonly colorScheme = ColorScheme.DARK; onDidChangeColorScheme = Event.None; } @@ -2141,6 +2143,7 @@ export class TestChatWidgetService implements IChatWidgetService { onDidAddWidget = Event.None; onDidBackgroundSession = Event.None; onDidChangeFocusedWidget = Event.None; + onDidChangeFocusedSession = Event.None; async reveal(widget: IChatWidget, preserveFocus?: boolean): Promise { return false; } async revealWidget(preserveFocus?: boolean): Promise { return undefined; } diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index 25ebf2bcc5281..e625aa79e5bbd 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -806,6 +806,8 @@ export class TestChatEntitlementService implements IChatEntitlementService { onDidChangeAnonymous = Event.None; readonly anonymousObs = observableValue({}, false); + markAnonymousRateLimited(): void { } + readonly previewFeaturesDisabled = false; } diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index 702928e173ace..74ce76f240dfa 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -103,6 +103,8 @@ export class TestNativeHostService implements INativeHostService { throw new Error('Method not implemented.'); } + async openSessionsWindow(): Promise { } + async toggleFullScreen(): Promise { } async isMaximized(): Promise { return true; } async isFullScreen(): Promise { return true; } diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index e720674e03f88..8291f38e8d17d 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -91,6 +91,7 @@ import '../platform/userDataProfile/electron-browser/userDataProfileStorageServi import './services/auxiliaryWindow/electron-browser/auxiliaryWindowService.js'; import '../platform/extensionManagement/electron-browser/extensionsProfileScannerService.js'; import '../platform/webContentExtractor/electron-browser/webContentExtractorService.js'; +import './services/browserView/electron-browser/playwrightWorkbenchService.js'; import './services/process/electron-browser/processService.js'; import './services/power/electron-browser/powerService.js'; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 63f9ec248841a..16c8ff488a027 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -176,10 +176,15 @@ declare module 'vscode' { */ readonly editedFileEvents?: ChatRequestEditedFileEvent[]; + /** + * The identifier of the language model that was used for this request, if known. + */ + readonly modelId?: string; + /** * @hidden */ - constructor(prompt: string, command: string | undefined, references: ChatPromptReference[], participant: string, toolReferences: ChatLanguageModelToolReference[], editedFileEvents: ChatRequestEditedFileEvent[] | undefined, id: string | undefined); + constructor(prompt: string, command: string | undefined, references: ChatPromptReference[], participant: string, toolReferences: ChatLanguageModelToolReference[], editedFileEvents: ChatRequestEditedFileEvent[] | undefined, id: string | undefined, modelId: string | undefined); } export class ChatResponseTurn2 { @@ -262,8 +267,6 @@ declare module 'vscode' { export interface LanguageModelToolInvocationOptions { chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - chatSessionId?: string; chatSessionResource?: Uri; chatInteractionId?: string; terminalCommand?: string; @@ -289,8 +292,6 @@ declare module 'vscode' { */ input: T; chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - chatSessionId?: string; chatSessionResource?: Uri; chatInteractionId?: string; /** diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index 48a3f1048feeb..1ded6ac9ba730 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -65,6 +65,15 @@ declare module 'vscode' { readonly category?: { label: string; order: number }; readonly statusIcon?: ThemeIcon; + + /** + * When set, this model is only shown in the model picker for the specified chat session type. + * Models with this property are excluded from the general model picker and only appear + * when the user is in a session matching this type. + * + * The value must match a `type` declared in a `chatSessions` extension contribution. + */ + readonly targetChatSessionType?: string; } export interface LanguageModelChatCapabilities { diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index c3641a37061e9..04df7ca03d790 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -35,6 +35,8 @@ declare module 'vscode' { /** * Registers a new {@link ChatSessionItemProvider chat session item provider}. * + * @deprecated Use {@linkcode createChatSessionItemController} instead. + * * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. * * @param chatSessionType The type of chat session the provider is for. @@ -46,12 +48,21 @@ declare module 'vscode' { /** * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. + * + * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. + * + * @param chatSessionType The type of chat session the provider is for. + * @param refreshHandler The controller's {@link ChatSessionItemController.refreshHandler refresh handler}. + * + * @returns A new controller instance that can be used to manage chat session items for the given chat session type. */ - export function createChatSessionItemController(id: string, refreshHandler: (token: CancellationToken) => Thenable): ChatSessionItemController; + export function createChatSessionItemController(chatSessionType: string, refreshHandler: ChatSessionItemControllerRefreshHandler): ChatSessionItemController; } /** * Provides a list of information about chat sessions. + * + * @deprecated Use {@linkcode ChatSessionItemController} instead. */ export interface ChatSessionItemProvider { /** @@ -77,7 +88,12 @@ declare module 'vscode' { } /** - * Provides a list of information about chat sessions. + * Extension callback invoked to refresh the collection of chat session items for a {@linkcode ChatSessionItemController}. + */ + export type ChatSessionItemControllerRefreshHandler = (token: CancellationToken) => Thenable; + + /** + * Manages chat sessions for a specific chat session type */ export interface ChatSessionItemController { readonly id: string; @@ -93,7 +109,7 @@ declare module 'vscode' { readonly items: ChatSessionItemCollection; /** - * Creates a new managed chat session item that be added to the collection. + * Creates a new managed chat session item that can be added to the collection. */ createChatSessionItem(resource: Uri, label: string): ChatSessionItem; @@ -102,7 +118,7 @@ declare module 'vscode' { * * This is also called on first load to get the initial set of items. */ - readonly refreshHandler: (token: CancellationToken) => Thenable; + readonly refreshHandler: ChatSessionItemControllerRefreshHandler; /** * Fired when an item's archived state changes. @@ -121,7 +137,8 @@ declare module 'vscode' { /** * Replaces the items stored by the collection. - * @param items Items to store. + * + * @param items Items to store. If two items have the same resource URI, the last one will be used. */ replace(items: readonly ChatSessionItem[]): void; @@ -136,31 +153,42 @@ declare module 'vscode' { /** * Adds the chat session item to the collection. If an item with the same resource URI already * exists, it'll be replaced. + * * @param item Item to add. */ add(item: ChatSessionItem): void; /** * Removes a single chat session item from the collection. + * * @param resource Item resource to delete. */ delete(resource: Uri): void; /** * Efficiently gets a chat session item by resource, if it exists, in the collection. + * * @param resource Item resource to get. + * * @returns The found item or undefined if it does not exist. */ get(resource: Uri): ChatSessionItem | undefined; } + /** + * A chat session show in the UI. + * + * This should be created by calling a {@link ChatSessionItemController.createChatSessionItem createChatSessionItem} + * method on the controller. The item can then be added to the controller's {@link ChatSessionItemController.items items collection} + * to show it in the UI. + */ export interface ChatSessionItem { /** * The resource associated with the chat session. * * This is uniquely identifies the chat session and is used to open the chat session. */ - resource: Uri; + readonly resource: Uri; /** * Human readable name of the session shown in the UI @@ -436,6 +464,11 @@ declare module 'vscode' { export interface ChatSessionContext { readonly chatSessionItem: ChatSessionItem; // Maps to URI of chat session editor (could be 'untitled-1', etc..) readonly isUntitled: boolean; + /** + * The initial option selections for the session, provided with the first request. + * Contains the options the user selected (or defaults) before the session was created. + */ + readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | ChatSessionProviderOptionItem }>; } export interface ChatSessionCapabilities { diff --git a/src/vscode-dts/vscode.proposed.envIsConnectionMetered.d.ts b/src/vscode-dts/vscode.proposed.envIsConnectionMetered.d.ts index fc89a57af7ace..62051fe8b0465 100644 --- a/src/vscode-dts/vscode.proposed.envIsConnectionMetered.d.ts +++ b/src/vscode-dts/vscode.proposed.envIsConnectionMetered.d.ts @@ -8,7 +8,7 @@ declare module 'vscode' { export namespace env { /** * Whether the current network connection is metered (such as mobile data or tethering). - * Always returns `false` if the `update.respectMeteredConnections` setting is disabled. + * Always returns `false` if the `network.meteredConnection` setting is set to `off`. */ export const isMeteredConnection: boolean; diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index 699ff90a850c2..9672580dfd896 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -118,9 +118,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", diff --git a/test/sanity/src/cli.test.ts b/test/sanity/src/cli.test.ts index d22761ee92cda..0b813085d3935 100644 --- a/test/sanity/src/cli.test.ts +++ b/test/sanity/src/cli.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Browser } from 'playwright'; +import { Page } from 'playwright'; import { TestContext } from './context.js'; import { GitHubAuth } from './githubAuth.js'; import { UITest } from './uiTest.js'; @@ -84,7 +84,7 @@ export function setup(context: TestContext) { const cliDataDir = context.createTempDir(); const test = new UITest(context); const auth = new GitHubAuth(context); - let browser: Browser | undefined; + let page: Page | undefined; context.log('Logging out of Dev Tunnel to ensure fresh authentication'); context.run(entryPoint, '--cli-data-dir', cliDataDir, 'tunnel', 'user', 'logout'); @@ -103,8 +103,9 @@ export function setup(context: TestContext) { const deviceCode = /To grant access .* use code ([A-Z0-9-]+)/.exec(line)?.[1]; if (deviceCode) { context.log(`Device code detected: ${deviceCode}, starting device flow authentication`); - browser = await context.launchBrowser(); - await auth.runDeviceCodeFlow(browser, deviceCode); + const browser = await context.launchBrowser(); + page = await browser.newPage(); + await auth.runDeviceCodeFlow(page, deviceCode); return; } @@ -114,12 +115,11 @@ export function setup(context: TestContext) { const url = context.getTunnelUrl(tunnelUrl, test.workspaceDir); context.log(`CLI started successfully with tunnel URL: ${url}`); - if (!browser) { + if (!page) { throw new Error('Browser instance is not available'); } context.log(`Navigating to ${url}`); - const page = await context.getPage(browser.newPage()); await page.goto(url); context.log('Waiting for the workbench to load'); @@ -131,7 +131,7 @@ export function setup(context: TestContext) { context.log('Clicking Allow on confirmation dialog'); await page.getByRole('button', { name: 'Allow' }).click(); - await auth.runUserWebFlow(page); + await auth.runAuthorizeFlow(page); context.log('Waiting for connection to be established'); await page.getByRole('button', { name: `remote ${tunnelId}` }).waitFor({ timeout: 5 * 60 * 1000 }); @@ -139,7 +139,7 @@ export function setup(context: TestContext) { await test.run(page); context.log('Closing browser'); - await browser.close(); + await page.context().browser()?.close(); test.validate(); return true; diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 95273f9f7fc18..8ee3cd5bb0d76 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -298,21 +298,39 @@ export class TestContext { const { url, sha256hash } = await this.fetchMetadata(target); const filePath = path.join(this.createTempDir(), path.basename(url)); - this.log(`Downloading ${url} to ${filePath}`); - const { body } = await this.fetchNoErrors(url); - - const stream = fs.createWriteStream(filePath); - await new Promise((resolve, reject) => { - body.on('error', reject); - stream.on('error', reject); - stream.on('finish', resolve); - body.pipe(stream); - }); + const maxRetries = 5; + let lastError: Error | undefined; - this.log(`Downloaded ${url} to ${filePath}`); - this.validateSha256Hash(filePath, sha256hash); + for (let attempt = 0; attempt < maxRetries; attempt++) { + if (attempt > 0) { + const delay = Math.pow(2, attempt - 1) * 1000; + this.log(`Retrying download (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); + await new Promise(resolve => setTimeout(resolve, delay)); + } - return filePath; + try { + this.log(`Downloading ${url} to ${filePath}`); + const { body } = await this.fetchNoErrors(url); + + const stream = fs.createWriteStream(filePath); + await new Promise((resolve, reject) => { + body.on('error', reject); + stream.on('error', reject); + stream.on('finish', resolve); + body.pipe(stream); + }); + + this.log(`Downloaded ${url} to ${filePath}`); + this.validateSha256Hash(filePath, sha256hash); + + return filePath; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + this.log(`Download attempt ${attempt + 1} failed: ${lastError.message}`); + } + } + + this.error(`Failed to download ${url} after ${maxRetries} attempts: ${lastError?.message}`); } /** diff --git a/test/sanity/src/githubAuth.ts b/test/sanity/src/githubAuth.ts index d7576d761cdf7..359ac030b7ba5 100644 --- a/test/sanity/src/githubAuth.ts +++ b/test/sanity/src/githubAuth.ts @@ -3,15 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Browser, Page } from 'playwright'; +import { Page } from 'playwright'; import { TestContext } from './context.js'; /** * Handles GitHub authentication flows in the browser. */ export class GitHubAuth { - // private readonly username = process.env.GITHUB_ACCOUNT; - // private readonly password = process.env.GITHUB_PASSWORD; + private readonly username = process.env.GITHUB_ACCOUNT; + private readonly password = process.env.GITHUB_PASSWORD; public constructor(private readonly context: TestContext) { } @@ -20,17 +20,43 @@ export class GitHubAuth { * @param browser Browser to use. * @param code Device authentication code to use. */ - public async runDeviceCodeFlow(browser: Browser, code: string) { + public async runDeviceCodeFlow(page: Page, code: string) { + if (!this.username || !this.password) { + this.context.error('GITHUB_ACCOUNT and GITHUB_PASSWORD environment variables must be set'); + } + this.context.log(`Running GitHub device flow with code ${code}`); - const page = await browser.newPage(); await page.goto('https://github.com/login/device'); + + this.context.log('Filling in GitHub credentials'); + await page.getByLabel('Username or email address').fill(this.username); + await page.getByLabel('Password').fill(this.password); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + + this.context.log('Confirming device activation'); + await page.getByRole('button', { name: 'Continue' }).click(); + + this.context.log('Entering device code'); + const codeChars = code.replace('-', ''); + for (let i = 0; i < codeChars.length; i++) { + await page.getByRole('textbox').nth(i).fill(codeChars[i]); + } + await page.getByRole('button', { name: 'Continue' }).click(); + + this.context.log('Authorizing device'); + await page.getByRole('button', { name: 'Authorize' }).click(); } /** - * Runs GitHub user authentication flow in the browser. - * @param page Authentication page. + * Handles the GitHub "Authorize" dialog in a popup. + * Clicks "Continue" to authorize the app with the already signed-in account. + * @param page Main page that triggers the GitHub OAuth popup. */ - public async runUserWebFlow(page: Page) { - this.context.log(`Running GitHub browser flow at ${page.url()}`); + public async runAuthorizeFlow(page: Page) { + this.context.log('Waiting for GitHub OAuth popup'); + const popup = await page.waitForEvent('popup'); + + this.context.log(`Authorizing app at ${popup.url()}`); + await popup.getByRole('button', { name: 'Continue' }).click(); } } diff --git a/test/sanity/src/uiTest.ts b/test/sanity/src/uiTest.ts index 06d2dc584887f..f6dda6bc1a4a2 100644 --- a/test/sanity/src/uiTest.ts +++ b/test/sanity/src/uiTest.ts @@ -102,7 +102,7 @@ export class UITest { await page.getByText(/Start typing/).focus(); this.context.log('Typing some content into the file'); - await page.keyboard.type('Hello, World!'); + await page.keyboard.type('Hello, World!', { delay: 100 }); await this.runCommand(page, 'File: Save'); } diff --git a/test/smoke/src/areas/chat/chatAnonymous.test.ts b/test/smoke/src/areas/chat/chatAnonymous.test.ts deleted file mode 100644 index 520292a96d4a7..0000000000000 --- a/test/smoke/src/areas/chat/chatAnonymous.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Application, Logger } from '../../../../automation'; -import { installAllHandlers } from '../../utils'; - -export function setup(logger: Logger) { - describe.skip('Chat Anonymous', () => { - - // Shared before/after handling - installAllHandlers(logger); - - it('can send a chat message with anonymous access', async function () { - const app = this.app as Application; - - // Enable anonymous access - await app.workbench.settingsEditor.addUserSetting('chat.allowAnonymousAccess', 'true'); - - // Open chat view - await app.workbench.quickaccess.runCommand('workbench.action.chat.open'); - - // Wait for chat view to be visible - await app.workbench.chat.waitForChatView(); - - // Send a message - await app.workbench.chat.sendMessage('Hello'); - - // Wait for a response to complete - await app.workbench.chat.waitForResponse(); - - // Wait for model name to appear in footer - await app.workbench.chat.waitForModelInFooter(); - }); - }); -} diff --git a/test/smoke/src/main.ts b/test/smoke/src/main.ts index c57fbc25ecb03..db83970d3b1bb 100644 --- a/test/smoke/src/main.ts +++ b/test/smoke/src/main.ts @@ -28,7 +28,6 @@ import { setup as setupLaunchTests } from './areas/workbench/launch.test'; import { setup as setupTerminalTests } from './areas/terminal/terminal.test'; import { setup as setupTaskTests } from './areas/task/task.test'; import { setup as setupChatTests } from './areas/chat/chatDisabled.test'; -import { setup as setupChatAnonymousTests } from './areas/chat/chatAnonymous.test'; import { setup as setupAccessibilityTests } from './areas/accessibility/accessibility.test'; const rootPath = path.join(__dirname, '..', '..', '..'); @@ -419,6 +418,5 @@ describe(`VSCode Smoke Tests (${opts.web ? 'Web' : 'Electron'})`, () => { if (!opts.web && !opts.remote && quality !== Quality.Dev && quality !== Quality.OSS) { setupLocalizationTests(logger); } if (!opts.web && !opts.remote) { setupLaunchTests(logger); } if (!opts.web) { setupChatTests(logger); } - if (!opts.web && quality === Quality.Insiders) { setupChatAnonymousTests(logger); } setupAccessibilityTests(logger, opts, quality); });