Skip to content

Commit

Permalink
Implement slash commands on interactive session providers (#176578)
Browse files Browse the repository at this point in the history
* Implement slash commands on interactive session providers

* Remove slashCommands from viewmodel
  • Loading branch information
roblourens authored Mar 9, 2023
1 parent ab4a0ba commit 2e34c9a
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 17 deletions.
3 changes: 3 additions & 0 deletions src/vs/workbench/api/browser/mainThreadInteractiveSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export class MainThreadInteractiveSession implements MainThreadInteractiveSessio
},
provideSuggestions: (token) => {
return this._proxy.$provideInitialSuggestions(handle, token);
},
provideSlashCommands: (session, token) => {
return this._proxy.$provideSlashCommands(handle, session.id, token);
}
});

Expand Down
4 changes: 3 additions & 1 deletion src/vs/workbench/api/common/extHost.protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { IRevealOptions, ITreeItem, IViewBadge } from 'vs/workbench/common/views
import { CallHierarchyItem } from 'vs/workbench/contrib/callHierarchy/common/callHierarchy';
import { DebugConfigurationProviderTriggerKind, IAdapterDescriptor, IConfig, IDebugSessionReplMode } from 'vs/workbench/contrib/debug/common/debug';
import { IInteractiveResponseErrorDetails, IInteractiveSessionResponseCommandFollowup } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionModel';
import { IInteractiveSlashCommand } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService';
import * as notebookCommon from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { CellExecutionUpdateType } from 'vs/workbench/contrib/notebook/common/notebookExecutionService';
import { ICellExecutionComplete, ICellExecutionStateUpdate } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService';
Expand Down Expand Up @@ -1125,7 +1126,8 @@ export interface ExtHostInteractiveSessionShape {
$prepareInteractiveSession(handle: number, initialState: any, token: CancellationToken): Promise<IInteractiveSessionDto | undefined>;
$resolveInteractiveRequest(handle: number, sessionId: number, context: any, token: CancellationToken): Promise<IInteractiveRequestDto | undefined>;
$provideInitialSuggestions(handle: number, token: CancellationToken): Promise<string[] | undefined>;
$provideInteractiveReply(handle: number, sessionid: number, request: IInteractiveRequestDto, token: CancellationToken): Promise<IInteractiveResponseDto | undefined>;
$provideInteractiveReply(handle: number, sessionId: number, request: IInteractiveRequestDto, token: CancellationToken): Promise<IInteractiveResponseDto | undefined>;
$provideSlashCommands(handle: number, sessionId: number, token: CancellationToken): Promise<IInteractiveSlashCommand[] | undefined>;
$releaseSession(sessionId: number): void;
}

Expand Down
26 changes: 25 additions & 1 deletion src/vs/workbench/api/common/extHostInteractiveSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters';
import { CancellationToken } from 'vs/base/common/cancellation';
import { toDisposable } from 'vs/base/common/lifecycle';
import { StopWatch } from 'vs/base/common/stopwatch';
Expand All @@ -11,6 +12,7 @@ import { localize } from 'vs/nls';
import { IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { ILogService } from 'vs/platform/log/common/log';
import { ExtHostInteractiveSessionShape, IInteractiveRequestDto, IInteractiveResponseDto, IInteractiveSessionDto, IMainContext, MainContext, MainThreadInteractiveSessionShape } from 'vs/workbench/api/common/extHost.protocol';
import { IInteractiveSlashCommand } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService';
import type * as vscode from 'vscode';

class InteractiveSessionProviderWrapper {
Expand Down Expand Up @@ -165,7 +167,7 @@ export class ExtHostInteractiveSession implements ExtHostInteractiveSessionShape
result = { errorDetails: { message: localize('emptyResponse', "Provider returned null response") } };
}
} catch (err) {
result = { errorDetails: { message: localize('errorResponse', "Error from provider: {0}", err.message) } };
result = { errorDetails: { message: localize('errorResponse', "Error from provider: {0}", err.message), responseIsIncomplete: true } };
this.logService.error(err);
}

Expand All @@ -185,6 +187,28 @@ export class ExtHostInteractiveSession implements ExtHostInteractiveSessionShape
throw new Error('Provider must implement either provideResponse or provideResponseWithProgress');
}

async $provideSlashCommands(handle: number, sessionId: number, token: CancellationToken): Promise<IInteractiveSlashCommand[] | undefined> {
const entry = this._interactiveSessionProvider.get(handle);
if (!entry) {
return undefined;
}

const realSession = this._interactiveSessions.get(sessionId);
if (!realSession) {
return undefined;
}

if (!entry.provider.provideSlashCommands) {
return undefined;
}

const slashCommands = await entry.provider.provideSlashCommands(realSession, token);
return slashCommands?.map(c => (<IInteractiveSlashCommand>{
...c,
kind: typeConvert.CompletionItemKind.from(c.kind)
}));
}

$releaseSession(sessionId: number) {
this._interactiveSessions.delete(sessionId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,28 @@ import { Disposable, DisposableStore, IDisposable, combinedDisposable, toDisposa
import { isEqual } from 'vs/base/common/resources';
import { URI } from 'vs/base/common/uri';
import 'vs/css!./media/interactiveSession';
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget';
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 { IModelService } from 'vs/editor/common/services/model';
import { localize } from 'vs/nls';
import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService, createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
import { WorkbenchObjectTree } from 'vs/platform/list/browser/listService';
import { defaultButtonStyles } from 'vs/platform/theme/browser/defaultStyles';
import { foreground } from 'vs/platform/theme/common/colorRegistry';
import { editorForeground, foreground } from 'vs/platform/theme/common/colorRegistry';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { DEFAULT_FONT_FAMILY } from 'vs/workbench/browser/style';
import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions';
import { InteractiveListItemRenderer, InteractiveSessionAccessibilityProvider, InteractiveSessionListDelegate, InteractiveTreeItem } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer';
import { InteractiveSessionEditorOptions } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions';
import { IInteractiveSessionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService';
import { IInteractiveSessionService, IInteractiveSlashCommand } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService';
import { IInteractiveSessionViewModel, InteractiveSessionViewModel, isRequestVM, isResponseVM } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';

Expand Down Expand Up @@ -54,13 +61,16 @@ function revealLastElement(list: WorkbenchObjectTree<any>) {
}

const INPUT_EDITOR_MAX_HEIGHT = 275;
const SLASH_COMMAND_DETAIL_DECORATION_TYPE = 'interactive-session-detail';
const SLASH_COMMAND_TEXT_DECORATION_TYPE = 'interactive-session-text';

export class InteractiveSessionWidget extends Disposable {
private _onDidFocus = this._register(new Emitter<void>());
readonly onDidFocus = this._onDidFocus.event;

private static readonly INPUT_SCHEME = 'interactiveSessionInput';
private static _counter = 0;
public readonly inputUri = URI.parse(`interactiveSessionInput:input-${InteractiveSessionWidget._counter++}`);
public readonly inputUri = URI.parse(`${InteractiveSessionWidget.INPUT_SCHEME}:input-${InteractiveSessionWidget._counter++}`);

private tree!: WorkbenchObjectTree<InteractiveTreeItem>;
private renderer!: InteractiveListItemRenderer;
Expand All @@ -80,6 +90,8 @@ export class InteractiveSessionWidget extends Disposable {
private viewModel: IInteractiveSessionViewModel | undefined;
private viewModelDisposables = new DisposableStore();

private cachedSlashCommands: IInteractiveSlashCommand[] | undefined;

constructor(
private readonly providerId: string,
private readonly viewId: string | undefined,
Expand All @@ -91,7 +103,10 @@ export class InteractiveSessionWidget extends Disposable {
@IModelService private readonly modelService: IModelService,
@IExtensionService private readonly extensionService: IExtensionService,
@IInteractiveSessionService private readonly interactiveSessionService: IInteractiveSessionService,
@IInteractiveSessionWidgetService interactiveSessionWidgetService: IInteractiveSessionWidgetService
@IInteractiveSessionWidgetService interactiveSessionWidgetService: IInteractiveSessionWidgetService,
@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,
@IThemeService private readonly themeService: IThemeService,
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
) {
super();
CONTEXT_IN_INTERACTIVE_SESSION.bindTo(contextKeyService).set(true);
Expand Down Expand Up @@ -155,7 +170,6 @@ export class InteractiveSessionWidget extends Disposable {
if (!this.inputModel) {
this.inputModel = this.modelService.getModel(this.inputUri) || this.modelService.createModel('', null, this.inputUri, true);
}
this.setModeAsync();
this.inputEditor.setModel(this.inputModel);

// Not sure why this is needed- the view is being rendered before it's visible, and then the list content doesn't show up
Expand All @@ -167,12 +181,6 @@ export class InteractiveSessionWidget extends Disposable {
this.container.style.setProperty('--vscode-interactive-result-editor-background-color', this.inputOptions.configuration.resultEditor.backgroundColor?.toString() ?? '');
}

private setModeAsync(): void {
this.extensionService.whenInstalledExtensionsRegistered().then(() => {
this.inputModel!.setLanguage('markdown');
});
}

private async renderWelcomeView(container: HTMLElement): Promise<void> {
if (this.welcomeViewContainer) {
dom.clearNode(this.welcomeViewContainer);
Expand Down Expand Up @@ -288,7 +296,38 @@ export class InteractiveSessionWidget extends Disposable {
options.wrappingStrategy = 'advanced';

const inputEditorElement = dom.append(inputContainer, $('.interactive-input-editor'));
this.inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, inputEditorElement, options, getSimpleCodeEditorWidgetOptions()));
this.inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, inputEditorElement, options, { ...getSimpleCodeEditorWidgetOptions(), isSimpleWidget: false }));
this.codeEditorService.registerDecorationType('interactive-session', SLASH_COMMAND_DETAIL_DECORATION_TYPE, {});
this.codeEditorService.registerDecorationType('interactive-session', SLASH_COMMAND_TEXT_DECORATION_TYPE, {
textDecoration: 'underline'
});

this._register(this.languageFeaturesService.completionProvider.register({ scheme: InteractiveSessionWidget.INPUT_SCHEME, hasAccessToAllModels: true }, {
triggerCharacters: ['/'],
provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext) => {
const slashCommands = await this.interactiveSessionService.getSlashCommands(this.viewModel!.sessionId, CancellationToken.None);
if (!slashCommands) {
return { suggestions: [] };
}

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,
};
})
};
}
}));

this._register(this.inputEditor.onDidChangeModelContent(e => {
this.updateInputEditorDecorations();
}));

this._register(this.inputEditor.onDidChangeModelContent(() => {
const currentHeight = Math.min(this.inputEditor.getContentHeight(), INPUT_EDITOR_MAX_HEIGHT);
Expand All @@ -303,6 +342,51 @@ export class InteractiveSessionWidget extends Disposable {
this._register(dom.addStandardDisposableListener(inputContainer, dom.EventType.BLUR, () => inputContainer.classList.remove('synthetic-focus')));
}

private async updateInputEditorDecorations() {
const theme = this.themeService.getColorTheme();
const value = this.inputModel?.getValue();
const slashCommands = this.cachedSlashCommands ?? await this.interactiveSessionService.getSlashCommands(this.viewModel!.sessionId, CancellationToken.None);
const command = value && slashCommands?.find(c => value.startsWith(`/${c.command} `));
if (command && command.detail && value === `/${command.command} `) {
const transparentForeground = theme.getColor(editorForeground)?.transparent(0.4);
const decoration: IDecorationOptions[] = [
{
range: {
startLineNumber: 1,
endLineNumber: 1,
startColumn: command.command.length + 2,
endColumn: 1000
},
renderOptions: {
after: {
contentText: command.detail,
color: transparentForeground ? transparentForeground.toString() : undefined
}
}
}
];
this.inputEditor.setDecorationsByType('interactive session', SLASH_COMMAND_DETAIL_DECORATION_TYPE, decoration);
} else {
this.inputEditor.setDecorationsByType('interactive session', SLASH_COMMAND_DETAIL_DECORATION_TYPE, []);
}

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

private async initializeSessionModel(initial = false) {
await this.extensionService.whenInstalledExtensionsRegistered();
const model = await this.interactiveSessionService.startSession(this.providerId, initial, CancellationToken.None);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { CancellationToken } from 'vs/base/common/cancellation';
import { IDisposable } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { ProviderResult } from 'vs/editor/common/languages';
import { CompletionItemKind, ProviderResult } from 'vs/editor/common/languages';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IInteractiveResponseErrorDetails, IInteractiveSessionResponseCommandFollowup, InteractiveSessionModel } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionModel';

Expand Down Expand Up @@ -48,6 +48,13 @@ export interface IInteractiveProvider {
resolveRequest?(session: IInteractiveSession, context: any, token: CancellationToken): ProviderResult<IInteractiveRequest>;
provideSuggestions?(token: CancellationToken): ProviderResult<string[] | undefined>;
provideReply(request: IInteractiveRequest, progress: (progress: IInteractiveProgress) => void, token: CancellationToken): ProviderResult<IInteractiveResponse>;
provideSlashCommands?(session: IInteractiveSession, token: CancellationToken): ProviderResult<IInteractiveSlashCommand[]>;
}

export interface IInteractiveSlashCommand {
command: string;
kind: CompletionItemKind;
detail?: string;
}

export const IInteractiveSessionService = createDecorator<IInteractiveSessionService>('IInteractiveSessionService');
Expand All @@ -62,6 +69,7 @@ export interface IInteractiveSessionService {
* Returns whether the request was accepted.
*/
sendRequest(sessionId: number, message: string, token: CancellationToken): boolean;
getSlashCommands(sessionId: number, token: CancellationToken): Promise<IInteractiveSlashCommand[] | undefined>;
clearSession(sessionId: number): void;
acceptNewSessionState(sessionId: number, state: any): void;
addInteractiveRequest(context: any): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { ILogService } from 'vs/platform/log/common/log';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { ISerializableInteractiveSessionsData, InteractiveSessionModel } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionModel';
import { IInteractiveProgress, IInteractiveProvider, IInteractiveSessionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService';
import { IInteractiveProgress, IInteractiveProvider, IInteractiveSessionService, IInteractiveSlashCommand } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';

const serializedInteractiveSessionKey = 'interactive.sessions';
Expand Down Expand Up @@ -119,6 +119,7 @@ export class InteractiveSessionService extends Disposable implements IInteractiv
this.trace('startSession', `Provider returned session with id ${session.id}`);
const model = this.instantiationService.createInstance(InteractiveSessionModel, session, providerId, someSessionHistory);
this._sessionModels.set(model.sessionId, model);

return model;
}

Expand Down Expand Up @@ -178,6 +179,24 @@ export class InteractiveSessionService extends Disposable implements IInteractiv
}
}

async getSlashCommands(sessionId: number, token: CancellationToken): Promise<IInteractiveSlashCommand[] | undefined> {
const model = this._sessionModels.get(sessionId);
if (!model) {
throw new Error(`Unknown session: ${sessionId}`);
}

const provider = this._providers.get(model.providerId);
if (!provider) {
throw new Error(`Unknown provider: ${model.providerId}`);
}

if (!provider.provideSlashCommands) {
return;
}

return withNullAsUndefined(await provider.provideSlashCommands(model.session, token));
}

acceptNewSessionState(sessionId: number, state: any): void {
this.trace('acceptNewSessionState', `sessionId: ${sessionId}`);
const model = this._sessionModels.get(sessionId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export interface IInteractiveResponseViewModel {
currentRenderedHeight: number | undefined;
}

export class InteractiveSessionViewModel extends Disposable {
export class InteractiveSessionViewModel extends Disposable implements IInteractiveSessionViewModel {
private readonly _onDidDisposeModel = this._register(new Emitter<void>());
readonly onDidDisposeModel = this._onDidDisposeModel.event;

Expand Down
8 changes: 8 additions & 0 deletions src/vscode-dts/vscode.proposed.interactive.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,16 @@ declare module 'vscode' {
title: string; // supports codicon strings
}

export interface InteractiveSessionSlashCommand {
command: string;
kind: CompletionItemKind;
detail?: string;
}

export interface InteractiveSessionProvider {
provideInitialSuggestions?(token: CancellationToken): ProviderResult<string[]>;
provideSlashCommands?(session: InteractiveSession, token: CancellationToken): ProviderResult<InteractiveSessionSlashCommand[]>;

prepareSession(initialState: InteractiveSessionState | undefined, token: CancellationToken): ProviderResult<InteractiveSession>;
resolveRequest(session: InteractiveSession, context: InteractiveSessionRequestArgs | string, token: CancellationToken): ProviderResult<InteractiveRequest>;
provideResponse?(request: InteractiveRequest, token: CancellationToken): ProviderResult<InteractiveResponse>;
Expand Down

0 comments on commit 2e34c9a

Please sign in to comment.