Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type { PermissionRequest } from '@github/copilot-sdk';
7
import { hasKey } from '../../../../base/common/types.js';
8
import { URI } from '../../../../base/common/uri.js';
9
import { appendEscapedMarkdownInlineCode, escapeMarkdownLinkLabel } from '../../../../base/common/htmlContent.js';
10
import { hash } from '../../../../base/common/hash.js';
11
import { localize } from '../../../../nls.js';
12
import type { IAgentToolPendingConfirmationSignal } from '../../common/agentService.js';
13
import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js';
14
import { StringOrMarkdown } from '../../common/state/protocol/state.js';
15
import { basename } from '../../../../base/common/resources.js';
16
17
// =============================================================================
18
// Copilot CLI built-in tool interfaces
19
//
20
// The Copilot CLI (via @github/copilot) exposes these built-in tools. Tool names
21
// and parameter shapes are not typed in the SDK -- they come from the CLI server
22
// as plain strings. These interfaces are derived from observing the CLI's actual
23
// tool events and the ShellConfig class in @github/copilot.
24
//
25
// Shell tool names follow a pattern per ShellConfig:
26
// shellToolName, readShellToolName, writeShellToolName,
27
// stopShellToolName, listShellsToolName
28
// For bash: bash, read_bash, write_bash, bash_shutdown, list_bash
29
// For powershell: powershell, read_powershell, write_powershell, list_powershell
30
// =============================================================================
31
32
/**
33
* Known Copilot CLI tool names. These are the `toolName` values that appear
34
* in `tool.execution_start` events from the SDK.
35
*/
36
const enum CopilotToolName {
37
Bash = 'bash',
38
ReadBash = 'read_bash',
39
WriteBash = 'write_bash',
40
BashShutdown = 'bash_shutdown',
41
ListBash = 'list_bash',
42
43
PowerShell = 'powershell',
44
ReadPowerShell = 'read_powershell',
45
WritePowerShell = 'write_powershell',
46
ListPowerShell = 'list_powershell',
47
48
View = 'view',
49
Edit = 'edit',
50
Create = 'create',
51
Grep = 'grep',
52
Glob = 'glob',
53
ApplyPatch = 'apply_patch',
54
GitApplyPatch = 'git_apply_patch',
55
WebSearch = 'web_search',
56
WebFetch = 'web_fetch',
57
AskUser = 'ask_user',
58
ReportIntent = 'report_intent',
59
Skill = 'skill',
60
ExitPlanMode = 'exit_plan_mode',
61
}
62
63
/** Parameters for the `bash` / `powershell` shell tools. */
64
interface ICopilotShellToolArgs {
65
command: string;
66
timeout?: number;
67
}
68
69
/** Parameters for file tools (`view`, `edit`, `create`). */
70
interface ICopilotFileToolArgs {
71
path: string;
72
}
73
74
/**
75
* Parameters for the `view` tool. The Copilot CLI accepts an optional
76
* `view_range: [startLine, endLine]` (1-based, inclusive). `endLine` may be
77
* `-1` to mean "to end of file".
78
*/
79
interface ICopilotViewToolArgs extends ICopilotFileToolArgs {
80
view_range?: number[];
81
}
82
83
/**
84
* Normalizes a `view_range` array. Returns `undefined` unless the array has
85
* exactly two integer elements with `startLine >= 0`. `endLine === -1` is
86
* preserved as the "to end of file" sentinel; otherwise `endLine` must be
87
* `>= startLine`.
88
*/
89
function formatViewRange(view_range: number[] | undefined): { startLine: number; endLine: number } | undefined {
90
if (!Array.isArray(view_range) || view_range.length !== 2) {
91
return undefined;
92
}
93
const [startLine, endLine] = view_range;
94
if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) {
95
return undefined;
96
}
97
if (startLine < 0) {
98
return undefined;
99
}
100
if (endLine !== -1 && endLine < startLine) {
101
return undefined;
102
}
103
return { startLine, endLine };
104
}
105
106
/** Parameters for the `grep` tool. */
107
interface ICopilotGrepToolArgs {
108
pattern: string;
109
path?: string;
110
include?: string;
111
}
112
113
/** Parameters for the `glob` tool. */
114
interface ICopilotGlobToolArgs {
115
pattern: string;
116
path?: string;
117
}
118
119
/** Set of tool names that perform file edits. */
120
const EDIT_TOOL_NAMES: ReadonlySet<string> = new Set([
121
CopilotToolName.Edit,
122
CopilotToolName.Create,
123
CopilotToolName.ApplyPatch,
124
CopilotToolName.GitApplyPatch,
125
]);
126
127
/**
128
* Returns true if the tool modifies files on disk.
129
*/
130
export function isEditTool(toolName: string): boolean {
131
return EDIT_TOOL_NAMES.has(toolName);
132
}
133
134
/**
135
* Extracts the target file path from an edit tool's parameters, if available.
136
*/
137
export function getEditFilePath(parameters: unknown): string | undefined {
138
if (typeof parameters === 'string') {
139
try {
140
parameters = JSON.parse(parameters);
141
} catch {
142
return undefined;
143
}
144
}
145
146
const args = parameters as ICopilotFileToolArgs | undefined;
147
return args?.path;
148
}
149
150
/** Set of tool names that execute shell commands (bash or powershell). */
151
const SHELL_TOOL_NAMES: ReadonlySet<string> = new Set([
152
CopilotToolName.Bash,
153
CopilotToolName.PowerShell,
154
]);
155
156
/** Set of tool names that write input to an interactive shell session. */
157
const WRITE_SHELL_TOOL_NAMES: ReadonlySet<string> = new Set([
158
CopilotToolName.WriteBash,
159
CopilotToolName.WritePowerShell,
160
]);
161
162
/** Set of tool names that read output from an interactive shell session. */
163
const READ_SHELL_TOOL_NAMES: ReadonlySet<string> = new Set([
164
CopilotToolName.ReadBash,
165
CopilotToolName.ReadPowerShell,
166
]);
167
168
/** Set of tool names that spawn subagent sessions. */
169
const SUBAGENT_TOOL_NAMES: ReadonlySet<string> = new Set([
170
'task',
171
]);
172
173
/**
174
* Tools that should not be shown to the user. These are internal tools
175
* used by the CLI for its own purposes (e.g., reporting intent to the model).
176
*
177
* `skill` is hidden because the SDK already emits a richer `skill.invoked`
178
* lifecycle event with the resolved skill file path; the agent session
179
* synthesizes a tool-start/complete pair from that event so the UI can
180
* render a clickable file link instead of just the skill name. See
181
* {@link synthesizeSkillToolCall}.
182
*/
183
const HIDDEN_TOOL_NAMES: ReadonlySet<string> = new Set([
184
CopilotToolName.ReportIntent,
185
CopilotToolName.Skill,
186
]);
187
188
/**
189
* Returns true if the tool should be hidden from the UI.
190
*/
191
export function isHiddenTool(toolName: string): boolean {
192
return HIDDEN_TOOL_NAMES.has(toolName);
193
}
194
195
/**
196
* Returns true if the tool executes shell commands.
197
*/
198
export function isShellTool(toolName: string): boolean {
199
return SHELL_TOOL_NAMES.has(toolName);
200
}
201
202
// =============================================================================
203
// Display helpers
204
//
205
// These functions translate Copilot CLI tool names and arguments into
206
// human-readable display strings. This logic lives here -- in the agent-host
207
// process -- so the IPC protocol stays agent-agnostic; the renderer never needs
208
// to know about specific tool names.
209
// =============================================================================
210
211
function truncate(text: string, maxLength: number): string {
212
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
213
}
214
215
/**
216
* Formats a file path as a markdown link `[](file-uri)` so it renders
217
* as a clickable file widget in the chat UI.
218
*/
219
function formatPathAsMarkdownLink(path: string): string {
220
const uri = URI.file(path);
221
return `[${basename(uri)}](${uri})`;
222
}
223
224
/**
225
* Wraps a localized message containing a markdown file link into a
226
* `StringOrMarkdown` object so the renderer treats it as markdown.
227
*/
228
function md(value: string): StringOrMarkdown {
229
return { markdown: value };
230
}
231
232
export function getToolDisplayName(toolName: string): string {
233
switch (toolName) {
234
case CopilotToolName.Bash: return localize('toolName.bash', "Bash");
235
case CopilotToolName.PowerShell: return localize('toolName.powershell', "PowerShell");
236
case CopilotToolName.ReadBash:
237
case CopilotToolName.ReadPowerShell: return localize('toolName.readShell', "Read Shell Output");
238
case CopilotToolName.WriteBash:
239
case CopilotToolName.WritePowerShell: return localize('toolName.writeShell', "Write Shell Input");
240
case CopilotToolName.BashShutdown: return localize('toolName.bashShutdown', "Stop Shell");
241
case CopilotToolName.ListBash:
242
case CopilotToolName.ListPowerShell: return localize('toolName.listShells', "List Shells");
243
case CopilotToolName.View: return localize('toolName.view', "View File");
244
case CopilotToolName.Edit: return localize('toolName.edit', "Edit File");
245
case CopilotToolName.Create: return localize('toolName.create', "Create File");
246
case CopilotToolName.Grep: return localize('toolName.grep', "Search");
247
case CopilotToolName.Glob: return localize('toolName.glob', "Find Files");
248
case CopilotToolName.ApplyPatch:
249
case CopilotToolName.GitApplyPatch: return localize('toolName.patch', "Patch");
250
case CopilotToolName.WebSearch: return localize('toolName.webSearch', "Web Search");
251
case CopilotToolName.WebFetch: return localize('toolName.webFetch', "Web Fetch");
252
case CopilotToolName.AskUser: return localize('toolName.askUser', "Ask User");
253
case CopilotToolName.ExitPlanMode: return localize('toolName.exitPlanMode', "Plan");
254
default: return toolName;
255
}
256
}
257
258
export function getInvocationMessage(toolName: string, displayName: string, parameters: Record<string, unknown> | undefined): StringOrMarkdown {
259
if (SHELL_TOOL_NAMES.has(toolName)) {
260
const args = parameters as ICopilotShellToolArgs | undefined;
261
if (args?.command) {
262
const firstLine = args.command.split('\n')[0];
263
return md(localize('toolInvoke.shellCmd', "Running {0}", appendEscapedMarkdownInlineCode(truncate(firstLine, 80))));
264
}
265
return localize('toolInvoke.shell', "Running {0} command", displayName);
266
}
267
268
if (WRITE_SHELL_TOOL_NAMES.has(toolName)) {
269
const args = parameters as ICopilotShellToolArgs | undefined;
270
if (args?.command) {
271
const firstLine = args.command.split('\n')[0];
272
return md(localize('toolInvoke.writeShellCmd', "Sending {0} to shell", appendEscapedMarkdownInlineCode(truncate(firstLine, 80))));
273
}
274
return localize('toolInvoke.writeShell', "Sending input to shell");
275
}
276
277
if (READ_SHELL_TOOL_NAMES.has(toolName)) {
278
return localize('toolInvoke.readShell', "Reading shell output");
279
}
280
281
switch (toolName) {
282
case CopilotToolName.View: {
283
const args = parameters as ICopilotViewToolArgs | undefined;
284
if (args?.path) {
285
const link = formatPathAsMarkdownLink(args.path);
286
const range = formatViewRange(args.view_range);
287
if (range) {
288
if (range.endLine === -1) {
289
return md(localize('toolInvoke.viewFileFromLine', "Reading {0}, line {1} to the end", link, range.startLine));
290
}
291
if (range.endLine !== range.startLine) {
292
return md(localize('toolInvoke.viewFileRange', "Reading {0}, lines {1} to {2}", link, range.startLine, range.endLine));
293
}
294
return md(localize('toolInvoke.viewFileLine', "Reading {0}, line {1}", link, range.startLine));
295
}
296
return md(localize('toolInvoke.viewFile', "Reading {0}", link));
297
}
298
return localize('toolInvoke.view', "Reading file");
299
}
300
case CopilotToolName.Edit: {
301
const args = parameters as ICopilotFileToolArgs | undefined;
302
if (args?.path) {
303
return md(localize('toolInvoke.editFile', "Editing {0}", formatPathAsMarkdownLink(args.path)));
304
}
305
return localize('toolInvoke.edit', "Editing file");
306
}
307
case CopilotToolName.Create: {
308
const args = parameters as ICopilotFileToolArgs | undefined;
309
if (args?.path) {
310
return md(localize('toolInvoke.createFile', "Creating {0}", formatPathAsMarkdownLink(args.path)));
311
}
312
return localize('toolInvoke.create', "Creating file");
313
}
314
case CopilotToolName.Grep: {
315
const args = parameters as ICopilotGrepToolArgs | undefined;
316
if (args?.pattern) {
317
return md(localize('toolInvoke.grepPattern', "Searching for {0}", appendEscapedMarkdownInlineCode(truncate(args.pattern, 80))));
318
}
319
return localize('toolInvoke.grep', "Searching files");
320
}
321
case CopilotToolName.Glob: {
322
const args = parameters as ICopilotGlobToolArgs | undefined;
323
if (args?.pattern) {
324
return md(localize('toolInvoke.globPattern', "Finding files matching {0}", appendEscapedMarkdownInlineCode(truncate(args.pattern, 80))));
325
}
326
return localize('toolInvoke.glob', "Finding files");
327
}
328
case CopilotToolName.ExitPlanMode:
329
return localize('toolInvoke.exitPlanMode', "Presenting plan");
330
default:
331
return localize('toolInvoke.generic', "Using \"{0}\"", displayName);
332
}
333
}
334
335
export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record<string, unknown> | undefined, success: boolean): StringOrMarkdown {
336
if (!success) {
337
return localize('toolComplete.failed', "\"{0}\" failed", displayName);
338
}
339
340
if (SHELL_TOOL_NAMES.has(toolName)) {
341
const args = parameters as ICopilotShellToolArgs | undefined;
342
if (args?.command) {
343
const firstLine = args.command.split('\n')[0];
344
return md(localize('toolComplete.shellCmd', "Ran {0}", appendEscapedMarkdownInlineCode(truncate(firstLine, 80))));
345
}
346
return localize('toolComplete.shell', "Ran {0} command", displayName);
347
}
348
349
if (WRITE_SHELL_TOOL_NAMES.has(toolName)) {
350
const args = parameters as ICopilotShellToolArgs | undefined;
351
if (args?.command) {
352
const firstLine = args.command.split('\n')[0];
353
return md(localize('toolComplete.writeShellCmd', "Sent {0} to shell", appendEscapedMarkdownInlineCode(truncate(firstLine, 80))));
354
}
355
return localize('toolComplete.writeShell', "Sent input to shell");
356
}
357
358
if (READ_SHELL_TOOL_NAMES.has(toolName)) {
359
return localize('toolComplete.readShell', "Read shell output");
360
}
361
362
switch (toolName) {
363
case CopilotToolName.View: {
364
const args = parameters as ICopilotViewToolArgs | undefined;
365
if (args?.path) {
366
const link = formatPathAsMarkdownLink(args.path);
367
const range = formatViewRange(args.view_range);
368
if (range) {
369
if (range.endLine === -1) {
370
return md(localize('toolComplete.viewFileFromLine', "Read {0}, line {1} to the end", link, range.startLine));
371
}
372
if (range.endLine !== range.startLine) {
373
return md(localize('toolComplete.viewFileRange', "Read {0}, lines {1} to {2}", link, range.startLine, range.endLine));
374
}
375
return md(localize('toolComplete.viewFileLine', "Read {0}, line {1}", link, range.startLine));
376
}
377
return md(localize('toolComplete.viewFile', "Read {0}", link));
378
}
379
return localize('toolComplete.view', "Read file");
380
}
381
case CopilotToolName.Edit: {
382
const args = parameters as ICopilotFileToolArgs | undefined;
383
if (args?.path) {
384
return md(localize('toolComplete.editFile', "Edited {0}", formatPathAsMarkdownLink(args.path)));
385
}
386
return localize('toolComplete.edit', "Edited file");
387
}
388
case CopilotToolName.Create: {
389
const args = parameters as ICopilotFileToolArgs | undefined;
390
if (args?.path) {
391
return md(localize('toolComplete.createFile', "Created {0}", formatPathAsMarkdownLink(args.path)));
392
}
393
return localize('toolComplete.create', "Created file");
394
}
395
case CopilotToolName.Grep: {
396
const args = parameters as ICopilotGrepToolArgs | undefined;
397
if (args?.pattern) {
398
return md(localize('toolComplete.grepPattern', "Searched for {0}", appendEscapedMarkdownInlineCode(truncate(args.pattern, 80))));
399
}
400
return localize('toolComplete.grep', "Searched files");
401
}
402
case CopilotToolName.Glob: {
403
const args = parameters as ICopilotGlobToolArgs | undefined;
404
if (args?.pattern) {
405
return md(localize('toolComplete.globPattern', "Found files matching {0}", appendEscapedMarkdownInlineCode(truncate(args.pattern, 80))));
406
}
407
return localize('toolComplete.glob', "Found files");
408
}
409
case CopilotToolName.ExitPlanMode:
410
return localize('toolComplete.exitPlanMode', "Exited plan mode");
411
default:
412
return localize('toolComplete.generic', "Used \"{0}\"", displayName);
413
}
414
}
415
416
// =============================================================================
417
// Skill event synthesis
418
//
419
// The Copilot SDK emits a `skill` tool call (which we hide) and, separately, a
420
// `skill.invoked` lifecycle event with the resolved skill file path. We turn
421
// the latter into a synthesized tool-start/complete pair so clients can render
422
// a clickable file link to the SKILL.md the agent loaded -- matching the
423
// existing `view`-tool display style. Live and replay paths share this helper
424
// so they stay in lock-step (see also the mirrored-pair gotcha for tool-call
425
// display in this file).
426
// =============================================================================
427
428
/** Subset of the SDK's `skill.invoked` payload that the synth helper needs. */
429
export interface ICopilotSkillInvokedData {
430
readonly name: string;
431
readonly path?: string;
432
readonly description?: string;
433
}
434
435
/**
436
* Builds a stable synthetic tool call id for a `skill.invoked` event so
437
* reconnect/replay produces the same id as the original live emit. The id
438
* is used unencoded as a path segment (e.g. by `ChatResponseResource.createUri`),
439
* so it must not contain characters like `/` -- we hash any fallback values
440
* that could carry filesystem paths or arbitrary text.
441
*/
442
export function getSkillSyntheticToolCallId(eventId: string | undefined, data: ICopilotSkillInvokedData): string {
443
if (eventId) {
444
return `synth-skill-${eventId}`;
445
}
446
const seed = data.path ?? data.name;
447
return `synth-skill-${hash(seed).toString(16)}`;
448
}
449
450
/**
451
* Synthesized data for a `skill.invoked` tool call. Used by both the live
452
* session handler and the history-replay mapper so the two paths render
453
* identically. Callers wrap this into protocol actions or {@link Turn}
454
* data; this helper avoids any agent-protocol coupling.
455
*/
456
export interface ISynthesizedSkillToolCall {
457
readonly toolCallId: string;
458
readonly toolName: string;
459
readonly displayName: string;
460
readonly invocationMessage: StringOrMarkdown;
461
readonly pastTenseMessage: StringOrMarkdown;
462
}
463
464
/**
465
* Synthesizes the data for a `skill.invoked` tool call (a tool-start /
466
* tool-complete pair). Returns the constituent fields without coupling to
467
* any specific event or action shape — callers compose them into protocol
468
* actions or {@link Turn} entries as needed.
469
*/
470
export function synthesizeSkillToolCall(
471
data: ICopilotSkillInvokedData,
472
eventId: string | undefined,
473
): ISynthesizedSkillToolCall {
474
const toolCallId = getSkillSyntheticToolCallId(eventId, data);
475
const displayName = localize('toolName.skill', "Read Skill");
476
// Use the skill name as the link text rather than the basename: every skill
477
// file is named SKILL.md, so `Reading skill [plan]` reads better than the
478
// always-identical `Reading skill [SKILL.md]`. The client may further upgrade
479
// this link to a rich pill based on the `SKILL.md` basename. Skill names and
480
// paths come from the SDK / agent host and are escaped to prevent markdown
481
// injection from a malicious skill author.
482
// Escape only the characters that would break out of markdown link text
483
// syntax (`\` and `]`); a full markdown escape would leave visible
484
// backslashes in renderers (like the skill pill) that extract link text
485
// without re-parsing markdown.
486
const escapedName = escapeMarkdownLinkLabel(data.name);
487
const skillLink = data.path ? `[${escapedName}](${URI.file(data.path)})` : undefined;
488
const invocationMessage: StringOrMarkdown = skillLink
489
? md(localize('toolInvoke.skill', "Reading skill {0}", skillLink))
490
: localize('toolInvoke.skillName', "Reading skill {0}", data.name);
491
const pastTenseMessage: StringOrMarkdown = skillLink
492
? md(localize('toolComplete.skill', "Read skill {0}", skillLink))
493
: localize('toolComplete.skillName', "Read skill {0}", data.name);
494
return {
495
toolCallId,
496
toolName: CopilotToolName.Skill,
497
displayName,
498
invocationMessage,
499
pastTenseMessage,
500
};
501
}
502
503
export function getToolInputString(toolName: string, parameters: Record<string, unknown> | undefined, rawArguments: string | undefined): string | undefined {
504
if (!parameters && !rawArguments) {
505
return undefined;
506
}
507
508
if (SHELL_TOOL_NAMES.has(toolName) || WRITE_SHELL_TOOL_NAMES.has(toolName)) {
509
const args = parameters as ICopilotShellToolArgs | undefined;
510
// Custom tool overrides may wrap the args: { kind: 'custom-tool', args: { command: '...' } }
511
const command = args?.command ?? (args as Record<string, unknown> | undefined)?.args;
512
if (typeof command === 'string') {
513
return command;
514
}
515
if (typeof command === 'object' && command !== null && hasKey(command, { command: true })) {
516
return (command as ICopilotShellToolArgs).command;
517
}
518
return rawArguments;
519
}
520
521
switch (toolName) {
522
case CopilotToolName.Grep: {
523
const args = parameters as ICopilotGrepToolArgs | undefined;
524
return args?.pattern ?? rawArguments;
525
}
526
default:
527
// For other tools, show the formatted JSON arguments
528
if (parameters) {
529
try {
530
return JSON.stringify(parameters, null, 2);
531
} catch {
532
return rawArguments;
533
}
534
}
535
return rawArguments;
536
}
537
}
538
539
/**
540
* Returns a rendering hint for the given tool. Currently only 'terminal' is
541
* supported, which tells the renderer to display the tool as a terminal command
542
* block.
543
*/
544
export function getToolKind(toolName: string): 'terminal' | 'subagent' | undefined {
545
if (SHELL_TOOL_NAMES.has(toolName)) {
546
return 'terminal';
547
}
548
if (SUBAGENT_TOOL_NAMES.has(toolName)) {
549
return 'subagent';
550
}
551
return undefined;
552
}
553
554
/**
555
* Extracts subagent metadata (agent name, description) from the parsed
556
* arguments of a Copilot SDK subagent tool call. The Copilot `task` tool
557
* uses `agent_type` (snake_case), which this normalizes into the generic
558
* `subagentAgentName` / `subagentDescription` shape used by the rest of the
559
* agent host code.
560
*
561
* Only call this for tools where {@link getToolKind} returned `'subagent'`.
562
*/
563
export function getSubagentMetadata(parameters: Record<string, unknown> | undefined): { agentName?: string; description?: string } {
564
if (!parameters) {
565
return {};
566
}
567
const agentName = typeof parameters.agent_type === 'string' && parameters.agent_type.length > 0
568
? parameters.agent_type
569
: undefined;
570
const description = typeof parameters.description === 'string' && parameters.description.length > 0
571
? parameters.description
572
: undefined;
573
return { agentName, description };
574
}
575
576
/**
577
* Returns the shell language identifier for syntax highlighting.
578
* Used when creating terminal tool-specific data for the renderer.
579
*/
580
export function getShellLanguage(toolName: string): string {
581
switch (toolName) {
582
case CopilotToolName.PowerShell:
583
case CopilotToolName.WritePowerShell:
584
case CopilotToolName.ReadPowerShell: return 'powershell';
585
default: return 'shellscript';
586
}
587
}
588
589
// =============================================================================
590
// Permission display
591
//
592
// Derives display fields from SDK permission requests for the tool
593
// confirmation UI. Colocated with the tool-start display helpers above so
594
// that formatting utilities (formatPathAsMarkdownLink, md, etc.) are shared.
595
// =============================================================================
596
597
export function tryStringify(value: unknown): string | undefined {
598
try {
599
return JSON.stringify(value);
600
} catch {
601
return undefined;
602
}
603
}
604
605
/**
606
* Extends the SDK's {@link PermissionRequest} with the known extra properties
607
* that arrive on the index-signature. The SDK defines these as `[key: string]: unknown`
608
* so this interface adds proper types for the fields we actually use.
609
*/
610
export interface ITypedPermissionRequest extends PermissionRequest {
611
/** File path — set for `read` permission requests. */
612
path?: string;
613
/** File path — set for `write` permission requests. */
614
fileName?: string;
615
/** Full shell command text — set for `shell` permission requests. */
616
fullCommandText?: string;
617
/** Human-readable intention describing the operation. */
618
intention?: string;
619
/** MCP server name — set for `mcp` permission requests. */
620
serverName?: string;
621
/** Tool name — set for `mcp` and `custom-tool` permission requests. */
622
toolName?: string;
623
/** Tool arguments — set for `custom-tool` permission requests. */
624
args?: Record<string, unknown>;
625
/** URL — set for `url` permission requests. */
626
url?: string;
627
/** Unified diff of the proposed change — set for `write` permission requests. */
628
diff?: string;
629
/** New file contents that will be written — set for `write` permission requests. */
630
newFileContents?: string;
631
}
632
633
/** Safely extract a string value from an SDK field that may be `unknown` at runtime. */
634
function str(value: unknown): string | undefined {
635
return typeof value === 'string' ? value : undefined;
636
}
637
638
/**
639
* Derives display fields from a permission request for the tool confirmation UI.
640
*/
641
export function getPermissionDisplay(request: ITypedPermissionRequest, workingDirectory?: URI): {
642
confirmationTitle: string;
643
invocationMessage: StringOrMarkdown;
644
toolInput?: string;
645
/** Normalized permission kind for auto-approval routing. */
646
permissionKind: IAgentToolPendingConfirmationSignal['permissionKind'];
647
/** File path extracted from the request. */
648
permissionPath?: string;
649
} {
650
const path = str(request.path) ?? str(request.fileName);
651
const fullCommandText = str(request.fullCommandText);
652
const intention = str(request.intention);
653
const serverName = str(request.serverName);
654
const toolName = str(request.toolName);
655
656
switch (request.kind) {
657
case 'shell': {
658
// Strip a redundant `cd <workingDirectory> && …` prefix so the
659
// confirmation dialog shows the simplified command.
660
const shellParams: Record<string, unknown> | undefined = fullCommandText ? { command: fullCommandText } : undefined;
661
stripRedundantCdPrefix(CopilotToolName.Bash, shellParams, workingDirectory);
662
const cleanedCommand = typeof shellParams?.command === 'string' ? shellParams.command : fullCommandText;
663
return {
664
confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"),
665
invocationMessage: intention ?? getInvocationMessage(CopilotToolName.Bash, getToolDisplayName(CopilotToolName.Bash), cleanedCommand ? { command: cleanedCommand } : undefined),
666
toolInput: cleanedCommand,
667
permissionKind: 'shell',
668
permissionPath: path,
669
};
670
}
671
case 'custom-tool': {
672
// Custom tool overrides (e.g. our shell tool). Extract the actual
673
// tool args from the SDK's wrapper envelope.
674
const args = typeof request.args === 'object' && request.args !== null ? request.args as Record<string, unknown> : undefined;
675
const sdkToolName = str(request.toolName);
676
if (args && sdkToolName && isShellTool(sdkToolName) && typeof args.command === 'string') {
677
stripRedundantCdPrefix(sdkToolName, args, workingDirectory);
678
const command = args.command as string;
679
return {
680
confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"),
681
invocationMessage: getInvocationMessage(sdkToolName, getToolDisplayName(sdkToolName), { command }),
682
toolInput: command,
683
permissionKind: 'shell',
684
permissionPath: path,
685
};
686
}
687
return {
688
confirmationTitle: localize('copilot.permission.default.title', "Allow tool call?"),
689
invocationMessage: md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))),
690
toolInput: args ? tryStringify(args) : tryStringify(request),
691
permissionKind: request.kind,
692
permissionPath: path,
693
};
694
}
695
case 'write':
696
return {
697
confirmationTitle: localize('copilot.permission.write.title', "Write file?"),
698
invocationMessage: getInvocationMessage(CopilotToolName.Edit, getToolDisplayName(CopilotToolName.Edit), path ? { path } : undefined),
699
toolInput: tryStringify(path ? { path } : request) ?? undefined,
700
permissionKind: 'write',
701
permissionPath: path,
702
};
703
case 'mcp': {
704
const title = toolName ?? localize('copilot.permission.mcp.defaultTool', "MCP Tool");
705
return {
706
confirmationTitle: serverName
707
? localize('copilot.permission.mcp.title', "Allow tool from {0}?", serverName)
708
: localize('copilot.permission.default.title', "Allow tool call?"),
709
invocationMessage: serverName ? `${serverName}: ${title}` : title,
710
toolInput: tryStringify({ serverName, toolName }) ?? undefined,
711
permissionKind: 'mcp',
712
permissionPath: path,
713
};
714
}
715
case 'read':
716
return {
717
confirmationTitle: localize('copilot.permission.read.title', "Read file?"),
718
invocationMessage: intention ?? getInvocationMessage(CopilotToolName.View, getToolDisplayName(CopilotToolName.View), path ? { path } : undefined),
719
toolInput: tryStringify(path ? { path, intention } : request) ?? undefined,
720
permissionKind: 'read',
721
permissionPath: path,
722
};
723
case 'url': {
724
const url = str(request.url);
725
// Parse through URL for punycode escaping, but preserve the raw value if parsing fails.
726
const normalizedUrl = url ? (URL.canParse(url) ? new URL(url).href : url) : undefined;
727
return {
728
confirmationTitle: localize('copilot.permission.url.title', "Fetch URL?"),
729
invocationMessage: md(localize('copilot.permission.url.message', "Allow fetching web content?")),
730
toolInput: normalizedUrl ? JSON.stringify({ url: normalizedUrl }) : undefined,
731
permissionKind: 'url',
732
};
733
}
734
default:
735
return {
736
confirmationTitle: localize('copilot.permission.default.title', "Allow tool call?"),
737
invocationMessage: md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))),
738
toolInput: tryStringify(request) ?? undefined,
739
permissionKind: request.kind,
740
permissionPath: path,
741
};
742
}
743
}
744
745