Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/vs/platform/actions/common/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export class MenuId {
static readonly TerminalTabContext = new MenuId('TerminalTabContext');
static readonly TerminalTabEmptyAreaContext = new MenuId('TerminalTabEmptyAreaContext');
static readonly TerminalStickyScrollContext = new MenuId('TerminalStickyScrollContext');
static readonly TerminalSelectionContext = new MenuId('TerminalSelectionContext');
static readonly WebviewContext = new MenuId('WebviewContext');
static readonly InlineCompletionsActions = new MenuId('InlineCompletionsActions');
static readonly InlineEditsActions = new MenuId('InlineEditsActions');
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/terminal/terminal.all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import '../terminalContrib/zoom/browser/terminal.zoom.contribution.js';
import '../terminalContrib/stickyScroll/browser/terminal.stickyScroll.contribution.js';
import '../terminalContrib/quickAccess/browser/terminal.quickAccess.contribution.js';
import '../terminalContrib/quickFix/browser/terminal.quickFix.contribution.js';
import '../terminalContrib/selectionDecoration/browser/terminal.selectionDecoration.contribution.js';
import '../terminalContrib/typeAhead/browser/terminal.typeAhead.contribution.js';
import '../terminalContrib/resizeDimensionsOverlay/browser/terminal.resizeDimensionsOverlay.contribution.js';
import '../terminalContrib/sendSequence/browser/terminal.sendSequence.contribution.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

.terminal-selection-decoration {
z-index: 10;
pointer-events: auto !important;
}

.terminal-selection-decoration .terminal-selection-action-bar {
display: inline-flex;
transform: translateY(-100%);
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border);
border-radius: 4px;
padding: 2px;
}

.terminal-selection-decoration .terminal-selection-action-bar .monaco-action-bar {
background: transparent;
}

.terminal-selection-decoration .terminal-selection-action-bar .monaco-action-bar .action-item .action-label {
padding: 2px 4px;
border-radius: 2px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import type { IDecoration, Terminal as RawXtermTerminal } from '@xterm/xterm';
import * as dom from '../../../../../base/browser/dom.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js';
import { localize, localize2 } from '../../../../../nls.js';
import { ITerminalContribution, ITerminalInstance, IXtermTerminal } from '../../../terminal/browser/terminal.js';
import { registerTerminalContribution, type IDetachedCompatibleTerminalContributionContext, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js';
import { RunOnceScheduler } from '../../../../../base/common/async.js';
import { MenuId } from '../../../../../platform/actions/common/actions.js';
import { registerActiveXtermAction } from '../../../terminal/browser/terminalActions.js';
import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey.js';
import { IChatWidgetService } from '../../../chat/browser/chat.js';
import { ChatAgentLocation } from '../../../chat/common/constants.js';
import { IChatRequestStringVariableEntry } from '../../../chat/common/attachments/chatVariableEntries.js';
import { URI } from '../../../../../base/common/uri.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js';
import './media/terminalSelectionDecoration.css';

// #region Terminal Contribution

class TerminalSelectionDecorationContribution extends Disposable implements ITerminalContribution {
static readonly ID = 'terminal.selectionDecoration';

static get(instance: ITerminalInstance): TerminalSelectionDecorationContribution | null {
return instance.getContribution<TerminalSelectionDecorationContribution>(TerminalSelectionDecorationContribution.ID);
}

private _xterm: IXtermTerminal & { raw: RawXtermTerminal } | undefined;
private readonly _decoration = this._register(new MutableDisposable<IDecoration>());
private readonly _decorationListeners = this._register(new DisposableStore());
private readonly _showDecorationScheduler: RunOnceScheduler;

constructor(
_ctx: ITerminalContributionContext | IDetachedCompatibleTerminalContributionContext,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
) {
super();
this._showDecorationScheduler = this._register(new RunOnceScheduler(() => this._showDecoration(), 200));
}

xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void {
this._xterm = xterm;
this._register(xterm.raw.onSelectionChange(() => this._onSelectionChange()));
}

private _onSelectionChange(): void {
// TODO: Show decoration in intuitive position regardless of where it starts
// TODO: Upstream to allow listening while selection is in progress
// Clear decoration immediately when selection changes
this._decoration.clear();
this._decorationListeners.clear();

// Only schedule showing the decoration if there's a selection
if (this._xterm?.raw.hasSelection()) {
this._showDecorationScheduler.schedule();
} else {
this._showDecorationScheduler.cancel();
}
}

private _showDecoration(): void {
if (!this._xterm) {
return;
}

// Only show if there's a selection
if (!this._xterm.raw.hasSelection()) {
return;
}

const selectionPosition = this._xterm.raw.getSelectionPosition();
if (!selectionPosition) {
return;
}

// Create a marker at the start of the selection
const marker = this._xterm.raw.registerMarker(selectionPosition.start.y - (this._xterm.raw.buffer.active.baseY + this._xterm.raw.buffer.active.cursorY));
if (!marker) {
return;
}

// Register the decoration
const decoration = this._xterm.raw.registerDecoration({
marker,
x: selectionPosition.start.x,
layer: 'top'
});

if (!decoration) {
marker.dispose();
return;
}

this._decoration.value = decoration;

this._decorationListeners.add(decoration.onRender(element => {
if (!element.classList.contains('terminal-selection-decoration')) {
this._setupDecorationElement(element);
}
}));
}

private _setupDecorationElement(element: HTMLElement): void {
element.classList.add('terminal-selection-decoration');

// Create the action bar container
const actionBarContainer = dom.append(element, dom.$('.terminal-selection-action-bar'));

// Create a MenuWorkbenchToolBar for the actions
this._decorationListeners.add(this._instantiationService.createInstance(
MenuWorkbenchToolBar,
actionBarContainer,
MenuId.TerminalSelectionContext,
{
menuOptions: { shouldForwardArgs: true },
toolbarOptions: { primaryGroup: () => true }
}
));

this._decorationListeners.add(dom.addDisposableListener(actionBarContainer, dom.EventType.MOUSE_DOWN, (e) => {
e.stopImmediatePropagation();
e.preventDefault();
}));
}
}

registerTerminalContribution(TerminalSelectionDecorationContribution.ID, TerminalSelectionDecorationContribution, true);

// #endregion

// #region Actions

const enum TerminalSelectionCommandId {
AttachSelectionToChat = 'workbench.action.terminal.attachSelectionToChat',
}

registerActiveXtermAction({
id: TerminalSelectionCommandId.AttachSelectionToChat,
title: localize2('workbench.action.terminal.attachSelectionToChat', 'Attach Selection to Chat'),
icon: Codicon.sparkle,
precondition: TerminalContextKeys.textSelectedInFocused,
run: async (_xterm, accessor, activeInstance) => {
const chatWidgetService = accessor.get(IChatWidgetService);

const selection = activeInstance.selection;
if (!selection) {
return;
}

let widget = chatWidgetService.lastFocusedWidget ?? chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat)?.find(w => w.attachmentCapabilities.supportsTerminalAttachments);

if (!widget) {
widget = await chatWidgetService.revealWidget();
}

if (!widget || !widget.attachmentCapabilities.supportsTerminalAttachments) {
return;
}

// Clear the selection after attaching
activeInstance.clearSelection();
Comment on lines +166 to +167
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The selection is cleared before attaching it to the chat widget. This creates a poor user experience because if the attachment fails (e.g., widget becomes null between the check on line 189 and this line, or addContext fails), the user loses their selection with no way to recover it. Consider clearing the selection only after successfully attaching it to the chat widget.

Copilot uses AI. Check for mistakes.

// Attach the selection as a string attachment
const attachment: IChatRequestStringVariableEntry = {
kind: 'string',
id: `terminalSelection:${Date.now()}`,
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Date.now() for ID generation can lead to collisions if multiple selections are attached in quick succession (within the same millisecond). Consider using a more robust ID generation approach, such as combining Date.now() with a counter or using crypto.randomUUID() if available.

Copilot uses AI. Check for mistakes.
name: localize('terminal.selection', "Terminal Selection"),
value: selection,
icon: Codicon.terminal,
uri: URI.parse(`terminal-selection:${Date.now()}`),
Comment on lines +170 to +176
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URI uses Date.now() which could produce duplicate URIs if called multiple times within the same millisecond, potentially causing issues with URI-based lookups or comparisons. The same value from line 199 should be reused here to ensure consistency, or a more robust unique identifier should be used.

Suggested change
const attachment: IChatRequestStringVariableEntry = {
kind: 'string',
id: `terminalSelection:${Date.now()}`,
name: localize('terminal.selection', "Terminal Selection"),
value: selection,
icon: Codicon.terminal,
uri: URI.parse(`terminal-selection:${Date.now()}`),
const now = Date.now();
const attachment: IChatRequestStringVariableEntry = {
kind: 'string',
id: `terminalSelection:${now}`,
name: localize('terminal.selection', "Terminal Selection"),
value: selection,
icon: Codicon.terminal,
uri: URI.parse(`terminal-selection:${now}`),

Copilot uses AI. Check for mistakes.
};

widget.attachmentModel.addContext(attachment);
widget.focusInput();
},
menu: [
{
id: MenuId.TerminalSelectionContext,
group: 'navigation',
order: 1,
}
]
});

// #endregion
Loading