Skip to content

Commit 2ca79c4

Browse files
authored
add id for TerminalCommand, add Add to Chat action for commands (microsoft#272943)
1 parent 0a33122 commit 2ca79c4

File tree

24 files changed

+335
-23
lines changed

24 files changed

+335
-23
lines changed

src/vs/platform/terminal/common/capabilities/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ interface IBaseTerminalCommand {
291291
isTrusted: boolean;
292292
timestamp: number;
293293
duration: number;
294+
id: string;
294295

295296
// Optional serializable
296297
cwd: string | undefined;

src/vs/platform/terminal/common/capabilities/commandDetection/terminalCommand.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
import { IMarkProperties, ISerializedTerminalCommand, ITerminalCommand } from '../capabilities.js';
77
import { ITerminalOutputMatcher, ITerminalOutputMatch } from '../../terminal.js';
88
import type { IBuffer, IBufferLine, IMarker, Terminal } from '@xterm/headless';
9+
import { generateUuid } from '../../../../../base/common/uuid.js';
910

1011
export interface ITerminalCommandProperties {
1112
command: string;
1213
commandLineConfidence: 'low' | 'medium' | 'high';
1314
isTrusted: boolean;
1415
timestamp: number;
1516
duration: number;
17+
id: string;
1618
marker: IMarker | undefined;
1719
cwd: string | undefined;
1820
exitCode: number | undefined;
@@ -48,6 +50,7 @@ export class TerminalCommand implements ITerminalCommand {
4850
get markProperties() { return this._properties.markProperties; }
4951
get executedX() { return this._properties.executedX; }
5052
get startX() { return this._properties.startX; }
53+
get id() { return this._properties.id; }
5154

5255
constructor(
5356
private readonly _xterm: Terminal,
@@ -72,6 +75,7 @@ export class TerminalCommand implements ITerminalCommand {
7275
command: isCommandStorageDisabled ? '' : serialized.command,
7376
commandLineConfidence: serialized.commandLineConfidence ?? 'low',
7477
isTrusted: serialized.isTrusted,
78+
id: serialized.id,
7579
promptStartMarker,
7680
marker,
7781
startX: serialized.startX,
@@ -107,6 +111,7 @@ export class TerminalCommand implements ITerminalCommand {
107111
timestamp: this.timestamp,
108112
duration: this.duration,
109113
markProperties: this.markProperties,
114+
id: this.id,
110115
};
111116
}
112117

@@ -271,13 +276,15 @@ export class PartialTerminalCommand implements ICurrentPartialCommand {
271276
cwd?: string;
272277
command?: string;
273278
commandLineConfidence?: 'low' | 'medium' | 'high';
279+
id: string;
274280

275281
isTrusted?: boolean;
276282
isInvalid?: boolean;
277283

278284
constructor(
279285
private readonly _xterm: Terminal,
280286
) {
287+
this.id = generateUuid();
281288
}
282289

283290
serialize(cwd: string | undefined): ISerializedTerminalCommand | undefined {
@@ -300,7 +307,8 @@ export class PartialTerminalCommand implements ICurrentPartialCommand {
300307
commandStartLineContent: undefined,
301308
timestamp: 0,
302309
duration: 0,
303-
markProperties: undefined
310+
markProperties: undefined,
311+
id: this.id
304312
};
305313
}
306314

@@ -315,6 +323,7 @@ export class PartialTerminalCommand implements ICurrentPartialCommand {
315323
command: ignoreCommandLine ? '' : (this.command || ''),
316324
commandLineConfidence: ignoreCommandLine ? 'low' : (this.commandLineConfidence || 'low'),
317325
isTrusted: !!this.isTrusted,
326+
id: this.id,
318327
promptStartMarker: this.promptStartMarker,
319328
marker: this.commandStartMarker,
320329
startX: this.commandStartX,

src/vs/workbench/contrib/chat/browser/actions/chatContext.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55
import { CancellationToken } from '../../../../../base/common/cancellation.js';
66
import { Codicon } from '../../../../../base/common/codicons.js';
7-
import { Disposable } from '../../../../../base/common/lifecycle.js';
7+
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
88
import { isElectron } from '../../../../../base/common/platform.js';
99
import { dirname } from '../../../../../base/common/resources.js';
1010
import { ThemeIcon } from '../../../../../base/common/themables.js';
@@ -29,6 +29,9 @@ import { IChatWidget } from '../chat.js';
2929
import { imageToHash, isImage } from '../chatPasteProviders.js';
3030
import { convertBufferToScreenshotVariable } from '../contrib/screenshot.js';
3131
import { ChatInstructionsPickerPick } from '../promptSyntax/attachInstructionsAction.js';
32+
import { ITerminalService } from '../../../terminal/browser/terminal.js';
33+
import { URI } from '../../../../../base/common/uri.js';
34+
import { ITerminalCommand, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js';
3235

3336

3437
export class ChatContextContributions extends Disposable implements IWorkbenchContribution {
@@ -258,6 +261,73 @@ class ClipboardImageContextValuePick implements IChatContextValueItem {
258261
}
259262
}
260263

264+
export class TerminalContext implements IChatContextValueItem {
265+
266+
readonly type = 'valuePick';
267+
readonly icon = Codicon.terminal;
268+
readonly label = localize('terminal', 'Terminal');
269+
constructor(private readonly _resource: URI, @ITerminalService private readonly _terminalService: ITerminalService) {
270+
271+
}
272+
async isEnabled(widget: IChatWidget) {
273+
const terminal = this._terminalService.getInstanceFromResource(this._resource);
274+
return !!widget.attachmentCapabilities.supportsTerminalAttachments && terminal?.isDisposed === false;
275+
}
276+
async asAttachment(widget: IChatWidget): Promise<IChatRequestVariableEntry | undefined> {
277+
const terminal = this._terminalService.getInstanceFromResource(this._resource);
278+
if (!terminal) {
279+
return;
280+
}
281+
282+
const command = terminal.capabilities.get(TerminalCapability.CommandDetection)?.commands.find(cmd => cmd.id === this._resource.query);
283+
if (!command) {
284+
return;
285+
}
286+
const attachment: IChatRequestVariableEntry = {
287+
kind: 'terminalCommand',
288+
id: `terminalCommand:${Date.now()}}`,
289+
value: this.asValue(command),
290+
name: command.command,
291+
command: command.command,
292+
output: command.getOutput(),
293+
exitCode: command.exitCode,
294+
resource: this._resource
295+
};
296+
const cleanup = new DisposableStore();
297+
let disposed = false;
298+
const disposeCleanup = () => {
299+
if (disposed) {
300+
return;
301+
}
302+
disposed = true;
303+
cleanup.dispose();
304+
};
305+
cleanup.add(widget.attachmentModel.onDidChange(e => {
306+
if (e.deleted.includes(attachment.id)) {
307+
disposeCleanup();
308+
}
309+
}));
310+
cleanup.add(terminal.onDisposed(() => {
311+
widget.attachmentModel.delete(attachment.id);
312+
widget.refreshParsedInput();
313+
disposeCleanup();
314+
}));
315+
return attachment;
316+
}
317+
318+
private asValue(command: ITerminalCommand): string {
319+
let value = `Command: ${command.command}`;
320+
const output = command.getOutput();
321+
if (output) {
322+
value += `\nOutput:\n${output}`;
323+
}
324+
if (typeof command.exitCode === 'number') {
325+
value += `\nExit Code: ${command.exitCode}`;
326+
}
327+
return value;
328+
}
329+
}
330+
261331
class ScreenshotContextValuePick implements IChatContextValueItem {
262332

263333
readonly type = 'valuePick';

src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export class ChatAttachmentModel extends Disposable {
3333
constructor(
3434
@IFileService private readonly fileService: IFileService,
3535
@ISharedWebContentExtractorService private readonly webContentExtractorService: ISharedWebContentExtractorService,
36-
@IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService
36+
@IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService,
3737
) {
3838
super();
3939
}

src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js';
1717
import { Iterable } from '../../../../base/common/iterator.js';
1818
import { KeyCode } from '../../../../base/common/keyCodes.js';
1919
import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
20+
import { Schemas } from '../../../../base/common/network.js';
2021
import { basename, dirname } from '../../../../base/common/path.js';
2122
import { ThemeIcon } from '../../../../base/common/themables.js';
2223
import { URI } from '../../../../base/common/uri.js';
@@ -52,8 +53,9 @@ import { revealInSideBarCommand } from '../../files/browser/fileActions.contribu
5253
import { CellUri } from '../../notebook/common/notebookCommon.js';
5354
import { INotebookService } from '../../notebook/common/notebookService.js';
5455
import { getHistoryItemEditorTitle } from '../../scm/browser/util.js';
56+
import { ITerminalService } from '../../terminal/browser/terminal.js';
5557
import { IChatContentReference } from '../common/chatService.js';
56-
import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, IPromptTextVariableEntry, ISCMHistoryItemVariableEntry, OmittedState, PromptFileVariableKind, ChatRequestToolReferenceEntry, ISCMHistoryItemChangeVariableEntry, ISCMHistoryItemChangeRangeVariableEntry } from '../common/chatVariableEntries.js';
58+
import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, IPromptTextVariableEntry, ISCMHistoryItemVariableEntry, OmittedState, PromptFileVariableKind, ChatRequestToolReferenceEntry, ISCMHistoryItemChangeVariableEntry, ISCMHistoryItemChangeRangeVariableEntry, ITerminalVariableEntry } from '../common/chatVariableEntries.js';
5759
import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../common/languageModels.js';
5860
import { ILanguageModelToolsService, ToolSet } from '../common/languageModelToolsService.js';
5961
import { getCleanPromptName } from '../common/promptSyntax/config/promptFileLocations.js';
@@ -91,6 +93,7 @@ abstract class AbstractChatAttachmentWidget extends Disposable {
9193
protected readonly currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
9294
@ICommandService protected readonly commandService: ICommandService,
9395
@IOpenerService protected readonly openerService: IOpenerService,
96+
@ITerminalService protected readonly terminalService?: ITerminalService,
9497
) {
9598
super();
9699
this.element = dom.append(container, $('.chat-attached-context-attachment.show-file-icons'));
@@ -160,6 +163,11 @@ abstract class AbstractChatAttachmentWidget extends Disposable {
160163
return;
161164
}
162165

166+
if (resource.scheme === Schemas.vscodeTerminal) {
167+
this.terminalService?.openResource(resource);
168+
return;
169+
}
170+
163171
// Open file in editor
164172
const openTextEditorOptions: ITextEditorOptions | undefined = range ? { selection: range } : undefined;
165173
const options: OpenInternalOptions = {
@@ -170,6 +178,7 @@ abstract class AbstractChatAttachmentWidget extends Disposable {
170178
...openOptions.editorOptions
171179
},
172180
};
181+
173182
await this.openerService.open(resource, options);
174183
this._onDidOpen.fire();
175184
this.element.focus();
@@ -249,6 +258,118 @@ export class FileAttachmentWidget extends AbstractChatAttachmentWidget {
249258
}
250259
}
251260

261+
262+
export class TerminalCommandAttachmentWidget extends AbstractChatAttachmentWidget {
263+
264+
constructor(
265+
attachment: ITerminalVariableEntry,
266+
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
267+
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
268+
container: HTMLElement,
269+
contextResourceLabels: ResourceLabels,
270+
@ICommandService commandService: ICommandService,
271+
@IOpenerService openerService: IOpenerService,
272+
@IHoverService private readonly hoverService: IHoverService,
273+
@ITerminalService protected override readonly terminalService: ITerminalService,
274+
) {
275+
super(attachment, options, container, contextResourceLabels, currentLanguageModel, commandService, openerService, terminalService);
276+
277+
const ariaLabel = localize('chat.terminalCommand', "Terminal command, {0}", attachment.command);
278+
const clickHandler = () => this.openResource(attachment.resource, { editorOptions: { preserveFocus: true } }, false, undefined);
279+
280+
this._register(createTerminalCommandElements(this.element, attachment, ariaLabel, this.hoverService, clickHandler));
281+
282+
this._register(dom.addDisposableListener(this.element, dom.EventType.KEY_DOWN, async (e: KeyboardEvent) => {
283+
if ((e.target as HTMLElement | null)?.closest('.monaco-button')) {
284+
return;
285+
}
286+
const event = new StandardKeyboardEvent(e);
287+
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
288+
dom.EventHelper.stop(e, true);
289+
await clickHandler();
290+
}
291+
}));
292+
293+
this.attachClearButton();
294+
}
295+
}
296+
297+
const MAX_TERMINAL_ATTACHMENT_OUTPUT_LENGTH = 2000;
298+
299+
function createTerminalCommandElements(
300+
element: HTMLElement,
301+
attachment: ITerminalVariableEntry,
302+
ariaLabel: string,
303+
hoverService: IHoverService,
304+
clickHandler: () => Promise<void>
305+
): IDisposable {
306+
const disposable = new DisposableStore();
307+
element.ariaLabel = ariaLabel;
308+
element.style.cursor = 'pointer';
309+
310+
const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$('span.codicon.codicon-terminal'));
311+
const textLabel = dom.$('span.chat-attached-context-custom-text', {}, attachment.command);
312+
element.appendChild(pillIcon);
313+
element.appendChild(textLabel);
314+
315+
disposable.add(dom.addDisposableListener(element, dom.EventType.CLICK, e => {
316+
if ((e.target as HTMLElement | null)?.closest('.monaco-button')) {
317+
return;
318+
}
319+
void clickHandler();
320+
}));
321+
322+
const hoverElement = dom.$('div.chat-attached-context-hover');
323+
hoverElement.setAttribute('aria-label', ariaLabel);
324+
325+
const commandTitle = dom.$('div', {}, typeof attachment.exitCode === 'number'
326+
? localize('chat.terminalCommandHoverCommandTitleExit', "Command: {0}, exit code: {1}", attachment.command, attachment.exitCode)
327+
: localize('chat.terminalCommandHoverCommandTitle', "Command"));
328+
commandTitle.classList.add('attachment-additional-info');
329+
const commandBlock = dom.$('pre.chat-terminal-command-block');
330+
hoverElement.append(commandTitle, commandBlock);
331+
332+
if (attachment.output && attachment.output.trim().length > 0) {
333+
const outputTitle = dom.$('div', {}, localize('chat.terminalCommandHoverOutputTitle', "Output"));
334+
outputTitle.classList.add('attachment-additional-info');
335+
const outputBlock = dom.$('pre.chat-terminal-command-output');
336+
let outputText = attachment.output;
337+
let truncated = false;
338+
if (outputText.length > MAX_TERMINAL_ATTACHMENT_OUTPUT_LENGTH) {
339+
outputText = `${outputText.slice(0, MAX_TERMINAL_ATTACHMENT_OUTPUT_LENGTH)}...`;
340+
truncated = true;
341+
}
342+
outputBlock.textContent = outputText;
343+
hoverElement.append(outputTitle, outputBlock);
344+
345+
if (truncated) {
346+
const truncatedInfo = dom.$('div', {}, localize('chat.terminalCommandHoverOutputTruncated', "Output truncated to first {0} characters.", MAX_TERMINAL_ATTACHMENT_OUTPUT_LENGTH));
347+
truncatedInfo.classList.add('attachment-additional-info');
348+
hoverElement.appendChild(truncatedInfo);
349+
}
350+
}
351+
352+
const hint = dom.$('div', {}, localize('chat.terminalCommandHoverHint', "Click to focus this command in the terminal."));
353+
hint.classList.add('attachment-additional-info');
354+
hoverElement.appendChild(hint);
355+
356+
const separator = dom.$('div.chat-attached-context-url-separator');
357+
const openLink = dom.$('a.chat-attached-context-url', {}, localize('chat.terminalCommandHoverOpen', "Open in terminal"));
358+
disposable.add(dom.addDisposableListener(openLink, 'click', e => {
359+
e.preventDefault();
360+
e.stopPropagation();
361+
void clickHandler();
362+
}));
363+
hoverElement.append(separator, openLink);
364+
365+
disposable.add(hoverService.setupDelayedHover(element, {
366+
...commonHoverOptions,
367+
content: hoverElement,
368+
}, commonHoverLifecycleOptions));
369+
370+
return disposable;
371+
}
372+
252373
export class ImageAttachmentWidget extends AbstractChatAttachmentWidget {
253374

254375
constructor(

src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import { URI } from '../../../../../base/common/uri.js';
1111
import { Range } from '../../../../../editor/common/core/range.js';
1212
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
1313
import { ResourceLabels } from '../../../../browser/labels.js';
14-
import { IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, OmittedState } from '../../common/chatVariableEntries.js';
14+
import { IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isTerminalVariableEntry, OmittedState } from '../../common/chatVariableEntries.js';
1515
import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../common/chatService.js';
16-
import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../chatAttachmentWidgets.js';
16+
import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../chatAttachmentWidgets.js';
1717

1818
export interface IChatAttachmentsContentPartOptions {
1919
readonly variables: IChatRequestVariableEntry[];
@@ -141,6 +141,8 @@ export class ChatAttachmentsContentPart extends Disposable {
141141
widget = this.instantiationService.createInstance(PromptTextAttachmentWidget, attachment, undefined, { shouldFocusClearButton: false, supportsDeletion: false }, container, this._contextResourceLabels);
142142
} else if (resource && (attachment.kind === 'file' || attachment.kind === 'directory')) {
143143
widget = this.instantiationService.createInstance(FileAttachmentWidget, resource, range, attachment, correspondingContentReference, undefined, { shouldFocusClearButton: false, supportsDeletion: false }, container, this._contextResourceLabels);
144+
} else if (isTerminalVariableEntry(attachment)) {
145+
widget = this.instantiationService.createInstance(TerminalCommandAttachmentWidget, attachment, undefined, { shouldFocusClearButton: false, supportsDeletion: false }, container, this._contextResourceLabels);
144146
} else if (isPasteVariableEntry(attachment)) {
145147
widget = this.instantiationService.createInstance(PasteAttachmentWidget, attachment, undefined, { shouldFocusClearButton: false, supportsDeletion: false }, container, this._contextResourceLabels);
146148
} else if (resource && isNotebookOutputVariableEntry(attachment)) {

0 commit comments

Comments
 (0)