Skip to content

Commit 2f1013a

Browse files
authored
Render slash commands as blocks (microsoft#187620)
1 parent 3ffd570 commit 2f1013a

File tree

5 files changed

+108
-17
lines changed

5 files changed

+108
-17
lines changed

src/vs/workbench/contrib/chat/browser/chat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface IChatWidget {
6060
acceptInput(query?: string): void;
6161
focusLastMessage(): void;
6262
focusInput(): void;
63+
getSlashCommandsSync(): ISlashCommand[] | undefined;
6364
getSlashCommands(): Promise<ISlashCommand[] | undefined>;
6465
getCodeBlockInfoForEditor(uri: URI): IChatCodeBlockInfo | undefined;
6566
getCodeBlockInfosForResponse(response: IChatResponseViewModel): IChatCodeBlockInfo[];

src/vs/workbench/contrib/chat/browser/chatWidget.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,10 @@ export class ChatWidget extends Disposable implements IChatWidget {
219219
}
220220
}
221221

222+
getSlashCommandsSync(): ISlashCommand[] | undefined {
223+
return this.lastSlashCommands;
224+
}
225+
222226
async getSlashCommands(): Promise<ISlashCommand[] | undefined> {
223227
if (!this.viewModel) {
224228
return;

src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,34 @@ import { ITextModel } from 'vs/editor/common/model';
1515
import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures';
1616
import { localize } from 'vs/nls';
1717
import { Registry } from 'vs/platform/registry/common/platform';
18-
import { editorForeground, textLinkForeground } from 'vs/platform/theme/common/colorRegistry';
18+
import { editorForeground, textCodeBlockBackground, textLinkForeground } from 'vs/platform/theme/common/colorRegistry';
1919
import { IThemeService } from 'vs/platform/theme/common/themeService';
2020
import { IChatWidget, IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat';
2121
import { ChatWidget } from 'vs/workbench/contrib/chat/browser/chatWidget';
2222
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
2323
import { ChatInputPart } from 'vs/workbench/contrib/chat/browser/chatInputPart';
2424
import { IChatService } from 'vs/workbench/contrib/chat/common/chatService';
25+
import { ContentWidgetPositionPreference, IContentWidget } from 'vs/editor/browser/editorBrowser';
26+
import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent';
27+
import { KeyCode } from 'vs/base/common/keyCodes';
28+
import { Selection } from 'vs/editor/common/core/selection';
2529

2630
const decorationDescription = 'chat';
2731
const slashCommandPlaceholderDecorationType = 'chat-session-detail';
2832
const slashCommandTextDecorationType = 'chat-session-text';
33+
const slashCommandContentWidgetId = 'chat-session-content-widget';
2934

3035
class InputEditorDecorations extends Disposable {
3136

37+
private _slashCommandDomNode = document.createElement('div');
38+
private _slashCommandContentWidget: IContentWidget | undefined;
39+
private _previouslyUsedSlashCommands = new Set<string>();
40+
3241
constructor(
3342
private readonly widget: IChatWidget,
3443
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
3544
@IThemeService private readonly themeService: IThemeService,
45+
@IChatService private readonly chatService: IChatService,
3646
) {
3747
super();
3848

@@ -43,14 +53,25 @@ class InputEditorDecorations extends Disposable {
4353

4454
this.updateInputEditorDecorations();
4555
this._register(this.widget.inputEditor.onDidChangeModelContent(() => this.updateInputEditorDecorations()));
46-
this._register(this.widget.onDidChangeViewModel(() => this.updateInputEditorDecorations()));
56+
this._register(this.widget.onDidChangeViewModel(() => {
57+
this._previouslyUsedSlashCommands.clear();
58+
this.updateInputEditorDecorations();
59+
}));
60+
this._register(this.chatService.onDidSubmitSlashCommand((e) => {
61+
if (e.sessionId === this.widget.viewModel?.sessionId && !this._previouslyUsedSlashCommands.has(e.slashCommand)) {
62+
this._previouslyUsedSlashCommands.add(e.slashCommand);
63+
}
64+
}));
4765
}
4866

4967
private updateRegisteredDecorationTypes() {
50-
const theme = this.themeService.getColorTheme();
5168
this.codeEditorService.removeDecorationType(slashCommandTextDecorationType);
69+
this.updateInputEditorContentWidgets({ hide: true });
5270
this.codeEditorService.registerDecorationType(decorationDescription, slashCommandTextDecorationType, {
53-
color: theme.getColor(textLinkForeground)?.toString()
71+
opacity: '0',
72+
after: {
73+
contentText: ' ',
74+
}
5475
});
5576
this.updateInputEditorDecorations();
5677
}
@@ -62,10 +83,10 @@ class InputEditorDecorations extends Disposable {
6283
}
6384

6485
private async updateInputEditorDecorations() {
65-
const value = this.widget.inputEditor.getValue();
86+
const inputValue = this.widget.inputEditor.getValue();
6687
const slashCommands = await this.widget.getSlashCommands(); // TODO this async call can lead to a flicker of the placeholder text when switching editor tabs
6788

68-
if (!value) {
89+
if (!inputValue) {
6990
const extensionPlaceholder = this.widget.viewModel?.inputPlaceholder;
7091
const defaultPlaceholder = slashCommands?.length ?
7192
localize('interactive.input.placeholderWithCommands', "Ask a question or type '/' for topics") :
@@ -88,32 +109,44 @@ class InputEditorDecorations extends Disposable {
88109
}
89110
];
90111
this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandPlaceholderDecorationType, decoration);
112+
this.updateInputEditorContentWidgets({ hide: true });
91113
return;
92114
}
93115

94-
const command = value && slashCommands?.find(c => value.startsWith(`/${c.command} `));
95-
if (command && command.detail && value === `/${command.command} `) {
96-
const decoration: IDecorationOptions[] = [
97-
{
116+
let slashCommandPlaceholderDecoration: IDecorationOptions[] | undefined;
117+
const command = inputValue && slashCommands?.find(c => inputValue.startsWith(`/${c.command} `));
118+
if (command && inputValue === `/${command.command} `) {
119+
const isFollowupSlashCommand = this._previouslyUsedSlashCommands.has(command.command);
120+
const shouldRenderFollowupPlaceholder = command.followupPlaceholder && isFollowupSlashCommand;
121+
if (shouldRenderFollowupPlaceholder || command.detail) {
122+
slashCommandPlaceholderDecoration = [{
98123
range: {
99124
startLineNumber: 1,
100125
endLineNumber: 1,
101-
startColumn: command.command.length + 2,
126+
startColumn: command && typeof command !== 'string' ? (command?.command.length + 2) : 1,
102127
endColumn: 1000
103128
},
104129
renderOptions: {
105130
after: {
106-
contentText: command.detail,
107-
color: this.getPlaceholderColor()
131+
contentText: shouldRenderFollowupPlaceholder ? command.followupPlaceholder : command.detail,
132+
color: this.getPlaceholderColor(),
133+
padding: '0 0 0 5px'
108134
}
109135
}
110-
}
111-
];
112-
this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandPlaceholderDecorationType, decoration);
113-
} else {
136+
}];
137+
this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandPlaceholderDecorationType, slashCommandPlaceholderDecoration);
138+
}
139+
}
140+
if (!slashCommandPlaceholderDecoration) {
114141
this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandPlaceholderDecorationType, []);
115142
}
116143

144+
if (command && inputValue.startsWith(`/${command.command} `)) {
145+
this.updateInputEditorContentWidgets({ command: command.command });
146+
} else {
147+
this.updateInputEditorContentWidgets({ hide: true });
148+
}
149+
117150
if (command && command.detail) {
118151
const textDecoration: IDecorationOptions[] = [
119152
{
@@ -130,6 +163,40 @@ class InputEditorDecorations extends Disposable {
130163
this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandTextDecorationType, []);
131164
}
132165
}
166+
167+
private async updateInputEditorContentWidgets(arg: { command: string } | { hide: true }) {
168+
const domNode = this._slashCommandDomNode;
169+
170+
if (this._slashCommandContentWidget && 'hide' in arg) {
171+
domNode.toggleAttribute('hidden', true);
172+
this.widget.inputEditor.removeContentWidget(this._slashCommandContentWidget);
173+
return;
174+
} else if ('command' in arg) {
175+
const theme = this.themeService.getColorTheme();
176+
domNode.style.padding = '0 0.4em';
177+
domNode.style.borderRadius = '3px';
178+
domNode.style.backgroundColor = theme.getColor(textCodeBlockBackground)?.toString() ?? '';
179+
domNode.style.color = theme.getColor(textLinkForeground)?.toString() ?? '';
180+
domNode.innerText = `${arg.command} `;
181+
domNode.toggleAttribute('hidden', false);
182+
183+
this._slashCommandContentWidget = {
184+
getId() { return slashCommandContentWidgetId; },
185+
getDomNode() { return domNode; },
186+
getPosition() {
187+
return {
188+
position: {
189+
lineNumber: 1,
190+
column: 1
191+
},
192+
preference: [ContentWidgetPositionPreference.EXACT]
193+
};
194+
},
195+
};
196+
197+
this.widget.inputEditor.addContentWidget(this._slashCommandContentWidget);
198+
}
199+
}
133200
}
134201

135202
class InputEditorSlashCommandFollowups extends Disposable {
@@ -139,6 +206,7 @@ class InputEditorSlashCommandFollowups extends Disposable {
139206
) {
140207
super();
141208
this._register(this.chatService.onDidSubmitSlashCommand(({ slashCommand, sessionId }) => this.repopulateSlashCommand(slashCommand, sessionId)));
209+
this._register(this.widget.inputEditor.onKeyUp((e) => this.handleKeyUp(e)));
142210
}
143211

144212
private async repopulateSlashCommand(slashCommand: string, sessionId: string) {
@@ -159,6 +227,22 @@ class InputEditorSlashCommandFollowups extends Disposable {
159227

160228
}
161229
}
230+
231+
private handleKeyUp(e: IKeyboardEvent) {
232+
if (e.keyCode !== KeyCode.Backspace) {
233+
return;
234+
}
235+
236+
const value = this.widget.inputEditor.getValue().split(' ')[0];
237+
const currentSelection = this.widget.inputEditor.getSelection();
238+
if (!value.startsWith('/') || !currentSelection?.isEmpty() || currentSelection?.startLineNumber !== 1 || currentSelection?.startColumn !== value.length + 1) {
239+
return;
240+
}
241+
242+
if (this.widget.getSlashCommandsSync()?.find((command) => `/${command.command}` === value)) {
243+
this.widget.inputEditor.executeEdits('chat-input-editor-slash-commands', [{ range: new Range(1, 1, 1, currentSelection.startColumn), text: null }], [new Selection(1, 1, 1, 1)]);
244+
}
245+
}
162246
}
163247

164248
ChatWidget.CONTRIBS.push(InputEditorDecorations, InputEditorSlashCommandFollowups);

src/vs/workbench/contrib/chat/common/chatService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface ISlashCommand {
7171
provider?: ISlashCommandProvider;
7272
sortText?: string;
7373
detail?: string;
74+
followupPlaceholder?: string;
7475
}
7576

7677
export interface IChatReplyFollowup {

src/vscode-dts/vscode.proposed.interactive.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ declare module 'vscode' {
133133
shouldRepopulate?: boolean;
134134
kind: CompletionItemKind;
135135
detail?: string;
136+
followupPlaceholder?: string;
136137
}
137138

138139
export interface InteractiveSessionReplyFollowup {

0 commit comments

Comments
 (0)