-
Notifications
You must be signed in to change notification settings - Fork 37.3k
[wip] Gutter edit menu #287103
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?
[wip] Gutter edit menu #287103
Conversation
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.
Pull request overview
This PR introduces a prototype gutter indicator feature that appears next to text selections in the editor. When text is selected, a gutter icon appears that opens a menu with AI-powered editing options including inline chat, attaching to chat, and explaining code.
Changes:
- Adds a new SelectionGutterIndicatorContribution editor contribution that displays a gutter indicator at the cursor position with enhanced prominence for text selections
- Extends the existing InlineEditsGutterIndicator component with a customization API to support custom styles, icons, and menu content
- Registers the new contribution in chat.contribution.ts
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| selectionGutterIndicator.ts | New editor contribution that creates a selection-based gutter indicator with custom menu showing AI editing options |
| chat.contribution.ts | Imports and registers the new selection gutter indicator feature |
| gutterIndicatorView.ts | Adds GutterIndicatorCustomization interface and extends InlineEditsGutterIndicatorData to support custom styles, icons, and menu factories |
Comments suppressed due to low confidence (3)
src/vs/workbench/contrib/chat/browser/selectionGutterIndicator/selectionGutterIndicator.ts:122
- This new editor contribution lacks test coverage. The codebase has comprehensive test coverage for chat features (as evidenced by the test/browser directory structure), but no tests were added for SelectionGutterIndicatorContribution. Consider adding tests to verify the gutter indicator appears correctly, the debouncing behavior works as expected, menu interactions function properly, and the feature respects the ChatContextKeys.enabled setting.
export class SelectionGutterIndicatorContribution extends Disposable implements IEditorContribution {
public static readonly ID = 'editor.contrib.selectionGutterIndicator';
public static get(editor: ICodeEditor): SelectionGutterIndicatorContribution | null {
return editor.getContribution<SelectionGutterIndicatorContribution>(SelectionGutterIndicatorContribution.ID);
}
constructor(
private readonly _editor: ICodeEditor,
@ICommandService private readonly _commandService: ICommandService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
) {
super();
const editorObs = observableCodeEditor(this._editor);
const focusIsInMenu = observableValue<boolean>(this, false);
// Debounce the selection to add a delay before showing the indicator
const debouncedSelection = debouncedObservable(editorObs.cursorSelection, 500);
// Create data observable based on the primary selection
const data = derived(reader => {
const selection = debouncedSelection.read(reader);
// Always show when we have a selection (even if empty)
if (!selection || selection.isEmpty()) {
return undefined;
}
// Use the cursor position (active end of selection) to determine the line
const cursorPosition = selection.getPosition();
const lineRange = new LineRange(cursorPosition.lineNumber, cursorPosition.lineNumber + 1);
// Check if there's actually selected text
const hasSelection = !selection.isEmpty();
// Create minimal gutter menu data (empty for prototype)
const gutterMenuData = new InlineSuggestionGutterMenuData(
undefined, // action
'Selection', // displayName
[], // extensionCommands
undefined, // alternativeAction
undefined, // modelInfo
undefined, // setModelId
);
// Create model with console.log actions for prototyping
const model = new SimpleInlineSuggestModel(
() => console.log('[SelectionGutterIndicator] accept'),
() => console.log('[SelectionGutterIndicator] jump'),
);
// Use different styles based on whether there's a selection
const styles = hasSelection
? {
// Primary (blue) styling when text is selected
background: 'var(--vscode-inlineEdit-gutterIndicator-primaryBackground)',
foreground: 'var(--vscode-inlineEdit-gutterIndicator-primaryForeground)',
border: 'var(--vscode-inlineEdit-gutterIndicator-primaryBorder)',
}
: {
// Secondary (less prominent) styling when no selection
background: 'var(--vscode-inlineEdit-gutterIndicator-secondaryBackground)',
foreground: 'var(--vscode-inlineEdit-gutterIndicator-secondaryForeground)',
border: 'var(--vscode-inlineEdit-gutterIndicator-secondaryBorder)',
};
return new InlineEditsGutterIndicatorData(
gutterMenuData,
lineRange,
model,
undefined, // altAction
{
styles,
// Use pencil icon
icon: Codicon.pencil,
// Custom menu content
menuContentFactory: (editorObs, close) => createSelectionMenu(editorObs, close, this._commandService),
}
);
});
// Instantiate the gutter indicator
this._register(this._instantiationService.createInstance(
InlineEditsGutterIndicator,
editorObs,
data,
constObservable(InlineEditTabAction.Inactive), // tabAction - not used with custom styles
constObservable(0), // verticalOffset
constObservable(false), // isHoveringOverInlineEdit
focusIsInMenu,
));
}
}
src/vs/workbench/contrib/chat/browser/selectionGutterIndicator/selectionGutterIndicator.ts:63
- The variable hasSelection is computed but then never used. Line 63 calculates this value, but the only place where selection emptiness is checked is on line 82 where it's rechecked directly. Either use this variable on line 82 or remove it to avoid unnecessary computation.
const hasSelection = !selection.isEmpty();
src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts:58
- The customization parameter is optional and added at the end of the constructor, which is good for backward compatibility. However, the API design could be improved by validating that when a custom menuContentFactory is provided, the menu content has appropriate dispose behavior. The current implementation assumes the factory returns a properly disposable LiveElement, but there's no validation or documentation about this requirement.
export class InlineEditsGutterIndicatorData {
constructor(
readonly gutterMenuData: InlineSuggestionGutterMenuData,
readonly originalRange: LineRange,
readonly model: SimpleInlineSuggestModel,
readonly altAction: InlineSuggestAlternativeAction | undefined,
readonly customization?: GutterIndicatorCustomization,
) { }
| registerEditorContribution( | ||
| SelectionGutterIndicatorContribution.ID, | ||
| SelectionGutterIndicatorContribution, | ||
| EditorContributionInstantiation.AfterFirstRender, | ||
| ); |
Copilot
AI
Jan 12, 2026
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.
This chat/AI feature is not gated on ChatContextKeys.enabled. According to coding guidelines, new AI features should not show up when users have disabled AI features. The editor contribution should check if chat is enabled and conditionally register or show/hide the gutter indicator based on this context. Consider checking ChatContextKeys.enabled.getValue(contextKeyService) in the constructor or during initialization to control whether the feature is active.
This issue also appears in the following locations of the same file:
- line 28
| // Create model with console.log actions for prototyping | ||
| const model = new SimpleInlineSuggestModel( | ||
| () => console.log('[SelectionGutterIndicator] accept'), | ||
| () => console.log('[SelectionGutterIndicator] jump'), |
Copilot
AI
Jan 12, 2026
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 model instance passed to InlineEditsGutterIndicatorData uses console.log for accept and jump actions (lines 77-78), which is meant for prototyping. For a production feature, these should either be replaced with actual functionality or clearly marked with TODO comments indicating they need implementation. Console logging in production code doesn't provide user value and may clutter console output.
| // Create model with console.log actions for prototyping | |
| const model = new SimpleInlineSuggestModel( | |
| () => console.log('[SelectionGutterIndicator] accept'), | |
| () => console.log('[SelectionGutterIndicator] jump'), | |
| // Create model with placeholder actions. | |
| // TODO: Implement accept and jump behaviors for the selection gutter indicator. | |
| const model = new SimpleInlineSuggestModel( | |
| () => { /* TODO: implement accept behavior for selection gutter indicator */ }, | |
| () => { /* TODO: implement jump behavior for selection gutter indicator */ }, |
| * Creates the custom menu content for the selection gutter indicator. | ||
| */ | ||
| function createSelectionMenu(editorObs: ObservableCodeEditor, close: (focusEditor: boolean) => void, commandService: ICommandService) { | ||
| const activeElement = observableValue<string | undefined>('active', undefined); |
Copilot
AI
Jan 12, 2026
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 activeElement observable is created with the identifier 'active' (line 128), but this appears to be a debug name rather than a descriptive identifier. Consider using a more descriptive name like 'activeMenuElement' to better reflect its purpose in tracking which menu element is active.
| return n.div({ | ||
| class: 'selection-gutter-menu', | ||
| style: { margin: '4px', minWidth: '250px' } | ||
| }, [ | ||
| // Header | ||
| n.div({ | ||
| style: { | ||
| color: 'var(--vscode-descriptionForeground)', | ||
| fontSize: '13px', | ||
| fontWeight: '600', | ||
| padding: '0 4px', | ||
| lineHeight: '28px', | ||
| } | ||
| }, [localize('selectionActions', "Selection Actions")]), | ||
|
|
||
| // Prompt input box | ||
| n.div({ | ||
| style: { | ||
| padding: '4px', | ||
| } | ||
| }, [ | ||
| n.elem('input', { | ||
| type: 'text', | ||
| placeholder: localize('promptPlaceholder', "Ask Copilot to Edit..."), | ||
| style: { | ||
| width: '100%', | ||
| padding: '6px 8px', | ||
| border: '1px solid var(--vscode-input-border)', | ||
| borderRadius: '4px', | ||
| backgroundColor: 'var(--vscode-input-background)', | ||
| color: 'var(--vscode-input-foreground)', | ||
| fontSize: '13px', | ||
| boxSizing: 'border-box', | ||
| outline: 'none', | ||
| }, | ||
| onkeydown: (e: KeyboardEvent) => { | ||
| if (e.key === 'Enter') { | ||
| const input = e.target as HTMLInputElement; | ||
| const prompt = input.value.trim(); | ||
| if (prompt) { | ||
| close(true); | ||
| commandService.executeCommand('inlineChat.start', { message: prompt, autoSend: true }); | ||
| } | ||
| } else if (e.key === 'Escape') { | ||
| close(true); | ||
| } | ||
| }, | ||
| onfocus: (e: FocusEvent) => { | ||
| const input = e.target as HTMLInputElement; | ||
| input.style.borderColor = 'var(--vscode-focusBorder)'; | ||
| }, | ||
| onblur: (e: FocusEvent) => { | ||
| const input = e.target as HTMLInputElement; | ||
| input.style.borderColor = 'var(--vscode-input-border)'; | ||
| }, | ||
| }), | ||
| ]), | ||
|
|
||
| // Separator | ||
| createMenuSeparator(), | ||
|
|
||
| // --- Inline Chat Actions --- | ||
| // Option: Edit Selection (runs inlineChat.start) | ||
| createMenuOption({ | ||
| id: 'editSelection', | ||
| title: localize('editSelection', "Edit Selection"), | ||
| icon: Codicon.sparkle, | ||
| isActive: activeElement.map(v => v === 'editSelection'), | ||
| onHoverChange: v => activeElement.set(v ? 'editSelection' : undefined, undefined), | ||
| onAction: () => { | ||
| close(true); | ||
| commandService.executeCommand('inlineChat.start'); | ||
| } | ||
| }), | ||
|
|
||
| // Separator | ||
| createMenuSeparator(), | ||
|
|
||
| // --- Chat View Actions --- | ||
| // Option: Attach to Chat | ||
| createMenuOption({ | ||
| id: 'attachToChat', | ||
| title: localize('attachToChat', "Attach to Chat"), | ||
| icon: Codicon.attach, | ||
| isActive: activeElement.map(v => v === 'attachToChat'), | ||
| onHoverChange: v => activeElement.set(v ? 'attachToChat' : undefined, undefined), | ||
| onAction: () => { | ||
| close(true); | ||
| commandService.executeCommand('workbench.action.chat.attachSelection'); | ||
| } | ||
| }), | ||
| // Option: Explain | ||
| createMenuOption({ | ||
| id: 'explain', | ||
| title: localize('explain', "Explain"), | ||
| icon: Codicon.comment, | ||
| isActive: activeElement.map(v => v === 'explain'), | ||
| onHoverChange: v => activeElement.set(v ? 'explain' : undefined, undefined), | ||
| onAction: () => { | ||
| close(true); | ||
| commandService.executeCommand('workbench.action.chat.open', { query: '/explain' }); | ||
| } | ||
| }), | ||
| ]).toDisposableLiveElement(); | ||
| } | ||
|
|
||
| function createMenuSeparator() { | ||
| return n.div({ | ||
| class: 'menu-separator', | ||
| style: { | ||
| padding: '4px 0', | ||
| } | ||
| }, [ | ||
| n.div({ | ||
| style: { | ||
| borderBottom: '1px solid var(--vscode-editorHoverWidget-border)', | ||
| } | ||
| }) | ||
| ]); | ||
| } | ||
|
|
||
| function createMenuOption(props: { | ||
| id: string; | ||
| title: string; | ||
| icon: ThemeIcon; | ||
| isActive: IObservable<boolean>; | ||
| onHoverChange: (isHovered: boolean) => void; | ||
| onAction: () => void; | ||
| }) { | ||
| return n.div({ | ||
| class: ['monaco-menu-option', props.isActive.map(v => v && 'active')], | ||
| onmouseenter: () => props.onHoverChange(true), | ||
| onmouseleave: () => props.onHoverChange(false), | ||
| onclick: props.onAction, | ||
| tabIndex: 0, | ||
| style: { borderRadius: '3px' } | ||
| }, [ | ||
| n.elem('span', { | ||
| style: { fontSize: '16px', display: 'flex' } | ||
| }, [renderIcon(props.icon)]), | ||
| n.elem('span', {}, [props.title]), | ||
| ]); |
Copilot
AI
Jan 12, 2026
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.
Inline styles are heavily used throughout the menu creation (e.g., lines 132, 136-142, 147-164, 239-246, 265, 268). This makes the code harder to maintain and doesn't follow VS Code's typical pattern of using CSS classes with theme variables. Consider extracting these styles into a CSS file (e.g., selectionGutterIndicator.css) and using class names instead of inline styles.
| * Customization options for the gutter indicator appearance and behavior. | ||
| */ | ||
| export interface GutterIndicatorCustomization { | ||
| /** Override the default styles (colors) */ | ||
| readonly styles?: { background: string; foreground: string; border: string }; | ||
| /** Override the default icon */ | ||
| readonly icon?: ThemeIcon; | ||
| /** Factory to create custom menu content instead of the default */ | ||
| readonly menuContentFactory?: (editorObs: ObservableCodeEditor, close: (focusEditor: boolean) => void) => LiveElement; | ||
| } | ||
|
|
Copilot
AI
Jan 12, 2026
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 new GutterIndicatorCustomization interface lacks JSDoc documentation. Given that this is a new public API being exported (line 42), it should have comprehensive documentation explaining when and how it should be used, what each property does, and providing usage examples. This is especially important for extensibility interfaces.
This issue also appears in the following locations of the same file:
- line 51
| * Customization options for the gutter indicator appearance and behavior. | |
| */ | |
| export interface GutterIndicatorCustomization { | |
| /** Override the default styles (colors) */ | |
| readonly styles?: { background: string; foreground: string; border: string }; | |
| /** Override the default icon */ | |
| readonly icon?: ThemeIcon; | |
| /** Factory to create custom menu content instead of the default */ | |
| readonly menuContentFactory?: (editorObs: ObservableCodeEditor, close: (focusEditor: boolean) => void) => LiveElement; | |
| } | |
| * Customization options for the inline edits gutter indicator. | |
| * | |
| * This interface is intended for callers that construct {@link InlineEditsGutterIndicatorData} | |
| * and want to override the default appearance and/or context menu content shown next to an | |
| * inline edit in the editor gutter. | |
| * | |
| * When a {@link GutterIndicatorCustomization} is provided, the gutter indicator will still | |
| * participate in the normal inline edits flow (accept, jump, alternative actions, extension | |
| * commands), but will use the customized styles, icon, or menu content instead of the | |
| * built-in defaults. | |
| * | |
| * @property styles | |
| * Override the default indicator colors. All values are CSS color strings (for example, | |
| * `'#rrggbb'`, `'rgba(...)'` or `var(--vscode-color-name)`). | |
| * If omitted, the colors defined by the current theme are used. | |
| * | |
| * @property icon | |
| * Override the default icon displayed in the gutter indicator. | |
| * If omitted, the standard inline edits codicon is used. | |
| * | |
| * @property menuContentFactory | |
| * Provide a factory that creates custom menu content for the indicator instead of the | |
| * default inline edits menu. The factory is called when the indicator is activated. | |
| * The returned {@link LiveElement} is rendered inside the hover-like menu UI. | |
| * The `close` callback should be invoked by the implementation when the menu should | |
| * be dismissed. Passing `true` to `close` will return focus to the editor. | |
| * | |
| * @example | |
| * ```ts | |
| * const customization: GutterIndicatorCustomization = { | |
| * styles: { | |
| * background: 'var(--vscode-editorGutter-addedBackground)', | |
| * foreground: 'var(--vscode-editorWidget-foreground)', | |
| * border: 'transparent', | |
| * }, | |
| * icon: Codicon.sparkle, | |
| * menuContentFactory: (editor, close) => { | |
| * // Build and return a LiveElement with custom actions. | |
| * // Call close(true) when the menu should be dismissed and focus | |
| * // should return to the editor. | |
| * return myCustomLiveElement; | |
| * }, | |
| * }; | |
| * ``` | |
| */ | |
| export interface GutterIndicatorCustomization { | |
| /** Override the default styles (colors) used for the gutter indicator. */ | |
| readonly styles?: { background: string; foreground: string; border: string }; | |
| /** Override the default icon shown for the gutter indicator. */ | |
| readonly icon?: ThemeIcon; | |
| /** | |
| * Factory to create custom menu content instead of the default inline edits menu. | |
| * The returned element is shown in a hover widget anchored to the gutter indicator. | |
| */ | |
| readonly menuContentFactory?: (editorObs: ObservableCodeEditor, close: (focusEditor: boolean) => void) => LiveElement; | |
| } | |
| /** | |
| * Data model used to render the inline edits gutter indicator and its menu. | |
| * | |
| * Instances of this class bundle together the information required to: | |
| * - Show the indicator next to the original range of an inline edit. | |
| * - Execute the primary inline suggest actions (accept, jump). | |
| * - Offer extension-provided commands and an alternative action (if any). | |
| * - Optionally customize the indicator appearance and menu via | |
| * {@link GutterIndicatorCustomization}. | |
| */ |
| const selection = debouncedSelection.read(reader); | ||
|
|
||
| // Always show when we have a selection (even if empty) | ||
| if (!selection || selection.isEmpty()) { |
Copilot
AI
Jan 12, 2026
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 condition on line 54 checks if selection is falsy OR isEmpty(), but then the code inside still tries to show the indicator for empty selections based on the comment on line 53. This creates a logical inconsistency - if you want to show indicators even for empty selections (cursor positions), the isEmpty() check should be removed from the return condition. If empty selections shouldn't show indicators, update the comment on line 53 to reflect that.
This issue also appears in the following locations of the same file:
- line 63
| if (!selection || selection.isEmpty()) { | |
| if (!selection) { |
| }, [ | ||
| n.elem('input', { | ||
| type: 'text', | ||
| placeholder: localize('promptPlaceholder', "Ask Copilot to Edit..."), |
Copilot
AI
Jan 12, 2026
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 input placeholder uses "Ask Copilot to Edit..." which is branding-specific. According to VS Code naming patterns, user-facing strings should generally use more generic language or respect user configuration. Consider using a more generic term or checking if this aligns with the project's branding guidelines for AI features.
| placeholder: localize('promptPlaceholder', "Ask Copilot to Edit..."), | |
| placeholder: localize('promptPlaceholder', "Describe how to edit the selection"), |
| class: ['monaco-menu-option', props.isActive.map(v => v && 'active')], | ||
| onmouseenter: () => props.onHoverChange(true), | ||
| onmouseleave: () => props.onHoverChange(false), | ||
| onclick: props.onAction, |
Copilot
AI
Jan 12, 2026
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 menu options lack keyboard accessibility. While tabIndex is set on line 264, there's no keyboard event handler (onkeydown/onkeyup) to allow activating the options with Enter or Space keys. Users relying on keyboard navigation won't be able to activate these menu items.
| onclick: props.onAction, | |
| onclick: props.onAction, | |
| onkeydown: e => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| props.onAction(); | |
| } | |
| }, |
an exploration for #287105
Explores using the gutter (like NES) to show a menu that can start or be inline chat and also contain other chat related actions. Could/should also include code actions to serve as an alternative to the lightbulb
Screen.Recording.2026-01-09.at.12.14.26.mov