diff --git a/extensions/copilot/CONTRIBUTING.md b/extensions/copilot/CONTRIBUTING.md index 253c79885c6ba5..128b5d08a3a5cb 100644 --- a/extensions/copilot/CONTRIBUTING.md +++ b/extensions/copilot/CONTRIBUTING.md @@ -380,7 +380,7 @@ Object.assign(product, { 'publicCodeMatchesUrl': 'https://aka.ms/github-copilot-match-public-code', 'manageSettingsUrl': 'https://aka.ms/github-copilot-settings', 'managePlanUrl': 'https://aka.ms/github-copilot-manage-plan', - 'manageAdditionalSpendUrl': 'https://aka.ms/github-copilot-manage-overage', + 'manageOverageUrl': 'https://aka.ms/github-copilot-manage-overage', 'upgradePlanUrl': 'https://aka.ms/github-copilot-upgrade-plan', 'signUpUrl': 'https://aka.ms/github-sign-up', 'provider': { diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 90c5f6602903c3..186ce0fffa7cf3 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -13,7 +13,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "1.0.34", + "@github/copilot": "^1.0.39", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -2931,26 +2931,26 @@ "license": "MIT" }, "node_modules/@github/copilot": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz", - "integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.39.tgz", + "integrity": "sha512-AY0VPYf6QQm88wUcOav2B36iedWKBUaMegKRxxY2uIHESiU6HueEuQR/n7D3U2UdD0zLox3jFRjYbZAsr2CgkQ==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.34", - "@github/copilot-darwin-x64": "1.0.34", - "@github/copilot-linux-arm64": "1.0.34", - "@github/copilot-linux-x64": "1.0.34", - "@github/copilot-win32-arm64": "1.0.34", - "@github/copilot-win32-x64": "1.0.34" + "@github/copilot-darwin-arm64": "1.0.39", + "@github/copilot-darwin-x64": "1.0.39", + "@github/copilot-linux-arm64": "1.0.39", + "@github/copilot-linux-x64": "1.0.39", + "@github/copilot-win32-arm64": "1.0.39", + "@github/copilot-win32-x64": "1.0.39" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz", - "integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.39.tgz", + "integrity": "sha512-E8WfNL43NMzMTDDpCiYikaEmYCMAr6mz8LHrJtkaFuVXVkBr/q2NI3hAtwHFy8M11Fac/MeIe3/VEymWwwh3kw==", "cpu": [ "arm64" ], @@ -2964,9 +2964,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz", - "integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.39.tgz", + "integrity": "sha512-0zbC4lDVX7l8Wvq+JSCMjO0xTN69nWLejTBCl3Ev5bP6P+/7wPURcUvZKoHEaXxOULQ3AGj0DwZNAsvvQkA/6Q==", "cpu": [ "x64" ], @@ -2980,9 +2980,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz", - "integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.39.tgz", + "integrity": "sha512-x88FuByweJlHlAmUZXjq4JlmtqgoM57Fe7nXzQkGr2Y5wnc2EDydBzFYEOlYDSWozQreimaJIm0KEMAA5T8/Fg==", "cpu": [ "arm64" ], @@ -2996,9 +2996,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz", - "integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.39.tgz", + "integrity": "sha512-ssahg8r7a0VCsHVXPRmFFXx70xNAxaTM2SZfG7qPRfFB2OM8gHrW26F2oikTklDF6D+A2MfSAMpzJLBUZbPnhw==", "cpu": [ "x64" ], @@ -3012,9 +3012,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", - "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.39.tgz", + "integrity": "sha512-hhBWGZQIywbp6MBxlqMX2GSmHqtUAOGwpo9b0igscecL4i0kz89QNasC+mKiN+zFEHP6I8gggOu87XPI17Io8Q==", "cpu": [ "arm64" ], @@ -3028,9 +3028,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", - "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.39.tgz", + "integrity": "sha512-0ehlMtBiwKjmfEY3hVZggdn7qrmPMC8ueBQv/b+6UY3SMRS/M/1Y7xkOCwG84NvJsktdSsk3SlQnE2LbkTVpSA==", "cpu": [ "x64" ], diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 982f95a21bf88b..b59e2adcaaa5a8 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6544,7 +6544,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "1.0.34", + "@github/copilot": "^1.0.39", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts index 553d461fc1db11..df94231840cb10 100644 --- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts +++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts @@ -160,14 +160,14 @@ export class ChatInputNotificationContribution extends Disposable { notification.message = vscode.l10n.t('Monthly Limit Reached'); notification.description = vscode.l10n.t("You've made the most of Copilot. Sign in to keep going."); notification.actions = [ - { label: vscode.l10n.t('View Usage'), commandId: 'workbench.action.chat.upgradePlan' }, + { label: vscode.l10n.t('View Usage'), commandId: 'workbench.action.chat.openCopilotStatus' }, { label: vscode.l10n.t('Sign In'), commandId: 'workbench.action.chat.triggerSetup' }, ]; } else { notification.message = vscode.l10n.t('Monthly Limit Reached'); notification.description = vscode.l10n.t("You've made the most of Copilot Free. Upgrade to keep going."); notification.actions = [ - { label: vscode.l10n.t('View Usage'), commandId: 'workbench.action.chat.upgradePlan' }, + { label: vscode.l10n.t('View Usage'), commandId: 'workbench.action.chat.openCopilotStatus' }, { label: vscode.l10n.t('Upgrade'), commandId: 'workbench.action.chat.upgradePlan' }, ]; } @@ -187,7 +187,7 @@ export class ChatInputNotificationContribution extends Disposable { notification.message = vscode.l10n.t('Monthly Limit at {0}%', warning.percentUsed); notification.description = vscode.l10n.t("You're getting the most out of Copilot \u2014 upgrade to keep going."); notification.actions = [ - { label: vscode.l10n.t('View Usage'), commandId: 'workbench.action.chat.upgradePlan' }, + { label: vscode.l10n.t('View Usage'), commandId: 'workbench.action.chat.openCopilotStatus' }, { label: vscode.l10n.t('Upgrade'), commandId: 'workbench.action.chat.upgradePlan' }, ]; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 1ebbda2b52740a..960888697309b6 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -19,6 +19,7 @@ import { createServiceIdentifier } from '../../../../util/common/services'; import { Emitter, Event } from '../../../../util/vs/base/common/event'; import { Lazy } from '../../../../util/vs/base/common/lazy'; import { Disposable } from '../../../../util/vs/base/common/lifecycle'; +import { ResourceSet } from '../../../../util/vs/base/common/map'; import { basename } from '../../../../util/vs/base/common/resources'; import { URI } from '../../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; @@ -382,10 +383,13 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { async getAgentsImpl(): Promise { const merged = new Map(); + const knownAgents = new ResourceSet(); for (const agent of await this.getSDKAgents()) { + const sourceUri = agent.path ? URI.file(agent.path) : URI.from({ scheme: 'copilotcli', path: `/agents/${agent.name}` }); + knownAgents.add(sourceUri); merged.set(agent.name.toLowerCase(), { agent: this.cloneAgent(agent), - sourceUri: URI.from({ scheme: 'copilotcli', path: `/agents/${agent.name}` }), + sourceUri, }); } for (const customAgent of await this.promptsService.getCustomAgents(CancellationToken.None)) { @@ -397,6 +401,9 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { if (customAgent.uri.path.toLowerCase().endsWith('.chatmode.md')) { continue; } + if (knownAgents.has(customAgent.uri)) { + continue; + } const info = this.toCustomAgent(customAgent); if (!info) { continue; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 64344343843ddb..b70361701d3c0c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -685,7 +685,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // Auto-approve all requests when the permission level allows it. if (this._permissionLevel === 'autoApprove' || this._permissionLevel === 'autopilot') { this.logService.trace(`[CopilotCLISession] Auto Approving ${permissionRequest.kind} request (permission level: ${this._permissionLevel})`); - this._sdkSession.respondToPermission(requestId, { kind: 'approved' }); + this._sdkSession.respondToPermission(requestId, { kind: 'approve-once' }); return; } @@ -730,7 +730,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes let response: PermissionRequestResult; if (this._permissionLevel === 'autoApprove' || this._permissionLevel === 'autopilot') { this.logService.trace(`[CopilotCLISession] Auto Approving ${permissionRequest.kind} request (permission level: ${this._permissionLevel})`); - response = { kind: 'approved' }; + response = { kind: 'approve-once' }; } else if (this._mcState) { const permissionResolutionTokenSource = new CancellationTokenSource(token); try { @@ -1846,7 +1846,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes logService.warn(`[CopilotCLISession] No pending MC permission request found for prompt ${promptId}`); break; } - pendingRequest.resolve(responseData?.approved ? { kind: 'approved' } : { kind: 'denied-interactively-by-user' }); + pendingRequest.resolve(responseData?.approved ? { kind: 'approve-once' } : { kind: 'denied-interactively-by-user' }); break; } case 'user_message': diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 42aea6c365d331..b42efd3f717eed 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -1235,6 +1235,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } private createCopilotSession(sdkSession: Session, workspaceInfo: IWorkspaceInfo, agentName: string | undefined, sessionManager: internal.LocalSessionManager): RefCountedSession { + sdkSession.setPermissionsRequired(true); const session = this.instantiationService.createInstance(CopilotCLISession, workspaceInfo, agentName, sdkSession, []); this._debugFileLogger.startSession(session.sessionId).catch(err => { this.logService.error('[CopilotCLISession] Failed to start debug log session', err); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts index 0d92735543381f..7eb7dba2096a64 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts @@ -66,30 +66,30 @@ export async function handleReadPermission( const file = Uri.file(permissionRequest.path); if (imageSupport.isTrustedImage(file)) { - return { kind: 'approved' }; + return { kind: 'approve-once' }; } if (isFileFromSessionWorkspace(file, workspaceInfo)) { logService.trace(`[CopilotCLISession] Auto Approving request to read file in session workspace ${permissionRequest.path}`); - return { kind: 'approved' }; + return { kind: 'approve-once' }; } if (workspaceService.getWorkspaceFolder(file)) { logService.trace(`[CopilotCLISession] Auto Approving request to read workspace file ${permissionRequest.path}`); - return { kind: 'approved' }; + return { kind: 'approve-once' }; } // Auto-approve reads of internal session resources (e.g. plan.md). const sessionDir = Uri.joinPath(Uri.file(getCopilotCLISessionStateDir()), sessionId); if (extUriBiasedIgnorePathCase.isEqualOrParent(file, sessionDir)) { logService.trace(`[CopilotCLISession] Auto Approving request to read Copilot CLI session resource ${permissionRequest.path}`); - return { kind: 'approved' }; + return { kind: 'approve-once' }; } // Auto-approve if the file was explicitly attached by the user. if (attachments.some(attachment => attachment.type === 'file' && isEqual(Uri.file(attachment.path), file))) { logService.trace(`[CopilotCLISession] Auto Approving request to read attached file ${permissionRequest.path}`); - return { kind: 'approved' }; + return { kind: 'approve-once' }; } const toolParams: CoreConfirmationToolParams = { @@ -149,7 +149,7 @@ export async function handleWritePermission( if (autoApprove) { logService.trace(`[CopilotCLISession] Auto Approving request ${editFile.fsPath}`); await trackEditIfNeeded(editTracker, toolCall, editFile, stream, logService); - return { kind: 'approved' }; + return { kind: 'approve-once' }; } } @@ -157,7 +157,7 @@ export async function handleWritePermission( const sessionDir = Uri.joinPath(Uri.file(getCopilotCLISessionStateDir()), sessionId); if (editFile && extUriBiasedIgnorePathCase.isEqualOrParent(editFile, sessionDir)) { logService.trace(`[CopilotCLISession] Auto Approving request to write to Copilot CLI session resource ${editFile.fsPath}`); - return { kind: 'approved' }; + return { kind: 'approve-once' }; } // Fall back to interactive confirmation. If approved, track the edit. @@ -174,10 +174,10 @@ export async function handleWritePermission( if (editFile) { await trackEditIfNeeded(editTracker, toolCall, editFile, stream, logService); } - return { kind: 'approved' }; + return { kind: 'approve-once' }; } const result = await invokeConfirmationTool(toolParams, toolParentCallId, toolsService, toolInvocationToken, logService, token); - if (result.kind === 'approved' && editFile) { + if (result.kind === 'approve-once' && editFile) { await trackEditIfNeeded(editTracker, toolCall, editFile, stream, logService); } return result; @@ -282,7 +282,7 @@ async function invokeConfirmationTool( const result = await toolsService.invokeTool(tool, { input, toolInvocationToken, subAgentInvocationId: toolParentCallId }, token); const firstResultPart = result.content.at(0); if (firstResultPart instanceof LanguageModelTextPart && typeof firstResultPart.value === 'string' && firstResultPart.value.toLowerCase() === 'yes') { - return { kind: 'approved' }; + return { kind: 'approve-once' }; } } catch (error) { logService.error(error, `[CopilotCLISession] Permission request error`); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index 3fdbcc3a8971c4..921392fa7071e5 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -344,7 +344,7 @@ describe('CopilotCLISession', () => { // Path must be absolute within workspace, should auto-approve await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None); - expect(result).toEqual({ kind: 'approved' }); + expect(result).toEqual({ kind: 'approve-once' }); }); it('auto-approves read permission for files in session state directory', async () => { @@ -360,7 +360,7 @@ describe('CopilotCLISession', () => { const stream = new MockChatResponseStream(); session.attachStream(stream); await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None); - expect(result).toEqual({ kind: 'approved' }); + expect(result).toEqual({ kind: 'approve-once' }); }); it('auto-approves write permission for files in session state directory', async () => { @@ -376,7 +376,7 @@ describe('CopilotCLISession', () => { const stream = new MockChatResponseStream(); session.attachStream(stream); await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None); - expect(result).toEqual({ kind: 'approved' }); + expect(result).toEqual({ kind: 'approve-once' }); }); it('auto-approves read permission for attached files outside workspace', async () => { @@ -394,7 +394,7 @@ describe('CopilotCLISession', () => { const attachments = [{ type: 'file' as const, path: attachedFilePath, displayName: 'attached-file.ts' }]; await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, attachments as any, undefined, authInfo, CancellationToken.None); - expect(result).toEqual({ kind: 'approved' }); + expect(result).toEqual({ kind: 'approve-once' }); }); it('does not auto-approve read permission for non-attached files outside workspace', async () => { @@ -434,7 +434,7 @@ describe('CopilotCLISession', () => { // Path must be absolute within workspace, should auto-approve await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None); - expect(result).toEqual({ kind: 'approved' }); + expect(result).toEqual({ kind: 'approve-once' }); }); it('auto-approves read permission for files in workspace folder when worktree is the working directory', async () => { @@ -459,7 +459,7 @@ describe('CopilotCLISession', () => { session.attachStream(stream); await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None); - expect(result).toEqual({ kind: 'approved' }); + expect(result).toEqual({ kind: 'approve-once' }); }); it('auto-approves read permission for files in the worktree when workspace has both worktree and repository', async () => { @@ -484,7 +484,7 @@ describe('CopilotCLISession', () => { session.attachStream(stream); await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None); - expect(result).toEqual({ kind: 'approved' }); + expect(result).toEqual({ kind: 'approve-once' }); }); it('requires read permission outside workspace and working directory', async () => { @@ -528,7 +528,7 @@ describe('CopilotCLISession', () => { await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Write' }, [], undefined, authInfo, CancellationToken.None); - expect(result).toEqual({ kind: 'approved' }); + expect(result).toEqual({ kind: 'approve-once' }); }); it('denies write permission when handler returns false', async () => { @@ -631,7 +631,7 @@ describe('CopilotCLISession', () => { // Assert ordering of trackEdit invocations exactly matches toolCallIds 1..10 expect(trackedOrder).toEqual(Array.from({ length: 10 }, (_, i) => String(i + 1))); - expect(permissionResults.every(r => r.kind === 'approved')).toBe(true); + expect(permissionResults.every(r => r.kind === 'approve-once')).toBe(true); expect(trackSpy).toHaveBeenCalledTimes(10); trackSpy.mockRestore(); @@ -747,7 +747,7 @@ describe('CopilotCLISession', () => { await requestPromise; - expect(permissionResult).toEqual({ kind: 'approved' }); + expect(permissionResult).toEqual({ kind: 'approve-once' }); const confirmationToolCalls = invokeToolSpy.mock.calls.filter(call => call[0] === 'vscode_get_confirmation' || call[0] === 'vscode_get_terminal_confirmation' ); @@ -799,7 +799,7 @@ describe('CopilotCLISession', () => { CancellationToken.None ); - expect(permissionResult).toEqual({ kind: 'approved' }); + expect(permissionResult).toEqual({ kind: 'approve-once' }); const confirmationToolCalls = invokeToolSpy.mock.calls.filter(call => call[0] === 'vscode_get_confirmation' || call[0] === 'vscode_get_terminal_confirmation' ); @@ -1607,7 +1607,7 @@ describe('CopilotCLISession', () => { await Promise.all([firstRequest, steeringRequest]); // The file was attached in the steering request, so it should be auto-approved - expect(permissionResult).toEqual({ kind: 'approved' }); + expect(permissionResult).toEqual({ kind: 'approve-once' }); }); it('updates the pending prompt to the latest steering message', async () => { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/permissionHelpers.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/permissionHelpers.spec.ts index 96fcfc3e7201bc..7c44c2db384f01 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/permissionHelpers.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/permissionHelpers.spec.ts @@ -313,7 +313,7 @@ describe('CopilotCLI permissionHelpers', () => { makeWorkspaceInfo(), makeWorkspaceService([]), makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token, ); - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); }); it('auto-approves files in session workspace (folder)', async () => { @@ -323,7 +323,7 @@ describe('CopilotCLI permissionHelpers', () => { makeWorkspaceInfo(URI.file('/workspace')), makeWorkspaceService([]), makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token, ); - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); }); it('auto-approves files in a VS Code workspace folder', async () => { @@ -333,7 +333,7 @@ describe('CopilotCLI permissionHelpers', () => { makeWorkspaceInfo(URI.file('/other')), makeWorkspaceService([URI.file('/vscode-ws')]), makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token, ); - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); }); it('auto-approves attached files', async () => { @@ -345,7 +345,7 @@ describe('CopilotCLI permissionHelpers', () => { makeWorkspaceInfo(), makeWorkspaceService([]), makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token, ); - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); }); it('falls back to confirmation tool for out-of-workspace reads and approves on "yes"', async () => { @@ -356,7 +356,7 @@ describe('CopilotCLI permissionHelpers', () => { makeWorkspaceInfo(URI.file('/workspace')), makeWorkspaceService([]), toolsService, undefined as unknown as ChatParticipantToolToken, logService, token, ); - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); expect(toolsService.invokeTool).toHaveBeenCalled(); }); @@ -446,7 +446,7 @@ describe('CopilotCLI permissionHelpers', () => { instaService, makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token, ); - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); }); it('auto-approves writes in working directory when isolation is enabled', async () => { @@ -463,7 +463,7 @@ describe('CopilotCLI permissionHelpers', () => { instaService, makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token, ); - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); }); it('falls back to confirmation for writes outside workspace', async () => { @@ -476,7 +476,7 @@ describe('CopilotCLI permissionHelpers', () => { instaService, toolsService, undefined as unknown as ChatParticipantToolToken, logService, token, ); - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); expect(toolsService.invokeTool).toHaveBeenCalled(); }); @@ -503,7 +503,7 @@ describe('CopilotCLI permissionHelpers', () => { undefined as unknown as ChatParticipantToolToken, logService, token, ); // No file => getFileEditConfirmationToolParams returns undefined => auto-approve - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); }); }); @@ -537,7 +537,7 @@ describe('CopilotCLI permissionHelpers', () => { req, undefined, toolsService, undefined as unknown as ChatParticipantToolToken, logService, token, ); - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); const callArgs = (toolsService.invokeTool as ReturnType).mock.calls[0]; expect(callArgs[0]).toBe(ToolName.CoreConfirmationTool); expect(callArgs[1].input.title).toBe('Copilot CLI Permission Request'); @@ -583,7 +583,7 @@ describe('CopilotCLI permissionHelpers', () => { req, undefined, toolsService, undefined as unknown as ChatParticipantToolToken, logService, token, ); - expect(result.kind).toBe('approved'); + expect(result.kind).toBe('approve-once'); }); }); }); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts index e5050690ae70c7..0cbad1ecd8c615 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts @@ -48,6 +48,9 @@ export class MockCliSdkSession { clearCustomAgent() { return; } + setPermissionsRequired(_required: boolean): void { + // no-op in tests + } } export class MockSkillLocations implements ICopilotCLISkills { diff --git a/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts b/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts index cdd01f1f16dfca..c3e35962c25d3b 100644 --- a/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts +++ b/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts @@ -52,6 +52,7 @@ export class MockWorkspaceIndexStateReporter extends Disposable implements Works interface ChatStatusItemState { readonly primary: { readonly message: string; + readonly icon?: string; readonly busy?: boolean; }; readonly details?: { @@ -154,6 +155,7 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { return this._writeStatusItem({ primary: { message: t('{0} repos with indexes', readyRepos.length), + icon: '$(warning)', }, details: { message: t(`[Try re-authenticating for {0} additional repos](${commandUri(reauthenticateCommandId, [inaccessibleRepo])} "${t('Try signing in again to use the codebase index')}")`, errorRepos.length), @@ -164,6 +166,7 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { return this._writeStatusItem({ primary: { message: t('Index unavailable'), + icon: '$(error)', }, details: { message: t(`[Try re-authenticating](${commandUri(reauthenticateCommandId, [inaccessibleRepo])} "${t('Try signing in again to use the codebase index')}")`), @@ -180,6 +183,7 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { message: state.remoteIndexState.repos.every(repo => repo.status === CodeSearchRepoStatus.NotYetIndexed) ? t('Index not yet built') : t('Index not yet built for a repo in the workspace'), + icon: '$(warning)', }, details: { message: `[${t`Build index`}](command:${buildRemoteIndexCommandId} "${t('Build Codebase Index')}")`, @@ -202,7 +206,8 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { ) { return this._writeStatusItem({ primary: { - message: t('Index ready') + message: t('Index ready'), + icon: '$(check)', }, }); } @@ -212,6 +217,7 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { return this._writeStatusItem({ primary: { message: t('Out of date'), + icon: '$(warning)', }, details: { message: `[${t`Update index`}](command:${buildRemoteIndexCommandId} "${t('Update Codebase Index')}")`, @@ -231,6 +237,7 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { this._writeStatusItem({ primary: { message: t('Codebase index not available'), + icon: '$(circle-slash)', }, details: undefined }); @@ -249,9 +256,11 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { this._statusItem.title = { label: statusTitle, link: codebaseSemanticSearchDocsLink, + helpText: t`Indexes your codebase for more relevant AI results.`, }; this._statusItem.description = coalesce([ + values.primary.icon, values.primary.message, values.primary.busy ? spinnerCodicon : undefined, ]).join(' '); diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts index 690e15a308babb..bb6ae6884df16f 100644 --- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts +++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts @@ -67,6 +67,7 @@ export class GitServiceImpl extends Disposable implements IGitService { // Extension is disabled / git is not available so we say all repositories are discovered this._onDidFinishInitialRepositoryDiscovery.fire(); + this._isInitialized.set(true, undefined); })); } } diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts index 988ec5855d3f56..638bb4d7c658d3 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts @@ -42,9 +42,10 @@ export interface ExternalIngestFileSet { readonly checkpoint: string; } -export function computeCheckpointHash(files: readonly { readonly docSha: Uint8Array }[]): string { - const hash = crypto.createHash('sha1'); +export function computeCheckpointHash(files: readonly { readonly uri: URI; readonly docSha: Uint8Array }[]): string { + const hash = crypto.createHash('sha256'); for (const file of files) { + hash.update(file.uri.toString()); hash.update(file.docSha); } return hash.digest().toString('base64'); diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts index 6a3cb6d2ae9c1e..495a0249715a14 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts @@ -907,35 +907,39 @@ export class ExternalIngestIndex extends Disposable { const workspaceFolders = this._workspaceService.getWorkspaceFolders(); for (const folder of workspaceFolders) { - const paths = await this._searchService.findFilesWithDefaultExcludes( - new RelativePattern(folder, '**/*'), - Number.MAX_SAFE_INTEGER, - CancellationToken.None - ); + try { + const paths = await this._searchService.findFilesWithDefaultExcludes( + new RelativePattern(folder, '**/*'), + Number.MAX_SAFE_INTEGER, + CancellationToken.None + ); - this._logService.trace(`ExternalIngestIndex::reconcileDbFiles() Found ${paths.length} candidate files in workspace folder ${folder.toString()}.`); + this._logService.trace(`ExternalIngestIndex::reconcileDbFiles() Found ${paths.length} candidate files in workspace folder ${folder.toString()}.`); - for (const uri of paths) { - // Skip files under code search repos - if (!await this.shouldTrackFile(uri, CancellationToken.None)) { - continue; - } + for (const uri of paths) { + // Skip files under code search repos + if (!await this.shouldTrackFile(uri, CancellationToken.None)) { + continue; + } - const stat = await this.safeStat(uri); - if (!stat) { - continue; - } + const stat = await this.safeStat(uri); + if (!stat) { + continue; + } - seen.add(uri); + seen.add(uri); - const existing = this.get(uri); - if (!existing) { - await this.tryAddOrUpdateFile(uri); - addedFileCount++; - } else if (existing.size !== stat.size || existing.mtime !== stat.mtime) { - await this.tryAddOrUpdateFile(uri); - updatedFileCount++; + const existing = this.get(uri); + if (!existing) { + await this.tryAddOrUpdateFile(uri); + addedFileCount++; + } else if (existing.size !== stat.size || existing.mtime !== stat.mtime) { + await this.tryAddOrUpdateFile(uri); + updatedFileCount++; + } } + } catch (err) { + this._logService.error(`ExternalIngestIndex::reconcileDbFiles() Error processing workspace folder ${folder.toString()}: ${toErrorMessage(err, true)}`); } } @@ -955,14 +959,23 @@ export class ExternalIngestIndex extends Disposable { } const addWatchersFolder = (folder: URI): IDisposable => { - const disposables = new DisposableStore(); + if (this._fileSystemService.isWritableFileSystem(folder.scheme) === false) { + return Disposable.None; + } - const watcher = disposables.add(this._fileSystemService.createFileSystemWatcher(new RelativePattern(folder, '**/*'))); - disposables.add(watcher.onDidCreate(uri => this.onFileAdded(uri))); - disposables.add(watcher.onDidChange(uri => this.onFileChanged(uri))); - disposables.add(watcher.onDidDelete(uri => this.onFileDeleted(uri))); + try { + const disposables = new DisposableStore(); + + const watcher = disposables.add(this._fileSystemService.createFileSystemWatcher(new RelativePattern(folder, '**/*'))); + disposables.add(watcher.onDidCreate(uri => this.onFileAdded(uri))); + disposables.add(watcher.onDidChange(uri => this.onFileChanged(uri))); + disposables.add(watcher.onDidDelete(uri => this.onFileDeleted(uri))); - return disposables; + return disposables; + } catch (err) { + this._logService.warn(`ExternalIngestIndex::registerWatcher() Failed to create watcher for ${folder.toString()}. ${err}`); + return Disposable.None; + } }; const watchersForWorkspaceFolders = new ResourceMap(); diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts b/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts index 56a400c581216a..0ea503e7623058 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts @@ -139,6 +139,10 @@ class MockFileSystem extends mock() impleme return entry.content; } + override isWritableFileSystem(scheme: string): boolean { + return false; + } + override createFileSystemWatcher(): FileSystemWatcher { return { onDidCreate: vi.fn(() => ({ dispose: vi.fn() })), diff --git a/extensions/copilot/test/e2e/cli.stest.ts b/extensions/copilot/test/e2e/cli.stest.ts index fdbc80b12dd21e..d0d22323de212e 100644 --- a/extensions/copilot/test/e2e/cli.stest.ts +++ b/extensions/copilot/test/e2e/cli.stest.ts @@ -6,12 +6,9 @@ import type { SessionOptions } from '@github/copilot/sdk'; import assert from 'assert'; import * as fs from 'fs/promises'; -import * as http from 'http'; import { platform, tmpdir } from 'os'; import * as path from 'path'; import type { ChatParticipantToolToken, ChatPromptReference } from 'vscode'; -import { OpenAIAdapterFactoryForSTests } from '../../src/extension/agents/node/adapters/openaiAdapterForSTests'; -import { ILanguageModelServer, ILanguageModelServerConfig, LanguageModelServer } from '../../src/extension/agents/node/langModelServer'; import { IAgentSessionsWorkspace } from '../../src/extension/chatSessions/common/agentSessionsWorkspace'; import { IChatSessionMetadataStore } from '../../src/extension/chatSessions/common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../../src/extension/chatSessions/common/chatSessionWorkspaceFolderService'; @@ -24,28 +21,26 @@ import { CopilotCLIAgents, CopilotCLIModels, CopilotCLISDK, ICopilotCLIAgents, I import { CopilotCLIImageSupport, ICopilotCLIImageSupport } from '../../src/extension/chatSessions/copilotcli/node/copilotCLIImageSupport'; import { CopilotCLIPromptResolver } from '../../src/extension/chatSessions/copilotcli/node/copilotcliPromptResolver'; import { ICopilotCLISession } from '../../src/extension/chatSessions/copilotcli/node/copilotcliSession'; -import { CopilotCLISessionService, ICopilotCLISessionService } from '../../src/extension/chatSessions/copilotcli/node/copilotcliSessionService'; +import { CopilotCLISessionService, ICopilotCLISessionService, ICreateSessionOptions } from '../../src/extension/chatSessions/copilotcli/node/copilotcliSessionService'; import { CopilotCLISkills, ICopilotCLISkills } from '../../src/extension/chatSessions/copilotcli/node/copilotCLISkills'; import { CopilotCLIMCPHandler, ICopilotCLIMCPHandler } from '../../src/extension/chatSessions/copilotcli/node/mcpHandler'; -import { IPromptVariablesService, NullPromptVariablesService } from '../../src/extension/prompt/node/promptVariablesService'; import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../../src/extension/chatSessions/copilotcli/node/userInputHelpers'; +import { IPromptVariablesService, NullPromptVariablesService } from '../../src/extension/prompt/node/promptVariablesService'; import { ChatSummarizerProvider } from '../../src/extension/prompt/node/summarizer'; import { MockChatResponseStream, TestChatRequest } from '../../src/extension/test/node/testHelpers'; import { IToolsService } from '../../src/extension/tools/common/toolsService'; import { TestToolsService } from '../../src/extension/tools/node/test/testToolsService'; import { IChatDebugFileLoggerService, NullChatDebugFileLoggerService } from '../../src/platform/chat/common/chatDebugFileLoggerService'; -import { IEndpointProvider } from '../../src/platform/endpoint/common/endpointProvider'; import { IFileSystemService } from '../../src/platform/filesystem/common/fileSystemService'; import { NodeFileSystemService } from '../../src/platform/filesystem/node/fileSystemServiceImpl'; -import { ILogService } from '../../src/platform/log/common/logService'; import { IMcpService, NullMcpService } from '../../src/platform/mcp/common/mcpService'; +import { IPromptsService } from '../../src/platform/promptFiles/common/promptsService'; +import { MockPromptsService } from '../../src/platform/promptFiles/test/common/mockPromptsService'; import { TestingServiceCollection } from '../../src/platform/test/node/services'; import { IQualifiedFile, SimulationWorkspace } from '../../src/platform/test/node/simulationWorkspace'; -import { createServiceIdentifier } from '../../src/util/common/services'; import { ChatReferenceDiagnostic } from '../../src/util/common/test/shims/chatTypes'; import { disposableTimeout, IntervalTimer } from '../../src/util/vs/base/common/async'; import { CancellationToken } from '../../src/util/vs/base/common/cancellation'; -import { Lazy } from '../../src/util/vs/base/common/lazy'; import { DisposableStore, IReference } from '../../src/util/vs/base/common/lifecycle'; import { URI } from '../../src/util/vs/base/common/uri'; import { SyncDescriptor } from '../../src/util/vs/platform/instantiation/common/descriptors'; @@ -53,10 +48,6 @@ import { IInstantiationService } from '../../src/util/vs/platform/instantiation/ import { ChatRequest, ChatSessionStatus, ChatToolInvocationPart, Diagnostic, DiagnosticSeverity, LanguageModelTextPart, LanguageModelToolResult2, Location, Range, Uri } from '../../src/vscodeTypes'; import { ssuite, stest } from '../base/stest'; -interface ChatToolResourcesInvocationData { - values: Array; -} - const permissionConfirmationInvocations: Array<{ name: string; input: unknown }> = []; class TestCopilotCLIToolsService extends TestToolsService { @@ -66,40 +57,55 @@ class TestCopilotCLIToolsService extends TestToolsService { return new LanguageModelToolResult2([new LanguageModelTextPart('yes')]); } + // `manage_todo_list` is invoked by CopilotCLISession at session start to clear any + // previous todo list, but the underlying tool does not implement `invoke` in the + // test toolsService. Return a no-op success result so session startup does not fail. + if (name === 'manage_todo_list') { + return new LanguageModelToolResult2([new LanguageModelTextPart('ok')]); + } return super.invokeTool(name, options, token); } } -const keys = ['COPILOT_ENABLE_ALT_PROVIDERS', 'COPILOT_AGENT_MODEL', 'GH_TOKEN', 'COPILOT_API_URL', 'GITHUB_COPILOT_API_TOKEN']; -const originalValues: Record = {}; -for (const key of keys) { - originalValues[key] = process.env[key]; -} - -function restoreEnvVariables() { - for (const key of keys) { - process.env[key] = originalValues[key]; +/** + * Reads the GitHub OAuth token from the environment. + * + * The token is loaded automatically by `dotenv.config()` in `test/simulationMain.ts` + * from the `.env` file at the workspace root. We only ever read `process.env` so the + * token value never appears in any tool call output, log line, or LM request emitted + * by this test file. + */ +function getGitHubTokenFromEnv(): string { + const token = process.env.GITHUB_OAUTH_TOKEN; + if (!token) { + throw new Error('GITHUB_OAUTH_TOKEN is not set. Add it to the .env file at the repo root (it is loaded by dotenv in test/simulationMain.ts).'); } + return token; } -let testCounter = 0; -function trackEnvVariablesBeforeTests() { - testCounter++; +// Force the Copilot CLI runtime to use the public CAPI endpoint regardless of +// the AuthInfo we hand it. The runtime's `getCopilotApiUrl()` checks +// `process.env.COPILOT_API_URL` first (highest precedence), so setting it here +// guarantees the model list is fetched against an endpoint we know works with +// the GITHUB_OAUTH_TOKEN, instead of getting an empty list and cascading into +// "No model available." +if (!process.env.COPILOT_API_URL) { + process.env.COPILOT_API_URL = 'https://api.githubcopilot.com'; } -/** - * Tests run in parallel, so only restore env variables after all tests have completed. - */ -function restoreEnvVariablesAfterTests() { - testCounter--; - if (testCounter === 0) { - restoreEnvVariables(); - } -} +// Force the SDK to route Anthropic models to `/v1/messages` instead of +// `/responses`. The default routing sends Claude models to `/responses`, +// which CAPI rejects with `400 model_not_supported`. The runtime reads ExP +// flag overrides from `process.env.COPILOT_EXP_`, +// which works without setting up an ExP service in tests. +// if (!process.env.COPILOT_EXP_COPILOT_CLI_ANTHROPIC_MESSAGES_API) { +// process.env.COPILOT_EXP_COPILOT_CLI_ANTHROPIC_MESSAGES_API = 'true'; +// } -function sessionOptionsFor(workingDirectory: Uri | undefined) { +function sessionOptionsFor(workingDirectory: Uri | undefined): ICreateSessionOptions { return { - workingDirectory, + // workingDirectory, + model: 'claude-opus-4.7', workspace: { folder: workingDirectory, repository: undefined, @@ -110,44 +116,6 @@ function sessionOptionsFor(workingDirectory: Uri | undefined) { } async function registerChatServices(testingServiceCollection: TestingServiceCollection) { - const ITestSessionOptionsProvider = createServiceIdentifier('ITestSessionOptionsProvider'); - class TestSessionOptionsProvider { - declare _serviceBrand: undefined; - - private readonly langModelServerConfig: Lazy>; - - constructor( - @ILanguageModelServer private readonly languageModelServer: ILanguageModelServer, - ) { - this.langModelServerConfig = new Lazy>(async () => { - await this.languageModelServer.start(); - return this.languageModelServer.getConfig(); - }); - } - - public async getOptions(): Promise> { - const serverConfig = await this.langModelServerConfig.value; - - const url = `http://localhost:${serverConfig.port}`; - const ghToken = serverConfig.nonce; - process.env.COPILOT_ENABLE_ALT_PROVIDERS = 'true'; - process.env.COPILOT_AGENT_MODEL = 'sweagent-capi:gpt-5'; - process.env.GH_TOKEN = ghToken; - process.env.COPILOT_API_URL = url; - process.env.GITHUB_COPILOT_API_TOKEN = ghToken; - return { - authInfo: { - type: 'env', - login: '', - envVar: 'GH_TOKEN', - token: ghToken, - host: url - }, - copilotUrl: url, - }; - } - } - class TestCustomSessionTitleService implements ICustomSessionTitleService { readonly _serviceBrand: undefined; private readonly titles = new Map(); @@ -167,12 +135,8 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl // Override to do nothing in tests } protected override async createSessionsOptions(options: { model?: string; workingDirectory?: Uri; workspace: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; sessionId?: string; debugTargetSessionIds?: readonly string[] }) { - const testOptionsProvider = this.instantiationService.invokeFunction((accessor) => accessor.get(ITestSessionOptionsProvider)); - const overrideOptions = await testOptionsProvider.getOptions(); const sessionOptions = await super.createSessionsOptions({ ...options, agent: undefined }); const mutableOptions = sessionOptions as SessionOptions; - mutableOptions.authInfo = overrideOptions.authInfo ?? sessionOptions.authInfo; - mutableOptions.copilotUrl = overrideOptions.copilotUrl ?? sessionOptions.copilotUrl; mutableOptions.enableStreaming = true; mutableOptions.skipCustomInstructions = true; return sessionOptions; @@ -184,59 +148,21 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl // Override to do nothing in tests } override async getAuthInfo(): Promise> { - const testOptionsProvider = this.instantiationService.invokeFunction((accessor) => accessor.get(ITestSessionOptionsProvider)); - const options = await testOptionsProvider.getOptions(); - return options.authInfo!; - } - } - - const requestHooks: ((body: string) => string)[] = []; - const responseHooks: ((body: string) => string)[] = []; - class TestLanguageModelServer extends LanguageModelServer { - constructor( - @ILogService logService: ILogService, - @IEndpointProvider endpointProvider: IEndpointProvider - ) { - super(logService, endpointProvider); - const oaiAdapterFactory = new OpenAIAdapterFactoryForSTests(); - this.adapterFactories.set('/chat/completions', oaiAdapterFactory); - requestHooks.forEach(requestHook => oaiAdapterFactory.addHooks(requestHook)); - responseHooks.forEach(responseHook => oaiAdapterFactory.addHooks(undefined, responseHook)); - this.requestHandlers.set('/graphql', { method: 'POST', handler: this.graphqlHandler.bind(this) }); - this.requestHandlers.set('/models', { method: 'GET', handler: this.modelsHandler.bind(this) }); - } - - private async graphqlHandler(req: http.IncomingMessage, res: http.ServerResponse): Promise { - res.writeHead(200, { 'Content-Type': 'application/json' }); - const data = { - viewer: { - login: '', - copilotEndpoints: { - api: `http://localhost:${this.config.port}` - } - } + return { + type: 'token', + token: getGitHubTokenFromEnv(), + host: 'https://github.com', + // Without `copilotUser.endpoints.api` the runtime's `getCopilotApiUrl()` + // returns undefined, `retrieveAvailableModels()` short-circuits to an + // empty list, and every model check below fails. Pointing it at the + // public Copilot API endpoint makes model resolution actually contact + // CAPI for the user's enabled models. + copilotUser: { + endpoints: { + api: 'https://api.githubcopilot.com', + }, + }, }; - res.end(JSON.stringify({ data })); - } - private async modelsHandler(req: http.IncomingMessage, res: http.ServerResponse): Promise { - res.writeHead(200, { 'Content-Type': 'application/json', 'x-github-request-id': 'TESTREQUESTID1234' }); - const endpoints = await this.endpointProvider.getAllChatEndpoints(); - const data = endpoints.map(e => { - return { - id: e.model, - name: e.model, - capabilities: { - supports: { - vision: e.supportsVision, - }, - limits: { - max_prompt_tokens: e.modelMaxPromptTokens, - max_context_window_tokens: e.maxOutputTokens, - } - } - }; - }); - res.end(JSON.stringify({ data })); } } @@ -256,8 +182,6 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl const delegatingSummarizerProvider = instaService.createInstance(ChatDelegationSummaryService, summarizer); testingServiceCollection.define(ICopilotCLISkills, new SyncDescriptor(CopilotCLISkills)); testingServiceCollection.define(ICopilotCLISessionService, new SyncDescriptor(TestCopilotCLISessionService)); - testingServiceCollection.define(ITestSessionOptionsProvider, new SyncDescriptor(TestSessionOptionsProvider)); - testingServiceCollection.define(ILanguageModelServer, new SyncDescriptor(TestLanguageModelServer)); testingServiceCollection.define(ICopilotCLIModels, new SyncDescriptor(CopilotCLIModels)); testingServiceCollection.define(ICopilotCLISDK, new SyncDescriptor(TestCopilotCLISDK)); testingServiceCollection.define(ICopilotCLIAgents, new SyncDescriptor(CopilotCLIAgents)); @@ -304,6 +228,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl onDidChangeWorktreeChanges: () => ({ dispose() { } }), } as IChatSessionWorktreeService); testingServiceCollection.define(IPromptVariablesService, new SyncDescriptor(NullPromptVariablesService)); + testingServiceCollection.define(IPromptsService, new SyncDescriptor(MockPromptsService)); testingServiceCollection.define(IChatDebugFileLoggerService, new NullChatDebugFileLoggerService()); const simulationWorkspace = new SimulationWorkspace(); simulationWorkspace.setupServices(testingServiceCollection); @@ -348,70 +273,8 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl simulationWorkspace.resetFromFiles(fileList, [workspaceUri]); } - function registerHooks(workingDirectory: string) { - requestHooks.push((body: string) => { - // Replace PID and values with static values - body = body.replace(/Current process PID: \d+ - CRITICAL: Do not kill this process or any parent processes as this is your own runtime\./g, - 'Current process PID: 1111 - CRITICAL: Do not kill this process or any parent processes as this is your own runtime.'); - body = body.replace(/[^<]+<\/current_datetime>/g, - '2025-01-01T12:10:00.111Z'); - return body; - }); - - // Any file/folder reference in body should be replaced with static values - const folderName = path.basename(workingDirectory); - const testPath = `/Users/testUser/vscode-copilot-chat/test/scenarios/test-cli/${folderName}`; - const testPathParent = `/Users/testUser/vscode-copilot-chat/test/scenarios/test-cli`; - const workingDirectoryParent = path.dirname(workingDirectory); - - function replacePaths(body: string, from: string, to: string) { - body = body - // Unix folders that are part of file names, e.g. /folder/file.txt - .replaceAll(`${from}/`, `${to}/`) - // Windows folders that are part of file names, e.g. c:\folder\file.txt - .replaceAll(`${from}\\`, `${to}\\`); - - // Any other references to the working directory - body = body.replaceAll(from, to); - - // Replace in JSON content, Unix folders that are part of file names, e.g. /folder/file.txt - from = from.replaceAll('/', '//').replaceAll('\\', '\\\\'); - to = to.replaceAll('/', '//').replaceAll('\\', '\\\\'); - - body = body - // Unix folders that are part of file names, e.g. /folder/file.txt - .replaceAll(`${from}/`, `${to}/`) - // Windows folders that are part of file names, e.g. c:\folder\file.txt - .replaceAll(`${from}\\`, `${to}\\`); - // Replace in JSON content, Any other references to the working directory - body = body.replaceAll(from, to); - return body; - } - - requestHooks.push((body: string) => { - body = replacePaths(body, workingDirectory, testPath); - body = replacePaths(body, workingDirectoryParent, testPathParent); - - // Replace references to vsc-copilot-chat root with test dir - body = replacePaths(body, vscCopilotRoot, testPath); - return body; - }); - - responseHooks.push((body: string) => { - body = replacePaths(body, testPath, workingDirectory); - body = replacePaths(body, testPathParent, workingDirectoryParent); - return body; - }); - } - return { sessionService: copilotCLISessionService, promptResolver, init: async (workingDirectory: URI) => { - if (platform() !== 'win32') { - // Paths conversions are only done for non-Windows platforms. - // Hooks are used to ensure we have stable paths on linux/macOS, so that request/responses can be cached. - registerHooks(workingDirectory.fsPath); - } - await populateWorkspaceFiles(workingDirectory.fsPath); await sdk.getPackage(); }, @@ -419,13 +282,11 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl }; } -const vscCopilotRoot = path.join(__dirname, '..'); // NOTE: Ensure all files/folders/workingDirectories are under test/scenarios/test-cli for path replacements to work correctly. const sourcePath = path.join(__dirname, '..', 'test', 'scenarios', 'test-cli'); let tmpDirCounter = 0; function testRunner(cb: (services: { sessionService: ICopilotCLISessionService; promptResolver: CopilotCLIPromptResolver; init: (workingDirectory: URI) => Promise; authInfo: NonNullable }, scenariosPath: string, toolInvocations: ChatToolInvocationPart[], stream: MockChatResponseStream, disposables: DisposableStore) => Promise) { return async (testingServiceCollection: TestingServiceCollection) => { - trackEnvVariablesBeforeTests(); const disposables = new DisposableStore(); // Temp folder can be `/var/folders/....` in our code we use `realpath` to resolve any symlinks. // That results in these temp folders being resolved as `/private/var/folders/...` on macOS. @@ -445,19 +306,18 @@ function testRunner(cb: (services: { sessionService: ICopilotCLISessionService; await cb(services, await fs.realpath(scenariosPath), toolInvocations, stream, disposables); } finally { await fs.rm(scenariosPath, { recursive: true }).catch(() => { /* Ignore */ }); - restoreEnvVariablesAfterTests(); disposables.dispose(); } }; } function assertStreamContains(stream: MockChatResponseStream, expectedContent: string, message?: string) { - const output = stream.output.join('\n'); + const output = stream.output.join(''); assert.ok(output.includes(expectedContent), message ?? `Expected response to include "${expectedContent}", actual output: ${output}`); } function assertNoErrorsInStream(stream: MockChatResponseStream) { - const output = stream.output.join('\n'); + const output = stream.output.join(''); assert.ok(!output.includes('❌'), `Expected no errors in stream, actual output: ${output}`); assert.ok(!output.includes('Error'), `Expected no errors in stream, actual output: ${output}`); } @@ -476,22 +336,6 @@ async function assertFileNotContains(filePath: string, expectedContent: string) assert.ok(!fileContent.includes(expectedContent), `Expected not to contain "${expectedContent}", contents = ${fileContent}`); } -function getToolInvocationsByName(toolInvocations: ChatToolInvocationPart[], toolName: string): ChatToolInvocationPart[] { - return toolInvocations.filter(t => t.toolName.toLocaleLowerCase() === toolName.toLocaleLowerCase()); -} - -function assertToolInvocationHasFiles(invocation: ChatToolInvocationPart, expectedFileCount: number, message?: string) { - const data = invocation.toolSpecificData as ChatToolResourcesInvocationData | undefined; - assert.ok(data, message ?? 'Expected toolSpecificData to exist'); - assert.ok(data.values, message ?? 'Expected toolSpecificData.values to exist'); - assert.strictEqual(data.values.length, expectedFileCount, message ?? `Expected ${expectedFileCount} files, got ${data.values.length}`); -} - -function assertToolInvocationMessageContains(invocation: ChatToolInvocationPart, expectedPattern: string, message?: string) { - const pastTenseMessage = typeof invocation.pastTenseMessage === 'string' ? invocation.pastTenseMessage : invocation.pastTenseMessage?.value; - assert.ok(pastTenseMessage?.includes(expectedPattern), message ?? `Expected pastTenseMessage to contain "${expectedPattern}", got "${pastTenseMessage}"`); -} - ssuite.skip({ title: '@cli', location: 'external' }, async (_) => { stest({ description: 'can start a session' }, testRunner(async ({ sessionService, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => { @@ -587,7 +431,7 @@ ssuite.skip({ title: '@cli', location: 'external' }, async (_) => { assert.strictEqual(session.object.status, ChatSessionStatus.Completed); assertNoErrorsInStream(stream); - const streamOutput = stream.output.join('\n'); + const streamOutput = stream.output.join(''); assert.ok(permissionConfirmationInvocations.length > 0, 'Expected permission to be requested for external file, output:' + streamOutput); }) ); @@ -797,121 +641,6 @@ ssuite.skip({ title: '@cli', location: 'external' }, async (_) => { assert.ok(permissionConfirmationInvocations.some(invocation => invocation.name === 'vscode_get_terminal_confirmation')); }) ); - - stest({ description: 'glob tool returns files with correct toolSpecificData' }, - testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => { - const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1')); - await init(workingDirectory); - const { prompt, attachments } = await resolvePromptWithFileReferences( - `Use the glob tool to find all JavaScript files (*.js) in the current directory. Do not use any other search tools.`, - [], - promptResolver - ); - - const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None); - disposables.add(session); - disposables.add(session.object.attachStream(stream)); - - await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None); - - assert.strictEqual(session.object.status, ChatSessionStatus.Completed); - assertNoErrorsInStream(stream); - - const globInvocations = getToolInvocationsByName(toolInvocations, 'search'); - assert.ok(globInvocations.length > 0, 'Expected at least one glob tool invocation'); - const invocation = globInvocations[globInvocations.length - 1]; - // wkspc1 has sample.js, utils.js, stringUtils.js - assertToolInvocationHasFiles(invocation, 3); - assertToolInvocationMessageContains(invocation, '3 result'); - }) - ); - - stest({ description: 'glob tool with no matches has empty toolSpecificData' }, - testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => { - const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1')); - await init(workingDirectory); - const { prompt, attachments } = await resolvePromptWithFileReferences( - `Use the glob tool to find all files matching *.xyz in the current directory. Do not use any other search tools.`, - [], - promptResolver - ); - - const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None); - disposables.add(session); - disposables.add(session.object.attachStream(stream)); - - await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None); - - assert.strictEqual(session.object.status, ChatSessionStatus.Completed); - assertNoErrorsInStream(stream); - - const globInvocations = getToolInvocationsByName(toolInvocations, 'search'); - assert.ok(globInvocations.length > 0, 'Expected at least one glob tool invocation'); - const invocation = globInvocations[globInvocations.length - 1]; - assertToolInvocationHasFiles(invocation, 0); - // When no results, the message ends with '.' (no result count) - const pastTenseMessage = typeof invocation.pastTenseMessage === 'string' ? invocation.pastTenseMessage : invocation.pastTenseMessage?.value; - assert.ok(pastTenseMessage?.endsWith('.'), `Expected pastTenseMessage to end with '.', got "${pastTenseMessage}"`); - }) - ); - - stest({ description: 'grep tool returns files with correct toolSpecificData' }, - testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => { - const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1')); - await init(workingDirectory); - const { prompt, attachments } = await resolvePromptWithFileReferences( - `Use the grep tool to search for the word 'function' in the current directory. Do not use any other search tools.`, - [], - promptResolver - ); - - const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None); - disposables.add(session); - disposables.add(session.object.attachStream(stream)); - - await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None); - - assert.strictEqual(session.object.status, ChatSessionStatus.Completed); - assertNoErrorsInStream(stream); - - const grepInvocations = getToolInvocationsByName(toolInvocations, 'search'); - assert.ok(grepInvocations.length > 0, 'Expected at least one grep tool invocation'); - const invocation = grepInvocations[grepInvocations.length - 1]; - // All JS files in wkspc1 contain 'function': sample.js, utils.js, stringUtils.js - const data = invocation.toolSpecificData as ChatToolResourcesInvocationData | undefined; - assert.ok(data && data.values && data.values.length > 0, 'Expected grep to find matching files'); - assertToolInvocationMessageContains(invocation, 'result'); - }) - ); - - stest({ description: 'grep tool with no matches has empty toolSpecificData' }, - testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => { - const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1')); - await init(workingDirectory); - const { prompt, attachments } = await resolvePromptWithFileReferences( - `Use the grep tool to search for the pattern 'xyzNonExistentPattern123' in the current directory. Do not use any other search tools.`, - [], - promptResolver - ); - - const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None); - disposables.add(session); - disposables.add(session.object.attachStream(stream)); - - await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None); - - assert.strictEqual(session.object.status, ChatSessionStatus.Completed); - assertNoErrorsInStream(stream); - - const grepInvocations = getToolInvocationsByName(toolInvocations, 'search'); - assert.ok(grepInvocations.length > 0, 'Expected at least one grep tool invocation'); - const invocation = grepInvocations[grepInvocations.length - 1]; - assertToolInvocationHasFiles(invocation, 0); - // When no results, the message ends with '.' (no result count) - const pastTenseMessage = typeof invocation.pastTenseMessage === 'string' ? invocation.pastTenseMessage : invocation.pastTenseMessage?.value; - assert.ok(pastTenseMessage?.endsWith('.'), `Expected pastTenseMessage to end with '.', got "${pastTenseMessage}"`); - }) - ); }); function createWithRequestWithFileReference(prompt: string, filesOrReferences: (string | ChatPromptReference)[]): ChatRequest { diff --git a/extensions/copilot/test/scenarios/test-tools/workspace/chatSetup.ts b/extensions/copilot/test/scenarios/test-tools/workspace/chatSetup.ts index 414ea24b6889cd..6453a34a7affa1 100644 --- a/extensions/copilot/test/scenarios/test-tools/workspace/chatSetup.ts +++ b/extensions/copilot/test/scenarios/test-tools/workspace/chatSetup.ts @@ -73,7 +73,7 @@ const defaultChat = { documentationUrl: product.defaultChatAgent?.documentationUrl ?? '', skusDocumentationUrl: product.defaultChatAgent?.skusDocumentationUrl ?? '', publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', - manageAdditionalSpendUrl: product.defaultChatAgent?.manageAdditionalSpendUrl ?? '', + manageOverageUrl: product.defaultChatAgent?.manageOverageUrl ?? '', upgradePlanUrl: product.defaultChatAgent?.upgradePlanUrl ?? '', providerName: product.defaultChatAgent?.providerName ?? '', enterpriseProviderId: product.defaultChatAgent?.enterpriseProviderId ?? '', @@ -1046,7 +1046,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr override async run(accessor: ServicesAccessor, from?: string): Promise { const openerService = accessor.get(IOpenerService); - openerService.open(URI.parse(defaultChat.manageAdditionalSpendUrl)); + openerService.open(URI.parse(defaultChat.manageOverageUrl)); } } diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index f80fc8464b7b1c..d8a5ebd9b4c2c6 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -178,6 +178,23 @@ "title": "%markdown.findAllFileReferences%", "category": "Markdown" }, + { + "command": "markdown.reopenAsPreview", + "title": "%markdown.reopenAsPreview.title%", + "category": "Markdown", + "icon": "$(preview)" + }, + { + "command": "markdown.reopenAsSource", + "title": "%markdown.reopenAsSource.title%", + "category": "Markdown", + "icon": "$(go-to-file)" + }, + { + "command": "markdown.togglePreview", + "title": "%markdown.togglePreview.title%", + "category": "Markdown" + }, { "command": "markdown.editor.insertLinkFromWorkspace", "title": "%markdown.editor.insertLinkFromWorkspace%", @@ -209,9 +226,19 @@ "alt": "markdown.showPreview", "group": "navigation" }, + { + "command": "markdown.reopenAsPreview", + "when": "activeEditor == workbench.editors.files.textFileEditor && resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused && !hasCustomMarkdownPreview", + "group": "navigation" + }, { "command": "markdown.showSource", - "when": "activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor'", + "when": "activeWebviewPanelId == 'markdown.preview'", + "group": "navigation" + }, + { + "command": "markdown.reopenAsSource", + "when": "activeCustomEditorId == 'vscode.markdown.preview.editor'", "group": "navigation" }, { @@ -313,21 +340,35 @@ { "command": "markdown.findAllFileReferences", "when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/" + }, + { + "command": "markdown.reopenAsPreview", + "when": "activeEditor == workbench.editors.files.textFileEditor && resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/", + "group": "navigation" + }, + { + "command": "markdown.reopenAsSource", + "when": "activeCustomEditorId == 'vscode.markdown.preview.editor'", + "group": "navigation" + }, + { + "command": "markdown.togglePreview", + "when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/" } ] }, "keybindings": [ - { - "command": "markdown.showPreview", - "key": "shift+ctrl+v", - "mac": "shift+cmd+v", - "when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused" - }, { "command": "markdown.showPreviewToSide", "key": "ctrl+k v", "mac": "cmd+k v", "when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused" + }, + { + "command": "markdown.togglePreview", + "key": "shift+ctrl+v", + "mac": "shift+cmd+v", + "when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused" } ], "configuration": [ @@ -640,7 +681,7 @@ "type": "string" }, "default": [], - "description": "%markdown.styles.dec%", + "markdownDescription": "%markdown.styles.dec%", "scope": "resource" }, "markdown.preview.breaks": { diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index 3c060cd9fb8f58..345c096bc96070 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -21,7 +21,10 @@ "markdown.previewSide.title": "Open Preview to the Side", "markdown.showLockedPreviewToSide.title": "Open Locked Preview to the Side", "markdown.showSource.title": "Show Source", - "markdown.styles.dec": "A list of URLs or local paths to CSS style sheets to use from the Markdown preview. Relative paths are interpreted relative to the folder open in the Explorer. If there is no open folder, they are interpreted relative to the location of the Markdown file. All '\\' need to be written as '\\\\'.", + "markdown.styles.dec": "A list of URLs or local paths to CSS style sheets to use from the Markdown preview. Relative paths are interpreted relative to the folder open in the Explorer. If there is no open folder, they are interpreted relative to the location of the Markdown file. All `\\` need to be written as `\\\\`.", + "markdown.reopenAsPreview.title": "Switch to Preview View", + "markdown.reopenAsSource.title": "Switch to Editor View", + "markdown.togglePreview.title": "Toggle Preview View", "markdown.showPreviewSecuritySelector.title": "Change Preview Security Settings", "markdown.trace.extension.desc": "Enable debug logging for the Markdown extension.", "markdown.trace.server.desc": "Traces the communication between VS Code and the Markdown language server.", @@ -37,17 +40,17 @@ "configuration.markdown.links.openLocation.description": "Controls where links in Markdown files should be opened.", "configuration.markdown.links.openLocation.currentGroup": "Open links in the active editor group.", "configuration.markdown.links.openLocation.beside": "Open links beside the active editor.", - "configuration.markdown.suggest.paths.enabled.description": "Enable path suggestions while writing links in Markdown files.", + "configuration.markdown.suggest.paths.enabled.description": "Controls whether path suggestions are shown while writing links in Markdown files.", "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions": "Enable suggestions for headers in other Markdown files in the current workspace. Accepting one of these suggestions inserts the full path to header in that file, for example: `[link text](/path/to/file.md#header)`.", "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.never": "Disable workspace header suggestions.", "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onDoubleHash": "Enable workspace header suggestions after typing `##` in a path, for example: `[link text](##`.", "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onSingleOrDoubleHash": "Enable workspace header suggestions after typing either `##` or `#` in a path, for example: `[link text](#` or `[link text](##`.", - "configuration.markdown.editor.drop.enabled": "Enable dropping files into a Markdown editor while holding Shift. Requires enabling `#editor.dropIntoEditor.enabled#`.", + "configuration.markdown.editor.drop.enabled": "Controls whether dropping files into a Markdown editor while holding Shift inserts Markdown links. Requires enabling `#editor.dropIntoEditor.enabled#`.", "configuration.markdown.editor.drop.enabled.always": "Always insert Markdown links.", "configuration.markdown.editor.drop.enabled.smart": "Smartly create Markdown links by default when not dropping into a code block or other special element. Use the drop widget to switch between pasting as plain text or as Markdown links.", "configuration.markdown.editor.drop.enabled.never": "Never create Markdown links.", "configuration.markdown.editor.drop.copyIntoWorkspace": "Controls if files outside of the workspace that are dropped into a Markdown editor should be copied into the workspace.\n\nUse `#markdown.copyFiles.destination#` to configure where copied dropped files should be created", - "configuration.markdown.editor.filePaste.enabled": "Enable pasting files into a Markdown editor to create Markdown links. Requires enabling `#editor.pasteAs.enabled#`.", + "configuration.markdown.editor.filePaste.enabled": "Controls whether pasting files into a Markdown editor creates Markdown links. Requires enabling `#editor.pasteAs.enabled#`.", "configuration.markdown.editor.filePaste.enabled.always": "Always insert Markdown links.", "configuration.markdown.editor.filePaste.enabled.smart": "Smartly create Markdown links by default when not pasting into a code block or other special element. Use the paste widget to switch between pasting as plain text or as Markdown links.", "configuration.markdown.editor.filePaste.enabled.never": "Never create Markdown links.", @@ -59,10 +62,10 @@ "configuration.pasteUrlAsFormattedLink.smart": "Smartly create Markdown links by default when not pasting into a code block or other special element. Use the paste widget to switch between pasting as plain text or as Markdown links.", "configuration.pasteUrlAsFormattedLink.smartWithSelection": "Smartly create Markdown links by default when you have selected text and are not pasting into a code block or other special element. Use the paste widget to switch between pasting as plain text or as Markdown links.", "configuration.pasteUrlAsFormattedLink.never": "Never create Markdown links.", - "configuration.markdown.validate.enabled.description": "Enable all error reporting in Markdown files.", - "configuration.markdown.validate.referenceLinks.enabled.description": "Validate reference links in Markdown files, for example: `[link][ref]`. Requires enabling `#markdown.validate.enabled#`.", - "configuration.markdown.validate.fragmentLinks.enabled.description": "Validate fragment links to headers in the current Markdown file, for example: `[link](#header)`. Requires enabling `#markdown.validate.enabled#`.", - "configuration.markdown.validate.fileLinks.enabled.description": "Validate links to other files in Markdown files, for example `[link](/path/to/file.md)`. This checks that the target files exists. Requires enabling `#markdown.validate.enabled#`.", + "configuration.markdown.validate.enabled.description": "Controls whether error reporting is enabled in Markdown files.", + "configuration.markdown.validate.referenceLinks.enabled.description": "Controls whether reference links in Markdown files are validated, for example: `[link][ref]`. Requires enabling `#markdown.validate.enabled#`.", + "configuration.markdown.validate.fragmentLinks.enabled.description": "Controls whether fragment links to headers in the current Markdown file are validated, for example: `[link](#header)`. Requires enabling `#markdown.validate.enabled#`.", + "configuration.markdown.validate.fileLinks.enabled.description": "Controls whether links to other files in Markdown files are validated, for example `[link](/path/to/file.md)`. This checks that the target files exist. Requires enabling `#markdown.validate.enabled#`.", "configuration.markdown.validate.fileLinks.markdownFragmentLinks.description": "Validate the fragment part of links to headers in other files in Markdown files, for example: `[link](/path/to/file.md#header)`. Inherits the setting value from `#markdown.validate.fragmentLinks.enabled#` by default.", "configuration.markdown.validate.ignoredLinks.description": "Configure links that should not be validated. For example adding `/about` would not validate the link `[about](/about)`, while the glob `/assets/**/*.svg` would let you skip validation for any link to `.svg` files under the `assets` directory.", "configuration.markdown.validate.unusedLinkDefinitions.description": "Validate link definitions that are unused in the current file.", @@ -74,7 +77,7 @@ "configuration.markdown.updateLinksOnFileMove.include": "Glob patterns that specifies files that trigger automatic link updates. See `#markdown.updateLinksOnFileMove.enabled#` for details about this feature.", "configuration.markdown.updateLinksOnFileMove.include.property": "The glob pattern to match file paths against. Set to true to enable the pattern.", "configuration.markdown.updateLinksOnFileMove.enableForDirectories": "Enable updating links when a directory is moved or renamed in the workspace.", - "configuration.markdown.occurrencesHighlight.enabled": "Enable highlighting link occurrences in the current document.", + "configuration.markdown.occurrencesHighlight.enabled": "Controls whether link occurrences in the current document are highlighted.", "configuration.markdown.copyFiles.destination": { "message": "Configures the path and file name of files created by copy/paste or drag and drop. This is a map of globs that match against a Markdown document path to the destination path where the new file should be created.\n\nThe destination path may use the following variables:\n\n- `${documentDirName}` — Absolute parent directory path of the Markdown document, e.g. `/Users/me/myProject/docs`.\n- `${documentRelativeDirName}` — Relative parent directory path of the Markdown document, e.g. `docs`. This is the same as `${documentDirName}` if the file is not part of a workspace.\n- `${documentFileName}` — The full filename of the Markdown document, e.g. `README.md`.\n- `${documentBaseName}` — The basename of the Markdown document, e.g. `README`.\n- `${documentExtName}` — The extension of the Markdown document, e.g. `md`.\n- `${documentFilePath}` — Absolute path of the Markdown document, e.g. `/Users/me/myProject/docs/README.md`.\n- `${documentRelativeFilePath}` — Relative path of the Markdown document, e.g. `docs/README.md`. This is the same as `${documentFilePath}` if the file is not part of a workspace.\n- `${documentWorkspaceFolder}` — The workspace folder for the Markdown document, e.g. `/Users/me/myProject`. This is the same as `${documentDirName}` if the file is not part of a workspace.\n- `${fileName}` — The file name of the dropped file, e.g. `image.png`.\n- `${fileExtName}` — The extension of the dropped file, e.g. `png`.\n- `${unixTime}` — The current Unix timestamp in milliseconds.\n- `${isoTime}` — The current time in ISO 8601 format, e.g. '2025-06-06T08:40:32.123Z'.", "comment": [ diff --git a/extensions/markdown-language-features/src/commands/index.ts b/extensions/markdown-language-features/src/commands/index.ts index 382b2a8b6d5119..0652d4e57052f3 100644 --- a/extensions/markdown-language-features/src/commands/index.ts +++ b/extensions/markdown-language-features/src/commands/index.ts @@ -14,6 +14,7 @@ import { RefreshPreviewCommand } from './refreshPreview'; import { ReloadPlugins } from './reloadPlugins'; import { RenderDocument } from './renderDocument'; import { ShowLockedPreviewToSideCommand, ShowPreviewCommand, ShowPreviewToSideCommand } from './showPreview'; +import { ReopenAsPreviewCommand, ReopenAsSourceCommand, TogglePreviewCommand } from './reopenAsPreview'; import { CopyImageCommand } from './copyImage'; import { ShowPreviewSecuritySelectorCommand } from './showPreviewSecuritySelector'; import { ShowSourceCommand } from './showSource'; @@ -35,6 +36,9 @@ export function registerMarkdownCommands( commandManager.register(new ShowPreviewToSideCommand(previewManager, telemetryReporter)); commandManager.register(new ShowLockedPreviewToSideCommand(previewManager, telemetryReporter)); commandManager.register(new ShowSourceCommand(previewManager)); + commandManager.register(new ReopenAsPreviewCommand()); + commandManager.register(new ReopenAsSourceCommand()); + commandManager.register(new TogglePreviewCommand()); commandManager.register(new RefreshPreviewCommand(previewManager, engine)); commandManager.register(new ShowPreviewSecuritySelectorCommand(previewSecuritySelector, previewManager)); commandManager.register(new ToggleLockCommand(previewManager)); diff --git a/extensions/markdown-language-features/src/commands/reopenAsPreview.ts b/extensions/markdown-language-features/src/commands/reopenAsPreview.ts new file mode 100644 index 00000000000000..045d94bd0cdbdf --- /dev/null +++ b/extensions/markdown-language-features/src/commands/reopenAsPreview.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Command } from '../commandManager'; + +export class ReopenAsPreviewCommand implements Command { + public readonly id = 'markdown.reopenAsPreview'; + + public async execute() { + await vscode.commands.executeCommand('reopenActiveEditorWith', 'vscode.markdown.preview.editor'); + } +} + +export class ReopenAsSourceCommand implements Command { + public readonly id = 'markdown.reopenAsSource'; + + public async execute() { + await vscode.commands.executeCommand('reopenActiveEditorWith', 'default'); + } +} + +export class TogglePreviewCommand implements Command { + public readonly id = 'markdown.togglePreview'; + + public async execute() { + if (vscode.window.activeTextEditor) { + // In source editor, switch to preview + await vscode.commands.executeCommand('reopenActiveEditorWith', 'vscode.markdown.preview.editor'); + } else { + // In custom editor preview, switch to source + await vscode.commands.executeCommand('reopenActiveEditorWith', 'default'); + } + } +} diff --git a/extensions/markdown-language-features/src/preview/preview.ts b/extensions/markdown-language-features/src/preview/preview.ts index cb08beaa68c414..384ad8e42a2e24 100644 --- a/extensions/markdown-language-features/src/preview/preview.ts +++ b/extensions/markdown-language-features/src/preview/preview.ts @@ -507,6 +507,8 @@ export class StaticMarkdownPreview extends Disposable implements IManagedMarkdow opener: MdLinkOpener, scrollLine?: number, ): StaticMarkdownPreview { + webview.iconPath = contentProvider.iconPath; + return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, topmostLineMonitor, logger, contributionProvider, opener, scrollLine); } diff --git a/product.json b/product.json index 614a73ee3d2f07..3b7b76b5ca3158 100644 --- a/product.json +++ b/product.json @@ -96,7 +96,7 @@ "publicCodeMatchesUrl": "https://aka.ms/github-copilot-match-public-code", "manageSettingsUrl": "https://aka.ms/github-copilot-settings", "managePlanUrl": "https://aka.ms/github-copilot-manage-plan", - "manageAdditionalSpendUrl": "https://aka.ms/github-copilot-manage-overage", + "manageOverageUrl": "https://aka.ms/github-copilot-manage-overage", "upgradePlanUrl": "https://aka.ms/github-copilot-upgrade-plan", "signUpUrl": "https://aka.ms/github-sign-up", "provider": { diff --git a/src/vs/base/browser/overlayLayoutElement.ts b/src/vs/base/browser/overlayLayoutElement.ts index 3a477b7f8c5435..6299d39120ee5c 100644 --- a/src/vs/base/browser/overlayLayoutElement.ts +++ b/src/vs/base/browser/overlayLayoutElement.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { setParentFlowTo } from './dom.js'; +import { getComputedStyle, setParentFlowTo } from './dom.js'; import { IDisposable } from '../common/lifecycle.js'; import { generateUuid } from '../common/uuid.js'; @@ -101,6 +101,24 @@ export class OverlayLayoutElement implements IDisposable { } this._updateClipping(options?.clippingContainer); + this._updateZIndex(anchorElement); + } + + /** + * Walk up from the anchor element to find the nearest ancestor with an explicit + * z-index and place the overlay one level above it. This ensures the overlay sits + * above modal layers or other stacking contexts. + */ + private _updateZIndex(anchorElement: HTMLElement): void { + let zIndex = ''; + for (let el: HTMLElement | null = anchorElement; el; el = el.parentElement) { + const computed = getComputedStyle(el).zIndex; + if (computed && computed !== 'auto') { + zIndex = String(Number(computed) + 1); + break; + } + } + this.content.style.zIndex = zIndex; } private _updateClipping(clippingContainer: HTMLElement | undefined): void { diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index c9a1c9d8591903..bc7c8f68eeb921 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -400,7 +400,7 @@ export interface IDefaultChatAgent { readonly publicCodeMatchesUrl: string; readonly manageSettingsUrl: string; readonly managePlanUrl: string; - readonly manageAdditionalSpendUrl: string; + readonly manageOverageUrl: string; readonly upgradePlanUrl: string; readonly signUpUrl: string; readonly termsStatementUrl: string; diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index cea7febc212a6e..df8ea4141a90cd 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -429,6 +429,14 @@ export interface IAgent { */ truncateSession?(session: URI, turnId?: string): Promise; + /** + * Notifies the provider that a session's archived state has changed. + * Providers may use this to clean up or restore per-session resources + * (for example, removing a session-owned worktree on archive and + * recreating it on unarchive). Optional. + */ + onArchivedChanged?(session: URI, isArchived: boolean): Promise; + /** * Receives client-provided customization refs and syncs them (e.g. copies * plugin files to local storage). Returns per-customization status with diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index cf3dcbbf497de9..c55a9cd5d96ffc 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -24,7 +24,27 @@ export interface IAgentHostGitService { getRepositoryRoot(workingDirectory: URI): Promise; getWorktreeRoots(workingDirectory: URI): Promise; addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise; + /** + * Adds a worktree for an existing branch (no `-b`). Used when restoring + * a worktree whose branch was preserved (e.g. unarchiving a session + * whose worktree was previously cleaned up on archive). + */ + addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise; removeWorktree(repositoryRoot: URI, worktree: URI): Promise; + /** + * Returns true when the named branch exists in the repository + * (`refs/heads/` resolves). Used by archive cleanup to + * confirm the branch is preserved before deleting the worktree, and by + * the unarchive path to confirm the branch is still around before + * recreating the worktree. + */ + branchExists(repositoryRoot: URI, branchName: string): Promise; + /** + * Returns true when the working tree has any tracked, staged, or + * untracked changes. Used by archive cleanup to skip removing a + * worktree that still contains uncommitted work. + */ + hasUncommittedChanges(workingDirectory: URI): Promise; /** * Computes the {@link ISessionGitState} for the working directory by * shelling out to `git`. Returns undefined if the directory is not a @@ -170,10 +190,26 @@ export class AgentHostGitService implements IAgentHostGitService { await this._runGit(repositoryRoot, ['worktree', 'add', '-b', branchName, worktree.fsPath, startPoint], { timeout: 30_000, throwOnError: true }); } + async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { + await this._runGit(repositoryRoot, ['worktree', 'add', worktree.fsPath, branchName], { timeout: 30_000, throwOnError: true }); + } + async removeWorktree(repositoryRoot: URI, worktree: URI): Promise { await this._runGit(repositoryRoot, ['worktree', 'remove', '--force', worktree.fsPath], { timeout: 30_000, throwOnError: true }); } + async branchExists(repositoryRoot: URI, branchName: string): Promise { + // `show-ref --verify --quiet` exits 0 when the ref exists and 1 otherwise. + // `_runGit` returns undefined on non-zero exit, so `!== undefined` is the existence signal. + const output = await this._runGit(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]); + return output !== undefined; + } + + async hasUncommittedChanges(workingDirectory: URI): Promise { + const output = await this._runGit(workingDirectory, ['status', '--porcelain']); + return !!output && output.trim().length > 0; + } + async computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise { // Bail fast if not inside a git work tree so callers can fall back // to other diff sources. diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 07b71f4d8be504..1b96619ebd93c7 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -816,6 +816,10 @@ export class AgentSideEffects extends Disposable { } case ActionType.SessionIsArchivedChanged: { this._persistSessionFlag(action.session, 'isArchived', action.isArchived ? 'true' : ''); + const agent = this._options.getAgent(action.session); + agent?.onArchivedChanged?.(URI.parse(action.session), action.isArchived).catch(err => { + this._logService.warn(`[AgentSideEffects] onArchivedChanged failed for ${action.session}`, err); + }); break; } case ActionType.SessionConfigChanged: { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 65b4a87ffce183..e1198886e008a3 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -902,20 +902,21 @@ export class CopilotAgent extends Disposable implements IAgent { } const rawTurns = await entry.getMessages(); + // If a worktree was created for this session at create-time, prepend // If a worktree was created for this session at create-time, prepend // the announcement to the first turn so it appears at the top of the // first response when the session is reopened. The live path // (sendMessage) handles the very first turn when the session is fresh; // this path takes over on subsequent loads, where // _pendingFirstTurnAnnouncements is empty. - const branchName = await this._readWorktreeBranchMetadata(session).catch(err => { + const worktreeMeta = await this._readWorktreeMetadata(session).catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to read worktree branch metadata`, err); return undefined; }); - if (!branchName) { + if (!worktreeMeta?.branchName) { return rawTurns; } - return prependAnnouncementToFirstTurn(rawTurns, buildWorktreeAnnouncementText(branchName)); + return prependAnnouncementToFirstTurn(rawTurns, buildWorktreeAnnouncementText(worktreeMeta.branchName)); } async disposeSession(session: URI): Promise { @@ -925,6 +926,90 @@ export class CopilotAgent extends Disposable implements IAgent { }); } + async onArchivedChanged(session: URI, isArchived: boolean): Promise { + const sessionId = AgentSession.id(session); + await this._sessionSequencer.queue(sessionId, async () => { + if (isArchived) { + await this._cleanupWorktreeOnArchive(session, sessionId); + } else { + await this._recreateWorktreeOnUnarchive(session, sessionId); + } + }); + } + + private async _cleanupWorktreeOnArchive(session: URI, sessionId: string): Promise { + const meta = await this._readWorktreeMetadata(session).catch(() => undefined); + if (!meta?.worktreePath || !meta.repositoryRoot) { + return; + } + const { branchName, worktreePath, repositoryRoot } = meta; + + // Skip if the worktree directory is already gone — nothing to clean. + try { + await fs.access(worktreePath.fsPath); + } catch { + this._createdWorktrees.delete(sessionId); + return; + } + + // Skip if the branch is missing — without it we can't safely recreate + // the worktree on unarchive, so leave the working tree intact. + const branchPresent = await this._gitService.branchExists(repositoryRoot, branchName).catch(() => false); + if (!branchPresent) { + this._logService.info(`[Copilot:${sessionId}] Skipping worktree cleanup: branch '${branchName}' is missing`); + return; + } + + // Skip if there are uncommitted changes — don't silently destroy work. + const dirty = await this._gitService.hasUncommittedChanges(worktreePath).catch(() => true); + if (dirty) { + this._logService.info(`[Copilot:${sessionId}] Skipping worktree cleanup: '${worktreePath.fsPath}' has uncommitted changes`); + return; + } + + try { + await this._gitService.removeWorktree(repositoryRoot, worktreePath); + this._logService.info(`[Copilot:${sessionId}] Removed worktree '${worktreePath.fsPath}' on archive`); + } catch (error) { + this._logService.warn(`[Copilot:${sessionId}] Failed to remove worktree '${worktreePath.fsPath}' on archive: ${error instanceof Error ? error.message : String(error)}`); + } finally { + this._createdWorktrees.delete(sessionId); + } + } + + private async _recreateWorktreeOnUnarchive(session: URI, sessionId: string): Promise { + const meta = await this._readWorktreeMetadata(session).catch(() => undefined); + if (!meta?.worktreePath || !meta.repositoryRoot) { + return; + } + const { branchName, worktreePath, repositoryRoot } = meta; + + // Skip if the worktree directory already exists — nothing to do. + try { + await fs.access(worktreePath.fsPath); + return; + } catch { + // expected when the worktree was cleaned up on archive + } + + // Skip if the branch is missing — we have no commit to attach the + // recreated worktree to. + const branchPresent = await this._gitService.branchExists(repositoryRoot, branchName).catch(() => false); + if (!branchPresent) { + this._logService.info(`[Copilot:${sessionId}] Skipping worktree recreation: branch '${branchName}' is missing`); + return; + } + + try { + await fs.mkdir(URI.joinPath(worktreePath, '..').fsPath, { recursive: true }); + await this._gitService.addExistingWorktree(repositoryRoot, worktreePath, branchName); + this._createdWorktrees.set(sessionId, { repositoryRoot, worktree: worktreePath }); + this._logService.info(`[Copilot:${sessionId}] Recreated worktree '${worktreePath.fsPath}' on unarchive`); + } catch (error) { + this._logService.warn(`[Copilot:${sessionId}] Failed to recreate worktree '${worktreePath.fsPath}' on unarchive: ${error instanceof Error ? error.message : String(error)}`); + } + } + async abortSession(session: URI): Promise { const sessionId = AgentSession.id(session); await this._sessionSequencer.queue(sessionId, async () => { @@ -1196,7 +1281,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName)); const sessionUri = AgentSession.uri(this.id, sessionId); try { - await this._writeWorktreeBranchMetadata(sessionUri, branchName, baseBranch); + await this._writeWorktreeMetadata(sessionUri, { branchName, baseBranch, worktreePath: worktree, repositoryRoot }); } catch (error) { this._logService.warn(`[Copilot:${sessionId}] Failed to persist worktree branch metadata: ${error instanceof Error ? error.message : String(error)}`); } @@ -1226,13 +1311,19 @@ export class CopilotAgent extends Disposable implements IAgent { private static readonly _META_PROJECT_URI = 'copilot.project.uri'; private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName'; private static readonly _META_WORKTREE_BRANCH = 'copilot.worktree.branchName'; + private static readonly _META_WORKTREE_PATH = 'copilot.worktree.path'; + private static readonly _META_WORKTREE_REPOSITORY_ROOT = 'copilot.worktree.repositoryRoot'; - private async _writeWorktreeBranchMetadata(session: URI, branchName: string, baseBranch: string | undefined): Promise { + private async _writeWorktreeMetadata(session: URI, metadata: { branchName: string; baseBranch: string | undefined; worktreePath: URI; repositoryRoot: URI }): Promise { const dbRef = this._sessionDataService.openDatabase(session); try { - const work: Promise[] = [dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_BRANCH, branchName)]; - if (baseBranch) { - work.push(dbRef.object.setMetadata(META_DIFF_BASE_BRANCH, baseBranch)); + const work: Promise[] = [ + dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_BRANCH, metadata.branchName), + dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_PATH, metadata.worktreePath.toString()), + dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_REPOSITORY_ROOT, metadata.repositoryRoot.toString()), + ]; + if (metadata.baseBranch) { + work.push(dbRef.object.setMetadata(META_DIFF_BASE_BRANCH, metadata.baseBranch)); } await Promise.all(work); } finally { @@ -1240,14 +1331,23 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readWorktreeBranchMetadata(session: URI): Promise { + private async _readWorktreeMetadata(session: URI): Promise<{ branchName: string; worktreePath?: URI; repositoryRoot?: URI } | undefined> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return undefined; } try { - const value = await ref.object.getMetadata(CopilotAgent._META_WORKTREE_BRANCH); - return value ?? undefined; + const [branchName, worktreePathRaw, repositoryRootRaw] = await Promise.all([ + ref.object.getMetadata(CopilotAgent._META_WORKTREE_BRANCH), + ref.object.getMetadata(CopilotAgent._META_WORKTREE_PATH), + ref.object.getMetadata(CopilotAgent._META_WORKTREE_REPOSITORY_ROOT), + ]); + if (!branchName) { + return undefined; + } + const worktreePath = worktreePathRaw ? URI.parse(worktreePathRaw) : undefined; + const repositoryRoot = repositoryRootRaw ? URI.parse(repositoryRootRaw) : undefined; + return { branchName, worktreePath, repositoryRoot }; } finally { ref.dispose(); } diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index bb792457ddcee0..1c59a949e49ab4 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -170,7 +170,10 @@ export function createNoopGitService(): import('../../node/agentHostGitService.j getRepositoryRoot: async () => undefined, getWorktreeRoots: async () => [], addWorktree: async () => { }, + addExistingWorktree: async () => { }, removeWorktree: async () => { }, + branchExists: async () => false, + hasUncommittedChanges: async () => false, getSessionGitState: async () => undefined, computeSessionFileDiffs: async () => undefined, showBlob: async () => undefined, diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 8a5fa4071907e3..195e81bd770bf3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -269,3 +269,65 @@ suite('AgentHostGitService - computeSessionFileDiffs (real git)', () => { assert.strictEqual(blob.toString(), 'original\n'); }); }); + +suite('AgentHostGitService - worktree helpers (real git)', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const hasGit = (() => { + try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } + })(); + + let tmpRoot: string | undefined; + let svc: AgentHostGitService | undefined; + const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + + setup(() => { + tmpRoot = undefined; + svc = createGitService(disposables); + }); + + teardown(() => { + if (tmpRoot) { + rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + function initRepo(): string { + tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-wt-')); + const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); + run('init', '-q', '-b', 'main'); + run('commit', '-q', '--allow-empty', '-m', 'initial'); + return tmpRoot!; + } + + (hasGit ? test : test.skip)('branchExists reports true for HEAD branch and false for missing branches', async () => { + const dir = initRepo(); + assert.strictEqual(await svc!.branchExists(URI.file(dir), 'main'), true); + assert.strictEqual(await svc!.branchExists(URI.file(dir), 'does-not-exist'), false); + }); + + (hasGit ? test : test.skip)('hasUncommittedChanges flips with untracked and committed work', async () => { + const dir = initRepo(); + assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false); + const fs = await import('fs/promises'); + await fs.writeFile(join(dir, 'a.txt'), 'hello'); + assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), true); + cp.execFileSync('git', ['add', 'a.txt'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['commit', '-q', '-m', 'add a'], { cwd: dir, env, stdio: 'pipe' }); + assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false); + }); + + (hasGit ? test : test.skip)('addExistingWorktree attaches a worktree for an existing branch (no -b)', async () => { + const dir = initRepo(); + cp.execFileSync('git', ['branch', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + const wtPath = join(dir, '..', `wt-${Date.now()}`); + try { + await svc!.addExistingWorktree(URI.file(dir), URI.file(wtPath), 'feature'); + const fs = await import('fs/promises'); + const stat = await fs.stat(wtPath); + assert.ok(stat.isDirectory(), 'worktree directory should exist'); + } finally { + rmSync(wtPath, { recursive: true, force: true }); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index df08b1c4f1b8ad..248a38a89f3e5b 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -340,7 +340,10 @@ suite('AgentService (node dispatcher)', () => { getRepositoryRoot: async () => undefined, getWorktreeRoots: async () => [], addWorktree: async () => { }, + addExistingWorktree: async () => { }, removeWorktree: async () => { }, + branchExists: async () => false, + hasUncommittedChanges: async () => false, getSessionGitState: async (uri: URI) => { calls.push(uri.fsPath); return gitState; }, computeSessionFileDiffs: async () => undefined, showBlob: async () => undefined, @@ -379,7 +382,10 @@ suite('AgentService (node dispatcher)', () => { getRepositoryRoot: async () => undefined, getWorktreeRoots: async () => [], addWorktree: async () => { }, + addExistingWorktree: async () => { }, removeWorktree: async () => { }, + branchExists: async () => false, + hasUncommittedChanges: async () => false, getSessionGitState: async () => undefined, computeSessionFileDiffs: async () => undefined, showBlob: async () => undefined, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index ffce98990bed5c..54a039f5bd6f6a 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -48,6 +48,10 @@ class TestAgentHostGitService implements IAgentHostGitService { repositoryRoot: URI | undefined = undefined; addedWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string; startPoint: string }[] = []; + addedExistingWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string }[] = []; + removedWorktrees: { repositoryRoot: URI; worktree: URI }[] = []; + existingBranches = new Set(); + dirtyWorkingDirectories = new Set(); async isInsideWorkTree(): Promise { return false; } async getCurrentBranch(): Promise { return undefined; } @@ -57,8 +61,20 @@ class TestAgentHostGitService implements IAgentHostGitService { async getWorktreeRoots(): Promise { return []; } async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise { this.addedWorktrees.push({ repositoryRoot, worktree, branchName, startPoint }); + this.existingBranches.add(branchName); + } + async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { + this.addedExistingWorktrees.push({ repositoryRoot, worktree, branchName }); + } + async removeWorktree(repositoryRoot: URI, worktree: URI): Promise { + this.removedWorktrees.push({ repositoryRoot, worktree }); + } + async branchExists(_repositoryRoot: URI, branchName: string): Promise { + return this.existingBranches.has(branchName); + } + async hasUncommittedChanges(workingDirectory: URI): Promise { + return this.dirtyWorkingDirectories.has(workingDirectory.fsPath); } - async removeWorktree(): Promise { } async getSessionGitState(): Promise { return undefined; } async computeSessionFileDiffs(): Promise { return undefined; } async showBlob(): Promise { return undefined; } @@ -629,5 +645,169 @@ suite('CopilotAgent', () => { await disposeAgent(agent); } }); + + test('onArchivedChanged removes the worktree on archive and recreates it on unarchive', async () => { + const sessionId = 'archive-cleanup-session'; + const session = AgentSession.uri('copilotcli', sessionId); + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + + const agent = createTestAgent(disposables, { + sessionDataService: disposables.add(new TestSessionDataService()), + copilotClient: new TestCopilotClient([]), + gitService, + }) as TestableCopilotAgent; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const workingDir = await agent.resolveWorktreeForTest({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', branch: 'main', branchNameHint: 'feat' }, + }, sessionId); + assert.ok(workingDir, 'worktree must be created'); + // Simulate the worktree directory existing on disk so the archive + // path's existence-check passes; the test git service has no real repo. + await fs.mkdir(workingDir!.fsPath, { recursive: true }); + + await agent.onArchivedChanged(session, true); + assert.deepStrictEqual( + gitService.removedWorktrees.map(r => r.worktree.fsPath), + [workingDir!.fsPath], + 'archive must remove the worktree once it is clean and the branch is preserved', + ); + + // Simulate that the worktree directory is gone after removal. + await fs.rm(workingDir!.fsPath, { recursive: true, force: true }); + + await agent.onArchivedChanged(session, false); + assert.deepStrictEqual( + gitService.addedExistingWorktrees.map(r => ({ worktree: r.worktree.fsPath, branchName: r.branchName })), + [{ worktree: workingDir!.fsPath, branchName: gitService.addedWorktrees[0].branchName }], + 'unarchive must recreate the worktree using the preserved branch', + ); + } finally { + await disposeAgent(agent); + } + }); + + test('onArchivedChanged skips removal when worktree has uncommitted changes', async () => { + const sessionId = 'archive-skip-dirty-session'; + const session = AgentSession.uri('copilotcli', sessionId); + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-dirty'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + + const agent = createTestAgent(disposables, { + sessionDataService: disposables.add(new TestSessionDataService()), + copilotClient: new TestCopilotClient([]), + gitService, + }) as TestableCopilotAgent; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const workingDir = await agent.resolveWorktreeForTest({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', branch: 'main', branchNameHint: 'feat' }, + }, sessionId); + await fs.mkdir(workingDir!.fsPath, { recursive: true }); + gitService.dirtyWorkingDirectories.add(workingDir!.fsPath); + + await agent.onArchivedChanged(session, true); + assert.deepStrictEqual(gitService.removedWorktrees, [], 'must not remove a dirty worktree'); + } finally { + await disposeAgent(agent); + } + }); + + test('onArchivedChanged skips removal when branch is missing', async () => { + const sessionId = 'archive-skip-no-branch-session'; + const session = AgentSession.uri('copilotcli', sessionId); + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-nobranch'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + + const agent = createTestAgent(disposables, { + sessionDataService: disposables.add(new TestSessionDataService()), + copilotClient: new TestCopilotClient([]), + gitService, + }) as TestableCopilotAgent; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const workingDir = await agent.resolveWorktreeForTest({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', branch: 'main', branchNameHint: 'feat' }, + }, sessionId); + await fs.mkdir(workingDir!.fsPath, { recursive: true }); + // Drop the branch so cleanup must skip. + gitService.existingBranches.clear(); + + await agent.onArchivedChanged(session, true); + assert.deepStrictEqual(gitService.removedWorktrees, [], 'must not remove a worktree whose branch is missing'); + } finally { + await disposeAgent(agent); + } + }); + + test('onArchivedChanged is a no-op when no worktree metadata is persisted', async () => { + const sessionId = 'archive-no-meta-session'; + const session = AgentSession.uri('copilotcli', sessionId); + const gitService = new TestAgentHostGitService(); + const agent = createTestAgent(disposables, { + sessionDataService: disposables.add(new TestSessionDataService()), + copilotClient: new TestCopilotClient([]), + gitService, + }) as TestableCopilotAgent; + + try { + await agent.authenticate('https://api.github.com', 'token'); + await agent.onArchivedChanged(session, true); + await agent.onArchivedChanged(session, false); + assert.deepStrictEqual({ + removed: gitService.removedWorktrees, + addedExisting: gitService.addedExistingWorktrees, + }, { removed: [], addedExisting: [] }); + } finally { + await disposeAgent(agent); + } + }); + + test('onArchivedChanged unarchive skips when worktree directory already exists', async () => { + const sessionId = 'unarchive-existing-session'; + const session = AgentSession.uri('copilotcli', sessionId); + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-exists'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + + const agent = createTestAgent(disposables, { + sessionDataService: disposables.add(new TestSessionDataService()), + copilotClient: new TestCopilotClient([]), + gitService, + }) as TestableCopilotAgent; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const workingDir = await agent.resolveWorktreeForTest({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', branch: 'main', branchNameHint: 'feat' }, + }, sessionId); + await fs.mkdir(workingDir!.fsPath, { recursive: true }); + + await agent.onArchivedChanged(session, false); + assert.deepStrictEqual(gitService.addedExistingWorktrees, [], 'must not recreate a worktree whose directory already exists'); + } finally { + await disposeAgent(agent); + } + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index 4f5812531daa39..be344064c06bf1 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -23,7 +23,10 @@ class TestAgentHostGitService implements IAgentHostGitService { async getRepositoryRoot(): Promise { return this.repositoryRoot; } async getWorktreeRoots(): Promise { return this.worktreeRoots; } async addWorktree(): Promise { } + async addExistingWorktree(): Promise { } async removeWorktree(): Promise { } + async branchExists(): Promise { return false; } + async hasUncommittedChanges(): Promise { return false; } async getSessionGitState(): Promise { return undefined; } async computeSessionFileDiffs(): Promise { return undefined; } async showBlob(): Promise { return undefined; } diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 4fc7ecfedc323b..2a62d380ad3985 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -93,6 +93,15 @@ export interface IEnvironmentService { // --- agent sessions workspace agentSessionsWorkspace?: URI; + /** + * When running as the embedded Agents app, the data home of the host + * VS Code application (e.g. `~/.vscode-insiders`). This is the base + * directory from which `hostUserRoamingDataHome`, `hostExtensionsHome`, + * and similar host paths are derived. `undefined` when not running as + * embedded. + */ + readonly hostUserHome?: URI; + /** * When running as the embedded Agents app, the user roaming data home of * the host VS Code application (i.e. the default profile's settings/User @@ -100,6 +109,12 @@ export interface IEnvironmentService { */ readonly hostUserRoamingDataHome?: URI; + /** + * When running as the embedded Agents app, the extensions directory of + * the host VS Code application. `undefined` when not running as embedded. + */ + readonly hostExtensionsHome?: URI; + // --- Policy policyFile?: URI; diff --git a/src/vs/platform/environment/common/environmentService.ts b/src/vs/platform/environment/common/environmentService.ts index 004d0614c938a3..98e9f861f88500 100644 --- a/src/vs/platform/environment/common/environmentService.ts +++ b/src/vs/platform/environment/common/environmentService.ts @@ -7,7 +7,7 @@ import { toLocalISOString } from '../../../base/common/date.js'; import { memoize } from '../../../base/common/decorators.js'; import { FileAccess, Schemas } from '../../../base/common/network.js'; import { dirname, join, normalize, resolve } from '../../../base/common/path.js'; -import { env } from '../../../base/common/process.js'; +import { env, platform } from '../../../base/common/process.js'; import { joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { NativeParsedArgs } from './argv.js'; @@ -299,6 +299,70 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron this.args['continueOn'] = value; } + @memoize + get hostUserHome(): URI | undefined { + if (!this.productService.embedded) { + return undefined; + } + if (!this.isBuilt) { + return undefined; + } + const quality = this.productService.quality; + let hostDataFolderName: string; + if (quality === 'stable') { + hostDataFolderName = '.vscode'; + } else if (quality === 'insider') { + hostDataFolderName = '.vscode-insiders'; + } else if (quality === 'exploration') { + hostDataFolderName = '.vscode-exploration'; + } else { + return undefined; + } + return joinPath(this.userHome, hostDataFolderName); + } + + @memoize + get hostUserRoamingDataHome(): URI | undefined { + if (!this.hostUserHome) { + return undefined; + } + const quality = this.productService.quality; + let hostProductName: string; + if (quality === 'stable') { + hostProductName = 'Code'; + } else if (quality === 'insider') { + hostProductName = 'Code - Insiders'; + } else if (quality === 'exploration') { + hostProductName = 'Code - Exploration'; + } else { + return undefined; + } + + let appDataPath: string; + switch (platform) { + case 'win32': + appDataPath = env['APPDATA'] || join(this.paths.homeDir, 'AppData', 'Roaming'); + break; + case 'darwin': + appDataPath = join(this.paths.homeDir, 'Library', 'Application Support'); + break; + default: + appDataPath = env['XDG_CONFIG_HOME'] || join(this.paths.homeDir, '.config'); + break; + } + + const hostUserDataPath = join(appDataPath, hostProductName); + return joinPath(URI.file(hostUserDataPath), 'User').with({ scheme: Schemas.vscodeUserData }); + } + + @memoize + get hostExtensionsHome(): URI | undefined { + if (!this.hostUserHome) { + return undefined; + } + return joinPath(this.hostUserHome, 'extensions'); + } + get args(): NativeParsedArgs { return this._args; } constructor( diff --git a/src/vs/platform/environment/node/environmentService.ts b/src/vs/platform/environment/node/environmentService.ts index d3daf31350a4bb..1bb9d708407e0d 100644 --- a/src/vs/platform/environment/node/environmentService.ts +++ b/src/vs/platform/environment/node/environmentService.ts @@ -4,11 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { homedir, tmpdir } from 'os'; -import { memoize } from '../../../base/common/decorators.js'; -import { INodeProcess } from '../../../base/common/platform.js'; -import { joinPath } from '../../../base/common/resources.js'; -import { URI } from '../../../base/common/uri.js'; -import { Schemas } from '../../../base/common/network.js'; import { NativeParsedArgs } from '../common/argv.js'; import { IDebugParams } from '../common/environment.js'; import { AbstractNativeEnvironmentService, parseDebugParams } from '../common/environmentService.js'; @@ -24,33 +19,6 @@ export class NativeEnvironmentService extends AbstractNativeEnvironmentService { userDataDir: getUserDataPath(args, productService.nameShort) }, productService); } - - @memoize - get hostUserRoamingDataHome(): URI | undefined { - if (!(process as INodeProcess).isEmbeddedApp) { - return undefined; - } - if (!this.isBuilt) { - return undefined; - } - const quality = this.productService.quality; - let hostProductName: string; - if (quality === 'stable') { - hostProductName = 'Code'; - } else if (quality === 'insider') { - hostProductName = 'Code - Insiders'; - } else if (quality === 'exploration') { - hostProductName = 'Code - Exploration'; - } else { - return undefined; - } - - // Honor the same env-var overrides that the host VS Code itself uses - // (portable mode and VSCODE_APPDATA), but intentionally skip --user-data-dir - // because that CLI arg belongs to the Agents app, not the host. - const hostUserDataPath = getUserDataPath(this.args, hostProductName); - return joinPath(URI.file(hostUserDataPath), 'User').with({ scheme: Schemas.vscodeUserData }); - } } export function parsePtyHostDebugPort(args: NativeParsedArgs, isBuilt: boolean): IDebugParams { diff --git a/src/vs/platform/menubar/electron-main/menubar.ts b/src/vs/platform/menubar/electron-main/menubar.ts index 0594c7e23ef57f..c7a145e7dee716 100644 --- a/src/vs/platform/menubar/electron-main/menubar.ts +++ b/src/vs/platform/menubar/electron-main/menubar.ts @@ -180,6 +180,10 @@ export class Menubar extends Disposable { this._register(this.windowsMainService.onDidChangeWindowsCount(e => this.onDidChangeWindowsCount(e))); this._register(this.nativeHostMainService.onDidBlurMainWindow(() => this.onDidChangeWindowFocus())); this._register(this.nativeHostMainService.onDidFocusMainWindow(() => this.onDidChangeWindowFocus())); + + // Rebuild menu when update state changes so update menu items reflect + // the current state (e.g. "Restart to Update" instead of "Check for Updates..."). + this._register(this.updateService.onStateChange(() => this.scheduleUpdateMenu())); } private get currentEnableMenuBarMnemonics(): boolean { diff --git a/src/vs/platform/networkFilter/common/networkFilterService.ts b/src/vs/platform/networkFilter/common/networkFilterService.ts index 9d3a540ee7de41..fb7d6b69d81183 100644 --- a/src/vs/platform/networkFilter/common/networkFilterService.ts +++ b/src/vs/platform/networkFilter/common/networkFilterService.ts @@ -17,12 +17,15 @@ import { AgentNetworkDomainSettingId } from './settings.js'; export const IAgentNetworkFilterService = createDecorator('agentNetworkFilterService'); +export const AgentNetworkFilterFetchWebToolName = 'fetchWebTool'; + /** * Service that filters network requests made by agent tools (fetch tool, * integrated browser) based on the configured allowed/denied domain lists. * - * Filtering is active when the `chat.agent.networkFilter` setting is enabled, - * or when the terminal sandbox service reports that sandboxing is enabled. + * Filtering is active for all callers when the `chat.agent.networkFilter` setting + * is enabled. When only sandboxing is enabled, filtering is active for fetch web + * page tool requests. This has to be revisited for integrated browser requests. * When both domain lists are empty, all domains are denied. * When a domain appears on the denied list it is always blocked, even if it * also matches an entry on the allowed list. @@ -34,9 +37,10 @@ export interface IAgentNetworkFilterService { * Extracts the domain from a URI and checks it against the configured * allowed/denied domain filter. * File URIs and URIs without an authority always pass. + * @param toolName Optional tool name for sandbox-only filtering. * @returns `true` if the URI's domain is allowed, `false` if blocked. */ - isUriAllowed(uri: URI): boolean; + isUriAllowed(uri: URI, toolName?: string): boolean; /** * Formats an error message for a blocked URI based on the current filter configuration. @@ -54,7 +58,7 @@ export interface IAgentNetworkFilterService { export class AgentNetworkFilterService extends Disposable implements IAgentNetworkFilterService { readonly _serviceBrand: undefined; - private enabled = false; + private networkFilterEnabled = false; private terminalSandboxEnabled = false; private allowedPatterns: string[] = []; private deniedPatterns: string[] = []; @@ -91,7 +95,7 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo private readConfiguration(): void { const networkFilterEnabled = this.configurationService.getValue(AgentNetworkDomainSettingId.NetworkFilter) ?? false; - this.enabled = networkFilterEnabled || this.terminalSandboxEnabled; + this.networkFilterEnabled = networkFilterEnabled; this.allowedPatterns = this.configurationService.getValue(AgentNetworkDomainSettingId.AllowedNetworkDomains) ?? []; this.deniedPatterns = this.configurationService.getValue(AgentNetworkDomainSettingId.DeniedNetworkDomains) ?? []; this.domainCache.clear(); @@ -107,9 +111,9 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo this.onDidChangeEmitter.fire(); } - isUriAllowed(uri: URI): boolean { + isUriAllowed(uri: URI, toolName?: string): boolean { // When domain filtering is inactive, allow all requests. - if (!this.enabled) { + if (!this.shouldFilter(toolName)) { return true; } @@ -131,6 +135,13 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo return result; } + // Determines whether network filtering should be applied for a given request + // based on the global network filter setting, the terminal sandbox state, and the tool making the request. + // For sandbox mode, network filtering is applied only when the global network filter is disabled + // and the request is coming from the fetch web tool. + private shouldFilter(toolName: string | undefined): boolean { + return this.networkFilterEnabled || (this.terminalSandboxEnabled && toolName === AgentNetworkFilterFetchWebToolName); + } formatError(uri: URI): string { const domain = extractDomainFromUri(uri); diff --git a/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts b/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts index 8b8aa23e8d4a93..4811d20ad4aaf1 100644 --- a/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts +++ b/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts @@ -9,7 +9,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ConfigurationTarget } from '../../../configuration/common/configuration.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; -import { AgentNetworkFilterService } from '../../common/networkFilterService.js'; +import { AgentNetworkFilterFetchWebToolName, AgentNetworkFilterService } from '../../common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../common/settings.js'; import { AgentSandboxSettingId } from '../../../sandbox/common/settings.js'; import { ITerminalSandboxService, NullTerminalSandboxService } from '../../../sandbox/common/terminalSandboxService.js'; @@ -60,7 +60,7 @@ suite('AgentNetworkFilterService', () => { assert.strictEqual(service.isUriAllowed(URI.parse('https://anything.test')), true); }); - test('network filter disabled with sandbox enabled activates filtering', async () => { + test('network filter disabled with sandbox enabled filters fetch web tool only', async () => { configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); terminalSandboxEnabled = true; configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); @@ -68,7 +68,9 @@ suite('AgentNetworkFilterService', () => { const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), false); + assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), true); + assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com'), AgentNetworkFilterFetchWebToolName), true); + assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com'), AgentNetworkFilterFetchWebToolName), false); }); test('denies all domains when both lists are empty', async () => { @@ -143,11 +145,12 @@ suite('AgentNetworkFilterService', () => { assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), false); }); - test('terminal sandbox enablement change fires onDidChange and updates filtering', async () => { + test('terminal sandbox enablement change fires onDidChange and updates fetch web tool filtering', async () => { configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), true); + assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com'), AgentNetworkFilterFetchWebToolName), true); let fired = false; disposables.add(service.onDidChange(() => { fired = true; })); @@ -157,7 +160,9 @@ suite('AgentNetworkFilterService', () => { await Promise.resolve(); assert.strictEqual(fired, true); + assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), true); assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), false); + assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com'), AgentNetworkFilterFetchWebToolName), true); + assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com'), AgentNetworkFilterFetchWebToolName), false); }); }); diff --git a/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts b/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts index 19842b144ad0a5..6aca94fadd3500 100644 --- a/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts +++ b/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts @@ -40,7 +40,7 @@ export class UserDataProfilesMainService extends UserDataProfilesService impleme @INativeEnvironmentService environmentService: INativeEnvironmentService, @IFileService fileService: IFileService, @ILogService logService: ILogService, - @IProductService private readonly productService: IProductService, + @IProductService productService: IProductService, ) { super(stateService, uriIdentityService, environmentService, fileService, logService); this.agentPluginsHome = URI.file(getAgentPluginsPath(environmentService.args, environmentService.userHome, productService.dataFolderName)); @@ -58,7 +58,7 @@ export class UserDataProfilesMainService extends UserDataProfilesService impleme if (!hostUserRoamingDataHome) { return defaultProfile; } - const hostAgentPluginsHome = getHostAgentPluginsPath(this.nativeEnvironmentService, this.productService); + const hostAgentPluginsHome = getHostAgentPluginsPath(this.nativeEnvironmentService); return { ...defaultProfile, keybindingsResource: joinPath(hostUserRoamingDataHome, 'keybindings.json'), @@ -77,27 +77,12 @@ export class UserDataProfilesMainService extends UserDataProfilesService impleme } } -function getHostAgentPluginsPath(environmentService: INativeEnvironmentService, productService: IProductService): string | undefined { - if (!(process as INodeProcess).isEmbeddedApp) { +function getHostAgentPluginsPath(environmentService: INativeEnvironmentService): string | undefined { + const hostUserHome = environmentService.hostUserHome; + if (!hostUserHome) { return undefined; } - if (!environmentService.isBuilt) { - return undefined; - } - - const quality = productService.quality; - let hostDataFolderName: string; - if (quality === 'stable') { - hostDataFolderName = '.vscode'; - } else if (quality === 'insider') { - hostDataFolderName = '.vscode-insiders'; - } else if (quality === 'exploration') { - hostDataFolderName = '.vscode-exploration'; - } else { - return undefined; - } - - return getAgentPluginsPath(environmentService.args, environmentService.userHome, hostDataFolderName); + return joinPath(hostUserHome, 'agent-plugins').fsPath; } function getAgentPluginsPath(args: NativeParsedArgs, userHome: URI, dataFolderName: string): string { diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index 629971159fca85..794ff855c3a884 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -277,6 +277,7 @@ export class SessionModelPicker extends Disposable { private readonly _delegate: IModelPickerDelegate; private readonly _modelPicker: ModelPickerActionItem; private _lastSessionType: string | undefined; + private _lastPushedSessionId: string | undefined; constructor( @IInstantiationService instantiationService: IInstantiationService, @@ -338,10 +339,28 @@ export class SessionModelPicker extends Disposable { const models = getAvailableModels(this._languageModelsService, this._sessionsManagementService); this._modelPicker.setEnabled(models.length > 0); - if (!this._currentModel.get() && models.length > 0) { + if (models.length === 0) { + return; + } + + const current = this._currentModel.get(); + if (!current) { const rememberedModelId = sessionType ? this._storageService.get(modelPickerStorageKey(sessionType), StorageScope.PROFILE) : undefined; const remembered = rememberedModelId ? models.find(m => m.identifier === rememberedModelId) : undefined; this._delegate.setModel(remembered ?? models[0]); + this._lastPushedSessionId = session?.sessionId; + } else if (session && session.sessionId !== this._lastPushedSessionId && models.some(m => m.identifier === current.identifier)) { + // Active session changed (e.g. user switched repository) but the + // previously selected model is still available. Re-push it so the + // new session's provider receives setModel — otherwise the request + // would be sent with the default model even though the picker UI + // still shows the user's selection. See #313385. + // + // Gated on sessionId so unrelated re-invocations of _initModel + // (e.g. from onDidChangeLanguageModels) don't redundantly write + // storage and dispatch provider.setModel for the same session. + this._delegate.setModel(current); + this._lastPushedSessionId = session.sessionId; } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts index 8fa751dae241c2..64cc0e8e7f532d 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, 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'; @@ -33,7 +33,7 @@ function stubServices( storedEntries?: Map; setModelSpy?: (sessionId: string, modelId: string) => void; }, -): { instantiationService: TestInstantiationService; storage: Map; activeSession: ReturnType> } { +): { instantiationService: TestInstantiationService; storage: Map; activeSession: ReturnType>; fireLanguageModelsChanged: () => void } { const instantiationService = disposables.add(new TestInstantiationService()); const models = opts?.models ?? []; const storage = opts?.storedEntries ?? new Map(); @@ -44,8 +44,10 @@ function stubServices( const setModelSpy = opts?.setModelSpy ?? (() => { }); + const onDidChangeLanguageModelsEmitter = disposables.add(new Emitter<{ added?: readonly { identifier: string }[]; removed?: readonly string[] }>()); + instantiationService.stub(ILanguageModelsService, { - onDidChangeLanguageModels: Event.None, + onDidChangeLanguageModels: onDidChangeLanguageModelsEmitter.event, getLanguageModelIds: () => models.map(m => m.identifier), lookupLanguageModel: (id: string) => models.find(m => m.identifier === id)?.metadata, } as Partial); @@ -72,7 +74,7 @@ function stubServices( // Stub IInstantiationService so SessionModelPicker can call createInstance for ModelPickerActionItem instantiationService.stub(IInstantiationService, instantiationService); - return { instantiationService, storage, activeSession }; + return { instantiationService, storage, activeSession, fireLanguageModelsChanged: () => onDidChangeLanguageModelsEmitter.fire({}) }; } suite('modelPickerStorageKey', () => { @@ -185,4 +187,45 @@ suite('SessionModelPicker', () => { // CLI key should still be intact assert.strictEqual(storage.get(modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE)), 'cli-m'); }); + + test('propagates selected model to a new session of the same type (#313385)', () => { + const models = [makeModel('cli-a', COPILOT_CLI_SESSION_TYPE), makeModel('cli-b', COPILOT_CLI_SESSION_TYPE)]; + const storedEntries = new Map([[modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE), 'cli-b']]); + const calls: { sessionId: string; modelId: string }[] = []; + const { instantiationService, activeSession } = stubServices(disposables, { + models, + activeSession: { providerId: 'default-copilot', sessionId: 's1', sessionType: COPILOT_CLI_SESSION_TYPE }, + storedEntries, + setModelSpy: (sessionId, modelId) => calls.push({ sessionId, modelId }), + }); + disposables.add(instantiationService.createInstance(SessionModelPicker)); + // Initial session receives the remembered model. + assert.ok(calls.some(c => c.sessionId === 's1' && c.modelId === 'cli-b')); + + // Switch to a new session of the same type (e.g. user picked a different repo). + activeSession.set({ providerId: 'default-copilot', sessionId: 's2', sessionType: COPILOT_CLI_SESSION_TYPE } as IActiveSession, undefined); + + // The new session must receive the same model so the request isn't sent with the default. + assert.ok(calls.some(c => c.sessionId === 's2' && c.modelId === 'cli-b')); + }); + + test('does not re-push model to the same session when language models change', () => { + const models = [makeModel('cli-a', COPILOT_CLI_SESSION_TYPE)]; + const calls: { sessionId: string; modelId: string }[] = []; + const { instantiationService, fireLanguageModelsChanged } = stubServices(disposables, { + models, + activeSession: { providerId: 'default-copilot', sessionId: 's1', sessionType: COPILOT_CLI_SESSION_TYPE }, + setModelSpy: (sessionId, modelId) => calls.push({ sessionId, modelId }), + }); + disposables.add(instantiationService.createInstance(SessionModelPicker)); + const initialCallCount = calls.filter(c => c.sessionId === 's1').length; + assert.ok(initialCallCount > 0, 'expected initial setModel to fire'); + + // Re-fire language-models-changed multiple times. The active session and + // selected model haven't changed, so the provider must not be re-notified. + fireLanguageModelsChanged(); + fireLanguageModelsChanged(); + + assert.strictEqual(calls.filter(c => c.sessionId === 's1').length, initialCallCount); + }); }); diff --git a/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css b/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css index 3fc399f509bb3c..759ad12a0d4bc8 100644 --- a/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css +++ b/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css @@ -423,6 +423,149 @@ margin-top: 4px; } +/* ---- Theme step ---- */ + +.sessions-walkthrough-theme-header { + text-align: center; + margin-bottom: 8px; +} + +.sessions-walkthrough-theme-header h2 { + margin: 0; + font-size: 22px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.sessions-walkthrough-theme-header p { + margin: 6px 0 0; + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.5; +} + +.sessions-walkthrough-theme-grid { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; + width: 100%; +} + +.sessions-walkthrough-theme-card { + border-radius: 8px; + border: 2px solid var(--vscode-widget-border, rgba(255, 255, 255, 0.1)); + cursor: pointer; + overflow: hidden; + transition: border-color 100ms, transform 100ms; + width: calc(25% - 8px); + min-width: 120px; + box-sizing: border-box; +} + +.sessions-walkthrough-theme-card:hover { + border-color: var(--vscode-focusBorder, #007acc); + transform: translateY(-1px); +} + +.sessions-walkthrough-theme-card:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.sessions-walkthrough-theme-card.selected { + border-color: var(--vscode-focusBorder, #007acc); + box-shadow: 0 0 0 1px var(--vscode-focusBorder, #007acc); +} + +.monaco-workbench.hc-black .sessions-walkthrough-theme-card, +.monaco-workbench.hc-light .sessions-walkthrough-theme-card { + border-width: 2px; + border-color: var(--vscode-contrastBorder); +} + +.monaco-workbench.hc-black .sessions-walkthrough-theme-card:hover, +.monaco-workbench.hc-light .sessions-walkthrough-theme-card:hover { + border-color: var(--vscode-contrastActiveBorder, var(--vscode-focusBorder)); +} + +.monaco-workbench.hc-black .sessions-walkthrough-theme-card:focus-visible, +.monaco-workbench.hc-light .sessions-walkthrough-theme-card:focus-visible { + outline: 2px solid var(--vscode-contrastActiveBorder, var(--vscode-focusBorder)); + outline-offset: 2px; +} + +.monaco-workbench.hc-black .sessions-walkthrough-theme-card.selected, +.monaco-workbench.hc-light .sessions-walkthrough-theme-card.selected { + border-color: var(--vscode-contrastActiveBorder, var(--vscode-focusBorder)); + box-shadow: 0 0 0 1px var(--vscode-contrastActiveBorder, var(--vscode-focusBorder)); +} + +.sessions-walkthrough-theme-preview { + overflow: hidden; +} + +.sessions-walkthrough-theme-preview-img { + display: block; + width: 100%; + height: auto; +} + +.sessions-walkthrough-vscode-theme-option { + display: flex; + justify-content: center; + margin-top: 16px; +} + +.sessions-walkthrough-vscode-theme-radio { + padding: 8px 20px; + border-radius: 8px; + border: 2px solid var(--vscode-radio-inactiveBorder, var(--vscode-widget-border, rgba(255, 255, 255, 0.1))); + background: var(--vscode-radio-inactiveBackground, transparent); + color: var(--vscode-radio-inactiveForeground, var(--vscode-foreground)); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: border-color 100ms, background 100ms; + text-align: center; +} + +.sessions-walkthrough-vscode-theme-radio:hover { + background: var(--vscode-radio-inactiveHoverBackground, color-mix(in srgb, var(--vscode-foreground) 8%, transparent)); +} + +.sessions-walkthrough-vscode-theme-radio.selected { + border-color: var(--vscode-focusBorder, #007acc); + box-shadow: 0 0 0 1px var(--vscode-focusBorder, #007acc); + background: var(--vscode-radio-activeBackground, transparent); + color: var(--vscode-radio-activeForeground, var(--vscode-foreground)); +} + +.sessions-walkthrough-vscode-theme-radio:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.sessions-walkthrough-theme-label { + padding: 4px 6px; + font-size: 12px; + font-weight: 500; + text-align: center; + color: var(--vscode-foreground); +} + +.sessions-walkthrough-theme-footer { + display: flex; + justify-content: center; + margin-top: 16px; +} + +@media (max-width: 480px) { + .sessions-walkthrough-theme-card { + width: calc(50% - 8px); + } +} + /* Reduced motion */ .monaco-reduce-motion .sessions-walkthrough-overlay, diff --git a/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts b/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts index 4c134230f4b58c..1de9e29507007d 100644 --- a/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts +++ b/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts @@ -8,7 +8,10 @@ import { disposableTimeout } from '../../../../base/common/async.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { $, addDisposableGenericMouseDownListener, append, EventType, addDisposableListener, getActiveElement, isHTMLElement } from '../../../../base/browser/dom.js'; import { localize } from '../../../../nls.js'; +import { FileAccess } from '../../../../base/common/network.js'; +import { IProductOnboardingTheme } from '../../../../base/common/product.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.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'; @@ -19,6 +22,8 @@ import { URI } from '../../../../base/common/uri.js'; import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { ChatSetupStrategy } from '../../../../workbench/contrib/chat/browser/chatSetup/chatSetup.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { IWorkbenchThemeService } from '../../../../workbench/services/themes/common/workbenchThemeService.js'; +import { IVSCodeThemeImporterService } from '../../../services/vscode/common/vsCodeThemeImporter.js'; export type WalkthroughOutcome = 'completed' | 'dismissed'; @@ -51,6 +56,7 @@ export class SessionsWalkthroughOverlay extends Disposable { private _outcomeResolved = false; private _isShowingWelcome = false; private _isShowingSignIn = false; + private _isShowingThemeStep = false; /** * Whether the overlay is currently displaying the signed-in welcome @@ -69,6 +75,17 @@ export class SessionsWalkthroughOverlay extends Disposable { */ get isShowingSignIn(): boolean { return this._isShowingSignIn; } + /** + * Transition to the theme selection step. Called by external code + * (e.g. the contribution) when the user signs in while the sign-in + * screen is visible, so the user still gets to pick a theme before + * the overlay dismisses. + */ + showThemeStep(): void { + this._isShowingSignIn = false; + this._renderThemeStep(); + } + /** Resolves when the user completes or dismisses the walkthrough. */ readonly outcome: Promise = new Promise(resolve => { this._resolveOutcome = resolve; }); @@ -81,6 +98,8 @@ export class SessionsWalkthroughOverlay extends Disposable { @IExtensionService private readonly extensionService: IExtensionService, @IOpenerService private readonly openerService: IOpenerService, @IProductService private readonly productService: IProductService, + @IVSCodeThemeImporterService private readonly vsCodeThemeImporter: IVSCodeThemeImporterService, + @IWorkbenchThemeService private readonly themeService: IWorkbenchThemeService, @ILogService private readonly logService: ILogService, ) { super(); @@ -95,6 +114,13 @@ export class SessionsWalkthroughOverlay extends Disposable { this._register(toDisposable(() => this.overlay.remove())); this._register(addDisposableListener(this.overlay, EventType.KEY_DOWN, (e: KeyboardEvent) => { if (e.key === 'Escape') { + if (this._isShowingThemeStep) { + // Remove the theme setting to reset to default + this.themeService.setColorTheme(undefined, ConfigurationTarget.USER); + this._isShowingWelcome = false; + this._isShowingThemeStep = false; + this.complete(); + } e.preventDefault(); e.stopPropagation(); return; @@ -291,7 +317,7 @@ export class SessionsWalkthroughOverlay extends Disposable { getStartedBtn.textContent = localize('walkthrough.welcome.getStarted', "Get Started"); stepDisposables.add(addDisposableListener(getStartedBtn, EventType.CLICK, () => { this._isShowingWelcome = false; - this.complete(); + this._renderThemeStep(); })); this.currentFocusableElements = [getStartedBtn, ...this.disclaimerLinks]; @@ -307,6 +333,186 @@ export class SessionsWalkthroughOverlay extends Disposable { return this.defaultAccountService.currentDefaultAccount !== null; } + // ------------------------------------------------------------------ + // Theme Step + + private _renderThemeStep(): void { + const stepDisposables = this.stepDisposables.value = new DisposableStore(); + this._isShowingWelcome = true; + this._isShowingThemeStep = true; + + // Start resolving the parent VS Code theme during the fade-out + const parentThemePromise = !isWeb + ? this.vsCodeThemeImporter.getVSCodeTheme() + : Promise.resolve(undefined); + + // Fade out current content, then render theme step + this.contentContainer.classList.add('sessions-walkthrough-fade-out'); + stepDisposables.add(disposableTimeout(async () => { + if (!this.overlay.isConnected) { + return; + } + const parentTheme = await parentThemePromise; + if (!this.overlay.isConnected) { + return; + } + // Only show the VS Code theme option if the parent theme is different from the 4 onboarding themes + const allOnboardingThemes = this.productService.onboardingThemes ?? []; + const shownThemes = allOnboardingThemes.filter(t => !t.id.startsWith('solarized')); + const parentThemeSettingsId = shownThemes.some(t => t.themeId === parentTheme) ? undefined : parentTheme; + this.contentContainer.classList.remove('sessions-walkthrough-fade-out'); + this._renderThemeStepContent(stepDisposables, parentThemeSettingsId); + }, fadeDuration)); + } + + private _renderThemeStepContent(stepDisposables: DisposableStore, parentThemeSettingsId: string | undefined): void { + this.contentContainer.textContent = ''; + this.footerContainer.textContent = ''; + this.disclaimerElement.classList.add('hidden'); + + // Header + const header = append(this.contentContainer, $('.sessions-walkthrough-theme-header')); + append(header, $('h2', undefined, localize('walkthrough.theme.title', "Choose Your Theme"))); + append(header, $('p', undefined, localize('walkthrough.theme.subtitle', "Pick a color theme to make it yours. You can always change it later."))); + + // Build theme list — exclude solarized variants for the base set + const allOnboardingThemes = this.productService.onboardingThemes ?? []; + const themes = allOnboardingThemes.filter(t => !t.id.startsWith('solarized')); + + const themeGrid = append(this.contentContainer, $('.sessions-walkthrough-theme-grid')); + themeGrid.setAttribute('role', 'radiogroup'); + themeGrid.setAttribute('aria-label', localize('walkthrough.theme.ariaLabel', "Choose a color theme")); + + // Pre-select the onboarding theme matching the current theme, or fall back to first + const currentTheme = this.themeService.getColorTheme(); + let selectedThemeId = themes.find(t => t.themeId === currentTheme.settingsId)?.id ?? themes[0]?.id; + + const themeCards: HTMLElement[] = []; + let vscodeThemeBtn: HTMLElement | undefined; + for (const theme of themes) { + const card = this._createThemeCard(stepDisposables, themeGrid, theme, themeCards, selectedThemeId, id => { + selectedThemeId = id; + if (vscodeThemeBtn) { + vscodeThemeBtn.classList.remove('selected'); + vscodeThemeBtn.setAttribute('aria-checked', 'false'); + } + }); + themeCards.push(card); + } + + // Show a VS Code theme option as a radio-style button below the grid + if (parentThemeSettingsId) { + const parentName = this.productService.embedded?.nameShort ?? 'VS Code'; + const option = append(this.contentContainer, $('.sessions-walkthrough-vscode-theme-option')); + vscodeThemeBtn = append(option, $('div.sessions-walkthrough-vscode-theme-radio')); + vscodeThemeBtn.setAttribute('role', 'radio'); + vscodeThemeBtn.setAttribute('aria-checked', 'false'); + vscodeThemeBtn.setAttribute('tabindex', '0'); + const labelText = localize( + 'walkthrough.theme.useVSCodeTheme', + "Use My {0} Theme \u00b7 {1}", + parentName, + parentThemeSettingsId, + ); + vscodeThemeBtn.textContent = labelText; + const selectVSCodeTheme = async () => { + for (const c of themeCards) { + c.classList.remove('selected'); + c.setAttribute('aria-checked', 'false'); + } + vscodeThemeBtn!.classList.add('selected'); + vscodeThemeBtn!.setAttribute('aria-checked', 'true'); + + // Apply the theme immediately if it's already available (built-in) + const allThemes = await this.themeService.getColorThemes(); + const match = allThemes.find(t => t.settingsId === parentThemeSettingsId); + if (match) { + this.themeService.setColorTheme(match.id, ConfigurationTarget.USER); + } else { + // Theme needs extension install + vscodeThemeBtn!.textContent = localize('walkthrough.theme.importing', "Importing theme\u2026"); + await this.vsCodeThemeImporter.importVSCodeTheme(); + vscodeThemeBtn!.textContent = labelText; + } + }; + stepDisposables.add(addDisposableListener(vscodeThemeBtn, EventType.CLICK, selectVSCodeTheme)); + stepDisposables.add(addDisposableListener(vscodeThemeBtn, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + vscodeThemeBtn!.click(); + } + })); + } + + // Footer with Continue button + const actions = append(this.footerContainer, $('.sessions-walkthrough-theme-footer')); + const continueBtn = append(actions, $('button.sessions-walkthrough-get-started-btn')) as HTMLButtonElement; + continueBtn.textContent = localize('walkthrough.theme.continue', "Continue"); + stepDisposables.add(addDisposableListener(continueBtn, EventType.CLICK, () => { + this._isShowingWelcome = false; + this._isShowingThemeStep = false; + this.complete(); + })); + + this.currentFocusableElements = [...themeCards, ...(vscodeThemeBtn ? [vscodeThemeBtn] : []), continueBtn]; + + stepDisposables.add(disposableTimeout(() => { + if (this.overlay.isConnected) { + continueBtn.focus(); + } + }, 0)); + } + + private _createThemeCard(stepDisposables: DisposableStore, parent: HTMLElement, theme: IProductOnboardingTheme, allCards: HTMLElement[], selectedThemeId: string, onSelect: (id: string) => void): HTMLElement { + const card = append(parent, $('div.sessions-walkthrough-theme-card')); + card.setAttribute('role', 'radio'); + card.setAttribute('aria-checked', theme.id === selectedThemeId ? 'true' : 'false'); + card.setAttribute('aria-label', theme.label); + card.setAttribute('tabindex', '0'); + + if (theme.id === selectedThemeId) { + card.classList.add('selected'); + } + + // SVG preview image + const preview = append(card, $('div.sessions-walkthrough-theme-preview')); + const img = append(preview, $('img.sessions-walkthrough-theme-preview-img')); + img.alt = ''; + img.src = FileAccess.asBrowserUri(`vs/workbench/contrib/welcomeOnboarding/browser/media/theme-preview-${theme.id}.svg`).toString(true); + + // Label + const label = append(card, $('div.sessions-walkthrough-theme-label')); + label.textContent = theme.label; + + stepDisposables.add(addDisposableListener(card, EventType.CLICK, () => { + onSelect(theme.id); + this._applyTheme(theme); + for (const c of allCards) { + c.classList.remove('selected'); + c.setAttribute('aria-checked', 'false'); + } + card.classList.add('selected'); + card.setAttribute('aria-checked', 'true'); + })); + + stepDisposables.add(addDisposableListener(card, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + card.click(); + } + })); + + return card; + } + + private async _applyTheme(theme: IProductOnboardingTheme): Promise { + const allThemes = await this.themeService.getColorThemes(); + const match = allThemes.find(t => t.settingsId === theme.themeId); + if (match) { + this.themeService.setColorTheme(match.id, ConfigurationTarget.USER); + } + } + private async _runSignIn(providerButtons: HTMLButtonElement[], error: HTMLElement, strategy: ChatSetupStrategy, titleEl: HTMLElement, subtitleEl: HTMLElement, signInActions: HTMLElement): Promise { await this._fadeToProgress(providerButtons, error, titleEl, subtitleEl, signInActions); if (this._shouldAbortUpdate(titleEl, subtitleEl)) { @@ -339,7 +545,7 @@ export class SessionsWalkthroughOverlay extends Disposable { return; } } - this.complete(); + this._renderThemeStep(); } else { await this._showErrorAndReset(error, localize('walkthrough.canceledError', "Sign-in was canceled. Please try again.")); } @@ -365,7 +571,7 @@ export class SessionsWalkthroughOverlay extends Disposable { const scopes = this.productService.defaultChatAgent?.providerScopes?.[0] ?? ['read:user', 'user:email', 'repo', 'workflow']; await this.authenticationService.createSession('github', scopes, { activateImmediate: true }); - this.complete(); + this._renderThemeStep(); } catch (err) { this.logService.error('[sessions walkthrough] Web sign-in failed:', err); await this._showErrorAndReset(error, localize('walkthrough.signInError', "Something went wrong. Please try again.")); diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index 0d0d9b2b5bc934..2c533a94fc8382 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -66,8 +66,7 @@ export function resetSessionsWelcome( store.add(defaultAccountService.onDidChangeDefaultAccount(account => { if (!walkthrough.isShowingWelcome && walkthrough.isShowingSignIn && account !== null) { storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - walkthrough.complete(); - store.dispose(); + walkthrough.showThemeStep(); } })); @@ -240,7 +239,7 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc if (!welcomeCompletionStored && !walkthrough.isShowingWelcome && walkthrough.isShowingSignIn && account !== null) { welcomeCompletionStored = true; this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - walkthrough.complete(); + walkthrough.showThemeStep(); } })); diff --git a/src/vs/sessions/services/vscode/common/vsCodeThemeImporter.ts b/src/vs/sessions/services/vscode/common/vsCodeThemeImporter.ts new file mode 100644 index 00000000000000..c5bc58f4b3609a --- /dev/null +++ b/src/vs/sessions/services/vscode/common/vsCodeThemeImporter.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { parse as parseJSONC } from '../../../../base/common/jsonc.js'; +import { getErrorMessage } from '../../../../base/common/errors.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { IExtensionManagementService } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { IExtensionsScannerService } from '../../../../platform/extensionManagement/common/extensionsScannerService.js'; +import { ExtensionType, IExtensionManifest } from '../../../../platform/extensions/common/extensions.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWorkbenchThemeService } from '../../../../workbench/services/themes/common/workbenchThemeService.js'; +import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; + +/** The VS Code configuration key for the active color theme. */ +export const COLOR_THEME_SETTINGS_ID = 'workbench.colorTheme'; + +/** + * Service that reads the parent VS Code installation's active color theme + * and can import it into the Agents app — installing the providing extension + * from the gallery if necessary. + */ +export interface IVSCodeThemeImporterService { + + readonly _serviceBrand: undefined; + + /** + * Resolves the parent VS Code's active color theme. Returns `undefined` + * when the parent settings cannot be read or the theme is already one of + * the onboarding themes displayed in the theme picker. + */ + getVSCodeTheme(): Promise; + + /** + * Imports the VS Code theme into the Agents app. + */ + importVSCodeTheme(): Promise; +} + +export const IVSCodeThemeImporterService = createDecorator('vsCodeThemeImporterService'); + +/** + * Describes a color theme from the parent VS Code installation. + */ +interface IParentThemeInfo { + /** The settingsId of the theme (e.g. "Dark Modern", "Monokai"). */ + readonly settingsId: string; + /** + * The location of the extension that provides this theme. + * `undefined` when the theme is already available (built-in or installed). + */ + readonly extensionLocation: URI | undefined; +} + +export class VSCodeThemeImporterService extends Disposable implements IVSCodeThemeImporterService { + + declare readonly _serviceBrand: undefined; + + private _parentThemePromise: Promise | undefined; + + constructor( + @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, + @IExtensionsScannerService private readonly extensionsScannerService: IExtensionsScannerService, + @IFileService private readonly fileService: IFileService, + @ILogService private readonly logService: ILogService, + @IUserDataProfileService private readonly userDataProfileService: IUserDataProfileService, + @IWorkbenchThemeService private readonly themeService: IWorkbenchThemeService, + ) { + super(); + } + + async getVSCodeTheme(): Promise { + if (!this._parentThemePromise) { + this._parentThemePromise = this._resolveVSCodeTheme(); + } + const themeInfo = await this._parentThemePromise; + return themeInfo?.settingsId; + } + + async importVSCodeTheme(): Promise { + try { + if (!this._parentThemePromise) { + this._parentThemePromise = this._resolveVSCodeTheme(); + } + const theme = await this._parentThemePromise; + if (!theme) { + return; + } + + // Install the extension from the host's extensions directory if needed + if (theme.extensionLocation) { + this.logService.info(`[VSCodeThemeImporter] Installing extension from ${theme.extensionLocation.toString()}`); + const profileLocation = this.userDataProfileService.currentProfile.extensionsResource; + await this.extensionManagementService.installFromLocation(theme.extensionLocation, profileLocation); + } + + // Apply the theme + const allThemes = await this.themeService.getColorThemes(); + const match = allThemes.find(t => t.settingsId === theme.settingsId); + if (match) { + await this.themeService.setColorTheme(match.id, ConfigurationTarget.USER); + return; + } + + this.logService.warn(`[VSCodeThemeImporter] Theme ${theme.settingsId} not found after import`); + } catch (err) { + this.logService.error(`[VSCodeThemeImporter] Failed to import theme:`, err); + } + } + + private async _resolveVSCodeTheme(): Promise { + try { + const settingsId = await this._readVSCodeThemeId(); + if (!settingsId) { + return undefined; + } + + // Find the extension providing this theme by scanning the host's extensions + const extensionLocation = await this._findThemeExtension(settingsId); + + return { settingsId, extensionLocation }; + } catch (err) { + this.logService.warn('[VSCodeThemeImporter] Failed to resolve VS Code theme:', err); + return undefined; + } + } + + /** + * Scans the host VS Code's extensions directory to find which extension + * provides the given theme. Returns the extension location URI, or + * `undefined` if the theme is already available (built-in or installed). + */ + private async _findThemeExtension(themeSettingsId: string): Promise { + const allThemes = await this.themeService.getColorThemes(); + if (allThemes.find(t => t.settingsId === themeSettingsId)) { + return undefined; + } + + const hostExtensionsHome = this.environmentService.hostExtensionsHome; + if (!hostExtensionsHome) { + return undefined; + } + + try { + const scanned = await this.extensionsScannerService.scanOneOrMultipleExtensions( + hostExtensionsHome, + ExtensionType.User, + {}, + ); + for (const ext of scanned) { + if (this._extensionProvidesTheme(ext.manifest, themeSettingsId)) { + return ext.location; + } + } + } catch (err) { + this.logService.warn('[VSCodeThemeImporter] Failed to scan host extensions:', err); + } + + return undefined; + } + + private _extensionProvidesTheme(manifest: IExtensionManifest, themeSettingsId: string): boolean { + const themes = manifest.contributes?.themes; + if (!Array.isArray(themes)) { + return false; + } + return themes.some(t => { + const id = (t as { id?: string; label?: string }).id ?? (t as { label?: string }).label; + return id === themeSettingsId; + }); + } + + private async _readVSCodeThemeId(): Promise { + const hostDataHome = this.environmentService.hostUserRoamingDataHome; + if (!hostDataHome) { + return undefined; + } + + try { + const settingsUri = joinPath(hostDataHome, 'settings.json'); + const content = await this.fileService.readFile(settingsUri); + const settings = parseJSONC>(content.value.toString()); + const themeId = settings[COLOR_THEME_SETTINGS_ID]; + if (typeof themeId === 'string') { + return themeId; + } + this.logService.warn('[VSCodeThemeImporter] workbench.colorTheme is not set in host settings.json', themeId); + return undefined; + } catch (e) { + this.logService.warn('[VSCodeThemeImporter] Failed to read host settings.json, falling back to default theme', getErrorMessage(e)); + return undefined; + } + } +} + +registerSingleton(IVSCodeThemeImporterService, VSCodeThemeImporterService, InstantiationType.Delayed); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index bb11f776b1b584..b7a05fe4bd05f6 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -460,5 +460,6 @@ import './contrib/welcome/browser/welcome.contribution.js'; import './contrib/policyBlocked/browser/policyBlocked.contribution.js'; import './services/sessions/browser/sessionsManagementService.js'; +import './services/vscode/common/vsCodeThemeImporter.js'; //#endregion diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 38fcfbcba40da1..076448b4b345b0 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3643,7 +3643,7 @@ export interface MainThreadTestingShape { export type ChatStatusItemDto = { id: string; - title: string | { label: string; link: string }; + title: string | { label: string; link: string; helpText?: string }; description: string; detail: string | undefined; }; diff --git a/src/vs/workbench/api/common/extHostChatStatus.ts b/src/vs/workbench/api/common/extHostChatStatus.ts index 47a8553731384d..bcd5b0f23f8f18 100644 --- a/src/vs/workbench/api/common/extHostChatStatus.ts +++ b/src/vs/workbench/api/common/extHostChatStatus.ts @@ -49,10 +49,10 @@ export class ExtHostChatStatus { const item = Object.freeze({ id: id, - get title(): string | { label: string; link: string } { + get title(): string | { label: string; link: string; helpText?: string } { return state.title; }, - set title(value: string | { label: string; link: string }) { + set title(value: string | { label: string; link: string; helpText?: string }) { state.title = value; syncState(); }, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index e6a6583b06cd90..66491e54474ff0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1122,7 +1122,8 @@ export function registerChatActions() { ChatContextKeys.Entitlement.planFree, ChatContextKeys.Entitlement.planEdu, ChatContextKeys.Entitlement.planPro, - ChatContextKeys.Entitlement.planProPlus + ChatContextKeys.Entitlement.planProPlus, + ChatContextKeys.Entitlement.planMax ), nonEnterpriseCopilotUsers ), diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts index 418c57e8aab7e3..3cda7d6650311a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts @@ -34,6 +34,7 @@ const LANGUAGE_MODELS_ENTITLEMENT_PRECONDITION = ContextKeyExpr.and(ChatContextK ChatContextKeys.Entitlement.planEdu, ChatContextKeys.Entitlement.planPro, ChatContextKeys.Entitlement.planProPlus, + ChatContextKeys.Entitlement.planMax, ChatContextKeys.Entitlement.planBusiness, ChatContextKeys.Entitlement.planEnterprise, ChatContextKeys.Entitlement.internal diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 3a75cdaee3127d..ae39b7028a357a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -41,6 +41,7 @@ import { ExtensionUrlHandlerOverrideRegistry, IExtensionUrlHandlerOverride } fro import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { IHostService } from '../../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; +import { InEditorZenModeContext } from '../../../../common/contextkeys.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; import { IExtension, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; @@ -59,7 +60,7 @@ import { ChatSetup } from './chatSetupRunner.js'; const defaultChat = { chatExtensionId: product.defaultChatAgent?.chatExtensionId ?? '', - manageAdditionalSpendUrl: product.defaultChatAgent?.manageAdditionalSpendUrl ?? '', + manageOverageUrl: product.defaultChatAgent?.manageOverageUrl ?? '', upgradePlanUrl: product.defaultChatAgent?.upgradePlanUrl ?? '', chatRefreshTokenCommand: product.defaultChatAgent?.chatRefreshTokenCommand ?? '', }; @@ -387,7 +388,8 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr ChatContextKeys.Entitlement.signedOut, ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), - ContextKeyExpr.has('updateTitleBar').negate() + ContextKeyExpr.has('updateTitleBar').negate(), + InEditorZenModeContext.negate(), ), }] }); @@ -474,6 +476,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr ContextKeyExpr.or( ChatContextKeys.Entitlement.planPro, ChatContextKeys.Entitlement.planProPlus, + ChatContextKeys.Entitlement.planMax, ChatContextKeys.Entitlement.planEdu, ) ), @@ -485,6 +488,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr ContextKeyExpr.or( ChatContextKeys.Entitlement.planPro, ChatContextKeys.Entitlement.planProPlus, + ChatContextKeys.Entitlement.planMax, ChatContextKeys.Entitlement.planEdu, ), ContextKeyExpr.or( @@ -498,7 +502,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr override async run(accessor: ServicesAccessor): Promise { const openerService = accessor.get(IOpenerService); - openerService.open(URI.parse(defaultChat.manageAdditionalSpendUrl)); + openerService.open(URI.parse(defaultChat.manageOverageUrl)); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 680fcbdb04eed2..0c039c6d50168f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -8,7 +8,8 @@ import { Gesture, EventType as TouchEventType } from '../../../../../base/browse import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; import { renderLabelWithIcons } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; -import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; +import { SelectBox } from '../../../../../base/browser/ui/selectBox/selectBox.js'; +import { Checkbox, TriStateCheckbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { IAction, toAction, WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification } from '../../../../../base/common/actions.js'; import { CancellationToken, cancelOnDispose } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -20,13 +21,12 @@ import { language } from '../../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { isObject } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; +import { stripIcons } from '../../../../../base/common/iconLabels.js'; import { IInlineCompletionsService } from '../../../../../editor/browser/services/inlineCompletionsService.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { ITextResourceConfigurationService } from '../../../../../editor/common/services/textResourceConfiguration.js'; import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import * as languages from '../../../../../editor/common/languages.js'; import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -35,11 +35,12 @@ import { IMarkdownRendererService } from '../../../../../platform/markdown/brows import { Link } from '../../../../../platform/opener/browser/link.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { defaultButtonStyles, defaultCheckboxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultSelectBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { DomWidget } from '../../../../../platform/domWidget/browser/domWidget.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; import { IChatEntitlementService, ChatEntitlementService, ChatEntitlement, IQuotaSnapshot, getChatPlanName } from '../../../../services/chat/common/chatEntitlementService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { isNewUser } from './chatStatus.js'; import { IChatStatusItemService, ChatStatusEntry } from './chatStatusItemService.js'; import product from '../../../../../platform/product/common/product.js'; @@ -87,6 +88,7 @@ export interface IChatStatusDashboardOptions { export class ChatStatusDashboard extends DomWidget { private static readonly QUICK_SETTINGS_COLLAPSED_KEY = 'chatStatusDashboard.quickSettingsCollapsed'; + private static readonly CONTRIBUTED_COLLAPSED_KEY_PREFIX = 'chatStatusDashboard.contributedCollapsed.'; readonly element = $('div.chat-status-bar-entry-tooltip'); @@ -109,7 +111,7 @@ export class ChatStatusDashboard extends DomWidget { @IInlineCompletionsService private readonly inlineCompletionsService: IInlineCompletionsService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, - @IQuickInputService private readonly quickInputService: IQuickInputService, + @IContextViewService private readonly contextViewService: IContextViewService, @IStorageService private readonly storageService: IStorageService, ) { super(); @@ -133,8 +135,7 @@ export class ChatStatusDashboard extends DomWidget { !this.options?.disableInlineSuggestionsSettings || !this.options?.disableModelSelection || !this.options?.disableProviderOptions || - !this.options?.disableCompletionsSnooze || - contributedEntries.length > 0; + !this.options?.disableCompletionsSnooze; // Title header with plan name, CTA buttons, and manage action let headerAdditionalSpendButton: Button | undefined; @@ -150,8 +151,9 @@ export class ChatStatusDashboard extends DomWidget { })); // Add Additional Spend / Upgrade buttons to the header - const canConfigureAdditionalSpend = this.chatEntitlementService.entitlement === ChatEntitlement.EDU || this.chatEntitlementService.entitlement === ChatEntitlement.Pro || this.chatEntitlementService.entitlement === ChatEntitlement.ProPlus; + const canConfigureAdditionalSpend = this.chatEntitlementService.entitlement === ChatEntitlement.EDU || this.chatEntitlementService.entitlement === ChatEntitlement.Pro || this.chatEntitlementService.entitlement === ChatEntitlement.ProPlus || this.chatEntitlementService.entitlement === ChatEntitlement.Max; const showUpgrade = this.chatEntitlementService.entitlement !== ChatEntitlement.ProPlus && + this.chatEntitlementService.entitlement !== ChatEntitlement.Max && this.chatEntitlementService.entitlement !== ChatEntitlement.Business && this.chatEntitlementService.entitlement !== ChatEntitlement.Enterprise; @@ -162,7 +164,7 @@ export class ChatStatusDashboard extends DomWidget { headerAdditionalSpendButton = this._store.add(new Button(header, { ...defaultButtonStyles, hoverDelegate: nativeHoverDelegate, secondary: true })); headerAdditionalSpendButton.element.classList.add('header-cta-button'); headerAdditionalSpendButton.label = initialAdditionalUsageEnabled ? localize('manageAdditionalSpend', "Manage Additional Spend") : localize('configureAdditionalSpend', "Configure Additional Spend"); - this._store.add(headerAdditionalSpendButton.onDidClick(() => this.runCommandAndClose(() => this.openerService.open(URI.parse(defaultChat.manageAdditionalSpendUrl))))); + this._store.add(headerAdditionalSpendButton.onDidClick(() => this.runCommandAndClose(() => this.openerService.open(URI.parse(defaultChat.manageOverageUrl))))); if (actionBarElement) { header.insertBefore(headerAdditionalSpendButton.element, actionBarElement); } @@ -198,10 +200,15 @@ export class ChatStatusDashboard extends DomWidget { includedContainer.appendChild($('div.description', undefined, localize('premiumIncluded', "Included with your organization's plan."))); } - // Quick Settings — collapsible region + // Next Edit Suggestions — collapsible region if (hasQuickSettingsContent) { const hasContentAbove = hasUsageSection || hasVisibleUsageContent || hasPremiumUnlimited; - this.renderQuickSettings(contributedEntries, hasContentAbove); + this.renderInlineSuggestionsSection(hasContentAbove); + } + + // Contributed sections (e.g. Codebase Semantic Index) — each gets its own collapsible + if (contributedEntries.length > 0) { + this.renderContributedSections(contributedEntries); } // New to Chat / Signed out @@ -224,7 +231,7 @@ export class ChatStatusDashboard extends DomWidget { } let chatQuotaIndicator: ((quota: IQuotaSnapshot | string) => void) | undefined; - if (chatQuota && !chatQuota.unlimited) { + if (chatQuota && !chatQuota.unlimited && !premiumChatQuota?.usageBasedBilling) { chatQuotaIndicator = this.createQuotaIndicator(container, chatQuota, localize('chatsLabel', "Chat messages"), resetLabel); } @@ -238,7 +245,9 @@ export class ChatStatusDashboard extends DomWidget { } let completionsQuotaIndicator: ((quota: IQuotaSnapshot | string) => void) | undefined; - if (completionsQuota && !completionsQuota.unlimited && completionsQuota.percentRemaining >= 0) { + const showCompletions = completionsQuota && !completionsQuota.unlimited && completionsQuota.percentRemaining >= 0 + && (!premiumChatQuota?.usageBasedBilling || this.chatEntitlementService.entitlement === ChatEntitlement.Free); + if (showCompletions) { completionsQuotaIndicator = this.createQuotaIndicator(container, completionsQuota, localize('completionsLabel', "Inline Suggestions"), resetLabel); } @@ -274,12 +283,27 @@ export class ChatStatusDashboard extends DomWidget { } } - private renderQuickSettings(contributedEntries: ChatStatusEntry[], hasContentAbove: boolean): void { + private renderInlineSuggestionsSection(hasContentAbove: boolean): void { const nonCollapsible = !!this.options?.disableQuickSettingsCollapsible; const collapsed = !nonCollapsible && this.storageService.getBoolean(ChatStatusDashboard.QUICK_SETTINGS_COLLAPSED_KEY, StorageScope.PROFILE, true); + // Compute status based on effective enablement for the active editor's language + const activeLanguageId = this.editorService.activeTextEditorLanguageId; + const getStatusText = () => { + if (!this.canUseChat()) { + return localize('inlineSuggestionsDisabled', "Disabled"); + } + const enabled = activeLanguageId + ? isCompletionsEnabled(this.configurationService, activeLanguageId) + : isCompletionsEnabled(this.configurationService); + return enabled + ? localize('inlineSuggestionsEnabled', "Enabled") + : localize('inlineSuggestionsDisabled', "Disabled"); + }; + let disclosureHeader: HTMLElement | undefined; let chevron: HTMLElement | undefined; + let statusEl: HTMLElement | undefined; if (!nonCollapsible) { disclosureHeader = this.element.appendChild($('button.collapsible-header')); if (!hasContentAbove) { @@ -290,18 +314,22 @@ export class ChatStatusDashboard extends DomWidget { chevron = disclosureHeader.appendChild($('span.collapsible-chevron')); chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); - disclosureHeader.appendChild($('span.collapsible-label', undefined, localize('quickSettingsTab', "Quick Settings"))); + disclosureHeader.appendChild($('span.collapsible-label', undefined, localize('inlineSuggestionsTab', "Inline Suggestions"))); + + statusEl = disclosureHeader.appendChild($('span.collapsible-status', undefined, getStatusText())); } const collapsibleContent = this.element.appendChild($('div.collapsible-content')); const collapsibleInner = collapsibleContent.appendChild($('div.collapsible-inner')); if (collapsed) { collapsibleContent.classList.add('collapsed'); + collapsibleInner.inert = true; } if (disclosureHeader && chevron) { const toggle = () => { const isCollapsed = collapsibleContent.classList.toggle('collapsed'); + collapsibleInner.inert = isCollapsed; disclosureHeader!.setAttribute('aria-expanded', String(!isCollapsed)); chevron!.className = 'collapsible-chevron'; chevron!.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); @@ -311,26 +339,126 @@ export class ChatStatusDashboard extends DomWidget { this._store.add(addDisposableListener(disclosureHeader, EventType.CLICK, () => toggle())); } + // Update status text when completions setting changes + if (statusEl) { + this._store.add(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(defaultChat.completionsEnablementSetting)) { + statusEl!.textContent = getStatusText(); + } + })); + } + this.renderInlineSuggestionsContent(collapsibleInner); + } - // Contributions + private renderContributedSections(contributedEntries: ChatStatusEntry[]): void { for (const item of contributedEntries) { - collapsibleInner.appendChild($('hr')); + const storageKey = ChatStatusDashboard.CONTRIBUTED_COLLAPSED_KEY_PREFIX + item.id; + const collapsed = this.storageService.getBoolean(storageKey, StorageScope.PROFILE, true); + + const headerLabel = typeof item.label === 'string' ? item.label : item.label.label; + const headerLink = typeof item.label === 'string' ? undefined : item.label.link; + const linkDescription = typeof item.label === 'string' ? undefined : item.label.helpText; + + const disclosureHeader = this.element.appendChild($('button.collapsible-header')); + disclosureHeader.setAttribute('aria-expanded', String(!collapsed)); + + const chevron = disclosureHeader.appendChild($('span.collapsible-chevron')); + chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + disclosureHeader.appendChild($('span.collapsible-label', undefined, headerLabel)); - const itemDisposables = this._store.add(new MutableDisposable()); + // Use renderLabelWithIcons for header status (plain text + icons only, no links inside button) + const statusEl = disclosureHeader.appendChild($('span.collapsible-status')); + statusEl.append(...renderLabelWithIcons(item.description)); + statusEl.title = stripIcons(item.description).trim(); + + const collapsibleContent = this.element.appendChild($('div.collapsible-content')); + const collapsibleInner = collapsibleContent.appendChild($('div.collapsible-inner')); + if (collapsed) { + collapsibleContent.classList.add('collapsed'); + collapsibleInner.inert = true; + } - let rendered = this.renderContributedChatStatusItem(item); - itemDisposables.value = rendered.disposables; - collapsibleInner.appendChild(rendered.element); + const toggle = () => { + const isCollapsed = collapsibleContent.classList.toggle('collapsed'); + collapsibleInner.inert = isCollapsed; + disclosureHeader.setAttribute('aria-expanded', String(!isCollapsed)); + chevron.className = 'collapsible-chevron'; + chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); + this.storageService.store(storageKey, isCollapsed, StorageScope.PROFILE, StorageTarget.USER); + }; + this._store.add(addDisposableListener(disclosureHeader, EventType.CLICK, () => toggle())); + + // Use a single disposable store for all contributed section content + const sectionDisposables = this._store.add(new MutableDisposable()); + const sectionStore = new DisposableStore(); + sectionDisposables.value = sectionStore; + + // Description with Learn More (use contributed data, not hardcoded text) + let descriptionEl: HTMLElement | undefined; + if (headerLink) { + descriptionEl = collapsibleInner.appendChild($('div.section-description')); + const descText = linkDescription + ? `${linkDescription} [${localize('learnMore', "Learn More")}](${headerLink})` + : `[${localize('learnMore', "Learn More")}](${headerLink})`; + this.renderTextPlus(descriptionEl, descText, sectionStore); + } + + // Detail content (action links like "Build index", etc.) + let detailEl: HTMLElement | undefined; + if (item.detail) { + detailEl = collapsibleInner.appendChild($('div.section-detail')); + this.renderTextPlus(detailEl, item.detail, sectionStore); + } + + // Listen for updates to re-render status and detail this._store.add(this.chatStatusItemService.onDidChange(e => { if (e.entry.id === item.id) { - const previousElement = rendered.element; - - rendered = this.renderContributedChatStatusItem(e.entry); - itemDisposables.value = rendered.disposables; + // Update status in header (plain text + icons only) + statusEl.textContent = ''; + statusEl.append(...renderLabelWithIcons(e.entry.description)); + statusEl.title = stripIcons(e.entry.description).trim(); + + // Re-render detail content + const newStore = new DisposableStore(); + sectionDisposables.value = newStore; + + if (detailEl) { + if (e.entry.detail) { + detailEl.textContent = ''; + this.renderTextPlus(detailEl, e.entry.detail, newStore); + } else { + detailEl.remove(); + detailEl = undefined; + } + } else if (e.entry.detail) { + detailEl = collapsibleInner.appendChild($('div.section-detail')); + this.renderTextPlus(detailEl, e.entry.detail, newStore); + } - previousElement.replaceWith(rendered.element); + // Re-render Learn More link if needed + const updatedLink = typeof e.entry.label === 'string' ? undefined : e.entry.label.link; + const updatedLinkDesc = typeof e.entry.label === 'string' ? undefined : e.entry.label.helpText; + if (descriptionEl) { + if (updatedLink) { + descriptionEl.textContent = ''; + const descText = updatedLinkDesc + ? `${updatedLinkDesc} [${localize('learnMore', "Learn More")}](${updatedLink})` + : `[${localize('learnMore', "Learn More")}](${updatedLink})`; + this.renderTextPlus(descriptionEl, descText, newStore); + } else { + descriptionEl.remove(); + descriptionEl = undefined; + } + } else if (updatedLink) { + descriptionEl = collapsibleInner.insertBefore($('div.section-description'), detailEl ?? null); + const descText = updatedLinkDesc + ? `${updatedLinkDesc} [${localize('learnMore', "Learn More")}](${updatedLink})` + : `[${localize('learnMore', "Learn More")}](${updatedLink})`; + this.renderTextPlus(descriptionEl, descText, newStore); + } } })); } @@ -412,17 +540,17 @@ export class ChatStatusDashboard extends DomWidget { modelContainer.appendChild($('span.model-text', undefined, localize('modelLabel', "Model"))); - const actionBar = modelContainer.appendChild($('div.model-action-bar')); - const toolbar = this._store.add(new ActionBar(actionBar, { hoverDelegate: nativeHoverDelegate })); - toolbar.push([toAction({ - id: 'workbench.action.selectInlineCompletionsModel', - label: currentModel.name, - tooltip: localize('selectModel', "Select Model"), - class: ThemeIcon.asClassName(Codicon.gear), - run: async () => { - await this.showModelPicker(provider); + const selectOptions = modelInfo.models.map(m => ({ text: m.name })); + const selectedIndex = modelInfo.models.findIndex(m => m.id === modelInfo.currentModelId); + const selectBox = this._store.add(new SelectBox(selectOptions, Math.max(0, selectedIndex), this.contextViewService, defaultSelectBoxStyles, { ariaLabel: localize('selectModel', "Select Model") })); + const selectContainer = modelContainer.appendChild($('div.model-select-container')); + selectBox.render(selectContainer); + this._store.add(selectBox.onDidSelect(async e => { + const selectedModel = modelInfo.models[e.index]; + if (selectedModel && selectedModel.id !== modelInfo.currentModelId && provider.setModelId) { + await provider.setModelId(selectedModel.id); } - })], { icon: false, label: true }); + })); } } } @@ -438,16 +566,17 @@ export class ChatStatusDashboard extends DomWidget { optionContainer.appendChild($('span.suggest-option-text', undefined, option.label)); - const actionBar = optionContainer.appendChild($('div.suggest-option-action-bar')); - const toolbar = this._store.add(new ActionBar(actionBar, { hoverDelegate: nativeHoverDelegate })); - toolbar.push([toAction({ - id: `workbench.action.selectProviderOption.${option.id}`, - label: currentValue.label, - tooltip: localize('selectOption', "Select {0}", option.label), - run: async () => { - await this.showProviderOptionPicker(provider, option); + const selectOptions = option.values.map(v => ({ text: v.label })); + const selectedIndex = option.values.findIndex(v => v.id === option.currentValueId); + const selectBox = this._store.add(new SelectBox(selectOptions, Math.max(0, selectedIndex), this.contextViewService, defaultSelectBoxStyles, { ariaLabel: localize('selectOption', "Select {0}", option.label) })); + const selectContainer = optionContainer.appendChild($('div.suggest-option-select-container')); + selectBox.render(selectContainer); + this._store.add(selectBox.onDidSelect(async e => { + const selectedValue = option.values[e.index]; + if (selectedValue && selectedValue.id !== option.currentValueId && provider.setProviderOption) { + await provider.setProviderOption(option.id, selectedValue.id); } - })], { icon: false, label: true }); + })); } } } @@ -489,36 +618,6 @@ export class ChatStatusDashboard extends DomWidget { return header; } - private renderContributedChatStatusItem(item: ChatStatusEntry): { element: HTMLElement; disposables: DisposableStore } { - const disposables = new DisposableStore(); - - const itemElement = $('div.contribution'); - - const headerLabel = typeof item.label === 'string' ? item.label : item.label.label; - const headerLink = typeof item.label === 'string' ? undefined : item.label.link; - this.renderHeader(itemElement, disposables, headerLabel, headerLink ? toAction({ - id: 'workbench.action.openChatStatusItemLink', - label: localize('learnMore', "Learn More"), - tooltip: localize('learnMore', "Learn More"), - class: ThemeIcon.asClassName(Codicon.linkExternal), - run: () => this.runCommandAndClose(() => this.openerService.open(URI.parse(headerLink))), - }) : undefined); - - const itemBody = itemElement.appendChild($('div.body')); - - const description = itemBody.appendChild($('span.description')); - this.renderTextPlus(description, item.description, disposables); - - if (item.detail) { - const separator = itemBody.appendChild($('span.separator')); - separator.textContent = '\u2014'; - const detail = itemBody.appendChild($('span.detail-item')); - this.renderTextPlus(detail, item.detail, disposables); - } - - return { element: itemElement, disposables }; - } - private renderTextPlus(target: HTMLElement, text: string, store: DisposableStore): void { for (const node of parseLinkedText(text).nodes) { if (typeof node === 'string') { @@ -658,11 +757,20 @@ export class ChatStatusDashboard extends DomWidget { // --- Inline Suggestions { const globalSetting = append(settings, $('div.setting')); - this.createInlineSuggestionsSetting(globalSetting, localize('settings.codeCompletions.allFiles', "All files"), '*'); + this.createInlineSuggestionsSetting(globalSetting, localize('settings.codeCompletions.allFiles', "Ghost text suggestions"), '*'); + + const overriddenHint = globalSetting.appendChild($('span.setting-overridden')); + const updateOverriddenHint = () => { + const obj = this.configurationService.getValue>(defaultChat.completionsEnablementSetting); + const hasOverride = modeId && isObject(obj) && typeof obj[modeId] !== 'undefined'; + overriddenHint.textContent = hasOverride ? localize('settings.overridden', "(overridden)") : ''; + }; + updateOverriddenHint(); if (modeId) { const languageSetting = append(settings, $('div.setting')); - this.createInlineSuggestionsSetting(languageSetting, localize('settings.codeCompletions.language', "{0}", this.languageService.getLanguageName(modeId) ?? modeId), modeId); + const languageName = this.languageService.getLanguageName(modeId) ?? modeId; + this.createTriStateLanguageSetting(languageSetting, localize('settings.codeCompletions.language', "Ghost text suggestions for {0}", languageName), modeId, updateOverriddenHint); } } @@ -714,6 +822,102 @@ export class ChatStatusDashboard extends DomWidget { this.createSetting(container, [defaultChat.completionsEnablementSetting], label, this.getCompletionsSettingAccessor(modeId)); } + private createTriStateLanguageSetting(container: HTMLElement, label: string, modeId: string, onStateChange: () => void): void { + const settingId = defaultChat.completionsEnablementSetting; + + const getState = (): boolean | 'mixed' => { + const obj = this.configurationService.getValue>(settingId); + if (!isObject(obj) || typeof obj[modeId] === 'undefined') { + return 'mixed'; // no override — inherits from * + } + return Boolean(obj[modeId]); + }; + + const checkbox = this._store.add(new TriStateCheckbox(label, getState(), { ...defaultCheckboxStyles })); + container.appendChild(checkbox.domNode); + + const settingLabel = append(container, $('span.setting-label', undefined, label)); + this._store.add(Gesture.addTarget(settingLabel)); + + const cycleState = () => { + const current = checkbox.checked; + // Cycle: true → false → mixed → true + if (current === true) { + checkbox.checked = false; + } else if (current === false) { + checkbox.checked = 'mixed'; + } else { + checkbox.checked = true; + } + }; + + const writeState = (state: boolean | 'mixed') => { + let result = this.configurationService.getValue>(settingId); + if (!isObject(result)) { + result = Object.create(null); + } + + if (state === 'mixed') { + // Remove the language key to inherit from * + const { [modeId]: _, ...rest } = result; + const inheritedEnablement = typeof rest['*'] === 'boolean' ? (rest['*'] ? 'enabled' : 'disabled') : 'enabled'; + this.telemetryService.publicLog2('chatStatus.settingChanged', { + settingIdentifier: settingId, + settingMode: modeId, + settingEnablement: inheritedEnablement + }); + this.configurationService.updateValue(settingId, rest); + } else { + this.telemetryService.publicLog2('chatStatus.settingChanged', { + settingIdentifier: settingId, + settingMode: modeId, + settingEnablement: state ? 'enabled' : 'disabled' + }); + this.configurationService.updateValue(settingId, { ...result, [modeId]: state }); + } + onStateChange(); + }; + + // Track previous state so onChange can apply tri-state cycling + let previousState = getState(); + + const cycleAndWrite = () => { + cycleState(); + previousState = checkbox.checked; + writeState(checkbox.checked); + }; + + [EventType.CLICK, TouchEventType.Tap].forEach(eventType => { + this._store.add(addDisposableListener(settingLabel, eventType, e => { + if (checkbox?.enabled) { + EventHelper.stop(e, true); + cycleAndWrite(); + checkbox.focus(); + } + })); + }); + + this._store.add(checkbox.onChange(() => { + // The internal Toggle only cycles true↔false; revert and apply our tri-state cycle + checkbox.checked = previousState; // undo internal toggle + cycleAndWrite(); + })); + + this._store.add(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(settingId)) { + checkbox.checked = getState(); + previousState = checkbox.checked; + onStateChange(); + } + })); + + if (!this.canUseChat()) { + container.classList.add('disabled'); + checkbox.disable(); + checkbox.checked = false; + } + } + private getCompletionsSettingAccessor(modeId = '*'): ISettingsAccessor { const settingId = defaultChat.completionsEnablementSetting; @@ -853,64 +1057,4 @@ export class ChatStatusDashboard extends DomWidget { updateIntervalTimer(); })); } - - private async showQuickPick( - items: IQuickPickItem[], - placeHolder: string, - apply: (selectedId: string) => Promise, - ): Promise { - const selected = await this.quickInputService.pick(items, { - placeHolder, - canPickMany: false - }); - - if (selected?.id) { - await apply(selected.id); - } - - this.hoverService.hideHover(true); - } - - private async showModelPicker(provider: languages.InlineCompletionsProvider): Promise { - if (!provider.modelInfo || !provider.setModelId) { - return; - } - - const modelInfo = provider.modelInfo; - await this.showQuickPick( - modelInfo.models.map(model => ({ - id: model.id, - label: model.name, - description: model.id === modelInfo.currentModelId ? localize('currentModel.description', "Currently selected") : undefined, - picked: model.id === modelInfo.currentModelId - })), - localize('selectModelFor', "Select a model for {0}", provider.displayName || 'inline completions'), - async (id) => { - if (id !== modelInfo.currentModelId) { - await provider.setModelId!(id); - } - }, - ); - } - - private async showProviderOptionPicker(provider: languages.InlineCompletionsProvider, option: languages.IInlineCompletionProviderOption): Promise { - if (!provider.setProviderOption) { - return; - } - - await this.showQuickPick( - option.values.map(value => ({ - id: value.id, - label: value.label, - description: value.id === option.currentValueId ? localize('currentOption.description', "Currently selected") : undefined, - picked: value.id === option.currentValueId, - })), - localize('selectProviderOptionFor', "Select {0}", option.label), - async (id) => { - if (id !== option.currentValueId) { - await provider.setProviderOption!(option.id, id); - } - }, - ); - } } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index 3e913bb307f009..cf329b280dc118 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -18,10 +18,12 @@ import { IInlineCompletionsService } from '../../../../../editor/browser/service import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { ChatStatusDashboard } from './chatStatusDashboard.js'; import { mainWindow } from '../../../../../base/browser/window.js'; -import { disposableWindowInterval } from '../../../../../base/browser/dom.js'; +import { $ as h, disposableWindowInterval } from '../../../../../base/browser/dom.js'; import { isNewUser } from './chatStatus.js'; import product from '../../../../../platform/product/common/product.js'; import { isCompletionsEnabled } from '../../../../../editor/common/services/completionsEnablement.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribution { @@ -30,6 +32,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu private entry: IStatusbarEntryAccessor | undefined = undefined; private readonly activeCodeEditorListener = this._register(new MutableDisposable()); + private readonly entryAnchor = h('span'); private runningSessionsCount: number; @@ -41,11 +44,38 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu @IConfigurationService private readonly configurationService: IConfigurationService, @IInlineCompletionsService private readonly completionsService: IInlineCompletionsService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IHoverService private readonly hoverService: IHoverService, ) { super(); this.runningSessionsCount = this.chatSessionsService.getInProgress().reduce((total, item) => total + item.count, 0); + this._register(CommandsRegistry.registerCommand('workbench.action.chat.openCopilotStatus', () => { + const target = this.entryAnchor.parentElement; + if (!target) { + return; + } + + const store = new DisposableStore(); + const content = ChatStatusDashboard.instantiateInContents(this.instantiationService, store, undefined); + const hover = this.hoverService.showInstantHover({ + content, + target, + persistence: { hideOnKeyDown: true, sticky: true }, + appearance: { maxHeightRatio: 0.9 }, + }, true); + if (hover) { + store.add(hover); + store.add(disposableWindowInterval(mainWindow, () => { + if (!content.isConnected) { + store.dispose(); + } + }, 2000)); + } else { + store.dispose(); + } + })); + this.update(); this.registerListeners(); @@ -187,6 +217,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu command: ShowTooltipCommand, showInAllWindows: true, kind, + content: this.entryAnchor, tooltip: { element: (token: CancellationToken) => { const store = new DisposableStore(); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusItemService.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusItemService.ts index b87135ccce61ee..bc340b9c69d9a7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusItemService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusItemService.ts @@ -27,7 +27,7 @@ export interface IChatStatusItemChangeEvent { export type ChatStatusEntry = { id: string; - label: string | { label: string; link: string }; + label: string | { label: string; link: string; helpText?: string }; description: string; detail: string | undefined; }; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css index e4b2b58edca4bb..901af5e8efcf5c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css @@ -55,6 +55,38 @@ align-items: center; } +.chat-status-bar-entry-tooltip .collapsible-label { + white-space: nowrap; + flex-shrink: 0; +} + +.chat-status-bar-entry-tooltip .collapsible-status { + margin-left: auto; + font-size: 12px; + font-weight: 400; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.chat-status-bar-entry-tooltip .section-description { + font-size: 12px; + line-height: 16px; + color: var(--vscode-descriptionForeground); +} + +.chat-status-bar-entry-tooltip .section-description .monaco-link { + white-space: nowrap; +} + +.chat-status-bar-entry-tooltip .section-detail { + font-size: 12px; + line-height: 16px; + color: var(--vscode-descriptionForeground); +} + .chat-status-bar-entry-tooltip .collapsible-content { display: grid; grid-template-rows: 1fr; @@ -262,6 +294,12 @@ color: var(--vscode-disabledForeground); } +.chat-status-bar-entry-tooltip .settings .setting .setting-overridden { + font-style: italic; + color: var(--vscode-descriptionForeground); + margin-left: 4px; +} + /* Model Selection */ .chat-status-bar-entry-tooltip .model-selection { @@ -283,6 +321,22 @@ color: var(--vscode-descriptionForeground); } +/* SelectBox containers */ + +.chat-status-bar-entry-tooltip .model-select-container, +.chat-status-bar-entry-tooltip .suggest-option-select-container { + margin-left: auto; + flex-shrink: 0; +} + +.chat-status-bar-entry-tooltip .model-select-container .monaco-select-box, +.chat-status-bar-entry-tooltip .suggest-option-select-container .monaco-select-box { + width: auto; + padding: 2px 23px 2px 8px; + height: 22px; + font-size: 12px; +} + /* Provider Options */ .chat-status-bar-entry-tooltip .suggest-option-selection { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts index ae58cf87b38b80..0958d14a225b20 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts @@ -67,6 +67,7 @@ export class ChatQuotaExceededPart extends Disposable implements IChatContentPar case ChatEntitlement.EDU: case ChatEntitlement.Pro: case ChatEntitlement.ProPlus: + case ChatEntitlement.Max: primaryButtonLabel = localize('enableAdditionalUsage', "Configure Additional Spend"); break; case ChatEntitlement.Free: diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 408ce5c1ab5582..ca0fde45d30b6f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -174,6 +174,7 @@ function shouldShowManageModelsAction(chatEntitlementService: IChatEntitlementSe chatEntitlementService.entitlement === ChatEntitlement.EDU || chatEntitlementService.entitlement === ChatEntitlement.Pro || chatEntitlementService.entitlement === ChatEntitlement.ProPlus || + chatEntitlementService.entitlement === ChatEntitlement.Max || chatEntitlementService.entitlement === ChatEntitlement.Business || chatEntitlementService.entitlement === ChatEntitlement.Enterprise || chatEntitlementService.isInternal; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css index 91c9a30d7b4856..40e88561850f64 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css @@ -8,13 +8,13 @@ display: none; } -/* Counteract the `.interactive-input-part` `gap: 4px` so the notification attaches to the input. */ +/* Counteract the `.interactive-input-part` `gap: 4px` so the notification attaches to the input. Add extra -6px to account for rounded corners. */ .interactive-session .interactive-input-part > .chat-input-notification-container { - margin-bottom: -4px; + margin-bottom: -10px; } .interactive-session .interactive-input-part > .chat-input-notification-container .chat-input-notification { - padding: 12px 16px; + padding: 18px 16px; box-sizing: border-box; border: 1px solid var(--vscode-input-border, transparent); border-bottom: none; diff --git a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts index 124343afa4da9b..c2262ce51c1c20 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts @@ -20,7 +20,7 @@ import { IChatService } from '../../common/chatService/chatService.js'; import { ChatImageMimeType } from '../../common/languageModels.js'; import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, IToolResultDataPart, IToolResultTextPart, ToolDataSource, ToolProgress } from '../../common/tools/languageModelToolsService.js'; import { InternalFetchWebPageToolId } from '../../common/tools/builtinTools/tools.js'; -import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; +import { AgentNetworkFilterFetchWebToolName, IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; export const FetchWebPageToolData: IToolData = { id: InternalFetchWebPageToolId, @@ -290,7 +290,7 @@ export class FetchWebPageTool implements IToolImpl { try { const uriObj = URI.parse(url); if (uriObj.scheme === 'http' || uriObj.scheme === 'https') { - if (!this._agentNetworkFilterService.isUriAllowed(uriObj)) { + if (!this._agentNetworkFilterService.isUriAllowed(uriObj, AgentNetworkFilterFetchWebToolName)) { blockedUris.add(url); } else { webUris.set(url, uriObj); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts new file mode 100644 index 00000000000000..a10e57679f44e8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts @@ -0,0 +1,301 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mainWindow } from '../../../../../base/browser/window.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IInlineCompletionsService } from '../../../../../editor/browser/services/inlineCompletionsService.js'; +import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { ChatStatusDashboard, IChatStatusDashboardOptions } from '../../../chat/browser/chatStatus/chatStatusDashboard.js'; +import { IChatStatusItemService } from '../../../chat/browser/chatStatus/chatStatusItemService.js'; + +interface IQuotaConfig { + percentRemaining: number; + unlimited: boolean; + usageBasedBilling?: boolean; + resetAt?: number; +} + +function createEntitlementService(opts: { + chat?: IQuotaConfig; + completions?: IQuotaConfig; + premiumChat?: IQuotaConfig; + additionalUsageEnabled?: boolean; + entitlement?: ChatEntitlement; +}): IChatEntitlementService { + return { + _serviceBrand: undefined, + organisations: undefined, + isInternal: false, + sku: undefined, + copilotTrackingId: undefined, + onDidChangeQuotaExceeded: Event.None, + onDidChangeQuotaRemaining: Event.None, + quotas: { + chat: opts.chat, + completions: opts.completions, + premiumChat: opts.premiumChat, + additionalUsageEnabled: opts.additionalUsageEnabled, + }, + update: (_token: CancellationToken) => Promise.resolve(), + onDidChangeSentiment: Event.None, + sentimentObs: observableValue({}, {}), + sentiment: { completed: true }, + onDidChangeEntitlement: Event.None, + entitlement: opts.entitlement ?? ChatEntitlement.Free, + entitlementObs: observableValue({}, opts.entitlement ?? ChatEntitlement.Free), + anonymous: false, + onDidChangeAnonymous: Event.None, + anonymousObs: observableValue({}, false), + markAnonymousRateLimited: () => { }, + setForceHidden: () => { }, + previewFeaturesDisabled: false, + clientByokEnabled: false, + } as IChatEntitlementService; +} + +function getQuotaLabels(element: HTMLElement): string[] { + const indicators = element.querySelectorAll('.quota-indicator:not(.included) .quota-title'); + return Array.from(indicators).map(el => el.textContent ?? ''); +} + +function getIncludedLabels(element: HTMLElement): string[] { + const indicators = element.querySelectorAll('.quota-indicator.included .quota-title'); + return Array.from(indicators).map(el => el.textContent ?? ''); +} + +function getQuotaValues(element: HTMLElement): string[] { + const values = element.querySelectorAll('.quota-indicator:not(.included) .quota-value'); + return Array.from(values).map(el => el.textContent ?? ''); +} + +const dashboardOptions: IChatStatusDashboardOptions = { + disableInlineSuggestionsSettings: true, + disableModelSelection: true, + disableProviderOptions: true, + disableCompletionsSnooze: true, +}; + +suite('ChatStatusDashboard', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createDashboard(entitlementService: IChatEntitlementService): ChatStatusDashboard { + const instantiationService = workbenchInstantiationService(undefined, store); + + instantiationService.stub(IChatEntitlementService, entitlementService); + instantiationService.stub(IChatStatusItemService, { + _serviceBrand: undefined, + onDidChange: Event.None, + setOrUpdateEntry: () => { }, + deleteEntry: () => { }, + getEntries: () => [], + }); + instantiationService.stub(IInlineCompletionsService, { + _serviceBrand: undefined, + onDidChangeIsSnoozing: Event.None, + snoozeTimeLeft: 0, + snooze: () => { }, + setSnoozeDuration: () => { }, + }); + instantiationService.stub(IMarkdownRendererService, { + _serviceBrand: undefined, + }); + + const dashboard = store.add(instantiationService.createInstance(ChatStatusDashboard, dashboardOptions)); + + mainWindow.document.body.appendChild(dashboard.element); + store.add({ dispose: () => dashboard.element.remove() }); + + return dashboard; + } + + // --- COPILOT FREE --- + + test('Free — PRU: shows Chat messages and Inline Suggestions', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 80, unlimited: false }, + completions: { percentRemaining: 70, unlimited: false }, + entitlement: ChatEntitlement.Free, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Chat messages', 'Inline Suggestions']); + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['20%', '30%']); + }); + + test('Free — PRU exhausted: shows Chat messages and Inline Suggestions at 0%', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 0, unlimited: false }, + completions: { percentRemaining: 0, unlimited: false }, + entitlement: ChatEntitlement.Free, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Chat messages', 'Inline Suggestions']); + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['100%', '100%']); + }); + + test('Free — TBB: shows Monthly Limit and Inline Suggestions, not Chat messages', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 80, unlimited: false }, + premiumChat: { percentRemaining: 60, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 70, unlimited: false }, + entitlement: ChatEntitlement.Free, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Monthly Limit', 'Inline Suggestions']); + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['40%', '30%']); + }); + + test('Free — TBB exhausted: shows Monthly Limit and Inline Suggestions at 0%', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 0, unlimited: false }, + premiumChat: { percentRemaining: 0, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 0, unlimited: false }, + entitlement: ChatEntitlement.Free, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Monthly Limit', 'Inline Suggestions']); + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['100%', '100%']); + }); + + // --- COPILOT PRO (EDU/Pro) --- + + test('EDU/Pro — PRU: shows Chat messages, Premium requests, and Inline Suggestions', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 80, unlimited: false }, + premiumChat: { percentRemaining: 60, unlimited: false }, + completions: { percentRemaining: 90, unlimited: false }, + entitlement: ChatEntitlement.Pro, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Chat messages', 'Premium requests', 'Inline Suggestions']); + }); + + test('EDU/Pro — TBB: shows only Monthly Limit, not Chat messages or Inline Suggestions', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 80, unlimited: false }, + premiumChat: { percentRemaining: 60, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 90, unlimited: false }, + entitlement: ChatEntitlement.Pro, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Monthly Limit']); + }); + + test('EDU/Pro — TBB exhausted (no overages): shows only Monthly Limit', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 0, unlimited: false }, + premiumChat: { percentRemaining: 0, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 90, unlimited: false }, + additionalUsageEnabled: false, + entitlement: ChatEntitlement.Pro, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Monthly Limit']); + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['100%']); + }); + + test('EDU/Pro — TBB exhausted (with overages): shows only Monthly Limit', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 0, unlimited: false }, + premiumChat: { percentRemaining: 0, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 90, unlimited: false }, + additionalUsageEnabled: true, + entitlement: ChatEntitlement.Pro, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Monthly Limit']); + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['100%']); + }); + + // --- COPILOT PRO+ --- + + test('Pro+ — PRU: shows Premium requests and Inline Suggestions', () => { + const dashboard = createDashboard(createEntitlementService({ + premiumChat: { percentRemaining: 60, unlimited: false }, + completions: { percentRemaining: 90, unlimited: false }, + entitlement: ChatEntitlement.ProPlus, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Premium requests', 'Inline Suggestions']); + }); + + test('Pro+ — TBB with quota: shows only Monthly Limit', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 80, unlimited: false }, + premiumChat: { percentRemaining: 60, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 90, unlimited: false }, + entitlement: ChatEntitlement.ProPlus, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Monthly Limit']); + }); + + test('Pro+ — TBB out of quota: shows only Monthly Limit', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 0, unlimited: false }, + premiumChat: { percentRemaining: 0, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 90, unlimited: false }, + entitlement: ChatEntitlement.ProPlus, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Monthly Limit']); + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['100%']); + }); + + // --- COPILOT MAX --- + + test('Max Yearly — no TBB: shows unlimited Premium Requests included indicator', () => { + const dashboard = createDashboard(createEntitlementService({ + premiumChat: { percentRemaining: 100, unlimited: true }, + completions: { percentRemaining: 100, unlimited: true }, + entitlement: ChatEntitlement.Max, + })); + + // Unlimited quotas are not shown as quota indicators + assert.deepStrictEqual(getQuotaLabels(dashboard.element), []); + // Instead shown as "included" indicator + assert.deepStrictEqual(getIncludedLabels(dashboard.element), ['Premium Requests']); + }); + + test('Max Monthly — TBB: shows unlimited Monthly Limit included indicator', () => { + const dashboard = createDashboard(createEntitlementService({ + premiumChat: { percentRemaining: 100, unlimited: true, usageBasedBilling: true }, + completions: { percentRemaining: 100, unlimited: true }, + entitlement: ChatEntitlement.Max, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), []); + assert.deepStrictEqual(getIncludedLabels(dashboard.element), ['Monthly Limit']); + }); + + // --- BUSINESS / ENTERPRISE --- + + test('Enterprise Managed — PRU: shows Premium requests with unlimited included', () => { + const dashboard = createDashboard(createEntitlementService({ + premiumChat: { percentRemaining: 100, unlimited: true }, + completions: { percentRemaining: 100, unlimited: true }, + entitlement: ChatEntitlement.Business, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), []); + assert.deepStrictEqual(getIncludedLabels(dashboard.element), ['Premium Requests']); + }); + + test('Enterprise — TBB (multi-quota): shows only Monthly Limit, not Chat messages or Inline Suggestions', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 80, unlimited: false }, + premiumChat: { percentRemaining: 60, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 70, unlimited: false }, + entitlement: ChatEntitlement.Enterprise, + })); + + assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Monthly Limit']); + }); +}); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts index 72e5285706a194..fb7d19ea673e57 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts @@ -722,13 +722,6 @@ export class ExtensionEditor extends EditorPane { this.contentDisposables.add(webview.onDidScroll(() => this.initialScrollProgress.set(webviewIndex, webview.initialScrollProgress))); - const removeLayoutParticipant = arrays.insert(this.layoutParticipants, { - layout: () => { - webview.setAnchorElement(container); - } - }); - this.contentDisposables.add(toDisposable(removeLayoutParticipant)); - let isDisposed = false; this.contentDisposables.add(toDisposable(() => { isDisposed = true; })); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 223811f281b917..16b7ff7487b6f5 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -1942,12 +1942,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD } const modalEditorContainer = this.editorGroupsService.activeModalEditorPart?.modalElement; - let clippingContainer: HTMLElement | undefined; - if (DOM.isHTMLElement(modalEditorContainer) && modalEditorContainer.contains(anchorElement)) { - clippingContainer = modalEditorContainer; - } else { - clippingContainer = this.layoutService.getContainer(DOM.getWindow(this.getDomNode()), Parts.EDITOR_PART); - } + const isModal = DOM.isHTMLElement(modalEditorContainer) && modalEditorContainer.contains(anchorElement); + const clippingContainer = isModal ? undefined : this.layoutService.getContainer(DOM.getWindow(this.getDomNode()), Parts.EDITOR_PART); this._overlayContainer.style.visibility = 'visible'; this._overlayLayout.setAnchorElement(anchorElement, { clippingContainer }); diff --git a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts index 2acf7dabc0993b..72514432fcf910 100644 --- a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts +++ b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts @@ -213,10 +213,10 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider options.filter?.(p)); } diff --git a/src/vs/workbench/contrib/search/common/search.ts b/src/vs/workbench/contrib/search/common/search.ts index e7a24b32a0d6a5..7e9d05938b066f 100644 --- a/src/vs/workbench/contrib/search/common/search.ts +++ b/src/vs/workbench/contrib/search/common/search.ts @@ -120,11 +120,11 @@ export async function getWorkspaceSymbols(query: string, token: CancellationToke } export interface IWorkbenchSearchConfigurationProperties extends ISearchConfigurationProperties { - quickOpen: { - includeSymbols: boolean; - includeHistory: boolean; - history: { - filterSortOrder: 'default' | 'recency'; + quickOpen?: { + includeSymbols?: boolean; + includeHistory?: boolean; + history?: { + filterSortOrder?: 'default' | 'recency'; }; }; } diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 081f82e2f1e8de..6b92f350f55d14 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -718,9 +718,20 @@ export async function registerTerminalConfiguration(getFontSnippets: () => Promi Registry.as(WorkbenchExtensions.ConfigurationMigration) .registerConfigurationMigrations([{ key: TerminalContribSettingId.DeprecatedAgentSandboxEnabled, - migrateFn: (value: boolean, valueAccessor) => { + migrateFn: (value: unknown, valueAccessor) => { + // The deprecated key `chat.agent.sandbox` is now also a namespace prefix + // for new settings such as `chat.agent.sandbox.enabled` and + // `chat.agent.sandbox.fileSystem.mac`. As a result, inspecting the + // deprecated key may return an object representing the namespace tree + // (e.g. `{ fileSystem: { mac: {...} } }`) even when the user never set + // the original boolean setting. Only migrate when the value is actually + // the original boolean type and skip writing back undefined to avoid + // clobbering the new sub-settings. + if (typeof value !== 'boolean') { + return []; + } const configurationKeyValuePairs: ConfigurationKeyValuePairs = []; - if (value !== undefined && valueAccessor(TerminalContribSettingId.AgentSandboxEnabled) === undefined) { + if (valueAccessor(TerminalContribSettingId.AgentSandboxEnabled) === undefined) { configurationKeyValuePairs.push([TerminalContribSettingId.AgentSandboxEnabled, { value: value ? 'on' : 'off' }]); } configurationKeyValuePairs.push([TerminalContribSettingId.DeprecatedAgentSandboxEnabled, { value: undefined }]); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index a336d2a497cac6..1a8878c36fd58c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -118,9 +118,22 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; + // Capture any command that is already executing in the terminal at + // strategy entry. We may send ETX (Ctrl+C) below to clear pending + // input from a prior interaction, which kills that prior command + // and produces an `onCommandFinished` event with exit code 130. + // Without this filter, the race below would resolve with the prior + // command's finished event before our new command has even started — + // causing the new command to be reported as having instantly exited + // 130 and cascading to every subsequent command on the same terminal. + const staleExecutingCommand = this._commandDetection.executingCommandObject; + const onCommandFinishedFiltered = staleExecutingCommand + ? Event.filter(this._commandDetection.onCommandFinished, e => e !== staleExecutingCommand, store) + : this._commandDetection.onCommandFinished; + const idlePromptPromise = trackIdleOnPrompt(this._instance, idlePollInterval, store, idlePollInterval, this._logService); const onDone = Promise.race([ - Event.toPromise(this._commandDetection.onCommandFinished, store).then(e => { + Event.toPromise(onCommandFinishedFiltered, store).then(e => { // When shell integration is basic, it means that the end execution event is // often misfired since we don't have command line verification. Because of this // we make sure the prompt is idle after the end execution event happens. @@ -172,10 +185,22 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute ); if (this._hasReceivedUserInput()) { - this._log('Command timed out, sending SIGINT and retrying'); - // Send SIGINT (Ctrl+C) - await this._instance.sendText('\x03', false); - await waitForIdle(this._instance.onData, 100); + // Only send SIGINT (Ctrl+C) when shell integration confirms a previous + // command is still executing. Sending Ctrl+C at an idle prompt can be + // misinterpreted by the shell as cancelling the command we are about + // to send via sendText, producing spurious "Command exited with code + // 130" results for what should be the next, unrelated command. + if (this._commandDetection.executingCommandObject !== undefined) { + this._log('Previous command still executing with pending input, sending SIGINT before retrying'); + await this._instance.sendText('\x03', false); + await waitForIdle(this._instance.onData, 100); + } else { + // Use Ctrl+U (kill line) to clear any pending input on the prompt + // without killing any running command. No-op on a clean prompt. + this._log('Prompt is idle; clearing pending input with Ctrl+U instead of SIGINT'); + await this._instance.sendText('\x15', false); + await waitForIdle(this._instance.onData, 100); + } } // Execute the command diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts index f2d73bf5123998..38fb445e6b6e51 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts @@ -73,9 +73,15 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS ); if (this._hasReceivedUserInput()) { - this._log('Command timed out, sending SIGINT and retrying'); - // Send SIGINT (Ctrl+C) - await this._instance.sendText('\x03', false); + // Use Ctrl+U (kill line) to clear any pending input on the prompt + // rather than Ctrl+C, which would kill any command currently running. + // Without shell integration we cannot reliably detect whether a + // previous command is still executing, and Ctrl+C at an idle prompt + // has been observed to produce spurious "Command exited with code + // 130" results for the next command. Ctrl+U is a no-op on an idle + // prompt. + this._log('Sending Ctrl+U to clear any pending input before sending command'); + await this._instance.sendText('\x15', false); await waitForIdle(this._instance.onData, 100); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index d05c522f474215..0503ad15ceb5a8 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -102,11 +102,24 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; + // Capture any command that is already executing in the terminal at + // strategy entry. `runCommand` (called below) may send ETX (Ctrl+C) + // to clear stale prompt input, which kills that prior command and + // produces an `onCommandFinished` event with exit code 130. Without + // this filter, the race below resolves with the prior command's + // finished event before our new command has even started — causing + // the new command to be reported as having instantly exited 130 and + // cascading to every subsequent command on the same terminal. + const staleExecutingCommand = this._commandDetection.executingCommandObject; + const onCommandFinishedFiltered = staleExecutingCommand + ? Event.filter(this._commandDetection.onCommandFinished, e => e !== staleExecutingCommand, store) + : this._commandDetection.onCommandFinished; + // Subscribe to terminal lifecycle events BEFORE any awaits so that we // don't miss events that fire while we're waiting for xterm to be // ready (e.g. the pty exits during xtermReadyPromise resolution). const onDone = Promise.race([ - Event.toPromise(this._commandDetection.onCommandFinished, store).then(e => { + Event.toPromise(onCommandFinishedFiltered, store).then(e => { this._log('onDone via end event'); return { 'type': 'success', diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts index d5dece36710d97..7d6c4ca8c72150 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts @@ -46,6 +46,14 @@ export class CommandLineBackgroundDetachRewriter extends Disposable implements I return undefined; } + // Skip detach-wrapping for commands that read interactively from stdin. + // `nohup` / `Start-Process` close stdin, which makes these programs hang + // or fail immediately (e.g. `expect`, `gdb`, `psql`, `passwd`). The user + // can still run them in mode='sync' and drive them via send_to_terminal. + if (this._readsFromStdin(options.commandLine)) { + return undefined; + } + if (options.os === OperatingSystem.Windows) { // PowerShell does not have a POSIX-style trailing `&` background operator, // so only rewrite explicit async-mode commands here. @@ -58,6 +66,46 @@ export class CommandLineBackgroundDetachRewriter extends Disposable implements I return this._rewriteForPosix(options); } + /** + * Returns true when the command line invokes a program that is known to + * require an interactive stdin. Detaching such a command would close stdin + * and either hang the program or make it exit with an error. + * + * The check is intentionally conservative — only well-known interactive + * front-ends are matched, and only when their command-line flags do not + * obviously force non-interactive behaviour. + */ + private _readsFromStdin(commandLine: string): boolean { + // Inspect the leading executable of the command line, ignoring leading + // `cd ... && ` / `cd ... ;` / env-var assignments, since those don't + // affect stdin behaviour. + const trimmed = commandLine + .replace(/^\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)+/, '') + .replace(/^\s*cd\s+\S+\s*(?:&&|;)\s*/i, '') + .trimStart(); + // Bare `expect`, `gdb`, `psql` (without `-c`/`-f`), `passwd`, `vi`/`vim`, + // `nano`, `less`, `more`, `top`, `htop`, `ssh` without `-T`, `mysql` + // without `-e`, `sftp`, `ftp`, `telnet`. + if (/^(expect|passwd|vi|vim|nano|less|more|top|htop|sftp|ftp|telnet|gdb|lldb)\b/.test(trimmed)) { + return true; + } + if (/^psql\b/.test(trimmed) && !/\s(-c|-f|--command|--file)\b/.test(trimmed)) { + return true; + } + if (/^mysql\b/.test(trimmed) && !/\s(-e|--execute)\b/.test(trimmed)) { + return true; + } + if (/^ssh\b/.test(trimmed) && !/\s-T\b/.test(trimmed) && !/\sssh\s+\S+\s+\S/.test(' ' + trimmed)) { + // `ssh host` with no command is interactive; `ssh host cmd` runs cmd non-interactively. + return true; + } + // `sudo` without `-n` (non-interactive) may prompt for a password. + if (/^sudo\b/.test(trimmed) && !/\s-n\b/.test(trimmed) && !/\bSUDO_ASKPASS\b/.test(commandLine)) { + return true; + } + return false; + } + private _rewriteForPosix(options: ICommandLineRewriterOptions): ICommandLineRewriterResult { const trimmed = options.commandLine.trimEnd(); 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 42ebdc664a4078..2b40563267f052 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -114,7 +114,8 @@ function createPowerShellModelDescription(shell: string, isSandboxEnabled: boole '- Use Get-Command to verify cmdlet/function availability', '', 'Async Mode:', - '- For long-running tasks (e.g., servers), use mode=async', + '- Use mode=async ONLY for processes that should keep running while you do other work (servers, watchers, dev daemons)', + '- For one-shot long-running commands where you have nothing to do until they finish (package installs, builds, downloads, test suites), use mode=sync with a generous timeout (e.g. 600000 / 10 min for installs, longer for big builds) so the command can complete before your turn ends', '- Returns a terminal ID for checking status and runtime later', '- Use Start-Job for background PowerShell jobs', '', @@ -200,7 +201,8 @@ Program Execution: - Use which or command -v to verify command availability Async Mode: -- For long-running tasks (e.g., servers), use mode=async +- Use mode=async ONLY for processes that should keep running while you do other work (servers, watchers, dev daemons) +- For one-shot long-running commands where you have nothing to do until they finish (package installs, builds, downloads, test suites), use mode=sync with a generous timeout (e.g. 600000 / 10 min for installs, longer for big builds) so the command can complete before your turn ends - Returns a terminal ID for checking status and runtime later Use ${TerminalToolId.SendToTerminal} to send commands or input to a terminal session.`]; @@ -323,7 +325,7 @@ export async function createRunInTerminalToolData( toolReferenceName: TOOL_REFERENCE_NAME, legacyToolReferenceFullNames: LEGACY_TOOL_REFERENCE_FULL_NAMES, displayName: localize('runInTerminalTool.displayName', 'Run in Terminal'), - modelDescription: `${modelDescription}\n\nExecution mode:\n- mode='sync': wait for completion (optionally capped by timeout); if still running when timeout elapses, return with a terminal ID.\n- mode='async': wait for an initial idle/output signal, then return with terminal output snapshot and ID. Timeout caps how long to wait for the initial idle/output signal.\n- Prefer mode='sync' for commands that will prompt for interactive input (e.g., npm init, interactive installers, configuration wizards).\n\nTimeout parameter: Only set 'timeout' when you want a hard cap on how long the tool tracks the command. Omit it to let the command run to completion. Package installs, builds, and long-running scripts should usually omit the timeout rather than guessing a value.\n\nTerminal notifications: When an async command finishes or a sync command times out, you will be automatically notified on your next turn with the exit code and terminal output. You will also be notified if the terminal needs input. Do NOT poll or sleep to wait for completion.`, + modelDescription: `${modelDescription}\n\nExecution mode:\n- mode='sync': wait for completion (optionally capped by timeout); if still running when timeout elapses, return with a terminal ID.\n- mode='async': wait for an initial idle/output signal, then return with terminal output snapshot and ID. Timeout caps how long to wait for the initial idle/output signal.\n- Prefer mode='sync' for commands that will prompt for interactive input (e.g., npm init, interactive installers, configuration wizards).\n\nTimeout parameter: For one-shot long-running commands, set a generous timeout as a safety net (e.g. 600000 for installs, longer for big builds). Omit timeout only for processes that should run indefinitely (servers, daemons). If the timeout elapses, you get a terminal ID and can check output later.\n\nTerminal notifications: When an async command finishes or a sync command times out, you will be automatically notified on your next turn with the exit code and terminal output. You will also be notified if the terminal needs input. Do NOT poll or sleep to wait for completion.`, userDescription: localize('runInTerminalTool.userDescription', 'Run commands in the terminal'), source: ToolDataSource.Internal, icon: Codicon.terminal, @@ -1678,9 +1680,16 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const resultText: string[] = []; if (!didSandboxWrapCommand) { if (didUserEditCommand) { - resultText.push(`Note: The user manually edited the command to \`${command}\`, and this is the output of running that command instead:\n`); + resultText.push(`Note: The user manually edited the command to \`${command}\` (terminal ID=${termId}), and this is the output of running that command instead:\n`); } else if (didToolEditCommand) { - resultText.push(`Note: The tool simplified the command to \`${command}\`, and this is the output of running that command instead:\n`); + // If the tool wrapped the command with `nohup` (POSIX) or `Start-Process` + // (Windows) to detach a background process, stdin is no longer connected. + // Tell the model so it does not try to drive interactive programs through it. + const wasDetachedToBackground = /(^|\s)nohup\s|Start-Process\b/.test(command); + const stdinHint = wasDetachedToBackground + ? ' Note that stdin is closed for detached background processes; do not try to send input via send_to_terminal — re-run with mode="sync" instead if interactive input is required.' + : ''; + resultText.push(`Note: The tool simplified the command to \`${command}\` (terminal ID=${termId}).${stdinHint} This is the output of running that command instead:\n`); } if (isBackgroundExecution && !executionOptions.persistentSession) { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts index 0dbd2ca5e5e454..bf86372512ebb4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts @@ -257,4 +257,60 @@ suite('CommandLineBackgroundDetachRewriter', () => { strictEqual(rewriter.rewrite(createOptions('echo hello', 'cmd.exe', OperatingSystem.Windows, true)), undefined); }); }); + + suite('Interactive front-end skip', () => { + const interactives = [ + 'expect setup_vm.exp', + 'gdb ./a.out', + 'lldb ./a.out', + 'passwd', + 'vim file.txt', + 'nano notes.md', + 'less /var/log/syslog', + 'sftp user@host', + 'telnet host 23', + 'psql', + 'psql mydb', + 'mysql -u root', + 'ssh user@host', + 'sudo apt-get install -y foo', + ]; + for (const cmd of interactives) { + test(`should skip detach-wrap for interactive: ${cmd}`, () => { + strictEqual(rewriter.rewrite(createOptions(cmd, '/bin/bash', OperatingSystem.Linux, true)), undefined); + }); + } + + test('should still wrap psql when -c is passed (non-interactive)', () => { + deepStrictEqual(rewriter.rewrite(createOptions('psql -c "select 1"', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup psql -c "select 1" &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'psql -c "select 1"', + }); + }); + + test('should still wrap mysql when -e is passed (non-interactive)', () => { + deepStrictEqual(rewriter.rewrite(createOptions('mysql -e "show databases"', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup mysql -e "show databases" &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'mysql -e "show databases"', + }); + }); + + test('should still wrap ssh when running a remote command (non-interactive)', () => { + deepStrictEqual(rewriter.rewrite(createOptions('ssh -T user@host', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup ssh -T user@host &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'ssh -T user@host', + }); + }); + + test('should still wrap sudo when -n is passed (non-interactive)', () => { + deepStrictEqual(rewriter.rewrite(createOptions('sudo -n systemctl restart nginx', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup sudo -n systemctl restart nginx &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'sudo -n systemctl restart nginx', + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts index 7e4187c0d326a4..8e5219e85ff461 100644 --- a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts @@ -14,13 +14,14 @@ import { localize } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { DisablementReason, IUpdateService, State, StateType } from '../../../../platform/update/common/update.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { InEditorZenModeContext } from '../../../common/contextkeys.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IChatService } from '../../chat/common/chatService/chatService.js'; import { computeProgressPercent } from '../common/updateUtils.js'; @@ -46,7 +47,7 @@ registerAction2(class UpdateIndicatorTitleBarAction extends Action2 { menu: [{ id: MenuId.TitleBarAdjacentCenter, order: 0, - when: UPDATE_TITLE_BAR_CONTEXT, + when: ContextKeyExpr.and(UPDATE_TITLE_BAR_CONTEXT, InEditorZenModeContext.negate()), }] }); } diff --git a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts index dd36822808a269..67950c3a04c271 100644 --- a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts +++ b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts @@ -191,9 +191,6 @@ export class WebviewEditor extends EditorPane { const isModal = isHTMLElement(modalEditorContainer) && this._element && modalEditorContainer.contains(this._element); this._clippingContainer = isModal ? undefined : this._workbenchLayoutService.getContainer(this.window, Parts.EDITOR_PART); - // When shown in a modal editor, the webview overlay must sit above the modal layer - input.webview.container.style.zIndex = isModal ? '2541' : ''; // One over the modal z-index - this._webviewVisibleDisposables.clear(); // Webviews are not part of the normal editor dom, so we have to register our own drag and drop handler on them. diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts index 40e38cd8e0699d..79eae7a197b2a5 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts @@ -263,7 +263,7 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ steps: [ createCopilotSetupStep('CopilotSetupAnonymous', CopilotAnonymousButton, 'chatAnonymous && !chatSetupCompleted', true), createCopilotSetupStep('CopilotSetupSignedOut', CopilotSignedOutButton, 'chatEntitlementSignedOut && !chatAnonymous', false), - createCopilotSetupStep('CopilotSetupComplete', CopilotCompleteButton, 'chatSetupCompleted && !chatSetupDisabled && (chatAnonymous || chatPlanPro || chatPlanProPlus || chatPlanBusiness || chatPlanEnterprise || chatPlanFree)', false), + createCopilotSetupStep('CopilotSetupComplete', CopilotCompleteButton, 'chatSetupCompleted && !chatSetupDisabled && (chatAnonymous || chatPlanPro || chatPlanProPlus || chatPlanMax || chatPlanBusiness || chatPlanEnterprise || chatPlanFree)', false), createCopilotSetupStep('CopilotSetupSignedIn', CopilotSignedInButton, '!chatEntitlementSignedOut && (!chatSetupCompleted || chatSetupDisabled || chatPlanCanSignUp)', false), { id: 'pickColorTheme', diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 644f0b2095742d..24496d8b287565 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -24,7 +24,7 @@ import { ITelemetryService } from '../../../platform/telemetry/common/telemetry. import { IWorkspaceContextService, WorkbenchState } from '../../../platform/workspace/common/workspace.js'; import { ToggleTitleBarConfigAction, TitleBarLeadingActionsGroup } from '../../browser/parts/titlebar/titlebarActions.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../common/contributions.js'; -import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../common/contextkeys.js'; +import { InEditorZenModeContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../common/contextkeys.js'; import { workbenchConfigurationNodeBase } from '../../common/configuration.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; import { ChatEntitlementContextKeys } from '../../services/chat/common/chatEntitlementService.js'; @@ -49,6 +49,7 @@ const OpenInAgentsVisibility = ContextKeyExpr.and( ContextKeyExpr.equals(`config.${OpenInAgentsEnabledSetting}`, true), IsSessionsWindowContext.toNegated(), IsAuxiliaryWindowContext.toNegated(), + InEditorZenModeContext.negate(), // Hide whenever the user has signaled (or policy/workspace trust dictates) // that AI features should not be shown in this window/workspace. ChatEntitlementContextKeys.Setup.hidden.negate(), diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index acdbe38f7dae8d..c223ae7fb65908 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -54,6 +54,7 @@ export namespace ChatEntitlementContextKeys { planPro: new RawContextKey('chatPlanPro', false, true), // True when user is a chat pro user. planEdu: new RawContextKey('chatPlanEdu', false, true), // True when user is a chat edu user. planProPlus: new RawContextKey('chatPlanProPlus', false, true), // True when user is a chat pro plus user. + planMax: new RawContextKey('chatPlanMax', false, true), // True when user is a chat max user. planBusiness: new RawContextKey('chatPlanBusiness', false, true), // True when user is a chat business user. planEnterprise: new RawContextKey('chatPlanEnterprise', false, true), // True when user is a chat enterprise user. @@ -91,6 +92,8 @@ export enum ChatEntitlement { Business = 8, /** Signed-up to Enterprise */ Enterprise = 9, + /** Signed-up to Max */ + Max = 11, } export interface IChatSentiment { @@ -207,6 +210,7 @@ export function isProUser(chatEntitlement: ChatEntitlement): boolean { return chatEntitlement === ChatEntitlement.EDU || chatEntitlement === ChatEntitlement.Pro || chatEntitlement === ChatEntitlement.ProPlus || + chatEntitlement === ChatEntitlement.Max || chatEntitlement === ChatEntitlement.Business || chatEntitlement === ChatEntitlement.Enterprise; } @@ -224,6 +228,8 @@ export function getChatPlanName(chatEntitlement: ChatEntitlement): string { return localize('plan.proName', 'Copilot Pro'); case ChatEntitlement.ProPlus: return localize('plan.proPlusName', 'Copilot Pro+'); + case ChatEntitlement.Max: + return localize('plan.maxName', 'Copilot Max'); case ChatEntitlement.Business: return localize('plan.businessName', 'Copilot Business'); case ChatEntitlement.Enterprise: @@ -324,6 +330,7 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme ChatEntitlementContextKeys.Entitlement.planBusiness.key, ChatEntitlementContextKeys.Entitlement.planEnterprise.key, ChatEntitlementContextKeys.Entitlement.planProPlus.key, + ChatEntitlementContextKeys.Entitlement.planMax.key, ChatEntitlementContextKeys.Entitlement.planFree.key, ChatEntitlementContextKeys.Entitlement.canSignUp.key, ChatEntitlementContextKeys.Entitlement.signedOut.key, @@ -384,6 +391,8 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme return ChatEntitlement.Enterprise; } else if (this.contextKeyService.getContextKeyValue(ChatEntitlementContextKeys.Entitlement.planProPlus.key) === true) { return ChatEntitlement.ProPlus; + } else if (this.contextKeyService.getContextKeyValue(ChatEntitlementContextKeys.Entitlement.planMax.key) === true) { + return ChatEntitlement.Max; } else if (this.contextKeyService.getContextKeyValue(ChatEntitlementContextKeys.Entitlement.planFree.key) === true) { return ChatEntitlement.Free; } else if (this.contextKeyService.getContextKeyValue(ChatEntitlementContextKeys.Entitlement.canSignUp.key) === true) { @@ -729,6 +738,8 @@ export class ChatEntitlementRequests extends Disposable { entitlement = ChatEntitlement.Pro; } else if (entitlementsData.copilot_plan === 'individual_pro') { entitlement = ChatEntitlement.ProPlus; + } else if (entitlementsData.copilot_plan === 'individual_max') { + entitlement = ChatEntitlement.Max; } else if (entitlementsData.copilot_plan === 'business') { entitlement = ChatEntitlement.Business; } else if (entitlementsData.copilot_plan === 'enterprise') { @@ -1052,6 +1063,7 @@ export class ChatEntitlementContext extends Disposable { private readonly eduContextKey: IContextKey; private readonly proContextKey: IContextKey; private readonly proPlusContextKey: IContextKey; + private readonly maxContextKey: IContextKey; private readonly businessContextKey: IContextKey; private readonly enterpriseContextKey: IContextKey; @@ -1093,6 +1105,7 @@ export class ChatEntitlementContext extends Disposable { this.eduContextKey = ChatEntitlementContextKeys.Entitlement.planEdu.bindTo(contextKeyService); this.proContextKey = ChatEntitlementContextKeys.Entitlement.planPro.bindTo(contextKeyService); this.proPlusContextKey = ChatEntitlementContextKeys.Entitlement.planProPlus.bindTo(contextKeyService); + this.maxContextKey = ChatEntitlementContextKeys.Entitlement.planMax.bindTo(contextKeyService); this.businessContextKey = ChatEntitlementContextKeys.Entitlement.planBusiness.bindTo(contextKeyService); this.enterpriseContextKey = ChatEntitlementContextKeys.Entitlement.planEnterprise.bindTo(contextKeyService); @@ -1236,6 +1249,7 @@ export class ChatEntitlementContext extends Disposable { this.eduContextKey.set(state.entitlement === ChatEntitlement.EDU); this.proContextKey.set(state.entitlement === ChatEntitlement.Pro); this.proPlusContextKey.set(state.entitlement === ChatEntitlement.ProPlus); + this.maxContextKey.set(state.entitlement === ChatEntitlement.Max); this.businessContextKey.set(state.entitlement === ChatEntitlement.Business); this.enterpriseContextKey.set(state.entitlement === ChatEntitlement.Enterprise); diff --git a/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts b/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts index b03afe16ca16df..ccbbd8773f31bb 100644 --- a/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts +++ b/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts @@ -14,7 +14,7 @@ declare module 'vscode' { /** * The main name of the entry, like 'Indexing Status' */ - title: string | { label: string; link: string }; + title: string | { label: string; link: string; helpText?: string }; /** * Optional additional description of the entry. diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 9cf13ba0e89c01..1ddb6453874c00 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -1292,7 +1292,7 @@ export class TestContext { await new Promise((resolve, reject) => { app.stderr.on('data', (data) => { const text = `[${name}] ${data.toString().trim()}`; - if (/ECONNRESET|ECONNABORTED/.test(text)) { + if (/ECONNRESET|ECONNABORTED|ECANCELED|EPIPE|SIGPIPE/.test(text)) { this.log(text); } else { reject(new Error(text)); diff --git a/test/sanity/src/uiTest.ts b/test/sanity/src/uiTest.ts index bdad4473840d1e..931f9732db7a5f 100644 --- a/test/sanity/src/uiTest.ts +++ b/test/sanity/src/uiTest.ts @@ -78,7 +78,7 @@ export class UITest { this.context.log('Dismissing welcome dialog (if shown)'); const closeButton = page.locator('button.onboarding-a-close-btn'); try { - await closeButton.waitFor({ state: 'visible', timeout: 5_000 }); + await closeButton.waitFor({ state: 'visible', timeout: 8_000 }); } catch { this.context.log('Welcome dialog not shown, continuing'); return;