Skip to content

Commit 3df70f3

Browse files
authored
Merge pull request #288358 from microsoft/tyriar/287772_py
Format python commands specially
2 parents eb67b92 + f75062a commit 3df70f3

File tree

9 files changed

+339
-9
lines changed

9 files changed

+339
-9
lines changed

src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,12 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS
105105
const { title, message, disclaimer, terminalCustomActions } = state.confirmationMessages;
106106

107107
// Use pre-computed confirmation data from runInTerminalTool (cd prefix extraction happens there for localization)
108-
const initialContent = terminalData.confirmation?.commandLine ?? (terminalData.commandLine.toolEdited ?? terminalData.commandLine.original).trimStart();
108+
// Use presentationOverrides for display if available (e.g., extracted Python code)
109+
const initialContent = terminalData.presentationOverrides?.commandLine ?? terminalData.confirmation?.commandLine ?? (terminalData.commandLine.toolEdited ?? terminalData.commandLine.original).trimStart();
109110
const cdPrefix = terminalData.confirmation?.cdPrefix ?? '';
111+
// When presentationOverrides is set, the editor should be read-only since the displayed content
112+
// differs from the actual command (e.g., extracted Python code vs full python -c command)
113+
const isReadOnly = !!terminalData.presentationOverrides;
110114

111115
const autoApproveEnabled = this.configurationService.getValue(TerminalContribSettingId.EnableAutoApprove) === true;
112116
const autoApproveWarningAccepted = this.storageService.getBoolean(TerminalToolConfirmationStorageKeys.TerminalAutoApproveWarningAccepted, StorageScope.APPLICATION, false);
@@ -143,12 +147,12 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS
143147
verticalPadding: 5,
144148
editorOptions: {
145149
wordWrap: 'on',
146-
readOnly: false,
150+
readOnly: isReadOnly,
147151
tabFocusMode: true,
148152
ariaLabel: typeof title === 'string' ? title : title.value
149153
}
150154
};
151-
const languageId = this.languageService.getLanguageIdByLanguageName(terminalData.language ?? 'sh') ?? 'shellscript';
155+
const languageId = this.languageService.getLanguageIdByLanguageName(terminalData.presentationOverrides?.language ?? terminalData.language ?? 'sh') ?? 'shellscript';
152156
const model = this._register(this.modelService.createModel(
153157
initialContent,
154158
this.languageService.createById(languageId),

src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,15 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
283283
getResolvedCommand: () => this._getResolvedCommand()
284284
}));
285285

286+
// Use presentationOverrides for display if available (e.g., extracted Python code with syntax highlighting)
287+
const displayCommand = terminalData.presentationOverrides?.commandLine ?? command;
288+
const displayLanguage = terminalData.presentationOverrides?.language ?? terminalData.language;
286289
const titlePart = this._register(_instantiationService.createInstance(
287290
ChatQueryTitlePart,
288291
elements.commandBlock,
289292
new MarkdownString([
290-
`\`\`\`${terminalData.language}`,
291-
`${command.replaceAll('```', '\\`\\`\\`')}`,
293+
`\`\`\`${displayLanguage}`,
294+
`${displayCommand.replaceAll('```', '\\`\\`\\`')}`,
292295
`\`\`\``
293296
].join('\n'), { supportThemeIcons: true }),
294297
undefined,

src/vs/workbench/contrib/chat/common/chatService/chatService.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,17 @@ export interface IChatTerminalToolInvocationData {
387387
/** The cd prefix to prepend back when user edits */
388388
cdPrefix?: string;
389389
};
390+
/**
391+
* Overrides to apply to the presentation of the tool call only, but not actually change the
392+
* command that gets run. For example, python -c "print('hello')" can be presented as just
393+
* the Python code with Python syntax highlighting.
394+
*/
395+
presentationOverrides?: {
396+
/** The command line to display in the UI */
397+
commandLine: string;
398+
/** The language for syntax highlighting */
399+
language: string;
400+
};
390401
/** Message for model recommending the use of an alternative tool */
391402
alternativeRecommendation?: string;
392403
language: string;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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 { OperatingSystem } from '../../../../../../../base/common/platform.js';
7+
8+
export interface ICommandLinePresenter {
9+
/**
10+
* Attempts to create a presentation for the given command line.
11+
* Command line presenters allow displaying an extracted/transformed version
12+
* of a command (e.g., Python code from `python -c "..."`) with appropriate
13+
* syntax highlighting, while the actual command remains unchanged.
14+
*
15+
* @returns The presentation result if this presenter handles the command, undefined otherwise.
16+
*/
17+
present(options: ICommandLinePresenterOptions): ICommandLinePresenterResult | undefined;
18+
}
19+
20+
export interface ICommandLinePresenterOptions {
21+
commandLine: string;
22+
shell: string;
23+
os: OperatingSystem;
24+
}
25+
26+
export interface ICommandLinePresenterResult {
27+
/**
28+
* The extracted/transformed command to display (e.g., the Python code).
29+
*/
30+
commandLine: string;
31+
32+
/**
33+
* The language ID for syntax highlighting (e.g., 'python').
34+
*/
35+
language: string;
36+
37+
/**
38+
* A human-readable name for the language (e.g., 'Python') used in UI labels.
39+
*/
40+
languageDisplayName: string;
41+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 { OperatingSystem } from '../../../../../../../base/common/platform.js';
7+
import { isPowerShell } from '../../runInTerminalHelpers.js';
8+
import type { ICommandLinePresenter, ICommandLinePresenterOptions, ICommandLinePresenterResult } from './commandLinePresenter.js';
9+
10+
/**
11+
* Command line presenter for Python inline commands (`python -c "..."`).
12+
* Extracts the Python code and sets up Python syntax highlighting.
13+
*/
14+
export class PythonCommandLinePresenter implements ICommandLinePresenter {
15+
present(options: ICommandLinePresenterOptions): ICommandLinePresenterResult | undefined {
16+
const extractedPython = extractPythonCommand(options.commandLine, options.shell, options.os);
17+
if (extractedPython) {
18+
return {
19+
commandLine: extractedPython,
20+
language: 'python',
21+
languageDisplayName: 'Python',
22+
};
23+
}
24+
return undefined;
25+
}
26+
}
27+
28+
/**
29+
* Extracts the Python code from a `python -c "..."` or `python -c '...'` command,
30+
* returning the code with properly unescaped quotes.
31+
*
32+
* @param commandLine The full command line to parse
33+
* @param shell The shell path (to determine quote escaping style)
34+
* @param os The operating system
35+
* @returns The extracted Python code, or undefined if not a python -c command
36+
*/
37+
export function extractPythonCommand(commandLine: string, shell: string, os: OperatingSystem): string | undefined {
38+
// Match python/python3 -c "..." pattern (double quotes)
39+
const doubleQuoteMatch = commandLine.match(/^python(?:3)?\s+-c\s+"(?<python>.+)"$/s);
40+
if (doubleQuoteMatch?.groups?.python) {
41+
let pythonCode = doubleQuoteMatch.groups.python.trim();
42+
43+
// Unescape quotes based on shell type
44+
if (isPowerShell(shell, os)) {
45+
// PowerShell uses backtick-quote (`") to escape quotes inside double-quoted strings
46+
pythonCode = pythonCode.replace(/`"/g, '"');
47+
} else {
48+
// Bash/sh/zsh use backslash-quote (\")
49+
pythonCode = pythonCode.replace(/\\"/g, '"');
50+
}
51+
52+
return pythonCode;
53+
}
54+
55+
// Match python/python3 -c '...' pattern (single quotes)
56+
// Single quotes in bash/sh/zsh are literal - no escaping inside
57+
// Single quotes in PowerShell are also literal
58+
const singleQuoteMatch = commandLine.match(/^python(?:3)?\s+-c\s+'(?<python>.+)'$/s);
59+
if (singleQuoteMatch?.groups?.python) {
60+
return singleQuoteMatch.groups.python.trim();
61+
}
62+
63+
return undefined;
64+
}

src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import { NoneExecuteStrategy } from '../executeStrategy/noneExecuteStrategy.js';
3838
import { RichExecuteStrategy } from '../executeStrategy/richExecuteStrategy.js';
3939
import { getOutput } from '../outputHelpers.js';
4040
import { extractCdPrefix, isFish, isPowerShell, isWindowsPowerShell, isZsh } from '../runInTerminalHelpers.js';
41+
import type { ICommandLinePresenter } from './commandLinePresenter/commandLinePresenter.js';
42+
import { PythonCommandLinePresenter } from './commandLinePresenter/pythonCommandLinePresenter.js';
4143
import { RunInTerminalToolTelemetry } from '../runInTerminalToolTelemetry.js';
4244
import { ShellIntegrationQuality, ToolTerminalCreator, type IToolTerminal } from '../toolTerminalCreator.js';
4345
import { TreeSitterCommandParser, TreeSitterCommandParserLanguage } from '../treeSitterCommandParser.js';
@@ -281,6 +283,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
281283

282284
private readonly _commandLineRewriters: ICommandLineRewriter[];
283285
private readonly _commandLineAnalyzers: ICommandLineAnalyzer[];
286+
private readonly _commandLinePresenters: ICommandLinePresenter[];
284287

285288
protected readonly _sessionTerminalAssociations = new ResourceMap<IToolTerminal>();
286289

@@ -330,6 +333,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
330333
this._register(this._instantiationService.createInstance(CommandLineFileWriteAnalyzer, this._treeSitterCommandParser, (message, args) => this._logService.info(`RunInTerminalTool#CommandLineFileWriteAnalyzer: ${message}`, args))),
331334
this._register(this._instantiationService.createInstance(CommandLineAutoApproveAnalyzer, this._treeSitterCommandParser, this._telemetry, (message, args) => this._logService.info(`RunInTerminalTool#CommandLineAutoApproveAnalyzer: ${message}`, args))),
332335
];
336+
this._commandLinePresenters = [
337+
new PythonCommandLinePresenter(),
338+
];
333339

334340
// Clear out warning accepted state if the setting is disabled
335341
this._register(Event.runAndSubscribe(this._configurationService.onDidChangeConfiguration, e => {
@@ -523,17 +529,40 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
523529
};
524530

525531
confirmationTitle = args.isBackground
526-
? localize('runInTerminal.background.inDirectory', "Run `{0}` command in `{1}`? (background terminal)", shellType, directoryLabel)
527-
: localize('runInTerminal.inDirectory', "Run `{0}` command in `{1}`?", shellType, directoryLabel);
532+
? localize('runInTerminal.background.inDirectory', "Run `{0}` command in background within `{1}`?", shellType, directoryLabel)
533+
: localize('runInTerminal.inDirectory', "Run `{0}` command within `{1}`?", shellType, directoryLabel);
528534
} else {
529535
toolSpecificData.confirmation = {
530536
commandLine: commandToDisplay,
531537
};
532538
confirmationTitle = args.isBackground
533-
? localize('runInTerminal.background', "Run `{0}` command? (background terminal)", shellType)
539+
? localize('runInTerminal.background', "Run `{0}` command in background?", shellType)
534540
: localize('runInTerminal', "Run `{0}` command?", shellType);
535541
}
536542

543+
// Check for presentation overrides (e.g., Python -c command extraction)
544+
// Use the command after cd prefix extraction if available, since that's what's displayed in the editor
545+
const commandForPresenter = extractedCd?.command ?? commandToDisplay;
546+
for (const presenter of this._commandLinePresenters) {
547+
const presenterResult = presenter.present({ commandLine: commandForPresenter, shell, os });
548+
if (presenterResult) {
549+
toolSpecificData.presentationOverrides = {
550+
commandLine: presenterResult.commandLine,
551+
language: presenterResult.language,
552+
};
553+
if (extractedCd && toolSpecificData.confirmation?.cwdLabel) {
554+
confirmationTitle = args.isBackground
555+
? localize('runInTerminal.presentationOverride.background.inDirectory', "Run `{0}` command in `{1}` in background within `{2}`?", presenterResult.languageDisplayName, shellType, toolSpecificData.confirmation.cwdLabel)
556+
: localize('runInTerminal.presentationOverride.inDirectory', "Run `{0}` command in `{1}` within `{2}`?", presenterResult.languageDisplayName, shellType, toolSpecificData.confirmation.cwdLabel);
557+
} else {
558+
confirmationTitle = args.isBackground
559+
? localize('runInTerminal.presentationOverride.background', "Run `{0}` command in `{1}` in background?", presenterResult.languageDisplayName, shellType)
560+
: localize('runInTerminal.presentationOverride', "Run `{0}` command in `{1}`?", presenterResult.languageDisplayName, shellType);
561+
}
562+
break;
563+
}
564+
}
565+
537566
const confirmationMessages = isFinalAutoApproved ? undefined : {
538567
title: confirmationTitle,
539568
message: new MarkdownString(args.explanation),

0 commit comments

Comments
 (0)