Skip to content

Conversation

@Tyriar
Copy link
Member

@Tyriar Tyriar commented Jan 4, 2026

No description provided.

@Tyriar Tyriar added this to the February 2026 milestone Jan 4, 2026
@Tyriar Tyriar self-assigned this Jan 4, 2026
Copilot AI review requested due to automatic review settings January 4, 2026 16:46
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a new terminal contribution that displays an interactive decoration button when text is selected in the terminal, allowing users to attach the selection to a chat widget.

Key Changes:

  • Implements a selection decoration that appears when terminal text is selected
  • Adds "Attach Selection to Chat" action with sparkle icon
  • Creates new MenuId.TerminalSelectionContext for selection-related actions

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
terminal.selectionDecoration.contribution.ts New terminal contribution that monitors terminal selection changes, displays a decoration button with 200ms debounce, and registers the "Attach Selection to Chat" action
terminalSelectionDecoration.css Styles for the selection decoration button including hover states and positioning
terminal.all.ts Registers the new selection decoration contribution module
actions.ts Adds MenuId.TerminalSelectionContext to support selection-specific context menu actions

// 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.
Comment on lines +197 to +203
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()}`),
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.
Comment on lines 1 to 218
/*---------------------------------------------------------------------------------------------
* 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 { IMenu, IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js';
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';
import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js';
import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.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 './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;
private readonly _menu: IMenu;

constructor(
_ctx: ITerminalContributionContext | IDetachedCompatibleTerminalContributionContext,
@IMenuService menuService: IMenuService,
@IContextKeyService contextKeyService: IContextKeyService,
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
) {
super();
this._showDecorationScheduler = this._register(new RunOnceScheduler(() => this._showDecoration(), 200));
this._menu = this._register(menuService.createMenu(MenuId.TerminalSelectionContext, contextKeyService));
}

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;
}

// Check if menu has any actions
const actions = getFlatContextMenuActions(this._menu.getActions({ shouldForwardArgs: true }));
if (actions.length === 0) {
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 button
const button = dom.append(element, dom.$('.terminal-selection-action-button'));
button.textContent = localize('addSelectionToChat', "Add Selection to Chat");

this._decorationListeners.add(dom.addDisposableListener(button, dom.EventType.CLICK, (e) => {
e.stopImmediatePropagation();
e.preventDefault();
this._showContextMenu(e, button);
}));

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

private _showContextMenu(e: MouseEvent, anchor: HTMLElement): void {
const actions = getFlatContextMenuActions(this._menu.getActions({ shouldForwardArgs: true }));
if (actions.length === 0) {
return;
}

// If only one action, run it directly
if (actions.length === 1) {
actions[0].run();
return;
}

const standardEvent = new StandardMouseEvent(dom.getWindow(anchor), e);
this._contextMenuService.showContextMenu({
getAnchor: () => standardEvent,
getActions: () => actions,
});
}
}

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();

// Attach the selection as a string attachment
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()}`),
};

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

// #endregion
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 new selection decoration contribution lacks test coverage. Given that other terminal contributions in the codebase have comprehensive test suites (e.g., clipboard, history, links, quickFix, suggest), consider adding tests to cover:

  • Decoration creation and rendering when selection changes
  • Decoration clearing when selection is cleared
  • Action execution and chat widget interaction
  • Edge cases like rapid selection changes or missing chat widgets

Copilot uses AI. Check for mistakes.
Comment on lines +193 to +194
// Clear the selection after attaching
activeInstance.clearSelection();
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants