-
Notifications
You must be signed in to change notification settings - Fork 37.9k
Add selection decoration terminal contrib #285815
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // 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()}`), | ||||||||||||||||||||||||||||||||
|
Comment on lines
+170
to
+176
|
||||||||||||||||||||||||||||||||
| 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}`), |
There was a problem hiding this comment.
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.