Skip to content
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

Render slash commands nicely in requests #176861

Merged
merged 2 commits into from Mar 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -0,0 +1,170 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Disposable } from 'vs/base/common/lifecycle';
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { IDecorationOptions } from 'vs/editor/common/editorCommon';
import { CompletionContext, CompletionItem, CompletionList } from 'vs/editor/common/languages';
import { ITextModel } from 'vs/editor/common/model';
import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures';
import { localize } from 'vs/nls';
import { Registry } from 'vs/platform/registry/common/platform';
import { editorForeground, textLinkForeground } from 'vs/platform/theme/common/colorRegistry';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { IInteractiveSessionWidget } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSession';
import { IInteractiveSessionWidgetService, InteractiveSessionWidget } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget';
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';

const decorationDescription = 'interactive session';
const slashCommandPlaceholderDecorationType = 'interactive-session-detail';
const slashCommandTextDecorationType = 'interactive-session-text';

class InputEditorDecorations extends Disposable {

constructor(
private readonly widget: IInteractiveSessionWidget,
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
@IThemeService private readonly themeService: IThemeService,
) {
super();

this.codeEditorService.registerDecorationType(decorationDescription, slashCommandPlaceholderDecorationType, {});

this._register(this.themeService.onDidColorThemeChange(() => this.updateRegisteredDecorationTypes()));
this.updateRegisteredDecorationTypes();

this.updateInputEditorDecorations();
this._register(this.widget.inputEditor.onDidChangeModelContent(() => this.updateInputEditorDecorations()));
this._register(this.widget.onDidChangeViewModel(() => this.updateInputEditorDecorations()));
}

private updateRegisteredDecorationTypes() {
const theme = this.themeService.getColorTheme();
this.codeEditorService.removeDecorationType(slashCommandTextDecorationType);
this.codeEditorService.registerDecorationType(decorationDescription, slashCommandTextDecorationType, {
color: theme.getColor(textLinkForeground)?.toString()
});
this.updateInputEditorDecorations();
}

private getPlaceholderColor(): string | undefined {
const theme = this.themeService.getColorTheme();
const transparentForeground = theme.getColor(editorForeground)?.transparent(0.4);
return transparentForeground?.toString();
}

private async updateInputEditorDecorations() {
const value = this.widget.inputEditor.getModel()?.getValue();
const slashCommands = await this.widget.getSlashCommands();

if (!value) {
const emptyPlaceholder = slashCommands?.length ?
localize('interactive.input.placeholderWithCommands', "Ask a question or type '/' for topics") :
localize('interactive.input.placeholderNoCommands', "Ask a question");
const decoration: IDecorationOptions[] = [
{
range: {
startLineNumber: 1,
endLineNumber: 1,
startColumn: 1,
endColumn: 1000
},
renderOptions: {
after: {
contentText: emptyPlaceholder,
color: this.getPlaceholderColor()
}
}
}
];
this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandPlaceholderDecorationType, decoration);
return;
}

const command = value && slashCommands?.find(c => value.startsWith(`/${c.command} `));
if (command && command.detail && value === `/${command.command} `) {
const decoration: IDecorationOptions[] = [
{
range: {
startLineNumber: 1,
endLineNumber: 1,
startColumn: command.command.length + 2,
endColumn: 1000
},
renderOptions: {
after: {
contentText: command.detail,
color: this.getPlaceholderColor()
}
}
}
];
this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandPlaceholderDecorationType, decoration);
} else {
this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandPlaceholderDecorationType, []);
}

if (command && command.detail) {
const textDecoration: IDecorationOptions[] = [
{
range: {
startLineNumber: 1,
endLineNumber: 1,
startColumn: 1,
endColumn: command.command.length + 2
}
}
];
this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandTextDecorationType, textDecoration);
} else {
this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandTextDecorationType, []);
}
}
}

InteractiveSessionWidget.CONTRIBS.push(InputEditorDecorations);

class SlashCommandCompletions extends Disposable {
constructor(
@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,
@IInteractiveSessionWidgetService private readonly interactiveSessionWidgetService: IInteractiveSessionWidgetService,
) {
super();

this._register(this.languageFeaturesService.completionProvider.register({ scheme: InteractiveSessionWidget.INPUT_SCHEME, hasAccessToAllModels: true }, {
triggerCharacters: ['/'],
provideCompletionItems: async (model: ITextModel, _position: Position, _context: CompletionContext, _token: CancellationToken) => {
const widget = this.interactiveSessionWidgetService.getWidgetByInputUri(model.uri);
if (!widget) {
return null;
}

const slashCommands = await widget.getSlashCommands();
if (!slashCommands) {
return null;
}

return <CompletionList>{
suggestions: slashCommands.map(c => {
const withSlash = `/${c.command}`;
return <CompletionItem>{
label: withSlash,
insertText: `${withSlash} `,
detail: c.detail,
range: new Range(1, 1, 1, 1),
kind: c.kind,
};
})
};
}
}));
}
}

Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(SlashCommandCompletions, LifecyclePhase.Eventually);
Expand Up @@ -110,3 +110,5 @@ registerInteractiveSessionTitleActions();
registerSingleton(IInteractiveSessionService, InteractiveSessionService, InstantiationType.Delayed);
registerSingleton(IInteractiveSessionContributionService, InteractiveSessionContributionService, InstantiationType.Delayed);
registerSingleton(IInteractiveSessionWidgetService, InteractiveSessionWidgetService, InstantiationType.Delayed);

import 'vs/workbench/contrib/interactiveSession/browser/contrib/interactiveSessionInputEditorDecorations';
@@ -0,0 +1,17 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { IInteractiveSlashCommand } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService';
import { IInteractiveSessionViewModel } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel';
import { Event } from 'vs/base/common/event';

export interface IInteractiveSessionWidget {
readonly onDidChangeViewModel: Event<void>;
readonly viewModel: IInteractiveSessionViewModel | undefined;
readonly inputEditor: ICodeEditor;

getSlashCommands(): Promise<IInteractiveSlashCommand[] | undefined>;
}
Expand Up @@ -46,6 +46,7 @@ import { IInteractiveSessionCodeBlockActionContext } from 'vs/workbench/contrib/
import { InteractiveSessionEditorOptions } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions';
import { interactiveSessionResponseHasProviderId } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionContextKeys';
import { IInteractiveSessionResponseCommandFollowup } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionModel';
import { IInteractiveSlashCommand } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService';
import { IInteractiveRequestViewModel, IInteractiveResponseViewModel, isRequestVM, isResponseVM } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel';
import { getNWords } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionWordCounter';

Expand All @@ -71,6 +72,11 @@ interface IItemHeightChangeParams {

const forceVerboseLayoutTracing = false;

export interface IInteractiveSessionRendererDelegate {
getListLength(): number;
getSlashCommands(): IInteractiveSlashCommand[];
}

export class InteractiveListItemRenderer extends Disposable implements ITreeRenderer<InteractiveTreeItem, FuzzyScore, IInteractiveListItemTemplate> {
static readonly cursorCharacter = '\u258c';
static readonly ID = 'item';
Expand All @@ -89,7 +95,7 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend

constructor(
private readonly editorOptions: InteractiveSessionEditorOptions,
private readonly delegate: { getListLength(): number },
private readonly delegate: IInteractiveSessionRendererDelegate,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IConfigurationService private readonly configService: IConfigurationService,
@ILogService private readonly logService: ILogService,
Expand Down Expand Up @@ -288,6 +294,12 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend
private renderMarkdown(markdown: IMarkdownString, element: InteractiveTreeItem, disposables: DisposableStore, templateData: IInteractiveListItemTemplate, fillInIncompleteTokens = false): IMarkdownRenderResult {
const disposablesList: IDisposable[] = [];
let codeBlockIndex = 0;

// TODO if the slash commands stay completely dynamic, this isn't quite right
const slashCommands = this.delegate.getSlashCommands();
const usedSlashCommand = slashCommands.find(s => markdown.value.startsWith(`/${s.command} `));
const toRender = usedSlashCommand ? markdown.value.slice(usedSlashCommand.command.length + 2) : markdown.value;
markdown = new MarkdownString(toRender);
const result = this.renderer.render(markdown, {
fillInIncompleteTokens,
codeBlockRendererSync: (languageId, text) => {
Expand All @@ -297,6 +309,15 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend
}
});

if (usedSlashCommand) {
const slashCommandElement = $('span.interactive-slash-command', { title: usedSlashCommand.detail }, `/${usedSlashCommand.command} `);
if (result.element.firstChild?.nodeName.toLowerCase() === 'p') {
result.element.firstChild.insertBefore(slashCommandElement, result.element.firstChild.firstChild);
} else {
result.element.insertBefore($('p', undefined, slashCommandElement), result.element.firstChild);
}
}

disposablesList.reverse().forEach(d => disposables.add(d));
return result;
}
Expand Down