Skip to content

Commit

Permalink
chat: add #problems reference to core (microsoft#242127)
Browse files Browse the repository at this point in the history
- Users can reference #problems in core now
- It will ask the user to pick a file, or all files, when there's >1
  file with errors. ([demo](https://memes.peet.io/img/25-02-8fbc8580-5f13-4714-b9d3-231edb53bdf7.mp4))
- Tweaked the icon to match the error icon in the markers view
  • Loading branch information
connor4312 authored Feb 27, 2025
1 parent 7d3d01e commit 39bf4cf
Show file tree
Hide file tree
Showing 9 changed files with 162 additions and 46 deletions.
3 changes: 2 additions & 1 deletion src/vs/platform/severityIcon/browser/media/severityIcon.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
.text-search-provider-messages .providerMessage .codicon.codicon-error,
.extensions-viewlet > .extensions .codicon.codicon-error,
.extension-editor .codicon.codicon-error,
.preferences-editor .codicon.codicon-error {
.preferences-editor .codicon.codicon-error,
.chat-attached-context-attachment .codicon.codicon-error {
color: var(--vscode-problemsErrorIcon-foreground);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -852,7 +852,7 @@ export class AttachContextAction extends Action2 {
});

const filter = await instantiationService.invokeFunction(accessor =>
createMarkersQuickPick(accessor, items => onBackgroundAccept(items.map(convert))));
createMarkersQuickPick(accessor, 'problem', items => onBackgroundAccept(items.map(convert))));
return filter && convert(filter);
}

Expand Down
5 changes: 1 addition & 4 deletions src/vs/workbench/contrib/chat/browser/chatDragAndDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,10 +383,7 @@ export class ChatDragAndDrop extends Themable {
filter = IDiagnosticVariableEntryFilterData.fromMarker(marker);
}

return {
...IDiagnosticVariableEntryFilterData.toEntry(filter),
...filter,
};
return IDiagnosticVariableEntryFilterData.toEntry(filter);
});
}

Expand Down
7 changes: 2 additions & 5 deletions src/vs/workbench/contrib/chat/browser/chatVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------------*/

import { coalesce } from '../../../../base/common/arrays.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { URI } from '../../../../base/common/uri.js';
import { Location } from '../../../../editor/common/languages.js';
import { IViewsService } from '../../../services/views/common/viewsService.js';
Expand All @@ -29,10 +28,8 @@ export class ChatVariablesService implements IChatVariablesService {

prompt.parts
.forEach((part, i) => {
if (part instanceof ChatRequestDynamicVariablePart) {
resolvedVariables[i] = { id: part.id, name: part.referenceText, range: part.range, value: part.data, fullName: part.fullName, icon: part.icon, isFile: part.isFile, isDirectory: part.isDirectory };
} else if (part instanceof ChatRequestToolPart) {
resolvedVariables[i] = { id: part.toolId, name: part.toolName, range: part.range, value: undefined, isTool: true, icon: ThemeIcon.isThemeIcon(part.icon) ? part.icon : undefined, fullName: part.displayName };
if (part instanceof ChatRequestDynamicVariablePart || part instanceof ChatRequestToolPart) {
resolvedVariables[i] = part.toVariableEntry();
}
});

Expand Down
131 changes: 102 additions & 29 deletions src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { coalesce } from '../../../../../base/common/arrays.js';
import { coalesce, groupBy } from '../../../../../base/common/arrays.js';
import { assertNever } from '../../../../../base/common/assert.js';
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { isCancellationError } from '../../../../../base/common/errors.js';
Expand Down Expand Up @@ -35,7 +36,7 @@ import { IWorkspaceContextService } from '../../../../../platform/workspace/comm
import { getExcludes, IFileQuery, ISearchComplete, ISearchConfiguration, ISearchService, QueryType } from '../../../../services/search/common/search.js';
import { ISymbolQuickPickItem } from '../../../search/browser/symbolsQuickAccess.js';
import { IDiagnosticVariableEntryFilterData } from '../../common/chatModel.js';
import { IChatRequestVariableValue, IDynamicVariable } from '../../common/chatVariables.js';
import { IChatRequestProblemsVariable, IChatRequestVariableValue, IDynamicVariable } from '../../common/chatVariables.js';
import { IChatWidget } from '../chat.js';
import { ChatWidget, IChatWidgetContrib } from '../chatWidget.js';
import { ChatFileReference } from './chatDynamicVariables/chatFileReference.js';
Expand Down Expand Up @@ -650,62 +651,84 @@ export class AddDynamicVariableAction extends Action2 {
}
registerAction2(AddDynamicVariableAction);

export async function createMarkersQuickPick(accessor: ServicesAccessor, onBackgroundAccept: (item: IDiagnosticVariableEntryFilterData[]) => void): Promise<IDiagnosticVariableEntryFilterData | undefined> {
export async function createMarkersQuickPick(accessor: ServicesAccessor, level: 'problem' | 'file', onBackgroundAccept?: (item: IDiagnosticVariableEntryFilterData[]) => void): Promise<IDiagnosticVariableEntryFilterData | undefined> {
const markers = accessor.get(IMarkerService).read();
if (!markers.length) {
return;
}

const uriIdentityService = accessor.get(IUriIdentityService);
const labelService = accessor.get(ILabelService);
markers.sort((a, b) => uriIdentityService.extUri.compare(a.resource, b.resource) || b.severity - a.severity);
const grouped = groupBy(markers, (a, b) => uriIdentityService.extUri.compare(a.resource, b.resource));

const severities = new Set<MarkerSeverity>();
type MarkerPickItem = IQuickPickItem & { resource?: URI; entry: IDiagnosticVariableEntryFilterData };
const items: (MarkerPickItem | IQuickPickSeparator)[] = [];
for (const marker of markers) {
if (!uriIdentityService.extUri.isEqual(marker.resource, (items.at(-1) as MarkerPickItem)?.resource)) {
items.push({ type: 'separator', label: labelService.getUriLabel(marker.resource, { relative: true }) });

let pickCount = 0;
for (const group of grouped) {
const resource = group[0].resource;
if (level === 'problem') {
items.push({ type: 'separator', label: labelService.getUriLabel(resource, { relative: true }) });
for (const marker of group) {
pickCount++;
severities.add(marker.severity);
items.push({
type: 'item',
resource: marker.resource,
label: marker.message,
description: localize('markers.panel.at.ln.col.number', "[Ln {0}, Col {1}]", '' + marker.startLineNumber, '' + marker.startColumn),
entry: IDiagnosticVariableEntryFilterData.fromMarker(marker),
});
}
} else if (level === 'file') {
const entry = { filterUri: resource };
pickCount++;
items.push({
type: 'item',
resource,
label: IDiagnosticVariableEntryFilterData.label(entry),
description: group[0].message + (group.length > 1 ? localize('problemsMore', '+ {0} more', group.length - 1) : ''),
entry,
});
for (const marker of group) {
severities.add(marker.severity);
}
} else {
assertNever(level);
}
}

severities.add(marker.severity);
items.push({
type: 'item',
resource: marker.resource,
label: marker.message,
description: localize('markers.panel.at.ln.col.number', "[Ln {0}, Col {1}]", '' + marker.startLineNumber, '' + marker.startColumn),
entry: IDiagnosticVariableEntryFilterData.fromMarker(marker),
});
if (pickCount < 2) { // single error in a URI
return items.find((i): i is MarkerPickItem => i.type === 'item')?.entry;
}

if (items.length === 2) { // single error in a URI
return (items[1] as MarkerPickItem).entry;
if (level === 'file') {
items.unshift({ type: 'separator', label: localize('markers.panel.files', 'Files') });
}

if (items.length > 2) {
if (severities.has(MarkerSeverity.Error)) {
items.unshift({ type: 'item', label: localize('markers.panel.allErrors', 'All Errors'), entry: { filterSeverity: MarkerSeverity.Error } });
}
if (severities.has(MarkerSeverity.Warning)) {
items.unshift({ type: 'item', label: localize('markers.panel.allWarnings', 'All Warnings'), entry: { filterSeverity: MarkerSeverity.Warning } });
}
if (severities.has(MarkerSeverity.Info)) {
items.unshift({ type: 'item', label: localize('markers.panel.allInfos', 'All Infos'), entry: { filterSeverity: MarkerSeverity.Info } });
}
if (severities.has(MarkerSeverity.Error)) {
items.unshift({ type: 'item', label: localize('markers.panel.allErrors', 'All Errors'), entry: { filterSeverity: MarkerSeverity.Error } });
}
if (severities.has(MarkerSeverity.Warning)) {
items.unshift({ type: 'item', label: localize('markers.panel.allWarnings', 'All Warnings'), entry: { filterSeverity: MarkerSeverity.Warning } });
}
if (severities.has(MarkerSeverity.Info)) {
items.unshift({ type: 'item', label: localize('markers.panel.allInfos', 'All Infos'), entry: { filterSeverity: MarkerSeverity.Info } });
}


const quickInputService = accessor.get(IQuickInputService);
const quickPick = quickInputService.createQuickPick<MarkerPickItem>({ useSeparators: true });
quickPick.canAcceptInBackground = true;
quickPick.canAcceptInBackground = !onBackgroundAccept;
quickPick.placeholder = localize('pickAProblem', 'Pick a problem to attach...');
quickPick.items = items;

return new Promise<IDiagnosticVariableEntryFilterData | undefined>(resolve => {
quickPick.onDidHide(() => resolve(undefined));
quickPick.onDidAccept(ev => {
if (ev.inBackground) {
onBackgroundAccept(quickPick.selectedItems.map(i => i.entry));
onBackgroundAccept?.(quickPick.selectedItems.map(i => i.entry));
} else {
resolve(quickPick.selectedItems[0]?.entry);
quickPick.dispose();
Expand All @@ -715,3 +738,53 @@ export async function createMarkersQuickPick(accessor: ServicesAccessor, onBackg
}).finally(() => quickPick.dispose());
}

export class SelectAndInsertProblemAction extends Action2 {
static readonly Name = 'problems';
static readonly ID = 'workbench.action.chat.selectAndInsertProblems';

constructor() {
super({
id: SelectAndInsertProblemAction.ID,
title: '' // not displayed
});
}

async run(accessor: ServicesAccessor, ...args: any[]) {
const logService = accessor.get(ILogService);
const context = args[0];
if (!isSelectAndInsertActionContext(context)) {
return;
}

const doCleanup = () => {
// Failed, remove the dangling `problem`
context.widget.inputEditor.executeEdits('chatInsertProblems', [{ range: context.range, text: `` }]);
};

const pick = await createMarkersQuickPick(accessor, 'file');
if (!pick) {
doCleanup();
return;
}

const editor = context.widget.inputEditor;
const originalRange = context.range;
const insertText = `#${SelectAndInsertProblemAction.Name}:${pick.filterUri ? basename(pick.filterUri) : MarkerSeverity.toString(pick.filterSeverity!)}`;

const varRange = new Range(originalRange.startLineNumber, originalRange.startColumn, originalRange.endLineNumber, originalRange.startColumn + insertText.length);
const success = editor.executeEdits('chatInsertProblems', [{ range: varRange, text: insertText + ' ' }]);
if (!success) {
logService.trace(`SelectAndInsertProblemsAction: failed to insert "${insertText}"`);
doCleanup();
return;
}

context.widget.getContrib<ChatDynamicVariableModel>(ChatDynamicVariableModel.ID)?.addReference({
id: 'vscode.problems',
prefix: SelectAndInsertProblemAction.Name,
range: varRange,
data: { id: 'vscode.problems', filter: pick } satisfies IChatRequestProblemsVariable,
});
}
}
registerAction2(SelectAndInsertProblemAction);
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com
import { IFileService } from '../../../../../platform/files/common/files.js';
import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
import { ILabelService } from '../../../../../platform/label/common/label.js';
import { IMarkerService } from '../../../../../platform/markers/common/markers.js';
import { Registry } from '../../../../../platform/registry/common/platform.js';
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../../common/contributions.js';
Expand All @@ -44,7 +45,7 @@ import { ILanguageModelToolsService } from '../../common/languageModelToolsServi
import { ChatEditingSessionSubmitAction, ChatSubmitAction } from '../actions/chatExecuteActions.js';
import { IChatWidget, IChatWidgetService } from '../chat.js';
import { ChatInputPart } from '../chatInputPart.js';
import { ChatDynamicVariableModel, getTopLevelFolders, searchFolders, SelectAndInsertFolderAction, SelectAndInsertFileAction, SelectAndInsertSymAction } from './chatDynamicVariables.js';
import { ChatDynamicVariableModel, SelectAndInsertFileAction, SelectAndInsertFolderAction, SelectAndInsertProblemAction, SelectAndInsertSymAction, getTopLevelFolders, searchFolders } from './chatDynamicVariables.js';

class SlashCommandCompletions extends Disposable {
constructor(
Expand Down Expand Up @@ -467,6 +468,7 @@ class BuiltinDynamicCompletions extends Disposable {
@IEditorService private readonly editorService: IEditorService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IFileService private readonly fileService: IFileService,
@IMarkerService markerService: IMarkerService,
) {
super();

Expand Down Expand Up @@ -598,6 +600,30 @@ class BuiltinDynamicCompletions extends Disposable {
return result;
});

// Problems completions, we just attach all problems in this case
this.registerVariableCompletions(SelectAndInsertProblemAction.Name, ({ widget, range, position, model }, token) => {
const stats = markerService.getStatistics();
if (!stats.errors && !stats.warnings) {
return null;
}

const result: CompletionList = { suggestions: [] };

const completedText = `${chatVariableLeader}${SelectAndInsertProblemAction.Name}:`;
const afterTextRange = new Range(position.lineNumber, range.replace.startColumn, position.lineNumber, range.replace.startColumn + completedText.length);
result.suggestions.push({
label: `${chatVariableLeader}${SelectAndInsertProblemAction.Name}`,
insertText: completedText,
documentation: localize('pickProblemsLabel', "Problems in your workspace"),
range,
kind: CompletionItemKind.Text,
command: { id: SelectAndInsertProblemAction.ID, title: SelectAndInsertProblemAction.ID, arguments: [{ widget, range: afterTextRange }] },
sortText: 'z'
});

return result;
});

this._register(CommandsRegistry.registerCommand(BuiltinDynamicCompletions.addReferenceCommand, (_services, arg) => this.cmdAddReference(arg)));

this.queryBuilder = this.instantiationService.createInstance(QueryBuilder);
Expand Down
5 changes: 3 additions & 2 deletions src/vs/workbench/contrib/chat/common/chatModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export interface IDiagnosticVariableEntryFilterData {
}

export namespace IDiagnosticVariableEntryFilterData {
export const icon = Codicon.warning;
export const icon = Codicon.error;

export function fromMarker(marker: IMarker): IDiagnosticVariableEntryFilterData {
return {
Expand All @@ -115,14 +115,15 @@ export namespace IDiagnosticVariableEntryFilterData {
};
}

export function toEntry(data: IDiagnosticVariableEntryFilterData) {
export function toEntry(data: IDiagnosticVariableEntryFilterData): IDiagnosticVariableEntry {
return {
id: id(data),
name: label(data),
icon,
value: data,
kind: 'diagnostic' as const,
range: data.filterRange ? new OffsetRange(data.filterRange.startLineNumber, data.filterRange.endLineNumber) : undefined,
...data,
};
}

Expand Down
15 changes: 14 additions & 1 deletion src/vs/workbench/contrib/chat/common/chatParserTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { ThemeIcon } from '../../../../base/common/themables.js';
import { IOffsetRange, OffsetRange } from '../../../../editor/common/core/offsetRange.js';
import { IRange } from '../../../../editor/common/core/range.js';
import { ChatAgentLocation, IChatAgentCommand, IChatAgentData, IChatAgentService, reviveSerializedAgent } from './chatAgents.js';
import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData } from './chatModel.js';
import { IChatSlashData } from './chatSlashCommands.js';
import { IChatRequestVariableValue } from './chatVariables.js';
import { IChatRequestProblemsVariable, IChatRequestVariableValue } from './chatVariables.js';
import { IToolData } from './languageModelToolsService.js';

// These are in a separate file to avoid circular dependencies with the dependencies of the parser
Expand Down Expand Up @@ -84,6 +85,10 @@ export class ChatRequestToolPart implements IParsedChatRequestPart {
get promptText(): string {
return this.text;
}

toVariableEntry(): IChatRequestVariableEntry {
return { id: this.toolId, name: this.toolName, range: this.range, value: undefined, isTool: true, icon: ThemeIcon.isThemeIcon(this.icon) ? this.icon : undefined, fullName: this.displayName };
}
}

/**
Expand Down Expand Up @@ -152,6 +157,14 @@ export class ChatRequestDynamicVariablePart implements IParsedChatRequestPart {
get promptText(): string {
return this.text;
}

toVariableEntry(): IChatRequestVariableEntry {
if (this.id === 'vscode.problems') {
return IDiagnosticVariableEntryFilterData.toEntry((this.data as IChatRequestProblemsVariable).filter);
}

return { id: this.id, name: this.referenceText, range: this.range, value: this.data, fullName: this.fullName, icon: this.icon, isFile: this.isFile, isDirectory: this.isDirectory };
}
}

export function reviveParsedChatRequest(serialized: IParsedChatRequest): IParsedChatRequest {
Expand Down
12 changes: 10 additions & 2 deletions src/vs/workbench/contrib/chat/common/chatVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { IRange } from '../../../../editor/common/core/range.js';
import { Location } from '../../../../editor/common/languages.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { ChatAgentLocation } from './chatAgents.js';
import { IChatModel, IChatRequestVariableData, IChatRequestVariableEntry } from './chatModel.js';
import { IChatModel, IChatRequestVariableData, IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData } from './chatModel.js';
import { IParsedChatRequest } from './chatParserTypes.js';
import { IChatContentReference, IChatProgressMessage } from './chatService.js';

Expand All @@ -24,7 +24,15 @@ export interface IChatVariableData {
canTakeArgument?: boolean;
}

export type IChatRequestVariableValue = string | URI | Location | unknown | Uint8Array;
export interface IChatRequestProblemsVariable {
id: 'vscode.problems';
filter: IDiagnosticVariableEntryFilterData;
}

export const isIChatRequestProblemsVariable = (obj: unknown): obj is IChatRequestProblemsVariable =>
typeof obj === 'object' && obj !== null && 'id' in obj && (obj as IChatRequestProblemsVariable).id === 'vscode.problems';

export type IChatRequestVariableValue = string | URI | Location | unknown | Uint8Array | IChatRequestProblemsVariable;

export type IChatVariableResolverProgress =
| IChatContentReference
Expand Down

0 comments on commit 39bf4cf

Please sign in to comment.