Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import { IToolsService } from '../../../tools/common/toolsService';
type ExitPlanModeActionType = Parameters<NonNullable<SessionOptions['onExitPlanMode']>>[0]['actions'][number];

const actionDescriptions: Record<ExitPlanModeActionType, { label: string; description: string }> = {
'autopilot': { label: 'Autopilot', description: l10n.t('Auto-approve all tool calls and continue until the task is done') },
'interactive': { label: 'Approve and Implement', description: l10n.t('Let the agent continue in interactive mode, asking for input and approval for each action.') },
'exit_only': { label: 'Approve', description: l10n.t('Approve plan, but do not execute the plan. I will execute the plan myself.') },
'autopilot_fleet': { label: 'Autopilot Fleet', description: l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.') },
'autopilot': { label: l10n.t("Implement with Autopilot"), description: l10n.t('Auto-approve all tool calls and continue until the task is done.') },
'autopilot_fleet': { label: l10n.t("Implement with Autopilot Fleet"), description: l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.') },
'interactive': { label: l10n.t("Implement Plan"), description: l10n.t('Implement the plan, asking for input and approval for each action.') },
'exit_only': { label: l10n.t("Approve Plan Only"), description: l10n.t('Approve the plan without executing it. I will implement it myself.') },
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,43 +197,43 @@ describe('handleExitPlanMode', () => {
});

it('returns approved with selected action mapped from label', async () => {
toolService.setResult({ rejected: false, action: 'Autopilot' });
toolService.setResult({ rejected: false, action: 'Implement with Autopilot' });
const event = makeEvent();
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'autopilot', autoApproveEdits: undefined });
});

it('maps "Approve and exit" label to exit_only', async () => {
toolService.setResult({ rejected: false, action: 'Approve' });
it('maps "Approve Plan Only" label to exit_only', async () => {
toolService.setResult({ rejected: false, action: 'Approve Plan Only' });
const event = makeEvent();
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
expect(result.selectedAction).toBe('exit_only');
});

it('sets autoApproveEdits when permissionLevel is autoApprove', async () => {
toolService.setResult({ rejected: false, action: 'Interactive' });
toolService.setResult({ rejected: false, action: 'Implement Plan' });
const event = makeEvent();
const result = await handleExitPlanMode(event, session as unknown as Session, 'autoApprove', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
expect(result.autoApproveEdits).toBe(true);
});

it('does not set autoApproveEdits when permissionLevel is interactive', async () => {
toolService.setResult({ rejected: false, action: 'Interactive' });
toolService.setResult({ rejected: false, action: 'Implement Plan' });
const event = makeEvent();
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
expect(result.autoApproveEdits).toBeUndefined();
});

it('passes actions with labels and recommended flag to tool', async () => {
toolService.setResult({ rejected: false, action: 'Interactive' });
toolService.setResult({ rejected: false, action: 'Implement Plan' });
const event = makeEvent({ actions: ['autopilot', 'exit_only'], recommendedAction: 'exit_only' });
await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
const call = toolService.invokeToolCalls[0];
expect(call.name).toBe('vscode_reviewPlan');
const input = call.input as any;
expect(input.actions).toHaveLength(2);
expect(input.actions[0]).toEqual(expect.objectContaining({ label: 'Autopilot', default: false }));
expect(input.actions[1]).toEqual(expect.objectContaining({ label: 'Approve', default: true }));
expect(input.actions[0]).toEqual(expect.objectContaining({ label: 'Implement with Autopilot', default: false }));
expect(input.actions[1]).toEqual(expect.objectContaining({ label: 'Approve Plan Only', default: true }));
});

it('includes plan path in tool input when plan path exists', async () => {
Expand Down
8 changes: 6 additions & 2 deletions src/vs/platform/actionWidget/browser/actionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1366,9 +1366,10 @@ export class ActionListWidget<T> extends Disposable {
}
for (let ci = 0; ci < group.actions.length; ci++) {
const child = group.actions[ci];
const icon = (child as IAction & { icon?: ThemeIcon }).icon
const extendedChild = child as IAction & { icon?: ThemeIcon; hoverContent?: string; onRemove?: () => void };
const icon = extendedChild.icon
?? ThemeIcon.fromId(child.checked ? Codicon.check.id : Codicon.blank.id);
const hoverContent = (child as IAction & { hoverContent?: string }).hoverContent;
const hoverContent = extendedChild.hoverContent;
submenuItems.push({
item: child,
kind: ActionListItemKind.Action,
Expand All @@ -1377,6 +1378,7 @@ export class ActionListWidget<T> extends Disposable {
group: { title: '', icon },
hideIcon: false,
hover: hoverContent ? { content: hoverContent } : {},
onRemove: extendedChild.onRemove,
});
}
if (gi < groupsWithActions.length - 1) {
Expand All @@ -1386,6 +1388,7 @@ export class ActionListWidget<T> extends Disposable {
// Also include non-SubmenuAction items directly
for (const action of element.submenuActions!) {
if (!(action instanceof SubmenuAction)) {
const extendedAction = action as IAction & { onRemove?: () => void };
submenuItems.push({
item: action,
kind: ActionListItemKind.Action,
Expand All @@ -1394,6 +1397,7 @@ export class ActionListWidget<T> extends Disposable {
group: { title: '' },
hideIcon: false,
hover: {},
onRemove: extendedAction.onRemove,
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { Event } from '../../../base/common/event.js';
import { URI } from '../../../base/common/uri.js';
import type { ISSHRemoteAgentHostService, ISSHAgentHostConnection, ISSHAgentHostConfig, ISSHConnectProgress, ISSHResolvedConfig } from '../common/sshRemoteAgentHost.js';

/**
Expand All @@ -26,6 +27,14 @@ export class NullSSHRemoteAgentHostService implements ISSHRemoteAgentHostService
return [];
}

async ensureUserSSHConfig(): Promise<URI> {
throw new Error('SSH is not supported in the browser.');
}

async listSSHConfigFiles(): Promise<URI[]> {
return [];
}

async resolveSSHConfig(_host: string): Promise<ISSHResolvedConfig> {
throw new Error('SSH is not supported in the browser.');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS
return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly };
}
const decoded = this._decodeUri(resource);
if (decoded.scheme === 'session-db') {
if (decoded.scheme === 'session-db' || decoded.scheme === 'git-blob') {
return { type: FileType.File, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly };
}

Expand Down
24 changes: 24 additions & 0 deletions src/vs/platform/agentHost/common/sshRemoteAgentHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { Event } from '../../../base/common/event.js';
import { IDisposable } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';

export const ISSHRemoteAgentHostService = createDecorator<ISSHRemoteAgentHostService>('sshRemoteAgentHostService');
Expand Down Expand Up @@ -107,6 +108,20 @@ export interface ISSHRemoteAgentHostService {
/** List SSH config host aliases (excluding wildcards). */
listSSHConfigHosts(): Promise<string[]>;

/**
* Ensure `~/.ssh/config` exists (creating it with the right permissions if
* missing) and return its URI. The parent `~/.ssh` directory is created
* with mode 0700 and the config file with mode 0600 on POSIX systems.
*/
ensureUserSSHConfig(): Promise<URI>;

/**
* List the known SSH configuration file URIs in priority order — typically the
* per-user `~/.ssh/config` (always returned, even if it does not yet exist) and
* the system-wide `/etc/ssh/ssh_config` (only when present on disk).
*/
listSSHConfigFiles(): Promise<URI[]>;

/** Resolve full SSH config for a host via `ssh -G`. */
resolveSSHConfig(host: string): Promise<ISSHResolvedConfig>;

Expand Down Expand Up @@ -202,6 +217,15 @@ export interface ISSHRemoteAgentHostMainService {
/** List SSH config host aliases (excluding wildcards). */
listSSHConfigHosts(): Promise<string[]>;

/**
* Ensure `~/.ssh/config` exists (creating it with the right permissions if
* missing) and return its URI.
*/
ensureUserSSHConfig(): Promise<URI>;

/** List the known SSH configuration file URIs (user config always included). */
listSSHConfigFiles(): Promise<URI[]>;

/** Resolve full SSH config for a host via `ssh -G`. */
resolveSSHConfig(host: string): Promise<ISSHResolvedConfig>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { ISharedProcessService } from '../../ipc/electron-browser/services.js';
Expand Down Expand Up @@ -91,6 +92,14 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
return this._mainService.listSSHConfigHosts();
}

async ensureUserSSHConfig(): Promise<URI> {
return this._mainService.ensureUserSSHConfig();
}

async listSSHConfigFiles(): Promise<URI[]> {
return this._mainService.listSSHConfigFiles();
}

async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {
return this._mainService.resolveSSHConfig(host);
}
Expand Down
Loading
Loading