Skip to content

Conversation

@jrieken
Copy link
Member

@jrieken jrieken commented Jan 12, 2026

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

Copilot AI review requested due to automatic review settings January 12, 2026 14:24
@jrieken jrieken self-assigned this Jan 12, 2026
@jrieken jrieken requested a review from hediet January 12, 2026 14:25
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 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,
	) { }

Comment on lines +274 to +278
registerEditorContribution(
SelectionGutterIndicatorContribution.ID,
SelectionGutterIndicatorContribution,
EditorContributionInstantiation.AfterFirstRender,
);
Copy link

Copilot AI Jan 12, 2026

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

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +75 to +78
// Create model with console.log actions for prototyping
const model = new SimpleInlineSuggestModel(
() => console.log('[SelectionGutterIndicator] accept'),
() => console.log('[SelectionGutterIndicator] jump'),
Copy link

Copilot AI Jan 12, 2026

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.

Suggested change
// 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 */ },

Copilot uses AI. Check for mistakes.
* 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);
Copy link

Copilot AI Jan 12, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +130 to +271
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]),
]);
Copy link

Copilot AI Jan 12, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to 50
* 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;
}

Copy link

Copilot AI Jan 12, 2026

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
Suggested change
* 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}.
*/

Copilot uses AI. Check for mistakes.
const selection = debouncedSelection.read(reader);

// Always show when we have a selection (even if empty)
if (!selection || selection.isEmpty()) {
Copy link

Copilot AI Jan 12, 2026

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
Suggested change
if (!selection || selection.isEmpty()) {
if (!selection) {

Copilot uses AI. Check for mistakes.
}, [
n.elem('input', {
type: 'text',
placeholder: localize('promptPlaceholder', "Ask Copilot to Edit..."),
Copy link

Copilot AI Jan 12, 2026

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.

Suggested change
placeholder: localize('promptPlaceholder', "Ask Copilot to Edit..."),
placeholder: localize('promptPlaceholder', "Describe how to edit the selection"),

Copilot uses AI. Check for mistakes.
class: ['monaco-menu-option', props.isActive.map(v => v && 'active')],
onmouseenter: () => props.onHoverChange(true),
onmouseleave: () => props.onHoverChange(false),
onclick: props.onAction,
Copy link

Copilot AI Jan 12, 2026

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.

Suggested change
onclick: props.onAction,
onclick: props.onAction,
onkeydown: e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
props.onAction();
}
},

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