From d5d7da162307615dd8e46b82eb8486136cb94989 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 10 Mar 2023 22:01:39 -0800 Subject: [PATCH 1/2] Refactor/improve input editor decorations --- ...nteractiveSessionInputEditorDecorations.ts | 171 ++++++++++++++++++ .../interactiveSession.contribution.ts | 2 + .../browser/interactiveSession.ts | 17 ++ .../browser/interactiveSessionWidget.ts | 167 +++++++---------- 4 files changed, 251 insertions(+), 106 deletions(-) create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/contrib/interactiveSessionInputEditorDecorations.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.ts diff --git a/src/vs/workbench/contrib/interactiveSession/browser/contrib/interactiveSessionInputEditorDecorations.ts b/src/vs/workbench/contrib/interactiveSession/browser/contrib/interactiveSessionInputEditorDecorations.ts new file mode 100644 index 0000000000000..5d1ed452c8930 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/contrib/interactiveSessionInputEditorDecorations.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + suggestions: slashCommands.map(c => { + const withSlash = `/${c.command}`; + return { + label: withSlash, + insertText: `${withSlash} `, + detail: c.detail, + range: new Range(1, 1, 1, 1), + kind: c.kind, + }; + }) + }; + } + })); + } +} + +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(SlashCommandCompletions, LifecyclePhase.Eventually); + diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.contribution.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.contribution.ts index 74552e224fd54..953d6b12fb5ff 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.contribution.ts +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.contribution.ts @@ -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'; diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.ts new file mode 100644 index 0000000000000..2cedad4965751 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.ts @@ -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; + readonly viewModel: IInteractiveSessionViewModel | undefined; + readonly inputEditor: ICodeEditor; + + getSlashCommands(): Promise; +} diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts index 3d9718a97a56e..ff5546f1a9d66 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts @@ -12,14 +12,8 @@ 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 { MenuId } from 'vs/platform/actions/common/actions'; @@ -29,10 +23,10 @@ import { IInstantiationService, createDecorator } from 'vs/platform/instantiatio 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 { editorForeground, foreground } from 'vs/platform/theme/common/colorRegistry'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { foreground } from 'vs/platform/theme/common/colorRegistry'; import { DEFAULT_FONT_FAMILY } from 'vs/workbench/browser/style'; import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions'; +import { IInteractiveSessionWidget } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSession'; import { InteractiveListItemRenderer, InteractiveSessionAccessibilityProvider, InteractiveSessionListDelegate, InteractiveTreeItem } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer'; import { InteractiveSessionEditorOptions } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions'; import { IInteractiveSessionService, IInteractiveSlashCommand } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; @@ -63,21 +57,29 @@ function revealLastElement(list: WorkbenchObjectTree) { } 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 { +export class InteractiveSessionWidget extends Disposable implements IInteractiveSessionWidget { + public static readonly CONTRIBS: { new(...args: [IInteractiveSessionWidget, ...any]): any }[] = []; + public static readonly INPUT_SCHEME = 'interactiveSessionInput'; + private _onDidFocus = this._register(new Emitter()); readonly onDidFocus = this._onDidFocus.event; - private static readonly INPUT_SCHEME = 'interactiveSessionInput'; + private _onDidChangeViewModel = this._register(new Emitter()); + readonly onDidChangeViewModel = this._onDidChangeViewModel.event; + private static _counter = 0; public readonly inputUri = URI.parse(`${InteractiveSessionWidget.INPUT_SCHEME}:input-${InteractiveSessionWidget._counter++}`); private tree!: WorkbenchObjectTree; private renderer!: InteractiveListItemRenderer; private inputEditorHeight = 0; - private inputEditor!: CodeEditorWidget; + + private _inputEditor!: CodeEditorWidget; + public get inputEditor() { + return this._inputEditor; + } + private inputOptions!: InteractiveSessionEditorOptions; private inputModel: ITextModel | undefined; private listContainer!: HTMLElement; @@ -89,10 +91,28 @@ export class InteractiveSessionWidget extends Disposable { private previousTreeScrollHeight: number = 0; - private viewModel: IInteractiveSessionViewModel | undefined; private viewModelDisposables = new DisposableStore(); + private _viewModel: InteractiveSessionViewModel | undefined; + private set viewModel(viewModel: InteractiveSessionViewModel | undefined) { + if (this._viewModel === viewModel) { + return; + } - private cachedSlashCommands: IInteractiveSlashCommand[] | undefined; + this.viewModelDisposables.clear(); + this.slashCommandsPromise = undefined; + + this._viewModel = viewModel; + if (viewModel) { + this.viewModelDisposables.add(viewModel); + } + this._onDidChangeViewModel.fire(); + } + + get viewModel() { + return this._viewModel; + } + + private slashCommandsPromise: Promise | undefined; constructor( private readonly providerId: string, @@ -107,9 +127,6 @@ export class InteractiveSessionWidget extends Disposable { @IInteractiveSessionService private readonly interactiveSessionService: IInteractiveSessionService, @IInteractiveSessionWidgetService interactiveSessionWidgetService: IInteractiveSessionWidgetService, @IContextMenuService private readonly contextMenuService: IContextMenuService, - @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, - @IThemeService private readonly themeService: IThemeService, - @ICodeEditorService private readonly codeEditorService: ICodeEditorService, ) { super(); CONTEXT_IN_INTERACTIVE_SESSION.bindTo(contextKeyService).set(true); @@ -134,13 +151,16 @@ export class InteractiveSessionWidget extends Disposable { if (this.viewModel) { this.onDidChangeItems(); } + + InteractiveSessionWidget.CONTRIBS.forEach(contrib => this._register(this.instantiationService.createInstance(contrib, this))); } focusInput(): void { - this.inputEditor.focus(); + this._inputEditor.focus(); } private onDidChangeItems() { + this.slashCommandsPromise = undefined; if (this.tree && this.visible) { const items = this.viewModel?.getItems() ?? []; const treeItems = items.map(item => { @@ -173,13 +193,25 @@ export class InteractiveSessionWidget extends Disposable { if (!this.inputModel) { this.inputModel = this.modelService.getModel(this.inputUri) || this.modelService.createModel('', null, this.inputUri, true); } - this.inputEditor.setModel(this.inputModel); + 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 this.onDidChangeItems(); } } + async getSlashCommands(): Promise { + if (!this.viewModel) { + return; + } + + if (!this.slashCommandsPromise) { + this.slashCommandsPromise = this.interactiveSessionService.getSlashCommands(this.viewModel.sessionId, CancellationToken.None); + } + + return this.slashCommandsPromise; + } + private onDidStyleChange(): void { this.container.style.setProperty('--vscode-interactive-result-editor-background-color', this.inputOptions.configuration.resultEditor.backgroundColor?.toString() ?? ''); } @@ -313,97 +345,21 @@ 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(), 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 { - suggestions: slashCommands.map(c => { - const withSlash = `/${c.command}`; - return { - 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._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, inputEditorElement, options, { ...getSimpleCodeEditorWidgetOptions(), isSimpleWidget: false })); - this._register(this.inputEditor.onDidChangeModelContent(() => { - const currentHeight = Math.min(this.inputEditor.getContentHeight(), INPUT_EDITOR_MAX_HEIGHT); + this._register(this._inputEditor.onDidChangeModelContent(() => { + const currentHeight = Math.min(this._inputEditor.getContentHeight(), INPUT_EDITOR_MAX_HEIGHT); if (this.bodyDimension && currentHeight !== this.inputEditorHeight) { this.inputEditorHeight = currentHeight; this.layout(this.bodyDimension.height, this.bodyDimension.width); } })); - this._register(this.inputEditor.onDidFocusEditorText(() => this._onDidFocus.fire())); + this._register(this._inputEditor.onDidFocusEditorText(() => this._onDidFocus.fire())); this._register(dom.addStandardDisposableListener(inputContainer, dom.EventType.FOCUS, () => inputContainer.classList.add('synthetic-focus'))); 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); @@ -411,11 +367,10 @@ export class InteractiveSessionWidget extends Disposable { throw new Error('Failed to start session'); } - this.viewModel = this.viewModelDisposables.add(this.instantiationService.createInstance(InteractiveSessionViewModel, model)); + this.viewModel = this.instantiationService.createInstance(InteractiveSessionViewModel, model); this.viewModelDisposables.add(this.viewModel.onDidChange(() => this.onDidChangeItems())); this.viewModelDisposables.add(this.viewModel.onDidDisposeModel(() => { this.viewModel = undefined; - this.viewModelDisposables.clear(); this.onDidChangeItems(); })); @@ -430,9 +385,9 @@ export class InteractiveSessionWidget extends Disposable { } if (this.viewModel) { - const input = query ?? this.inputEditor.getValue(); + const input = query ?? this._inputEditor.getValue(); if (this.interactiveSessionService.sendRequest(this.viewModel.sessionId, input, CancellationToken.None)) { - this.inputEditor.setValue(''); + this._inputEditor.setValue(''); revealLastElement(this.tree); } } @@ -467,7 +422,7 @@ export class InteractiveSessionWidget extends Disposable { layout(height: number, width: number): void { this.bodyDimension = new dom.Dimension(width, height); - const inputHeight = Math.min(this.inputEditor.getContentHeight(), height, INPUT_EDITOR_MAX_HEIGHT); + const inputHeight = Math.min(this._inputEditor.getContentHeight(), height, INPUT_EDITOR_MAX_HEIGHT); const inputWrapperPadding = 24; const lastElementVisible = this.tree.scrollTop + this.tree.renderHeight >= this.tree.scrollHeight; const listHeight = height - inputHeight - inputWrapperPadding; @@ -482,7 +437,7 @@ export class InteractiveSessionWidget extends Disposable { this.welcomeViewContainer.style.height = `${height - inputHeight - inputWrapperPadding}px`; this.listContainer.style.height = `${height - inputHeight - inputWrapperPadding}px`; - this.inputEditor.layout({ width: width - inputWrapperPadding, height: inputHeight }); + this._inputEditor.layout({ width: width - inputWrapperPadding, height: inputHeight }); } } From d3b88585206ef936d7b3bc48b6d97f2180ffc81f Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 11 Mar 2023 08:40:08 -0800 Subject: [PATCH 2/2] Render slash commands nicely in requests --- ...nteractiveSessionInputEditorDecorations.ts | 1 - .../browser/interactiveSessionListRenderer.ts | 23 ++++++++++++++++++- .../browser/interactiveSessionWidget.ts | 22 ++++++++++++++---- .../browser/media/interactiveSession.css | 4 ++++ 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/interactiveSession/browser/contrib/interactiveSessionInputEditorDecorations.ts b/src/vs/workbench/contrib/interactiveSession/browser/contrib/interactiveSessionInputEditorDecorations.ts index 5d1ed452c8930..7701a17f6e5db 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/contrib/interactiveSessionInputEditorDecorations.ts +++ b/src/vs/workbench/contrib/interactiveSession/browser/contrib/interactiveSessionInputEditorDecorations.ts @@ -168,4 +168,3 @@ class SlashCommandCompletions extends Disposable { } Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(SlashCommandCompletions, LifecyclePhase.Eventually); - diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts index 369ee47f3b752..ff94d0d4d8b4c 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts @@ -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'; @@ -71,6 +72,11 @@ interface IItemHeightChangeParams { const forceVerboseLayoutTracing = false; +export interface IInteractiveSessionRendererDelegate { + getListLength(): number; + getSlashCommands(): IInteractiveSlashCommand[]; +} + export class InteractiveListItemRenderer extends Disposable implements ITreeRenderer { static readonly cursorCharacter = '\u258c'; static readonly ID = 'item'; @@ -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, @@ -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) => { @@ -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; } diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts index ff5546f1a9d66..a422cba96f87f 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts @@ -27,7 +27,7 @@ import { foreground } from 'vs/platform/theme/common/colorRegistry'; import { DEFAULT_FONT_FAMILY } from 'vs/workbench/browser/style'; import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions'; import { IInteractiveSessionWidget } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSession'; -import { InteractiveListItemRenderer, InteractiveSessionAccessibilityProvider, InteractiveSessionListDelegate, InteractiveTreeItem } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer'; +import { IInteractiveSessionRendererDelegate, InteractiveListItemRenderer, InteractiveSessionAccessibilityProvider, InteractiveSessionListDelegate, InteractiveTreeItem } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer'; import { InteractiveSessionEditorOptions } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions'; import { IInteractiveSessionService, IInteractiveSlashCommand } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; import { IInteractiveSessionViewModel, InteractiveSessionViewModel, isRequestVM, isResponseVM } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel'; @@ -99,12 +99,16 @@ export class InteractiveSessionWidget extends Disposable implements IInteractive } this.viewModelDisposables.clear(); - this.slashCommandsPromise = undefined; this._viewModel = viewModel; if (viewModel) { this.viewModelDisposables.add(viewModel); } + + this.slashCommandsPromise = undefined; + this.lastSlashCommands = undefined; + this.getSlashCommands(); // start the refresh + this._onDidChangeViewModel.fire(); } @@ -112,6 +116,7 @@ export class InteractiveSessionWidget extends Disposable implements IInteractive return this._viewModel; } + private lastSlashCommands: IInteractiveSlashCommand[] | undefined; private slashCommandsPromise: Promise | undefined; constructor( @@ -160,7 +165,6 @@ export class InteractiveSessionWidget extends Disposable implements IInteractive } private onDidChangeItems() { - this.slashCommandsPromise = undefined; if (this.tree && this.visible) { const items = this.viewModel?.getItems() ?? []; const treeItems = items.map(item => { @@ -207,6 +211,7 @@ export class InteractiveSessionWidget extends Disposable implements IInteractive if (!this.slashCommandsPromise) { this.slashCommandsPromise = this.interactiveSessionService.getSlashCommands(this.viewModel.sessionId, CancellationToken.None); + this.slashCommandsPromise.then(commands => this.lastSlashCommands = commands); } return this.slashCommandsPromise; @@ -251,7 +256,11 @@ export class InteractiveSessionWidget extends Disposable implements IInteractive private createList(listContainer: HTMLElement): void { const scopedInstantiationService = this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService])); const delegate = scopedInstantiationService.createInstance(InteractiveSessionListDelegate); - this.renderer = scopedInstantiationService.createInstance(InteractiveListItemRenderer, this.inputOptions, { getListLength: () => this.tree.getNode(null).visibleChildrenCount }); + const rendererDelegate: IInteractiveSessionRendererDelegate = { + getListLength: () => this.tree.getNode(null).visibleChildrenCount, + getSlashCommands: () => this.lastSlashCommands ?? [], + }; + this.renderer = scopedInstantiationService.createInstance(InteractiveListItemRenderer, this.inputOptions, rendererDelegate); this.tree = >scopedInstantiationService.createInstance( WorkbenchObjectTree, 'InteractiveSession', @@ -368,7 +377,10 @@ export class InteractiveSessionWidget extends Disposable implements IInteractive } this.viewModel = this.instantiationService.createInstance(InteractiveSessionViewModel, model); - this.viewModelDisposables.add(this.viewModel.onDidChange(() => this.onDidChangeItems())); + this.viewModelDisposables.add(this.viewModel.onDidChange(() => { + this.slashCommandsPromise = undefined; + this.onDidChangeItems(); + })); this.viewModelDisposables.add(this.viewModel.onDidDisposeModel(() => { this.viewModel = undefined; this.onDidChangeItems(); diff --git a/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css b/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css index f9fbb37bbfd5f..c568c9a118515 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css +++ b/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css @@ -223,3 +223,7 @@ .interactive-session .interactive-response .interactive-response-error-details .codicon { color: var(--vscode-errorForeground); } + +.interactive-session .interactive-request .value .interactive-slash-command { + color: var(--vscode-textLink-foreground); +}