From 825e84e1440e67de992d003fecb3c8e99ce17a1c Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 1 Mar 2023 17:47:18 -0500 Subject: [PATCH] Implement interactive session provider api (#175810) Implement interactive session provider --- build/lib/i18n.resources.json | 4 + src/vs/base/browser/markdownRenderer.ts | 97 +++- .../test/browser/markdownRenderer.test.ts | 184 ++++++- .../browser/viewportSemanticTokens.ts | 2 +- .../smartSelect/browser/smartSelect.ts | 2 +- .../api/browser/extensionHost.contribution.ts | 1 + .../browser/mainThreadInteractiveSession.ts | 102 ++++ .../workbench/api/common/extHost.api.impl.ts | 19 + .../workbench/api/common/extHost.protocol.ts | 50 +- .../api/common/extHostInteractiveSession.ts | 165 ++++++ .../interactiveSession.contribution.ts | 104 ++++ .../browser/interactiveSessionActions.ts | 183 +++++++ ...teractiveSessionContributionServiceImpl.ts | 129 +++++ .../browser/interactiveSessionEditor.ts | 90 ++++ .../browser/interactiveSessionEditorInput.ts | 38 ++ .../browser/interactiveSessionListRenderer.ts | 478 ++++++++++++++++++ .../browser/interactiveSessionOptions.ts | 117 +++++ .../browser/interactiveSessionSidebar.ts | 81 +++ .../browser/interactiveSessionWidget.ts | 356 +++++++++++++ .../browser/media/interactiveSession.css | 180 +++++++ .../common/interactiveSessionColors.ts | 27 + .../interactiveSessionContributionService.ts | 20 + .../common/interactiveSessionModel.ts | 214 ++++++++ .../common/interactiveSessionService.ts | 55 ++ .../common/interactiveSessionServiceImpl.ts | 222 ++++++++ .../common/interactiveSessionViewModel.ts | 172 +++++++ .../preferences/browser/settingsLayout.ts | 7 +- .../common/extensionsApiProposals.ts | 1 + src/vs/workbench/workbench.common.main.ts | 2 + .../vscode.proposed.interactive.d.ts | 89 ++++ 30 files changed, 3178 insertions(+), 13 deletions(-) create mode 100644 src/vs/workbench/api/browser/mainThreadInteractiveSession.ts create mode 100644 src/vs/workbench/api/common/extHostInteractiveSession.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.contribution.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionActions.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionContributionServiceImpl.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditor.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditorInput.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionSidebar.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css create mode 100644 src/vs/workbench/contrib/interactiveSession/common/interactiveSessionColors.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/common/interactiveSessionContributionService.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/common/interactiveSessionModel.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/common/interactiveSessionService.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/common/interactiveSessionServiceImpl.ts create mode 100644 src/vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel.ts create mode 100644 src/vscode-dts/vscode.proposed.interactive.d.ts diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index fe36be3b5b7..7ee1233c738 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -154,6 +154,10 @@ "name": "vs/workbench/contrib/notebook", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/interactiveSession", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/quickaccess", "project": "vscode-workbench" diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index d1a2e239055..0776f71c54a 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -33,6 +33,7 @@ export interface MarkedOptions extends marked.MarkedOptions { export interface MarkdownRenderOptions extends FormattedTextRenderOptions { readonly codeBlockRenderer?: (languageId: string, value: string) => Promise; readonly asyncRenderCallback?: () => void; + readonly fillInIncompleteTokens?: boolean; } const defaultMarkedRenderers = Object.freeze({ @@ -228,7 +229,19 @@ export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRende value = markdownEscapeEscapedIcons(value); } - let renderedMarkdown = marked.parse(value, markedOptions); + let renderedMarkdown: string; + if (options.fillInIncompleteTokens) { + // The defaults are applied by parse but not lexer()/parser(), and they need to be present + const opts = { + ...marked.defaults, + ...markedOptions + }; + const tokens = marked.lexer(value, opts); + const newTokens = fillInIncompleteTokens(tokens); + renderedMarkdown = marked.parser(newTokens, opts); + } else { + renderedMarkdown = marked.parse(value, markedOptions); + } // Rewrite theme icons if (markdown.supportThemeIcons) { @@ -518,3 +531,85 @@ const plainTextRenderer = new Lazy(() => { }; return renderer; }); + +function mergeRawTokenText(tokens: marked.Token[]): string { + let mergedTokenText = ''; + tokens.forEach(token => { + mergedTokenText += token.raw; + }); + return mergedTokenText; +} + +export function fillInIncompleteTokens(tokens: marked.TokensList): marked.TokensList { + let i: number; + let newTokens: marked.Token[] | undefined; + for (i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token.type === 'paragraph' && token.raw.match(/(\n|^)```/)) { + // If the code block was complete, it would be in a type='code' + newTokens = completeCodeBlock(tokens.slice(i)); + break; + } + + if (token.type === 'paragraph' && token.raw.match(/(\n|^)\|/)) { + newTokens = completeTable(tokens.slice(i)); + break; + } + } + + if (newTokens) { + const newTokensList = [ + ...tokens.slice(0, i), + ...newTokens, + ]; + (newTokensList as marked.TokensList).links = tokens.links; + return newTokensList as marked.TokensList; + } + + return tokens; +} + +function completeCodeBlock(tokens: marked.Token[]): marked.Token[] { + const mergedRawText = mergeRawTokenText(tokens); + return marked.lexer(mergedRawText + '\n```'); +} + +function completeTable(tokens: marked.Token[]): marked.Token[] { + const mergedRawText = mergeRawTokenText(tokens); + const lines = mergedRawText.split('\n'); + + let numCols: number | undefined; // The number of line1 col headers + let hasSeparatorRow = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (typeof numCols === 'undefined' && line.match(/^\s*\|/)) { + const line1Matches = line.match(/(\|[^\|]+)(?=\||$)/g); + if (line1Matches) { + numCols = line1Matches.length; + } + } else if (typeof numCols === 'number') { + if (line.match(/^\s*\|/)) { + if (i !== lines.length - 1) { + // We got the line1 header row, and the line2 separator row, but there are more lines, and it wasn't parsed as a table! + // That's strange and means that the table is probably malformed in the source, so I won't try to patch it up. + return tokens; + } + + // Got a line2 separator row- partial or complete, doesn't matter, we'll replace it with a correct one + hasSeparatorRow = true; + } else { + // The line after the header row isn't a valid separator row, so the table is malformed, don't fix it up + return tokens; + } + } + } + + if (typeof numCols === 'number' && numCols > 0) { + const prefixText = hasSeparatorRow ? lines.slice(0, -1).join('\n') : mergedRawText; + const line1EndsInPipe = !!prefixText.match(/\|\s*$/); + const newRawText = prefixText + (line1EndsInPipe ? '' : '|') + `\n|${' --- |'.repeat(numCols)}`; + return marked.lexer(newRawText); + } + + return tokens; +} diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts index f30fc44b962..223a35238bb 100644 --- a/src/vs/base/test/browser/markdownRenderer.test.ts +++ b/src/vs/base/test/browser/markdownRenderer.test.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { renderMarkdown, renderMarkdownAsPlaintext } from 'vs/base/browser/markdownRenderer'; +import { fillInIncompleteTokens, renderMarkdown, renderMarkdownAsPlaintext } from 'vs/base/browser/markdownRenderer'; import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; +import { marked } from 'vs/base/common/marked/marked'; import { parse } from 'vs/base/common/marshalling'; import { isWeb } from 'vs/base/common/platform'; import { URI } from 'vs/base/common/uri'; @@ -325,4 +326,185 @@ suite('MarkdownRenderer', () => { assert.strictEqual(result.innerHTML, ``); }); }); + + suite('fillInIncompleteTokens', () => { + function ignoreRaw(...tokenLists: marked.Token[][]): void { + tokenLists.forEach(tokens => { + tokens.forEach(t => t.raw = ''); + }); + } + + const completeTable = '| a | b |\n| --- | --- |'; + + test('complete table', () => { + const tokens = marked.lexer(completeTable); + const newTokens = fillInIncompleteTokens(tokens); + assert.equal(newTokens, tokens); + }); + + test('full header only', () => { + const incompleteTable = '| a | b |'; + const tokens = marked.lexer(incompleteTable); + const completeTableTokens = marked.lexer(completeTable); + + const newTokens = fillInIncompleteTokens(tokens); + assert.deepStrictEqual(newTokens, completeTableTokens); + }); + + test('full header only with trailing space', () => { + const incompleteTable = '| a | b | '; + const tokens = marked.lexer(incompleteTable); + const completeTableTokens = marked.lexer(completeTable); + + const newTokens = fillInIncompleteTokens(tokens); + ignoreRaw(newTokens, completeTableTokens); + assert.deepStrictEqual(newTokens, completeTableTokens); + }); + + test('incomplete header', () => { + const incompleteTable = '| a | b'; + const tokens = marked.lexer(incompleteTable); + const completeTableTokens = marked.lexer(completeTable); + + const newTokens = fillInIncompleteTokens(tokens); + + ignoreRaw(newTokens, completeTableTokens); + assert.deepStrictEqual(newTokens, completeTableTokens); + }); + + test('incomplete header one column', () => { + const incompleteTable = '| a '; + const tokens = marked.lexer(incompleteTable); + const completeTableTokens = marked.lexer(incompleteTable + '|\n| --- |'); + + const newTokens = fillInIncompleteTokens(tokens); + + ignoreRaw(newTokens, completeTableTokens); + assert.deepStrictEqual(newTokens, completeTableTokens); + }); + + test('full header with extras', () => { + const incompleteTable = '| a **bold** | b _italics_ |'; + const tokens = marked.lexer(incompleteTable); + const completeTableTokens = marked.lexer(incompleteTable + '\n| --- | --- |'); + + const newTokens = fillInIncompleteTokens(tokens); + assert.deepStrictEqual(newTokens, completeTableTokens); + }); + + test('full header with leading text', () => { + // Parsing this gives one token and one 'text' subtoken + const incompleteTable = 'here is a table\n| a | b |'; + const tokens = marked.lexer(incompleteTable); + const completeTableTokens = marked.lexer(incompleteTable + '\n| --- | --- |'); + + const newTokens = fillInIncompleteTokens(tokens); + assert.deepStrictEqual(newTokens, completeTableTokens); + }); + + test('full header with leading other stuff', () => { + // Parsing this gives one token and one 'text' subtoken + const incompleteTable = '```js\nconst xyz = 123;\n```\n| a | b |'; + const tokens = marked.lexer(incompleteTable); + const completeTableTokens = marked.lexer(incompleteTable + '\n| --- | --- |'); + + const newTokens = fillInIncompleteTokens(tokens); + assert.deepStrictEqual(newTokens, completeTableTokens); + }); + + test('full header with incomplete separator', () => { + const incompleteTable = '| a | b |\n| ---'; + const tokens = marked.lexer(incompleteTable); + const completeTableTokens = marked.lexer(completeTable); + + const newTokens = fillInIncompleteTokens(tokens); + assert.deepStrictEqual(newTokens, completeTableTokens); + }); + + test('full header with incomplete separator 2', () => { + const incompleteTable = '| a | b |\n| --- |'; + const tokens = marked.lexer(incompleteTable); + const completeTableTokens = marked.lexer(completeTable); + + const newTokens = fillInIncompleteTokens(tokens); + assert.deepStrictEqual(newTokens, completeTableTokens); + }); + + test('full header with incomplete separator 3', () => { + const incompleteTable = '| a | b |\n|'; + const tokens = marked.lexer(incompleteTable); + const completeTableTokens = marked.lexer(completeTable); + + const newTokens = fillInIncompleteTokens(tokens); + assert.deepStrictEqual(newTokens, completeTableTokens); + }); + + test('not a table', () => { + const incompleteTable = '| a | b |\nsome text'; + const tokens = marked.lexer(incompleteTable); + + const newTokens = fillInIncompleteTokens(tokens); + assert.deepStrictEqual(newTokens, tokens); + }); + + test('not a table 2', () => { + const incompleteTable = '| a | b |\n| --- |\nsome text'; + const tokens = marked.lexer(incompleteTable); + + const newTokens = fillInIncompleteTokens(tokens); + assert.deepStrictEqual(newTokens, tokens); + }); + + test('complete code block', () => { + const completeCodeblock = '```js\nconst xyz = 123;\n```'; + const tokens = marked.lexer(completeCodeblock); + const newTokens = fillInIncompleteTokens(tokens); + assert.equal(newTokens, tokens); + }); + + test('code block header only', () => { + const incompleteCodeblock = '```js'; + const tokens = marked.lexer(incompleteCodeblock); + const newTokens = fillInIncompleteTokens(tokens); + + const completeCodeblockTokens = marked.lexer(incompleteCodeblock + '\n```'); + assert.deepStrictEqual(newTokens, completeCodeblockTokens); + }); + + test('code block header no lang', () => { + const incompleteCodeblock = '```'; + const tokens = marked.lexer(incompleteCodeblock); + const newTokens = fillInIncompleteTokens(tokens); + + const completeCodeblockTokens = marked.lexer(incompleteCodeblock + '\n```'); + assert.deepStrictEqual(newTokens, completeCodeblockTokens); + }); + + test('code block header and some code', () => { + const incompleteCodeblock = '```js\nconst'; + const tokens = marked.lexer(incompleteCodeblock); + const newTokens = fillInIncompleteTokens(tokens); + + const completeCodeblockTokens = marked.lexer(incompleteCodeblock + '\n```'); + assert.deepStrictEqual(newTokens, completeCodeblockTokens); + }); + + test('code block header with leading text', () => { + const incompleteCodeblock = 'some text\n```js'; + const tokens = marked.lexer(incompleteCodeblock); + const newTokens = fillInIncompleteTokens(tokens); + + const completeCodeblockTokens = marked.lexer(incompleteCodeblock + '\n```'); + assert.deepStrictEqual(newTokens, completeCodeblockTokens); + }); + + test('code block header with leading text and some code', () => { + const incompleteCodeblock = 'some text\n```js\nconst'; + const tokens = marked.lexer(incompleteCodeblock); + const newTokens = fillInIncompleteTokens(tokens); + + const completeCodeblockTokens = marked.lexer(incompleteCodeblock + '\n```'); + assert.deepStrictEqual(newTokens, completeCodeblockTokens); + }); + }); }); diff --git a/src/vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens.ts b/src/vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens.ts index ccf205ae1b6..52a1f9401ac 100644 --- a/src/vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens.ts +++ b/src/vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens.ts @@ -22,7 +22,7 @@ import { DocumentRangeSemanticTokensProvider } from 'vs/editor/common/languages' import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { ISemanticTokensStylingService } from 'vs/editor/common/services/semanticTokensStyling'; -class ViewportSemanticTokensContribution extends Disposable implements IEditorContribution { +export class ViewportSemanticTokensContribution extends Disposable implements IEditorContribution { public static readonly ID = 'editor.contrib.viewportSemanticTokens'; diff --git a/src/vs/editor/contrib/smartSelect/browser/smartSelect.ts b/src/vs/editor/contrib/smartSelect/browser/smartSelect.ts index 662b3dacf90..4464bb9cd54 100644 --- a/src/vs/editor/contrib/smartSelect/browser/smartSelect.ts +++ b/src/vs/editor/contrib/smartSelect/browser/smartSelect.ts @@ -51,7 +51,7 @@ class SelectionRanges { } } -class SmartSelectController implements IEditorContribution { +export class SmartSelectController implements IEditorContribution { static readonly ID = 'editor.contrib.smartSelectController'; diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index d875768425d..deaa4b2cf62 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -70,6 +70,7 @@ import './mainThreadNotebookKernels'; import './mainThreadNotebookDocumentsAndEditors'; import './mainThreadNotebookRenderers'; import './mainThreadInteractive'; +import './mainThreadInteractiveSession'; import './mainThreadTask'; import './mainThreadLabelService'; import './mainThreadTunnelService'; diff --git a/src/vs/workbench/api/browser/mainThreadInteractiveSession.ts b/src/vs/workbench/api/browser/mainThreadInteractiveSession.ts new file mode 100644 index 00000000000..fbbd9edb1f7 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadInteractiveSession.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableMap } from 'vs/base/common/lifecycle'; +import { ExtHostContext, ExtHostInteractiveSessionShape, IInteractiveRequestDto, MainContext, MainThreadInteractiveSessionShape } from 'vs/workbench/api/common/extHost.protocol'; +import { IInteractiveSessionContributionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionContributionService'; +import { IInteractiveProgress, IInteractiveRequest, IInteractiveResponse, IInteractiveSessionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; +import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; + +@extHostNamedCustomer(MainContext.MainThreadInteractiveSession) +export class MainThreadInteractiveSession implements MainThreadInteractiveSessionShape { + + private readonly _inputRegistrations = new DisposableMap(); + + private readonly _registrations = new DisposableMap(); + private readonly _activeRequestProgressCallbacks = new Map void>(); + + private readonly _proxy: ExtHostInteractiveSessionShape; + + constructor( + extHostContext: IExtHostContext, + @IInteractiveSessionService private readonly _interactiveSessionService: IInteractiveSessionService, + @IInteractiveSessionContributionService private readonly interactiveSessionContribService: IInteractiveSessionContributionService + ) { + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostInteractiveSession); + } + + dispose(): void { + this._inputRegistrations.dispose(); + this._registrations.dispose(); + } + + async $registerInteractiveSessionProvider(handle: number, id: string): Promise { + if (!this.interactiveSessionContribService.registeredProviders.find(staticProvider => staticProvider.id === id)) { + throw new Error(`Provider ${id} must be declared in the package.json.`); + } + + const unreg = this._interactiveSessionService.registerProvider({ + id, + prepareSession: async (initialState, token) => { + const session = await this._proxy.$prepareInteractiveSession(handle, initialState, token); + if (!session) { + return undefined; + } + + return { + ...session, + dispose: () => { + this._proxy.$releaseSession(session.id); + } + }; + }, + resolveRequest: async (session, context, token) => { + const dto = await this._proxy.$resolveInteractiveRequest(handle, session.id, context, token); + return { + session, + ...dto + }; + }, + provideReply: async (request, progress, token) => { + const id = `${handle}_${request.session.id}`; + this._activeRequestProgressCallbacks.set(id, progress); + try { + const requestDto: IInteractiveRequestDto = { + message: request.message, + }; + const dto = await this._proxy.$provideInteractiveReply(handle, request.session.id, requestDto, token); + return { + session: request.session, + ...dto + }; + } finally { + this._activeRequestProgressCallbacks.delete(id); + } + }, + provideSuggestions: (token) => { + return this._proxy.$provideInitialSuggestions(handle, token); + } + }); + + this._registrations.set(handle, unreg); + } + + $acceptInteractiveResponseProgress(handle: number, sessionId: number, progress: IInteractiveProgress): void { + const id = `${handle}_${sessionId}`; + this._activeRequestProgressCallbacks.get(id)?.(progress); + } + + async $acceptInteractiveSessionState(sessionId: number, state: any): Promise { + this._interactiveSessionService.acceptNewSessionState(sessionId, state); + } + + $addInteractiveSessionRequest(context: any): void { + this._interactiveSessionService.addInteractiveRequest(context); + } + + async $unregisterInteractiveSessionProvider(handle: number): Promise { + this._registrations.deleteAndDispose(handle); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 817acbc126e..6026e2809fc 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -97,6 +97,7 @@ import { IExtHostLocalizationService } from 'vs/workbench/api/common/extHostLoca import { EditSessionIdentityMatch } from 'vs/platform/workspace/common/editSessions'; import { ExtHostProfileContentHandlers } from 'vs/workbench/api/common/extHostProfileContentHandler'; import { ExtHostQuickDiff } from 'vs/workbench/api/common/extHostQuickDiff'; +import { ExtHostInteractiveSession } from 'vs/workbench/api/common/extHostInteractiveSession'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -191,6 +192,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostUriOpeners = rpcProtocol.set(ExtHostContext.ExtHostUriOpeners, new ExtHostUriOpeners(rpcProtocol)); const extHostProfileContentHandlers = rpcProtocol.set(ExtHostContext.ExtHostProfileContentHandlers, new ExtHostProfileContentHandlers(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostInteractive, new ExtHostInteractive(rpcProtocol, extHostNotebook, extHostDocumentsAndEditors, extHostCommands, extHostLogService)); + const extHostInteractiveSession = rpcProtocol.set(ExtHostContext.ExtHostInteractiveSession, new ExtHostInteractiveSession(rpcProtocol, extHostLogService)); // Check that no named customers are missing const expected = Object.values>(ExtHostContext); @@ -1208,6 +1210,22 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I } }; + // namespace: interactive + const interactive: typeof vscode.interactive = { + // IMPORTANT + // this needs to be updated whenever the API proposal changes + _version: 1, + + registerInteractiveSessionProvider(id: string, provider: vscode.InteractiveSessionProvider) { + checkProposedApiEnabled(extension, 'interactive'); + return extHostInteractiveSession.registerInteractiveSessionProvider(extension, id, provider); + }, + addInteractiveRequest(context: vscode.InteractiveSessionRequestArgs) { + checkProposedApiEnabled(extension, 'interactive'); + return extHostInteractiveSession.addInteractiveSessionRequest(context); + } + }; + return { version: initData.version, // namespaces @@ -1217,6 +1235,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I debug, env, extensions, + interactive, l10n, languages, notebooks, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index c61347eb893..b3933af7d0d 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -12,6 +12,7 @@ import { IMarkdownString } from 'vs/base/common/htmlContent'; import { IDisposable } from 'vs/base/common/lifecycle'; import * as performance from 'vs/base/common/performance'; import Severity from 'vs/base/common/severity'; +import { ThemeColor, ThemeIcon } from 'vs/base/common/themables'; import { URI, UriComponents, UriDto } from 'vs/base/common/uri'; import { RenderLineNumbersType, TextEditorCursorStyle } from 'vs/editor/common/config/editorOptions'; import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; @@ -20,8 +21,8 @@ import { IRange } from 'vs/editor/common/core/range'; import { ISelection, Selection } from 'vs/editor/common/core/selection'; import { IChange } from 'vs/editor/common/diff/smartLinesDiffComputer'; import * as editorCommon from 'vs/editor/common/editorCommon'; -import * as languages from 'vs/editor/common/languages'; import { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes'; +import * as languages from 'vs/editor/common/languages'; import { CharacterPair, CommentRule, EnterAction } from 'vs/editor/common/languages/languageConfiguration'; import { EndOfLineSequence } from 'vs/editor/common/model'; import { IModelChangedEvent } from 'vs/editor/common/model/mirrorTextModel'; @@ -39,9 +40,10 @@ import * as quickInput from 'vs/platform/quickinput/common/quickInput'; import { IRemoteConnectionData, TunnelDescription } from 'vs/platform/remote/common/remoteAuthorityResolver'; import { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from 'vs/platform/telemetry/common/gdprTypings'; import { TelemetryLevel } from 'vs/platform/telemetry/common/telemetry'; +import { ISerializableEnvironmentVariableCollection } from 'vs/platform/terminal/common/environmentVariable'; import { ICreateContributedTerminalProfileOptions, IProcessProperty, IShellLaunchConfigDto, ITerminalEnvironment, ITerminalLaunchError, ITerminalProfile, TerminalExitReason, TerminalLocation } from 'vs/platform/terminal/common/terminal'; -import { ThemeIcon, ThemeColor } from 'vs/base/common/themables'; import { ProvidedPortAttributes, TunnelCreationOptions, TunnelOptions, TunnelPrivacyId, TunnelProviderFeatures } from 'vs/platform/tunnel/common/tunnel'; +import { EditSessionIdentityMatch } from 'vs/platform/workspace/common/editSessions'; import { WorkspaceTrustRequestOptions } from 'vs/platform/workspace/common/workspaceTrust'; import * as tasks from 'vs/workbench/api/common/shared/tasks'; import { SaveReason } from 'vs/workbench/common/editor'; @@ -52,11 +54,9 @@ import * as notebookCommon from 'vs/workbench/contrib/notebook/common/notebookCo import { CellExecutionUpdateType } from 'vs/workbench/contrib/notebook/common/notebookExecutionService'; import { ICellExecutionComplete, ICellExecutionStateUpdate } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; import { ICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange'; -import { OutputChannelUpdateMode } from 'vs/workbench/services/output/common/output'; import { InputValidationType } from 'vs/workbench/contrib/scm/common/scm'; import { IWorkspaceSymbol } from 'vs/workbench/contrib/search/common/search'; -import { ISerializableEnvironmentVariableCollection } from 'vs/platform/terminal/common/environmentVariable'; -import { CoverageDetails, ExtensionRunTestsRequest, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, IStartControllerTests, TestResultState, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes'; +import { CoverageDetails, ExtensionRunTestsRequest, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, IStartControllerTests, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, TestResultState, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes'; import { Timeline, TimelineChangeEvent, TimelineOptions, TimelineProviderDescriptor } from 'vs/workbench/contrib/timeline/common/timeline'; import { TypeHierarchyItem } from 'vs/workbench/contrib/typeHierarchy/common/typeHierarchy'; import { AuthenticationProviderInformation, AuthenticationSession, AuthenticationSessionsChangeEvent } from 'vs/workbench/services/authentication/common/authentication'; @@ -64,14 +64,14 @@ import { EditorGroupColumn } from 'vs/workbench/services/editor/common/editorGro import { IExtensionDescriptionDelta, IStaticWorkspaceData } from 'vs/workbench/services/extensions/common/extensionHostProtocol'; import { IResolveAuthorityResult } from 'vs/workbench/services/extensions/common/extensionHostProxy'; import { ActivationKind, ExtensionActivationReason, MissingExtensionDependency } from 'vs/workbench/services/extensions/common/extensions'; -import { createProxyIdentifier, Dto, IRPCProtocol, SerializableObjectWithBuffers } from 'vs/workbench/services/extensions/common/proxyIdentifier'; +import { Dto, IRPCProtocol, SerializableObjectWithBuffers, createProxyIdentifier } from 'vs/workbench/services/extensions/common/proxyIdentifier'; import { ILanguageStatus } from 'vs/workbench/services/languageStatus/common/languageStatusService'; +import { OutputChannelUpdateMode } from 'vs/workbench/services/output/common/output'; import { CandidatePort } from 'vs/workbench/services/remote/common/remoteExplorerService'; import { ITextQueryBuilderOptions } from 'vs/workbench/services/search/common/queryBuilder'; import * as search from 'vs/workbench/services/search/common/search'; -import { EditSessionIdentityMatch } from 'vs/platform/workspace/common/editSessions'; -import { TerminalCommandMatchResult, TerminalQuickFixCommand, TerminalQuickFixOpener } from 'vscode'; import { ISaveProfileResult } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; +import { TerminalCommandMatchResult, TerminalQuickFixCommand, TerminalQuickFixOpener } from 'vscode'; export type TerminalQuickFix = TerminalQuickFixCommand | TerminalQuickFixOpener; @@ -1074,6 +1074,38 @@ export interface MainThreadUrlsShape extends IDisposable { $createAppUri(uri: UriComponents): Promise; } +export interface IInteractiveSessionDto { + id: number; +} + +export interface IInteractiveRequestDto { + message: string; +} + +export interface IInteractiveResponseDto { + followups?: string[]; +} + +export interface IInteractiveResponseProgressDto { + responsePart: string; +} + +export interface MainThreadInteractiveSessionShape extends IDisposable { + $registerInteractiveSessionProvider(handle: number, id: string): Promise; + $acceptInteractiveSessionState(sessionId: number, state: any): Promise; + $addInteractiveSessionRequest(context: any): void; + $unregisterInteractiveSessionProvider(handle: number): Promise; + $acceptInteractiveResponseProgress(handle: number, sessionId: number, progress: IInteractiveResponseProgressDto): void; +} + +export interface ExtHostInteractiveSessionShape { + $prepareInteractiveSession(handle: number, initialState: any, token: CancellationToken): Promise; + $resolveInteractiveRequest(handle: number, sessionId: number, context: any, token: CancellationToken): Promise; + $provideInitialSuggestions(handle: number, token: CancellationToken): Promise; + $provideInteractiveReply(handle: number, sessionid: number, request: IInteractiveRequestDto, token: CancellationToken): Promise; + $releaseSession(sessionId: number): void; +} + export interface ExtHostUrlsShape { $handleExternalUri(handle: number, uri: UriComponents): Promise; } @@ -2378,6 +2410,7 @@ export const MainContext = { MainThreadNotebookKernels: createProxyIdentifier('MainThreadNotebookKernels'), MainThreadNotebookRenderers: createProxyIdentifier('MainThreadNotebookRenderers'), MainThreadInteractive: createProxyIdentifier('MainThreadInteractive'), + MainThreadInteractiveSession: createProxyIdentifier('MainThreadInteractiveSession'), MainThreadTheming: createProxyIdentifier('MainThreadTheming'), MainThreadTunnelService: createProxyIdentifier('MainThreadTunnelService'), MainThreadTimeline: createProxyIdentifier('MainThreadTimeline'), @@ -2433,6 +2466,7 @@ export const ExtHostContext = { ExtHostNotebookKernels: createProxyIdentifier('ExtHostNotebookKernels'), ExtHostNotebookRenderers: createProxyIdentifier('ExtHostNotebookRenderers'), ExtHostInteractive: createProxyIdentifier('ExtHostInteractive'), + ExtHostInteractiveSession: createProxyIdentifier('ExtHostInteractiveSession'), ExtHostTheming: createProxyIdentifier('ExtHostTheming'), ExtHostTunnelService: createProxyIdentifier('ExtHostTunnelService'), ExtHostAuthentication: createProxyIdentifier('ExtHostAuthentication'), diff --git a/src/vs/workbench/api/common/extHostInteractiveSession.ts b/src/vs/workbench/api/common/extHostInteractiveSession.ts new file mode 100644 index 00000000000..7b1340c2661 --- /dev/null +++ b/src/vs/workbench/api/common/extHostInteractiveSession.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { toDisposable } from 'vs/base/common/lifecycle'; +import { withNullAsUndefined } from 'vs/base/common/types'; +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 type * as vscode from 'vscode'; + +class InteractiveSessionProviderWrapper { + + private static _pool = 0; + + readonly handle: number = InteractiveSessionProviderWrapper._pool++; + + constructor( + readonly extension: Readonly, + readonly provider: vscode.InteractiveSessionProvider, + ) { } +} + +export class ExtHostInteractiveSession implements ExtHostInteractiveSessionShape { + private static _nextId = 0; + + private readonly _interactiveSessionProvider = new Map(); + private readonly _interactiveSessions = new Map(); + + private readonly _proxy: MainThreadInteractiveSessionShape; + + constructor( + mainContext: IMainContext, + _logService: ILogService + ) { + this._proxy = mainContext.getProxy(MainContext.MainThreadInteractiveSession); + } + + //#region interactive session + + registerInteractiveSessionProvider(extension: Readonly, id: string, provider: vscode.InteractiveSessionProvider): vscode.Disposable { + const wrapper = new InteractiveSessionProviderWrapper(extension, provider); + this._interactiveSessionProvider.set(wrapper.handle, wrapper); + this._proxy.$registerInteractiveSessionProvider(wrapper.handle, id); + return toDisposable(() => { + this._proxy.$unregisterInteractiveSessionProvider(wrapper.handle); + this._interactiveSessionProvider.delete(wrapper.handle); + }); + } + + addInteractiveSessionRequest(context: vscode.InteractiveSessionRequestArgs): void { + this._proxy.$addInteractiveSessionRequest(context); + } + + async $prepareInteractiveSession(handle: number, initialState: any, token: CancellationToken): Promise { + const entry = this._interactiveSessionProvider.get(handle); + if (!entry) { + return undefined; + } + + const session = await entry.provider.prepareSession(initialState, token); + if (!session) { + return undefined; + } + + const id = ExtHostInteractiveSession._nextId++; + this._interactiveSessions.set(id, session); + + return { id }; + } + + async $resolveInteractiveRequest(handle: number, sessionId: number, context: any, token: CancellationToken): Promise { + const entry = this._interactiveSessionProvider.get(handle); + if (!entry) { + return undefined; + } + + const realSession = this._interactiveSessions.get(sessionId); + if (!realSession) { + return undefined; + } + + if (!entry.provider.resolveRequest) { + return undefined; + } + const request = await entry.provider.resolveRequest(realSession, context, token); + if (request) { + return { + message: request.message, + }; + } + + return undefined; + } + + async $provideInitialSuggestions(handle: number, token: CancellationToken): Promise { + const entry = this._interactiveSessionProvider.get(handle); + if (!entry) { + return undefined; + } + + if (!entry.provider.provideInitialSuggestions) { + return undefined; + } + + return withNullAsUndefined(await entry.provider.provideInitialSuggestions(token)); + } + + async $provideInteractiveReply(handle: number, sessionId: number, request: IInteractiveRequestDto, token: CancellationToken): Promise { + const entry = this._interactiveSessionProvider.get(handle); + if (!entry) { + return undefined; + } + + const realSession = this._interactiveSessions.get(sessionId); + if (!realSession) { + return; + } + + const requestObj: vscode.InteractiveRequest = { + session: realSession, + message: request.message, + }; + + if (entry.provider.provideResponse) { + const res = await entry.provider.provideResponse(requestObj, token); + if (realSession.saveState) { + const newState = realSession.saveState(); + this._proxy.$acceptInteractiveSessionState(sessionId, newState); + } + + if (!res) { + return; + } + + this._proxy.$acceptInteractiveResponseProgress(handle, sessionId, { responsePart: res.content }); + return { followups: res.followups }; + } else if (entry.provider.provideResponseWithProgress) { + const progressObj: vscode.Progress = { + report: (progress: vscode.InteractiveProgress) => this._proxy.$acceptInteractiveResponseProgress(handle, sessionId, { responsePart: progress.content }) + }; + const res = await entry.provider.provideResponseWithProgress(requestObj, progressObj, token); + if (realSession.saveState) { + const newState = realSession.saveState(); + this._proxy.$acceptInteractiveSessionState(sessionId, newState); + } + + if (!res) { + return; + } + + return { followups: res.followups }; + } + + throw new Error('provider must implement either provideResponse or provideResponseWithProgress'); + } + + $releaseSession(sessionId: number) { + this._interactiveSessions.delete(sessionId); + } + + //#endregion +} diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.contribution.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.contribution.ts new file mode 100644 index 00000000000..710190e16f7 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.contribution.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { isMacintosh } from 'vs/base/common/platform'; +import * as nls from 'vs/nls'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from 'vs/workbench/browser/editor'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions'; +import { EditorExtensions } from 'vs/workbench/common/editor'; +import { registerInteractiveSessionActions } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionActions'; +import { InteractiveSessionContributionService } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionContributionServiceImpl'; +import { InteractiveSessionEditor } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditor'; +import { InteractiveSessionEditorInput } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditorInput'; +import { IInteractiveSessionContributionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionContributionService'; +import { IInteractiveSessionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; +import { IEditorResolverService, RegisteredEditorPriority } from 'vs/workbench/services/editor/common/editorResolverService'; +import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; +import '../common/interactiveSessionColors'; +import { InteractiveSessionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionServiceImpl'; + + +// Register configuration +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +configurationRegistry.registerConfiguration({ + id: 'interactiveSessionSidebar', + title: nls.localize('interactiveSessionConfigurationTitle', "Interactive Session"), + type: 'object', + properties: { + 'interactiveSession.editor.fontSize': { + type: 'number', + description: nls.localize('interactiveSession.editor.fontSize', "Controls the font size in pixels in the Interactive Session Sidebar."), + default: isMacintosh ? 12 : 14, + }, + 'interactiveSession.editor.fontFamily': { + type: 'string', + description: nls.localize('interactiveSession.editor.fontFamily', "Controls the font family in the Interactive Session Sidebar."), + default: 'default' + }, + 'interactiveSession.editor.fontWeight': { + type: 'string', + description: nls.localize('interactiveSession.editor.fontWeight', "Controls the font weight in the Interactive Session Sidebar."), + default: 'default' + }, + 'interactiveSession.editor.lineHeight': { + type: 'number', + description: nls.localize('interactiveSession.editor.lineHeight', "Controls the line height in pixels in the Interactive Session Sidebar. Use 0 to compute the line height from the font size."), + default: 0 + } + } +}); + + +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + InteractiveSessionEditor, + InteractiveSessionEditor.ID, + nls.localize('interactiveSession', "Interactive Session") + ), + [ + new SyncDescriptor(InteractiveSessionEditorInput) + ] +); + +class InteractiveSessionResolverContribution extends Disposable { + constructor( + @IEditorResolverService editorResolverService: IEditorResolverService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this._register(editorResolverService.registerEditor( + `${InteractiveSessionEditor.SCHEME}:**/**`, + { + id: InteractiveSessionEditor.ID, + label: nls.localize('interactiveSession', "Interactive Session"), + priority: RegisteredEditorPriority.builtin + }, + { + singlePerResource: true, + canSupportResource: resource => resource.scheme === InteractiveSessionEditor.SCHEME + }, + { + createEditorInput: ({ resource, options }) => { + return { editor: instantiationService.createInstance(InteractiveSessionEditorInput, resource), options }; + } + } + )); + } +} + +const workbenchContributionsRegistry = Registry.as(WorkbenchExtensions.Workbench); +workbenchContributionsRegistry.registerWorkbenchContribution(InteractiveSessionResolverContribution, LifecyclePhase.Starting); + +registerInteractiveSessionActions(); + +registerSingleton(IInteractiveSessionService, InteractiveSessionService, InstantiationType.Delayed); +registerSingleton(IInteractiveSessionContributionService, InteractiveSessionContributionService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionActions.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionActions.ts new file mode 100644 index 00000000000..bd5b85ffb91 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionActions.ts @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from 'vs/base/common/codicons'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorAction, ServicesAccessor, registerEditorAction } from 'vs/editor/browser/editorExtensions'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { localize } from 'vs/nls'; +import { Action2, IAction2Options, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; +import { ActiveEditorContext } from 'vs/workbench/common/contextkeys'; +import { IViewsService } from 'vs/workbench/common/views'; +import { IInteractiveSessionEditorOptions, InteractiveSessionEditor } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditor'; +import { InteractiveSessionViewPane } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionSidebar'; +import { CONTEXT_IN_INTERACTIVE_INPUT, CONTEXT_IN_INTERACTIVE_SESSION, InteractiveSessionWidget } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; + +const category = { value: localize('interactiveSession.category', "Interactive Session"), original: 'Interactive Session' }; + +export const ClearInteractiveSessionActionDescriptor: Readonly = { + id: 'workbench.action.interactiveSession.clear', + title: { + value: localize('interactiveSession.clear.label', "Clear"), + original: 'Clear' + }, + category, + icon: Codicon.clearAll, + f1: false +}; + +export function registerInteractiveSessionActions() { + registerEditorAction(class InteractiveSessionAcceptInput extends EditorAction { + constructor() { + super({ + id: 'interactiveSession.action.acceptInput', + label: localize({ key: 'actions.ineractiveSession.acceptInput', comment: ['Apply input from the interactive session input box'] }, "Interactive Session Accept Input"), + alias: 'Interactive Session Accept Input', + precondition: CONTEXT_IN_INTERACTIVE_INPUT, + kbOpts: { + kbExpr: EditorContextKeys.textInputFocus, + primary: KeyCode.Enter, + weight: KeybindingWeight.EditorContrib + } + }); + } + + run(_accessor: ServicesAccessor, editor: ICodeEditor): void | Promise { + const editorUri = editor.getModel()?.uri; + if (editorUri) { + InteractiveSessionWidget.getViewByInputUri(editorUri)?.acceptInput(); + } + } + }); + + // registerAction2(class OpenInteractiveSessionWindow extends Action2 { + // constructor() { + // super({ + // id: 'workbench.action.interactiveSession.start', + // title: localize('interactiveSession', 'Open Interactive Session...'), + // icon: Codicon.commentDiscussion, + // precondition: ContextKeyExpr.and(CTX_INTERACTIVE_EDITOR_VISIBLE), + // f1: false, + // menu: { + // id: MENU_INTERACTIVE_EDITOR_WIDGET, + // group: 'Z', + // order: 1 + // } + // }); + // } + + // override run(accessor: ServicesAccessor, ...args: any[]): void { + // const viewsService = accessor.get(IViewsService); + // viewsService.openView(InteractiveSessionViewPane.ID, true); + // } + // }); + + registerAction2(class ClearEditorAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.interactiveSessionEditor.clear', + title: { + value: localize('interactiveSession.clear.label', "Clear"), + original: 'Clear' + }, + icon: Codicon.clearAll, + f1: false, + menu: [{ + id: MenuId.EditorTitle, + group: 'navigation', + order: 0, + when: ActiveEditorContext.isEqualTo(InteractiveSessionEditor.ID), + }] + }); + } + run(accessor: ServicesAccessor, ...args: any[]) { + const editorService = accessor.get(IEditorService); + if (editorService.activeEditorPane instanceof InteractiveSessionEditor) { + editorService.activeEditorPane.clear(); + } + } + }); + + registerEditorAction(class FocusInteractiveSessionAction extends EditorAction { + constructor() { + super({ + id: 'interactiveSession.action.focus', + label: localize('actions.interactiveSession.focus', "Focus Interactive Session"), + alias: 'Focus Interactive Session', + precondition: CONTEXT_IN_INTERACTIVE_INPUT, + kbOpts: { + kbExpr: EditorContextKeys.textInputFocus, + primary: KeyMod.CtrlCmd | KeyCode.UpArrow, + weight: KeybindingWeight.EditorContrib + } + }); + } + + run(_accessor: ServicesAccessor, editor: ICodeEditor): void | Promise { + const editorUri = editor.getModel()?.uri; + if (editorUri) { + InteractiveSessionWidget.getViewByInputUri(editorUri)?.focusLastMessage(); + } + } + }); + + registerAction2(class FocusInteractiveSessionInputAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.interactiveSession.focusInput', + title: { + value: localize('interactiveSession.focusInput.label', "Focus Input"), + original: 'Focus Input' + }, + f1: false, + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.DownArrow, + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and(CONTEXT_IN_INTERACTIVE_SESSION, ContextKeyExpr.not(EditorContextKeys.focus.key)) + } + }); + } + run(accessor: ServicesAccessor, ...args: any[]) { + const viewsService = accessor.get(IViewsService); + const interactiveSessionView = viewsService.getActiveViewWithId(InteractiveSessionViewPane.ID) as InteractiveSessionViewPane; + if (interactiveSessionView) { + interactiveSessionView.focus(); + } + } + }); + + registerAction2(class ClearAction extends Action2 { + constructor() { + super(ClearInteractiveSessionActionDescriptor); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + // TODO hacks + InteractiveSessionViewPane.instances.forEach(instance => instance.clear()); + } + }); +} + +export function getOpenInteractiveSessionEditorAction(id: string, label: string) { + return class OpenInteractiveSessionEditor extends Action2 { + constructor() { + super({ + id: `workbench.action.openInteractiveSession.${id}`, + title: { value: localize('interactiveSession.open', "Open Interactive Session Editor ({0})", label), original: `Open Interactive Session Editor (${label})` }, + f1: true, + category + }); + } + + async run(accessor: ServicesAccessor) { + const editorService = accessor.get(IEditorService); + await editorService.openEditor({ resource: InteractiveSessionEditor.getNewEditorUri(), options: { providerId: id } }); + } + }; +} diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionContributionServiceImpl.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionContributionServiceImpl.ts new file mode 100644 index 00000000000..3a57b778e2f --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionContributionServiceImpl.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from 'vs/base/common/codicons'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import * as resources from 'vs/base/common/resources'; +import { localize } from 'vs/nls'; +import { MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; +import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainer, ViewContainerLocation, Extensions as ViewExtensions } from 'vs/workbench/common/views'; +import { ClearInteractiveSessionActionDescriptor, getOpenInteractiveSessionEditorAction } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionActions'; +import { INTERACTIVE_SIDEBAR_PANEL_ID, InteractiveSessionViewPane, IInteractiveSessionViewOptions } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionSidebar'; +import { IInteractiveSessionContributionService, IInteractiveSessionProviderContribution } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionContributionService'; +import * as extensionsRegistry from 'vs/workbench/services/extensions/common/extensionsRegistry'; + +const interactiveSessionExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'interactiveSession', + jsonSchema: { + description: localize('vscode.extension.contributes.interactiveSession', 'Contributes an Interactive Session provider'), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { id: '', program: '', runtime: '' } }], + properties: { + id: { + description: localize('vscode.extension.contributes.interactiveSession.id', "Unique identifier for this Interactive Session provider."), + type: 'string' + }, + label: { + description: localize('vscode.extension.contributes.interactiveSession.label', "Display name for this Interactive Session provider."), + type: 'string' + }, + icon: { + description: localize('vscode.extension.contributes.interactiveSession.icon', "An icon for this Interactive Session provider."), + type: 'string' + }, + } + } + } +}); + +export class InteractiveSessionContributionService implements IInteractiveSessionContributionService { + declare _serviceBrand: undefined; + + private _registrationDisposables = new Map(); + private _registeredProviders = new Map(); + + constructor() { + interactiveSessionExtensionPoint.setHandler((extensions, delta) => { + for (const extension of delta.added) { + const extensionDisposable = new DisposableStore(); + for (const providerDescriptor of extension.value) { + this.registerInteractiveSessionProvider(extension.description, providerDescriptor); + this._registeredProviders.set(providerDescriptor.id, providerDescriptor); + } + this._registrationDisposables.set(extension.description.identifier.value, extensionDisposable); + } + + for (const extension of delta.removed) { + const registration = this._registrationDisposables.get(extension.description.identifier.value); + if (registration) { + registration.dispose(); + this._registrationDisposables.delete(extension.description.identifier.value); + } + + for (const providerDescriptor of extension.value) { + this._registeredProviders.delete(providerDescriptor.id); + } + } + }); + } + + public get registeredProviders(): IInteractiveSessionProviderContribution[] { + return Array.from(this._registeredProviders.values()); + } + + private registerInteractiveSessionProvider(extension: Readonly, providerDescriptor: IInteractiveSessionProviderContribution): IDisposable { + // Register View Container + const viewContainerId = INTERACTIVE_SIDEBAR_PANEL_ID + '.' + providerDescriptor.id; + const viewContainer: ViewContainer = Registry.as(ViewExtensions.ViewContainersRegistry).registerViewContainer({ + id: viewContainerId, + title: providerDescriptor.label, + icon: providerDescriptor.icon !== '' ? resources.joinPath(extension.extensionLocation, providerDescriptor.icon) : Codicon.commentDiscussion, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [viewContainerId, { mergeViewWithContainerWhenSingleView: true }]), + storageId: viewContainerId, + hideIfEmpty: true, + order: 100, + }, ViewContainerLocation.Sidebar); + + // Register View + const viewId = InteractiveSessionViewPane.ID + '.' + providerDescriptor.id; + const viewDescriptor: IViewDescriptor[] = [{ + id: viewId, + name: providerDescriptor.label, + canToggleVisibility: false, + canMoveView: true, + ctorDescriptor: new SyncDescriptor(InteractiveSessionViewPane, [{ providerId: providerDescriptor.id }]), + when: ContextKeyExpr.deserialize(providerDescriptor.when), + }]; + Registry.as(ViewExtensions.ViewsRegistry).registerViews(viewDescriptor, viewContainer); + + // Clear action in view title + const menuItem = MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + command: ClearInteractiveSessionActionDescriptor, + when: ContextKeyExpr.equals('view', viewId), + group: 'navigation', + order: 0 + }); + + // "Open Interactive Session Editor" Action + const openEditor = registerAction2(getOpenInteractiveSessionEditorAction(providerDescriptor.id, providerDescriptor.label)); + + return { + dispose: () => { + Registry.as(ViewExtensions.ViewsRegistry).deregisterViews(viewDescriptor, viewContainer); + Registry.as(ViewExtensions.ViewContainersRegistry).deregisterViewContainer(viewContainer); + menuItem.dispose(); + openEditor.dispose(); + } + }; + } +} diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditor.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditor.ts new file mode 100644 index 00000000000..6555ccd1c00 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditor.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dimension, IDomPosition } from 'vs/base/browser/dom'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { URI } from 'vs/base/common/uri'; +import { IEditorOptions } from 'vs/platform/editor/common/editor'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IStorageService } from 'vs/platform/storage/common/storage'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { editorBackground } from 'vs/platform/theme/common/colorRegistry'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { EditorPane } from 'vs/workbench/browser/parts/editor/editorPane'; +import { IEditorOpenContext } from 'vs/workbench/common/editor'; +import { SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; +import { InteractiveSessionEditorInput } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditorInput'; +import { InteractiveSessionWidget } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget'; + +export interface IInteractiveSessionEditorOptions extends IEditorOptions { + providerId: string; +} + +export class InteractiveSessionEditor extends EditorPane { + static readonly ID: string = 'workbench.editor.interactiveSession'; + static readonly SCHEME: string = 'interactiveSession'; + + private static _counter = 0; + static getNewEditorUri(): URI { + return URI.from({ scheme: InteractiveSessionEditor.SCHEME, path: `interactiveSession-${InteractiveSessionEditor._counter++}` }); + } + + private widget: InteractiveSessionWidget | undefined; + private parentElement: HTMLElement | undefined; + private dimension: Dimension | undefined; + + constructor( + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStorageService storageService: IStorageService, + ) { + super(InteractiveSessionEditor.ID, telemetryService, themeService, storageService); + } + + public clear() { + if (this.widget) { + this.widget.clear(); + } + } + + protected override createEditor(parent: HTMLElement): void { + this.parentElement = parent; + } + + public override focus(): void { + if (this.widget) { + this.widget.focusInput(); + } + } + + override async setInput(input: InteractiveSessionEditorInput, options: IInteractiveSessionEditorOptions, context: IEditorOpenContext, token: CancellationToken): Promise { + super.setInput(input, options, context, token); + + // TODO would be much cleaner if I can create the widget first and set its provider id later + if (!this.widget) { + this.widget = this.instantiationService.createInstance(InteractiveSessionWidget, options.providerId, undefined, () => editorBackground, () => SIDE_BAR_BACKGROUND, () => SIDE_BAR_BACKGROUND); + if (!this.parentElement) { + throw new Error('InteractiveSessionEditor lifecycle issue: Parent element not set'); + } + + this.widget.render(this.parentElement); + this.widget.setVisible(true); + + if (this.dimension) { + this.layout(this.dimension, undefined); + } + } + } + + override layout(dimension: Dimension, position?: IDomPosition | undefined): void { + if (this.widget) { + this.widget.layout(dimension.height, dimension.width); + } + + this.dimension = dimension; + } +} + diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditorInput.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditorInput.ts new file mode 100644 index 00000000000..d5af48f6965 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditorInput.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from 'vs/base/common/uri'; +import * as nls from 'vs/nls'; +import { IUntypedEditorInput } from 'vs/workbench/common/editor'; +import { EditorInput } from 'vs/workbench/common/editor/editorInput'; +import { InteractiveSessionEditor } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionEditor'; + +export class InteractiveSessionEditorInput extends EditorInput { + static readonly ID: string = 'workbench.input.interactiveSession'; + + constructor(readonly resource: URI) { + super(); + } + + override get editorId(): string | undefined { + return InteractiveSessionEditor.ID; + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + return otherInput instanceof InteractiveSessionEditorInput && otherInput.resource.toString() === this.resource.toString(); + } + + override get typeId(): string { + return InteractiveSessionEditorInput.ID; + } + + override getName(): string { + return nls.localize('interactiveSessionEditorName', "Interactive Session"); + } + + override async resolve(): Promise { + return null; + } +} diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts new file mode 100644 index 00000000000..8209921afdd --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts @@ -0,0 +1,478 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { Button } from 'vs/base/browser/ui/button/button'; +import { IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; +import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; +import { ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree'; +import { IntervalTimer } from 'vs/base/common/async'; +import { Codicon } from 'vs/base/common/codicons'; +import { Emitter, Event } from 'vs/base/common/event'; +import { FuzzyScore } from 'vs/base/common/filters'; +import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; +import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { ThemeIcon } from 'vs/base/common/themables'; +import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { EDITOR_FONT_DEFAULTS } from 'vs/editor/common/config/editorOptions'; +import { Range } from 'vs/editor/common/core/range'; +import { ILanguageService } from 'vs/editor/common/languages/language'; +import { ITextModel } from 'vs/editor/common/model'; +import { IModelService } from 'vs/editor/common/services/model'; +import { BracketMatchingController } from 'vs/editor/contrib/bracketMatching/browser/bracketMatching'; +import { ContextMenuController } from 'vs/editor/contrib/contextmenu/browser/contextmenu'; +import { IMarkdownRenderResult, MarkdownRenderer } from 'vs/editor/contrib/markdownRenderer/browser/markdownRenderer'; +import { ViewportSemanticTokensContribution } from 'vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens'; +import { SmartSelectController } from 'vs/editor/contrib/smartSelect/browser/smartSelect'; +import { localize } from 'vs/nls'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ILogService } from 'vs/platform/log/common/log'; +import { defaultButtonStyles } from 'vs/platform/theme/browser/defaultStyles'; +import { FloatingClickMenu } from 'vs/workbench/browser/codeeditor'; +import { InteractiveSessionInputOptions } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions'; +import { IInteractiveRequestViewModel, IInteractiveResponseViewModel, isRequestVM, isResponseVM } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel'; +import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreventer'; +import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard'; +import { getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions'; + +const $ = dom.$; + +export type InteractiveTreeItem = IInteractiveRequestViewModel | IInteractiveResponseViewModel; + +interface IInteractiveListItemTemplate { + rowContainer: HTMLElement; + header: HTMLElement; + avatar: HTMLElement; + username: HTMLElement; + value: HTMLElement; + elementDisposables: DisposableStore; +} + +interface IItemHeightChangeParams { + element: InteractiveTreeItem; + height: number; +} + +const wordRenderRate = 8; // words/sec + +const enableVerboseLayoutTracing = false; + +export class InteractiveListItemRenderer extends Disposable implements ITreeRenderer { + static readonly cursorCharacter = '\u258c'; + static readonly ID = 'item'; + + private readonly renderer: MarkdownRenderer; + + protected readonly _onDidChangeItemHeight = this._register(new Emitter()); + readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; + + protected readonly _onDidSelectFollowup = this._register(new Emitter()); + readonly onDidSelectFollowup: Event = this._onDidSelectFollowup.event; + + private readonly _editorPool: EditorPool; + + private _currentLayoutWidth: number = 0; + + constructor( + private readonly options: InteractiveSessionInputOptions, + private readonly delegate: { getListLength(): number }, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configService: IConfigurationService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.renderer = this.instantiationService.createInstance(MarkdownRenderer, {}); + this._editorPool = this._register(this.instantiationService.createInstance(EditorPool, this.options)); + } + + get templateId(): string { + return InteractiveListItemRenderer.ID; + } + + private traceLayout(method: string, message: string) { + if (enableVerboseLayoutTracing) { + this.logService.info(`${method}: ${message}`); + } + } + + private shouldRenderProgressively(): boolean { + return this.configService.getValue('interactive.experimental.progressiveRendering'); + } + + layout(width: number): void { + this._currentLayoutWidth = width; + this._editorPool.inUse.forEach(editor => { + editor.layout(width); + }); + } + + renderTemplate(container: HTMLElement): IInteractiveListItemTemplate { + const rowContainer = dom.append(container, $('.interactive-item-container')); + const header = dom.append(rowContainer, $('.header')); + const avatar = dom.append(header, $('.avatar')); + const username = document.createElement('h3'); + header.appendChild(username); + const value = dom.append(rowContainer, $('.value')); + const elementDisposables = new DisposableStore(); + + const template: IInteractiveListItemTemplate = { header, avatar, username, value, rowContainer, elementDisposables }; + return template; + } + + renderElement(node: ITreeNode, index: number, templateData: IInteractiveListItemTemplate): void { + const { element } = node; + const kind = isRequestVM(element) ? 'request' : 'response'; + this.traceLayout('renderElement', `${kind}, index=${index}`); + + templateData.rowContainer.classList.toggle('interactive-request', isRequestVM(element)); + templateData.rowContainer.classList.toggle('interactive-response', isResponseVM(element)); + templateData.username.textContent = isRequestVM(element) ? localize('username', "Username") : localize('response', "Response"); + + const avatarIcon = dom.$(ThemeIcon.asCSSSelector(isRequestVM(element) ? Codicon.account : Codicon.hubot)); + templateData.avatar.replaceChildren(avatarIcon); + + if (isResponseVM(element) && index === this.delegate.getListLength() - 1 && (!element.isComplete || element.renderData) && this.shouldRenderProgressively()) { + this.traceLayout('renderElement', `start progressive render ${kind}, index=${index}`); + const progressiveRenderingDisposables = templateData.elementDisposables.add(new DisposableStore()); + const timer = templateData.elementDisposables.add(new IntervalTimer()); + const runProgressiveRender = () => { + progressiveRenderingDisposables.clear(); + const toRender = this.getProgressiveMarkdownToRender(element); + if (toRender) { + if (element.renderData?.isFullyRendered) { + this.traceLayout('runProgressiveRender', `end progressive render ${kind}, index=${index}`); + progressiveRenderingDisposables.clear(); + this.basicRenderElement(element.response.value, element, index, templateData); + timer.cancel(); + } else { + const plusCursor = toRender.match(/```.*$/) ? toRender + `\n${InteractiveListItemRenderer.cursorCharacter}` : toRender + ` ${InteractiveListItemRenderer.cursorCharacter}`; + const result = this.renderMarkdown(element, index, new MarkdownString(plusCursor), progressiveRenderingDisposables, templateData, true); + dom.clearNode(templateData.value); + templateData.value.appendChild(result.element); + progressiveRenderingDisposables.add(result); + } + } + }; + runProgressiveRender(); + timer.cancelAndSet(runProgressiveRender, 1000 / wordRenderRate); + } else if (isResponseVM(element)) { + this.basicRenderElement(element.response.value, element, index, templateData); + } else { + this.basicRenderElement(element.model.message, element, index, templateData); + } + } + + private basicRenderElement(markdownValue: string, element: InteractiveTreeItem, index: number, templateData: IInteractiveListItemTemplate) { + const result = this.renderMarkdown(element, index, new MarkdownString(markdownValue), templateData.elementDisposables, templateData); + dom.clearNode(templateData.value); + templateData.value.appendChild(result.element); + templateData.elementDisposables.add(result); + + if (isResponseVM(element) && element.followups?.length && index === this.delegate.getListLength() - 1) { + const followupsContainer = dom.append(templateData.value, $('.interactive-response-followups')); + element.followups.forEach(q => { + const button = templateData.elementDisposables.add(new Button(followupsContainer, defaultButtonStyles)); + button.label = `"${q}"`; + templateData.elementDisposables.add(button.onDidClick(() => this._onDidSelectFollowup.fire(q))); + return button; + }); + } + } + + private renderMarkdown(element: InteractiveTreeItem, index: number, markdown: IMarkdownString, disposables: DisposableStore, templateData: IInteractiveListItemTemplate, fillInIncompleteTokens = false): IMarkdownRenderResult { + const notifyHeightChange = () => { + const height = templateData.rowContainer.clientHeight; + if (height && (typeof element.currentRenderedHeight === 'undefined' || element.currentRenderedHeight !== height)) { + element.currentRenderedHeight = height; + this.traceLayout('notifyHeightChange', `index=${index}, height=${height}`); + this._onDidChangeItemHeight.fire({ element, height }); + } + }; + + let didRenderEditor = false; + const disposablesList: IDisposable[] = []; + const result = this.renderer.render(markdown, { + fillInIncompleteTokens, + codeBlockRenderer: async (languageId, value) => { + didRenderEditor = true; + + const editorInfo = this._editorPool.get(); + disposablesList.push(editorInfo); + editorInfo.setText(value); + editorInfo.setLanguage(languageId); + + const layoutEditor = (context: string) => { + editorInfo.layout(this._currentLayoutWidth); + }; + + layoutEditor('init'); + + disposables.add(editorInfo.textModel.onDidChangeContent(() => { + layoutEditor('textmodel'); + })); + + return editorInfo.element; + }, + asyncRenderCallback: () => { + // do updateElementHeight when all editors have been rendered and layed out + notifyHeightChange(); + } + }); + + if (!didRenderEditor) { + disposables.add(dom.scheduleAtNextAnimationFrame(() => { + notifyHeightChange(); + })); + } + + disposablesList.reverse().forEach(d => disposables.add(d)); + return result; + } + + private getProgressiveMarkdownToRender(element: IInteractiveResponseViewModel): string | undefined { + const renderData = element.renderData ?? { renderPosition: 0, renderTime: 0 }; + const numWordsToRender = renderData.renderTime === 0 ? + 1 : + renderData.renderPosition + Math.floor((Date.now() - renderData.renderTime) / 1000 * wordRenderRate); + + if (numWordsToRender === renderData.renderPosition) { + return undefined; + } + + let wordCount = numWordsToRender; + let i = 0; + const wordSeparatorCharPattern = /[\s\|\-]/; + while (i < element.response.value.length && wordCount > 0) { + // Consume word separator chars + while (i < element.response.value.length && element.response.value[i].match(wordSeparatorCharPattern)) { + i++; + } + + // Consume word chars + while (i < element.response.value.length && !element.response.value[i].match(wordSeparatorCharPattern)) { + i++; + } + + wordCount--; + } + + const value = element.response.value.substring(0, i); + + element.renderData = { + renderPosition: numWordsToRender - wordCount, + renderTime: Date.now(), + isFullyRendered: i >= element.response.value.length + }; + + return value; + } + + disposeElement(node: ITreeNode, index: number, templateData: IInteractiveListItemTemplate): void { + templateData.elementDisposables.clear(); + } + + disposeTemplate(templateData: IInteractiveListItemTemplate): void { + } +} + +export class InteractiveSessionListDelegate implements IListVirtualDelegate { + constructor( + @ILogService private readonly logService: ILogService + ) { } + + private _traceLayout(method: string, message: string) { + if (enableVerboseLayoutTracing) { + this.logService.info(`InteractiveSessionListDelegate#${method}: ${message}`); + } + } + + getHeight(element: InteractiveTreeItem): number { + const kind = isRequestVM(element) ? 'request' : 'response'; + const height = element.currentRenderedHeight ?? 40; + this._traceLayout('getHeight', `${kind}, height=${height}`); + return height; + } + + getTemplateId(element: InteractiveTreeItem): string { + return InteractiveListItemRenderer.ID; + } +} + +export class InteractiveSessionAccessibilityProvider implements IListAccessibilityProvider { + + getWidgetAriaLabel(): string { + return localize('interactiveSession', "Interactive Session"); + } + + getAriaLabel(element: InteractiveTreeItem): string { + if (isRequestVM(element)) { + return localize('interactiveRequest', "Request: {0}", element.model.message); + } + + if (isResponseVM(element)) { + return localize('interactiveResponse', "Response: {0}", element.response.value); + } + + return ''; + } +} + +interface IInteractiveResultEditorInfo { + readonly element: HTMLElement; + readonly textModel: ITextModel; + layout(width: number): void; + setLanguage(langugeId: string): void; + setText(text: string): void; + dispose(): void; +} + +class EditorPool extends Disposable { + private _pool: ResourcePool; + + public get inUse(): ReadonlySet { + return this._pool.inUse; + } + + constructor( + private readonly options: InteractiveSessionInputOptions, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILanguageService private readonly languageService: ILanguageService, + @IModelService private readonly modelService: IModelService, + ) { + super(); + this._pool = this._register(new ResourcePool(() => this.editorFactory())); + + // TODO listen to changes on options + } + + private editorFactory(): IInteractiveResultEditorInfo { + const disposables = new DisposableStore(); + const wrapper = $('.interactive-result-editor-wrapper'); + const editor = disposables.add(this.instantiationService.createInstance(CodeEditorWidget, wrapper, { + ...getSimpleEditorOptions(), + readOnly: true, + wordWrap: 'off', + lineNumbers: 'off', + selectOnLineNumbers: true, + scrollBeyondLastLine: false, + lineDecorationsWidth: 8, + dragAndDrop: false, + bracketPairColorization: this.options.configuration.resultEditor.bracketPairColorization, + padding: { top: 2, bottom: 2 }, + fontFamily: this.options.configuration.resultEditor.fontFamily === 'default' ? EDITOR_FONT_DEFAULTS.fontFamily : this.options.configuration.resultEditor.fontFamily, + fontSize: this.options.configuration.resultEditor.fontSize, + fontWeight: this.options.configuration.resultEditor.fontWeight, + lineHeight: this.options.configuration.resultEditor.lineHeight, + mouseWheelZoom: false, + scrollbar: { + alwaysConsumeMouseWheel: false + } + }, { + isSimpleWidget: false, + contributions: EditorExtensionsRegistry.getSomeEditorContributions([ + MenuPreventer.ID, + SelectionClipboardContributionID, + ContextMenuController.ID, + + ViewportSemanticTokensContribution.ID, + BracketMatchingController.ID, + FloatingClickMenu.ID, + SmartSelectController.ID, + ]) + })); + + const vscodeLanguageId = this.languageService.getLanguageIdByLanguageName('javascript'); + const textModel = disposables.add(this.modelService.createModel('', this.languageService.createById(vscodeLanguageId), undefined)); + editor.setModel(textModel); + + return { + element: wrapper, + textModel, + layout: (width: number) => { + const realContentHeight = editor.getContentHeight(); + editor.layout({ width, height: realContentHeight }); + }, + setText: (newText: string) => { + let currentText = textModel.getLinesContent().join('\n'); + if (newText === currentText) { + return; + } + + let removedChars = 0; + if (currentText.endsWith(` ${InteractiveListItemRenderer.cursorCharacter}`)) { + removedChars = 2; + } else if (currentText.endsWith(InteractiveListItemRenderer.cursorCharacter)) { + removedChars = 1; + } + + if (removedChars > 0) { + currentText = currentText.slice(0, currentText.length - removedChars); + } + + if (newText.startsWith(currentText)) { + const text = newText.slice(currentText.length); + const lastLine = textModel.getLineCount(); + const lastCol = textModel.getLineMaxColumn(lastLine); + const insertAtCol = lastCol - removedChars; + textModel.applyEdits([{ range: new Range(lastLine, insertAtCol, lastLine, lastCol), text }]); + } else { + // console.log(`Failed to optimize setText`); + textModel.setValue(newText); + } + }, + setLanguage: (languageId: string) => { + const vscodeLanguageId = this.languageService.getLanguageIdByLanguageName(languageId); + if (vscodeLanguageId) { + textModel.setLanguage(vscodeLanguageId); + } + }, + dispose: () => { + disposables.dispose(); + } + }; + } + + get(): IInteractiveResultEditorInfo { + const object = this._pool.get(); + return { + ...object, + dispose: () => this._pool.release(object) + }; + } +} + +class ResourcePool extends Disposable { + private readonly pool: T[] = []; + + private _inUse = new Set; + public get inUse(): ReadonlySet { + return this._inUse; + } + + constructor( + private readonly _itemFactory: () => T, + ) { + super(); + } + + get(): T { + if (this.pool.length > 0) { + const item = this.pool.pop()!; + this._inUse.add(item); + return item; + } + + const item = this._register(this._itemFactory()); + this._inUse.add(item); + return item; + } + + release(item: T): void { + this._inUse.delete(item); + this.pool.push(item); + } +} diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions.ts new file mode 100644 index 00000000000..e9fcd8d86c4 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Color } from 'vs/base/common/color'; +import { Emitter } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IBracketPairColorizationOptions } from 'vs/editor/common/config/editorOptions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IViewDescriptorService } from 'vs/workbench/common/views'; + +export interface IInteractiveSessionConfiguration { + editor: { + readonly fontSize: number; + readonly fontFamily: string; + readonly lineHeight: number; + readonly fontWeight: string; + }; +} + +export interface IInteractiveSessionEditorOptions { + readonly inputEditor: IInteractiveSessionInputEditorOptions; + readonly resultEditor: IInteractiveSessionResultEditorOptions; +} + +export interface IInteractiveSessionInputEditorOptions { + readonly backgroundColor: Color | undefined; + readonly accessibilitySupport: string; +} + +export interface IInteractiveSessionResultEditorOptions { + readonly fontSize: number; + readonly fontFamily: string; + readonly lineHeight: number; + readonly fontWeight: string; + readonly backgroundColor: Color | undefined; + readonly bracketPairColorization: IBracketPairColorizationOptions; + + // Bring these back if we make the editors editable + // readonly cursorBlinking: string; + // readonly accessibilitySupport: string; +} + + +export class InteractiveSessionInputOptions extends Disposable { + private static readonly lineHeightEm = 1.4; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private _config!: IInteractiveSessionEditorOptions; + public get configuration(): IInteractiveSessionEditorOptions { + return this._config; + } + + private static readonly relevantSettingIds = [ + 'interactiveSession.editor.lineHeight', + 'interactiveSession.editor.fontSize', + 'interactiveSession.editor.fontFamily', + 'interactiveSession.editor.fontWeight', + 'editor.cursorBlinking', + 'editor.accessibilitySupport', + 'editor.bracketPairColorization.enabled', + 'editor.bracketPairColorization.independentColorPoolPerBracketType', + ]; + + constructor( + viewId: string | undefined, + private readonly inputEditorBackgroundColorDelegate: () => string, + private readonly resultEditorBackgroundColorDelegate: () => string, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IThemeService private readonly themeService: IThemeService, + @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService + ) { + super(); + + this._register(this.themeService.onDidColorThemeChange(e => this.update())); + this._register(this.viewDescriptorService.onDidChangeLocation(e => { + if (e.views.some(v => v.id === viewId)) { + this.update(); + } + })); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (InteractiveSessionInputOptions.relevantSettingIds.some(id => e.affectsConfiguration(id))) { + this.update(); + } + })); + this.update(); + } + + private update() { + const editorConfig = this.configurationService.getValue('editor'); + const interactiveSessionSidebarEditor = this.configurationService.getValue('interactiveSession').editor; + const accessibilitySupport = this.configurationService.getValue<'auto' | 'off' | 'on'>('editor.accessibilitySupport'); + this._config = { + inputEditor: { + backgroundColor: this.themeService.getColorTheme().getColor(this.inputEditorBackgroundColorDelegate()), + accessibilitySupport, + }, + resultEditor: { + backgroundColor: this.themeService.getColorTheme().getColor(this.resultEditorBackgroundColorDelegate()), + fontSize: interactiveSessionSidebarEditor.fontSize, + fontFamily: interactiveSessionSidebarEditor.fontFamily === 'default' ? editorConfig.fontFamily : interactiveSessionSidebarEditor.fontFamily, + fontWeight: interactiveSessionSidebarEditor.fontWeight, + lineHeight: interactiveSessionSidebarEditor.lineHeight ? interactiveSessionSidebarEditor.lineHeight : InteractiveSessionInputOptions.lineHeightEm * interactiveSessionSidebarEditor.fontSize, + bracketPairColorization: { + enabled: this.configurationService.getValue('editor.bracketPairColorization.enabled'), + independentColorPoolPerBracketType: this.configurationService.getValue('editor.bracketPairColorization.independentColorPoolPerBracketType'), + }, + } + + }; + this._onDidChange.fire(); + } +} diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionSidebar.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionSidebar.ts new file mode 100644 index 00000000000..dc6666dc0f8 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionSidebar.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { editorBackground } from 'vs/platform/theme/common/colorRegistry'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IViewPaneOptions, ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; +import { IViewDescriptorService } from 'vs/workbench/common/views'; +import { InteractiveSessionWidget } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget'; + +export interface IInteractiveSessionViewOptions { + readonly providerId: string; +} + +export const INTERACTIVE_SIDEBAR_PANEL_ID = 'workbench.panel.interactiveSessionSidebar'; +export class InteractiveSessionViewPane extends ViewPane { + static instances: InteractiveSessionViewPane[] = []; + static ID = 'workbench.panel.interactiveSession.view'; + + private view: InteractiveSessionWidget; + + constructor( + interactivSessionViewOptions: IInteractiveSessionViewOptions, + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + // TODO hacks + InteractiveSessionViewPane.instances.push(this); + this.view = this._register(this.instantiationService.createInstance(InteractiveSessionWidget, interactivSessionViewOptions.providerId, this.id, () => this.getBackgroundColor(), () => this.getBackgroundColor(), () => editorBackground)); + + this._register(this.onDidChangeBodyVisibility(visible => { + this.view.setVisible(visible); + })); + } + + protected override renderBody(parent: HTMLElement): void { + super.renderBody(parent); + this.view.render(parent); + } + + acceptInput(): void { + this.view.acceptInput(); + } + + clear(): void { + this.view.clear(); + } + + focusInput(): void { + this.view.focusInput(); + } + + override focus(): void { + super.focus(); + this.view.focusInput(); + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.view.layout(height, width); + } +} + + diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts new file mode 100644 index 00000000000..adc78185158 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts @@ -0,0 +1,356 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { Button } from 'vs/base/browser/ui/button/button'; +import { ITreeElement } from 'vs/base/browser/ui/tree/tree'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import 'vs/css!./media/interactiveSession'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { ITextModel } from 'vs/editor/common/model'; +import { IModelService } from 'vs/editor/common/services/model'; +import { localize } from 'vs/nls'; +import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } 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 { DEFAULT_FONT_FAMILY } from 'vs/workbench/browser/style'; +import { InteractiveListItemRenderer, InteractiveSessionAccessibilityProvider, InteractiveSessionListDelegate, InteractiveTreeItem } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer'; +import { InteractiveSessionInputOptions } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions'; +import { IInteractiveSessionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; +import { InteractiveSessionViewModel, IInteractiveSessionViewModel, isRequestVM, isResponseVM } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel'; +import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; + +const $ = dom.$; + +export const CONTEXT_IN_INTERACTIVE_INPUT = new RawContextKey('inInteractiveInput', false, { type: 'boolean', description: localize('inInteractiveInput', "True when focus is in the interactive input, false otherwise.") }); +export const CONTEXT_IN_INTERACTIVE_SESSION = new RawContextKey('inInteractiveSession', false, { type: 'boolean', description: localize('inInteractiveSession', "True when focus is in the interactive session widget, false otherwise.") }); + +function revealLastElement(list: WorkbenchObjectTree) { + list.scrollTop = list.scrollHeight - list.renderHeight; +} + +export class InteractiveSessionWidget extends Disposable { + private static readonly widgetsByInputUri = new Map(); + static getViewByInputUri(inputUri: URI): InteractiveSessionWidget | undefined { + return InteractiveSessionWidget.widgetsByInputUri.get(inputUri.toString()); + } + + private static _counter = 0; + private readonly inputUri = URI.parse(`interactiveSessionInput:input-${InteractiveSessionWidget._counter++}`); + + private tree!: WorkbenchObjectTree; + private renderer!: InteractiveListItemRenderer; + private inputEditor!: CodeEditorWidget; + private inputOptions!: InteractiveSessionInputOptions; + private inputModel: ITextModel | undefined; + private listContainer!: HTMLElement; + private container!: HTMLElement; + private welcomeViewContainer!: HTMLElement; + private welcomeViewDisposables = this._register(new DisposableStore()); + private bodyDimension: dom.Dimension | undefined; + private visible = false; + + private previousTreeScrollHeight: number = 0; + + private viewModel: IInteractiveSessionViewModel | undefined; + private viewModelDisposables = new DisposableStore(); + + constructor( + private readonly providerId: string, + private readonly viewId: string | undefined, + private readonly listBackgroundColorDelegate: () => string, + private readonly inputEditorBackgroundColorDelegate: () => string, + private readonly resultEditorBackgroundColorDelegate: () => string, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IModelService private readonly modelService: IModelService, + @IExtensionService private readonly extensionService: IExtensionService, + @IInteractiveSessionService private readonly interactiveSessionService: IInteractiveSessionService + ) { + super(); + CONTEXT_IN_INTERACTIVE_SESSION.bindTo(contextKeyService).set(true); + + InteractiveSessionWidget.widgetsByInputUri.set(this.inputUri.toString(), this); + this.initializeSessionModel(true); + } + + render(parent: HTMLElement): void { + this.container = dom.append(parent, $('.interactive-session')); + this.listContainer = dom.append(this.container, $(`.interactive-list`)); + + this.inputOptions = this._register(this.instantiationService.createInstance(InteractiveSessionInputOptions, this.viewId, this.inputEditorBackgroundColorDelegate, this.resultEditorBackgroundColorDelegate)); + this.renderWelcomeView(this.container); + this.createList(this.listContainer); + this.createInput(this.container); + + this._register(this.inputOptions.onDidChange(() => this.onDidStyleChange())); + this.onDidStyleChange(); + + // Do initial render + if (this.viewModel) { + this.onDidChangeItems(); + } + } + + focusInput(): void { + this.inputEditor.focus(); + } + + private onDidChangeItems() { + if (this.tree && this.visible && this.viewModel) { + const items = this.viewModel.getItems(); + const treeItems = items.map(item => { + return >{ + element: item, + collapsed: false, + collapsible: false + }; + }); + + if (treeItems.length) { + this.setWelcomeViewVisible(false); + const lastItem = treeItems[treeItems.length - 1]; + this.tree.setChildren(null, treeItems, { + diffIdentityProvider: { + getId(element) { + const isLastAndResponse = isResponseVM(element) && element === lastItem.element; + return element.id + (isLastAndResponse ? '_last' : ''); + }, + } + }); + revealLastElement(this.tree); + } + } + } + + setVisible(visible: boolean): void { + this.visible = visible; + if (visible) { + 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 + this.onDidChangeItems(); + } + } + + private onDidStyleChange(): void { + 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 { + if (this.welcomeViewContainer) { + dom.clearNode(this.welcomeViewContainer); + } else { + this.welcomeViewContainer = dom.append(container, $('.interactive-session-welcome-view')); + } + + this.welcomeViewDisposables.clear(); + const suggestions = await this.interactiveSessionService.provideSuggestions(this.providerId, CancellationToken.None); + const suggElements = suggestions?.map(sugg => { + const button = this.welcomeViewDisposables.add(new Button(this.welcomeViewContainer, defaultButtonStyles)); + button.label = `"${sugg}"`; + this.welcomeViewDisposables.add(button.onDidClick(() => this.acceptInput(sugg))); + return button; + }); + if (suggElements && suggElements.length > 0) { + this.setWelcomeViewVisible(true); + } else { + this.setWelcomeViewVisible(false); + } + } + + private setWelcomeViewVisible(visible: boolean): void { + if (visible) { + dom.show(this.welcomeViewContainer); + dom.hide(this.listContainer); + } else { + dom.hide(this.welcomeViewContainer); + dom.show(this.listContainer); + } + } + + 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 }); + this.tree = >scopedInstantiationService.createInstance( + WorkbenchObjectTree, + 'InteractiveSession', + listContainer, + delegate, + [this.renderer], + { + identityProvider: { getId: (e: InteractiveTreeItem) => e.id }, + supportDynamicHeights: false, + hideTwistiesOfChildlessElements: true, + accessibilityProvider: new InteractiveSessionAccessibilityProvider(), + keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (e: InteractiveTreeItem) => isRequestVM(e) ? e.model.message : e.response.value }, + setRowLineHeight: false, + overrideStyles: { + listFocusBackground: this.listBackgroundColorDelegate(), + listInactiveFocusBackground: this.listBackgroundColorDelegate(), + listActiveSelectionBackground: this.listBackgroundColorDelegate(), + listFocusAndSelectionBackground: this.listBackgroundColorDelegate(), + listInactiveSelectionBackground: this.listBackgroundColorDelegate(), + listHoverBackground: this.listBackgroundColorDelegate(), + listBackground: this.listBackgroundColorDelegate(), + listFocusForeground: foreground, + listHoverForeground: foreground, + listInactiveFocusForeground: foreground, + listInactiveSelectionForeground: foreground, + listActiveSelectionForeground: foreground, + listFocusAndSelectionForeground: foreground, + } + }); + + this._register(this.tree.onDidChangeContentHeight(() => { + this.onDidChangeTreeContentHeight(); + })); + this._register(this.renderer.onDidChangeItemHeight(e => { + this.tree.updateElementHeight(e.element, e.height); + this.onDidChangeTreeContentHeight(); + })); + this._register(this.renderer.onDidSelectFollowup(followup => { + this.acceptInput(followup); + })); + } + + private onDidChangeTreeContentHeight(): void { + if (this.tree.scrollHeight !== this.previousTreeScrollHeight) { + // Due to rounding, the scrollTop + renderHeight will not exactly match the scrollHeight. + // Consider the tree to be scrolled all the way down if it is within 2px of the bottom. + // const lastElementWasVisible = this.list.scrollTop + this.list.renderHeight >= this.previousTreeScrollHeight - 2; + const lastElementWasVisible = this.tree.scrollTop + this.tree.renderHeight >= this.previousTreeScrollHeight; + if (lastElementWasVisible) { + setTimeout(() => { + // Can't set scrollTop during this event listener, the list might overwrite the change + revealLastElement(this.tree); + }, 0); + } + } + + this.previousTreeScrollHeight = this.tree.scrollHeight; + } + + private createInput(container: HTMLElement): void { + const inputContainer = dom.append(container, $('.interactive-input-wrapper')); + + const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(inputContainer)); + CONTEXT_IN_INTERACTIVE_INPUT.bindTo(inputScopedContextKeyService).set(true); + const scopedInstantiationService = this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService])); + + const options = getSimpleEditorOptions(); + options.readOnly = false; + options.ariaLabel = localize('interactiveSessionInput', "Interactive Session Input"); + options.fontFamily = DEFAULT_FONT_FAMILY; + options.fontSize = 13; + options.lineHeight = 20; + options.padding = { top: 8, bottom: 7 }; + options.cursorWidth = 1; + + this.inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, inputContainer, options, getSimpleCodeEditorWidgetOptions())); + + this._register(this.inputEditor.onDidChangeModelContent(() => { + if (this.bodyDimension) { + this.layout(this.bodyDimension.height, this.bodyDimension.width); + } + })); + + 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 initializeSessionModel(initial = false) { + await this.extensionService.whenInstalledExtensionsRegistered(); + const model = await this.interactiveSessionService.startSession(this.providerId, initial, CancellationToken.None); + if (!model) { + throw new Error('Failed to start session'); + } + + this.viewModel = new InteractiveSessionViewModel(model); + this.viewModelDisposables.add(this.viewModel.onDidChange(() => this.onDidChangeItems())); + this.viewModelDisposables.add(this.viewModel.onDidDispose(() => { + this.viewModel = undefined; + this.viewModelDisposables.clear(); + this.onDidChangeItems(); + })); + + if (this.tree) { + this.onDidChangeItems(); + } + } + + async acceptInput(query?: string): Promise { + if (!this.viewModel) { + await this.initializeSessionModel(); + } + + if (this.viewModel) { + const input = query ?? this.inputEditor.getValue(); + if (this.interactiveSessionService.sendRequest(this.viewModel.sessionId, input, CancellationToken.None)) { + this.inputEditor.setValue(''); + } + } + } + + focusLastMessage(): void { + if (!this.viewModel) { + return; + } + + const items = this.viewModel.getItems(); + const lastItem = items[items.length - 1]; + if (!lastItem) { + return; + } + + this.tree.setFocus([lastItem]); + this.tree.domFocus(); + } + + clear(): void { + if (this.viewModel) { + this.interactiveSessionService.clearSession(this.viewModel.sessionId); + this.focusInput(); + this.renderWelcomeView(this.container); + } + } + + layout(height: number, width: number): void { + this.bodyDimension = new dom.Dimension(width, height); + const inputHeight = Math.min(this.inputEditor.getContentHeight(), height); + const inputWrapperPadding = 24; + const lastElementVisible = this.tree.scrollTop + this.tree.renderHeight >= this.tree.scrollHeight; + const listHeight = height - inputHeight - inputWrapperPadding; + + this.tree.layout(listHeight, width); + this.tree.getHTMLElement().style.height = `${listHeight}px`; + this.renderer.layout(width); + if (lastElementVisible) { + revealLastElement(this.tree); + } + + this.welcomeViewContainer.style.height = `${height - inputHeight - inputWrapperPadding}px`; + this.listContainer.style.height = `${height - inputHeight - inputWrapperPadding}px`; + + this.inputEditor.layout({ width: width - inputWrapperPadding, height: inputHeight }); + } +} diff --git a/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css b/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css new file mode 100644 index 00000000000..0bd7f7aef62 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.interactive-list .monaco-list-row:not(:first-of-type) { + border-top: 1px solid var(--vscode-interactive-responseBorder); +} + +.interactive-list .monaco-list-row:last-of-type { + border-bottom: 1px solid var(--vscode-interactive-responseBorder); +} + +.interactive-list .monaco-list-row .monaco-tl-twistie { + /* Hide twisties */ + display: none !important; +} + +.interactive-list .interactive-item-container { + display: flex; + flex-direction: column; + gap: 12px; +} + +.interactive-list .interactive-item-container .header { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} + +.interactive-list .interactive-item-container .header h3 { + margin: 0; + font-size: 12px; + font-weight: 600; +} + +.interactive-list .interactive-item-container .header .avatar { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--vscode-badge-background); +} + +.interactive-list .interactive-item-container .header .avatar .codicon { + color: var(--vscode-badge-foreground); +} + +.interactive-list .interactive-item-container .value { + width: 100%; + overflow: hidden; +} + +.interactive-list .interactive-item-container .value table { + width: 100%; + text-align: left; + margin-bottom: 16px; +} + +.interactive-list .interactive-item-container .value table, +.interactive-list .interactive-item-container .value table td, +.interactive-list .interactive-item-container .value table th { + border: 1px solid var(--vscode-interactive-responseBorder); + border-collapse: collapse; + padding: 4px 6px; +} + +.interactive-list { + overflow: hidden; +} + +.interactive-list .monaco-list-row .interactive-request, +.interactive-list .monaco-list-row .interactive-response { + user-select: text; + -webkit-user-select: text; +} + +.interactive-list .monaco-list-row .interactive-response { + background-color: var(--vscode-interactive-responseBackground); + padding: 16px 20px 0 20px +} + +.interactive-list .monaco-list-row .interactive-response .value { + padding-bottom: 12px; +} + +.interactive-list .monaco-list-row .interactive-request { + padding: 16px 20px; +} + +.interactive-list .monaco-list-row .value { + white-space: normal; +} + +.interactive-list .monaco-list-row .value p { + margin: 0; +} + +.interactive-list .monaco-list-row .monaco-tokenized-source, +.interactive-list .monaco-list-row code { + font-family: var(--monaco-monospace-font); +} + +.interactive-session .interactive-input-wrapper { + display: flex; + border-radius: 2px; + box-sizing: border-box; + padding: 12px; + cursor: text; + border-top: 1px solid var(--vscode-interactive-responseBorder); +} + +.interactive-session .interactive-input-wrapper .monaco-editor-background { + background-color: var(--vscode-input-background); + padding: 0 8px; +} + +/* TODO @daviddossett only apply focus border on focus */ +.interactive-session .interactive-input-wrapper .monaco-editor { + border: 1px solid var(--vscode-focusBorder); +} + +.interactive-session .interactive-input-wrapper .monaco-editor, +.interactive-session .interactive-input-wrapper .monaco-editor .overflow-guard { + border-radius: 4px; +} + +.interactive-session .interactive-input-wrapper .monaco-editor .cursors-layer { + padding-left: 4px; +} + +.interactive-session .monaco-inputbox { + width: 100%; +} + +.interactive-session .interactive-result-editor-wrapper .monaco-editor, +.interactive-session .interactive-result-editor-wrapper .monaco-editor .overflow-guard { + border-radius: 4px; +} + +.interactive-session .interactive-response .monaco-editor .margin, +.interactive-session .interactive-response .monaco-editor .monaco-editor-background { + background-color: var(--vscode-interactive-result-editor-background-color); +} + +.interactive-result-editor-wrapper { + margin: 16px 0; +} + +.interactive-session .interactive-session-welcome-view { + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: start; + padding: 20px; + gap: 16px; +} + +.interactive-session .interactive-session-welcome-view .monaco-button { + max-width: 400px; + margin: 0; +} + +.interactive-session .interactive-response .interactive-session-response-followups { + display: flex; + flex-direction: column; + gap: 8px; + align-items: start; + margin-bottom: 1em; /* This is matching the margin on rendered markdown */ +} + +.interactive-session .interactive-response .interactive-session-response-followups .monaco-button { + width: 100%; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; +} diff --git a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionColors.ts b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionColors.ts new file mode 100644 index 00000000000..dd1350b79a3 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionColors.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Color, RGBA } from 'vs/base/common/color'; +import { localize } from 'vs/nls'; +import { registerColor } from 'vs/platform/theme/common/colorRegistry'; + + +export const interactiveResponseBackground = registerColor( + 'interactive.responseBackground', + { dark: new Color(new RGBA(255, 255, 255, 0.03)), light: new Color(new RGBA(0, 0, 0, 0.03)), hcDark: null, hcLight: null, }, + localize('interactive.responseBackground', 'The resting background color of an interactive response.') +); + +export const interactiveResponseActiveBackground = registerColor( + 'interactive.responseActiveBackground', + { dark: new Color(new RGBA(255, 255, 255, 0.10)), light: new Color(new RGBA(0, 0, 0, 0.10)), hcDark: null, hcLight: null, }, + localize('interactive.responseActiveBackground', 'The active background color of an interactive response. Used when the response shows a fade out animation on load.') +); + +export const interactiveResponseBorder = registerColor( + 'interactive.responseBorder', + { dark: new Color(new RGBA(255, 255, 255, 0.10)), light: new Color(new RGBA(0, 0, 0, 0.10)), hcDark: null, hcLight: null, }, + localize('interactive.responseBorder', 'The border color of an interactive response.') +); diff --git a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionContributionService.ts b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionContributionService.ts new file mode 100644 index 00000000000..57487c0b428 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionContributionService.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; + +export const IInteractiveSessionContributionService = createDecorator('IInteractiveSessionContributionService'); +export interface IInteractiveSessionContributionService { + _serviceBrand: undefined; + + registeredProviders: IInteractiveSessionProviderContribution[]; +} + +export interface IInteractiveSessionProviderContribution { + id: string; + label: string; + icon: string; + when?: string; +} diff --git a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionModel.ts b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionModel.ts new file mode 100644 index 00000000000..bbe0c69bbe3 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionModel.ts @@ -0,0 +1,214 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IInteractiveSession } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; + +export interface IInteractiveRequestModel { + readonly id: string; + readonly message: string; + readonly response: IInteractiveResponseModel | undefined; +} + +export interface IInteractiveResponseModel { + readonly onDidChange: Event; + readonly id: string; + readonly response: IMarkdownString; + readonly isComplete: boolean; + readonly followups?: string[]; +} + +export function isRequest(item: unknown): item is IInteractiveRequestModel { + return !!item && typeof (item as IInteractiveRequestModel).message !== 'undefined'; +} + +export function isResponse(item: unknown): item is IInteractiveResponseModel { + return !isRequest(item); +} + +export class InteractiveRequestModel implements IInteractiveRequestModel { + private static nextId = 0; + + public response: InteractiveResponseModel | undefined; + + private _id: string; + public get id(): string { + return this._id; + } + + constructor(public readonly message: string) { + this._id = 'request_' + InteractiveRequestModel.nextId++; + } +} + +export class InteractiveResponseModel extends Disposable implements IInteractiveResponseModel { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private static nextId = 0; + + private _id: string; + public get id(): string { + return this._id; + } + + private _isComplete: boolean; + public get isComplete(): boolean { + return this._isComplete; + } + + private _followups: string[] | undefined; + public get followups(): string[] | undefined { + return this._followups; + } + + constructor(public response: IMarkdownString, isComplete: boolean = false, followups?: string[]) { + super(); + this._isComplete = isComplete; + this._followups = followups; + this._id = 'response_' + InteractiveResponseModel.nextId++; + } + + updateContent(responsePart: string) { + this.response = new MarkdownString(this.response.value + responsePart); + this._onDidChange.fire(); + } + + complete(followups: string[] | undefined): void { + this._isComplete = true; + this._followups = followups; + this._onDidChange.fire(); + } +} + +export interface IInteractiveSessionModel { + readonly onDidDispose: Event; + readonly onDidChange: Event; + readonly sessionId: number; + getRequests(): IInteractiveRequestModel[]; +} + +export interface IDeserializedInteractiveSessionData { + requests: InteractiveRequestModel[]; + providerState: any; +} + +export interface ISerializableInteractiveSessionData { + requests: { message: string; response: string | undefined }[]; + providerState: any; +} + +export type IInteractiveSessionChangeEvent = IInteractiveSessionAddRequestEvent | IInteractiveSessionAddResponseEvent | IInteractiveSessionClearEvent; + +export interface IInteractiveSessionAddRequestEvent { + kind: 'addRequest'; + request: IInteractiveRequestModel; +} + +export interface IInteractiveSessionAddResponseEvent { + kind: 'addResponse'; + response: IInteractiveResponseModel; +} + +export interface IInteractiveSessionClearEvent { + kind: 'clear'; +} + +export class InteractiveSessionModel extends Disposable implements IInteractiveSessionModel { + private readonly _onDidDispose = this._register(new Emitter()); + readonly onDidDispose = this._onDidDispose.event; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private _requests: InteractiveRequestModel[]; + private _providerState: any; + + static deserialize(obj: ISerializableInteractiveSessionData): IDeserializedInteractiveSessionData { + const requests = obj.requests; + if (!Array.isArray(requests)) { + throw new Error(`Malformed session data: ${obj}`); + } + + const requestModels = requests.map((r: any) => { + const request = new InteractiveRequestModel(r.message); + if (r.response) { + request.response = new InteractiveResponseModel(new MarkdownString(r.response), true); + } + return request; + }); + return { requests: requestModels, providerState: obj.providerState }; + } + + get sessionId(): number { + return this.session.id; + } + + constructor(public readonly session: IInteractiveSession, public readonly providerId: string, initialData?: IDeserializedInteractiveSessionData) { + super(); + this._requests = initialData ? initialData.requests : []; + this._providerState = initialData ? initialData.providerState : undefined; + } + + acceptNewProviderState(providerState: any): void { + this._providerState = providerState; + } + + clear(): void { + this._requests.forEach(r => r.response?.dispose()); + this._requests = []; + this._onDidChange.fire({ kind: 'clear' }); + } + + getRequests(): InteractiveRequestModel[] { + return this._requests; + } + + addRequest(request: InteractiveRequestModel): void { + // TODO this is suspicious, maybe the request should know that it is "in progress" instead of having a fake response model. + // But the response already knows that it is "in progress" and so does a map in the session service. + request.response = new InteractiveResponseModel(new MarkdownString('')); + + this._requests.push(request); + this._onDidChange.fire({ kind: 'addRequest', request }); + } + + mergeResponseContent(request: InteractiveRequestModel, part: string): void { + if (request.response) { + request.response.updateContent(part); + } else { + request.response = new InteractiveResponseModel(new MarkdownString(part)); + } + } + + completeResponse(request: InteractiveRequestModel, followups?: string[]): void { + request.response!.complete(followups); + } + + setResponse(request: InteractiveRequestModel, response: InteractiveResponseModel): void { + request.response = response; + this._onDidChange.fire({ kind: 'addResponse', response }); + } + + toJSON(): ISerializableInteractiveSessionData { + return { + requests: this._requests.map(r => { + return { + message: r.message, + response: r.response ? r.response.response.value : undefined, + }; + }), + providerState: this._providerState + }; + } + + override dispose() { + this._requests.forEach(r => r.response?.dispose()); + this._onDidDispose.fire(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionService.ts b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionService.ts new file mode 100644 index 00000000000..ae14a628e9d --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionService.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { ProviderResult } from 'vs/editor/common/languages'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { InteractiveSessionModel } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionModel'; + +export interface IInteractiveSession { + id: number; + dispose?(): void; +} + +export interface IInteractiveRequest { + session: IInteractiveSession; + message: string; +} + +export interface IInteractiveResponse { + session: IInteractiveSession; + followups?: string[]; +} + +export interface IInteractiveProgress { + responsePart: string; +} + +export interface IPersistedInteractiveState { } +export interface IInteractiveProvider { + id: string; + prepareSession(initialState: IPersistedInteractiveState | undefined, token: CancellationToken): ProviderResult; + resolveRequest?(session: IInteractiveSession, context: any, token: CancellationToken): ProviderResult; + provideSuggestions(token: CancellationToken): ProviderResult; + provideReply(request: IInteractiveRequest, progress: (progress: IInteractiveProgress) => void, token: CancellationToken): ProviderResult; +} + +export const IInteractiveSessionService = createDecorator('IInteractiveSessionService'); + +export interface IInteractiveSessionService { + _serviceBrand: undefined; + registerProvider(provider: IInteractiveProvider): IDisposable; + startSession(providerId: string, allowRestoringSession: boolean, token: CancellationToken): Promise; + + /** + * Returns whether the request was accepted. + */ + sendRequest(sessionId: number, message: string, token: CancellationToken): boolean; + clearSession(sessionId: number): void; + acceptNewSessionState(sessionId: number, state: any): void; + addInteractiveRequest(context: any): void; + provideSuggestions(providerId: string, token: CancellationToken): Promise; +} diff --git a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionServiceImpl.ts b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionServiceImpl.ts new file mode 100644 index 00000000000..4f0d124325c --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionServiceImpl.ts @@ -0,0 +1,222 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Iterable } from 'vs/base/common/iterator'; +import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { withNullAsUndefined } from 'vs/base/common/types'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { InteractiveRequestModel, InteractiveSessionModel, IDeserializedInteractiveSessionData } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionModel'; +import { IInteractiveProgress, IInteractiveProvider, IInteractiveSessionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; + +const serializedInteractiveSessionKey = 'interactive.sessions'; + +export class InteractiveSessionService extends Disposable implements IInteractiveSessionService { + declare _serviceBrand: undefined; + + private readonly _providers = new Map(); + private readonly _sessionModels = new Map(); + private readonly _pendingRequestSessions = new Set(); + private readonly _unprocessedPersistedSessions: IDeserializedInteractiveSessionData[]; + + constructor( + @IStorageService storageService: IStorageService, + @ILogService private readonly logService: ILogService, + @IExtensionService private readonly extensionService: IExtensionService + ) { + super(); + const sessionData = storageService.get(serializedInteractiveSessionKey, StorageScope.WORKSPACE, ''); + if (sessionData) { + this._unprocessedPersistedSessions = this.restoreInteractiveSessions(sessionData); + this.trace('constructor', `Restored ${this._unprocessedPersistedSessions.length} persisted sessions`); + } else { + this._unprocessedPersistedSessions = []; + this.trace('constructor', 'No persisted sessions'); + } + + this._register(storageService.onWillSaveState(e => { + const serialized = JSON.stringify(Array.from(this._sessionModels.values())); + this.trace('onWillSaveState', `Persisting ${this._sessionModels.size} sessions`); + storageService.store(serializedInteractiveSessionKey, serialized, StorageScope.WORKSPACE, StorageTarget.MACHINE); + })); + } + + private trace(method: string, message: string): void { + this.logService.trace(`[InteractiveSessionService#${method}] ${message}`); + } + + private error(method: string, message: string): void { + this.logService.error(`[InteractiveSessionService#${method}] ${message}`); + } + + private restoreInteractiveSessions(sessionData: string): IDeserializedInteractiveSessionData[] { + try { + const obj = JSON.parse(sessionData); + if (!Array.isArray(obj)) { + throw new Error('Expected array'); + } + + return obj.map(item => InteractiveSessionModel.deserialize(item)); + } catch (err) { + this.error('restoreInteractiveSessions', `Malformed session data: ${err}. [${sessionData.substring(0, 20)}...]`); + return []; + } + } + + async startSession(providerId: string, allowRestoringSession: boolean, token: CancellationToken): Promise { + this.trace('startSession', `providerId=${providerId}, allowRestoringSession=${allowRestoringSession}`); + await this.extensionService.activateByEvent(`onInteractiveSession:${providerId}`); + + const provider = this._providers.get(providerId); + if (!provider) { + throw new Error(`Unknown provider: ${providerId}`); + } + + const someSessionHistory = allowRestoringSession ? this._unprocessedPersistedSessions.shift() : undefined; + this.trace('startSession', `Has history: ${!!someSessionHistory}. Including provider state: ${!!someSessionHistory?.providerState}`); + const session = await provider.prepareSession(someSessionHistory?.providerState, token); + if (!session) { + if (someSessionHistory) { + this._unprocessedPersistedSessions.unshift(someSessionHistory); + } + + this.trace('startSession', 'Provider returned no session'); + return undefined; + } + + this.trace('startSession', `Provider returned session with id ${session.id}`); + const model = new InteractiveSessionModel(session, providerId, someSessionHistory); + this._sessionModels.set(model.sessionId, model); + return model; + } + + sendRequest(sessionId: number, message: string, token: CancellationToken): boolean { + this.trace('sendRequest', `sessionId: ${sessionId}, message: ${message.substring(0, 20)}[...]`); + if (!message.trim()) { + this.trace('sendRequest', 'Rejected empty message'); + return false; + } + + 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 (this._pendingRequestSessions.has(sessionId)) { + this.trace('sendRequest', `Session ${sessionId} already has a pending request`); + return false; + } + + // TODO log failures, add dummy response with error message + const _sendRequest = async (): Promise => { + try { + this._pendingRequestSessions.add(sessionId); + const request = new InteractiveRequestModel(message); + model.addRequest(request); + const progressCallback = (progress: IInteractiveProgress) => { + this.trace('sendRequest', `Provider returned progress for session ${sessionId}, ${progress.responsePart.length} chars`); + model.mergeResponseContent(request, progress.responsePart); + }; + const rawResponse = await provider.provideReply({ session: model.session, message }, progressCallback, token); + if (!rawResponse) { + this.trace('sendRequest', `Provider returned no response for session ${sessionId}`); + return; + } + + model.completeResponse(request, rawResponse.followups); + this.trace('sendRequest', `Provider returned response for session ${sessionId} with ${rawResponse.followups} followups`); + } finally { + this._pendingRequestSessions.delete(sessionId); + } + }; + + // Return immediately that the request was accepted, don't wait + _sendRequest(); + return true; + } + + acceptNewSessionState(sessionId: number, state: any): void { + this.trace('acceptNewSessionState', `sessionId: ${sessionId}`); + const model = this._sessionModels.get(sessionId); + if (!model) { + throw new Error(`Unknown session: ${sessionId}`); + } + + model.acceptNewProviderState(state); + } + + async addInteractiveRequest(context: any): Promise { + // TODO How to decide which session this goes to? + const model = Iterable.first(this._sessionModels.values()); + if (!model) { + // If no session, create one- how and is the service the right place to decide this? + this.trace('addInteractiveRequest', 'No session available'); + return; + } + + const provider = this._providers.get(model.providerId); + if (!provider || !provider.resolveRequest) { + this.trace('addInteractiveRequest', 'No provider available'); + return undefined; + } + + this.trace('addInteractiveRequest', `Calling resolveRequest for session ${model.sessionId}`); + const request = await provider.resolveRequest(model.session, context, CancellationToken.None); + if (!request) { + this.trace('addInteractiveRequest', `Provider returned no request for session ${model.sessionId}`); + return; + } + + // Maybe this API should queue a request after the current one? + this.trace('addInteractiveRequest', `Sending resolved request for session ${model.sessionId}`); + this.sendRequest(model.sessionId, request.message, CancellationToken.None); + } + + clearSession(sessionId: number): void { + this.trace('clearSession', `sessionId: ${sessionId}`); + const model = this._sessionModels.get(sessionId); + if (!model) { + throw new Error(`Unknown session: ${sessionId}`); + } + + model.dispose(); + this._sessionModels.delete(sessionId); + } + + registerProvider(provider: IInteractiveProvider): IDisposable { + this.trace('registerProvider', `Adding new interactive session provider`); + + this._providers.set(provider.id, provider); + + return toDisposable(() => { + this.trace('registerProvider', `Disposing interactive session provider`); + this._providers.delete(provider.id); + }); + } + + getAll() { + return [...this._providers]; + } + + async provideSuggestions(providerId: string, token: CancellationToken): Promise { + await this.extensionService.activateByEvent(`onInteractiveSession:${providerId}`); + + const provider = this._providers.get(providerId); + if (!provider) { + throw new Error(`Unknown provider: ${providerId}`); + } + + const suggestions = await provider.provideSuggestions(token); + this.trace('provideSuggestions', `Provider returned ${suggestions?.length} suggestions`); + return withNullAsUndefined(suggestions); + } +} diff --git a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel.ts b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel.ts new file mode 100644 index 00000000000..8d414399a01 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IInteractiveRequestModel, IInteractiveResponseModel, IInteractiveSessionModel } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionModel'; + +export function isRequestVM(item: unknown): item is IInteractiveRequestViewModel { + return !!item && typeof (item as IInteractiveRequestViewModel).model !== 'undefined'; +} + +export function isResponseVM(item: unknown): item is IInteractiveResponseViewModel { + return !isRequestVM(item); +} + +export interface IInteractiveSessionViewModel { + sessionId: number; + onDidDispose: Event; + onDidChange: Event; + getItems(): (IInteractiveRequestViewModel | IInteractiveResponseViewModel)[]; +} + +export interface IInteractiveRequestViewModel { + readonly id: string; + readonly model: IInteractiveRequestModel; + currentRenderedHeight: number | undefined; +} + +export interface IInteractiveResponseRenderData { + renderPosition: number; + renderTime: number; + isFullyRendered: boolean; +} + +export interface IInteractiveResponseViewModel { + readonly onDidChange: Event; + readonly id: string; + readonly response: IMarkdownString; + readonly isComplete: boolean; + readonly followups?: string[]; + renderData?: IInteractiveResponseRenderData; + currentRenderedHeight: number | undefined; +} + +export class InteractiveSessionViewModel extends Disposable { + private readonly _onDidDispose = this._register(new Emitter()); + readonly onDidDispose = this._onDidDispose.event; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private readonly _items: (IInteractiveRequestViewModel | IInteractiveResponseViewModel)[] = []; + + get sessionId() { + return this.model.sessionId; + } + + constructor(private readonly model: IInteractiveSessionModel) { + super(); + + model.getRequests().forEach((request, i) => { + this._items.push(new InteractiveRequestViewModel(request)); + if (request.response) { + this._items.push(new InteractiveResponseViewModel(request.response)); + } + }); + + this._register(model.onDidDispose(() => this._onDidDispose.fire())); + this._register(model.onDidChange(e => { + if (e.kind === 'clear') { + this._items.length = 0; + this._onDidChange.fire(); + } else if (e.kind === 'addRequest') { + this._items.push(new InteractiveRequestViewModel(e.request)); + if (e.request.response) { + this.onAddResponse(e.request.response); + } + } else if (e.kind === 'addResponse') { + this.onAddResponse(e.response); + } + + this._onDidChange.fire(); + })); + } + + private onAddResponse(responseModel: IInteractiveResponseModel) { + const response = new InteractiveResponseViewModel(responseModel); + this._register(response.onDidChange(() => this._onDidChange.fire())); + this._items.push(response); + } + + getItems() { + return this._items; + } + + override dispose() { + super.dispose(); + this._items + .filter((item): item is InteractiveResponseViewModel => item instanceof InteractiveResponseViewModel) + .forEach((item: InteractiveResponseViewModel) => item.dispose()); + } +} + +export class InteractiveRequestViewModel implements IInteractiveRequestViewModel { + get id() { + return this.model.id; + } + + currentRenderedHeight: number | undefined; + + constructor(readonly model: IInteractiveRequestModel) { } +} + +export class InteractiveResponseViewModel extends Disposable implements IInteractiveResponseViewModel { + private _changeCount = 0; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private _isPlaceholder = false; + + get id() { + return this._model.id + `_${this._changeCount}`; + } + + get response(): IMarkdownString { + if (this._isPlaceholder) { + return new MarkdownString('Thinking...'); + } + + return this._model.response; + } + + get isComplete() { + return this._model.isComplete; + } + + get followups() { + return this._model.followups; + } + + renderData: IInteractiveResponseRenderData | undefined = undefined; + + currentRenderedHeight: number | undefined; + + constructor(private readonly _model: IInteractiveResponseModel) { + super(); + + this._isPlaceholder = !_model.response.value && !_model.isComplete; + + this._register(_model.onDidChange(() => { + if (this._isPlaceholder && _model.response.value) { + this._isPlaceholder = false; + if (this.renderData) { + this.renderData.renderPosition = 0; + } + } + + // new data -> new id, new content to render + this._changeCount++; + if (this.renderData) { + this.renderData.isFullyRendered = false; + this.renderData.renderTime = Date.now(); + } + + this._onDidChange.fire(); + })); + } +} diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index 011fd6e5835..8348485db9f 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -202,7 +202,12 @@ export const tocData: ITOCEntry = { id: 'features/mergeEditor', label: localize('mergeEditor', 'Merge Editor'), settings: ['mergeEditor.*'] - } + }, + { + id: 'features/interactiveSession', + label: localize('interactiveSession', 'Interactive Session'), + settings: ['interactiveSession.*'] + }, ] }, { diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 9f06058245a..e3f7e8257d6 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -38,6 +38,7 @@ export const allApiProposals = Object.freeze({ idToken: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.idToken.d.ts', indentSize: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.indentSize.d.ts', inlineCompletionsAdditions: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts', + interactive: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.interactive.d.ts', interactiveWindow: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.interactiveWindow.d.ts', ipc: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.ipc.d.ts', notebookCellExecutionState: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.notebookCellExecutionState.d.ts', diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index c8bbd3e3301..0dfae69dc05 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -168,6 +168,8 @@ import 'vs/workbench/contrib/contextmenu/browser/contextmenu.contribution'; // Notebook import 'vs/workbench/contrib/notebook/browser/notebook.contribution'; +import 'vs/workbench/contrib/interactiveSession/browser/interactiveSession.contribution'; + // Interactive import 'vs/workbench/contrib/interactive/browser/interactive.contribution'; diff --git a/src/vscode-dts/vscode.proposed.interactive.d.ts b/src/vscode-dts/vscode.proposed.interactive.d.ts new file mode 100644 index 00000000000..8f9c9255add --- /dev/null +++ b/src/vscode-dts/vscode.proposed.interactive.d.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // todo@API make classes + export interface InteractiveEditorSession { + placeholder?: string; + } + + // todo@API make classes + export interface InteractiveEditorRequest { + session: InteractiveEditorSession; + prompt: string; + + selection: Selection; + wholeRange: Range; + } + + // todo@API make classes + export interface InteractiveEditorResponse { + edits: TextEdit[]; + placeholder?: string; + } + + export interface TextDocumentContext { + document: TextDocument; + selection: Selection; + action?: string; + } + + export interface InteractivEditorSessionProvider { + // Create a session. The lifetime of this session is the duration of the editing session with the input mode widget. + prepareInteractiveEditorSession(context: TextDocumentContext, token: CancellationToken): ProviderResult; + + provideInteractivEditorResponse(request: InteractiveEditorRequest, token: CancellationToken): ProviderResult; + + // eslint-disable-next-line local/vscode-dts-provider-naming + releaseInteractiveEditorSession?(session: InteractiveEditorSession): any; + } + + + export interface InteractiveSessionState { } + + export interface InteractiveSession { + saveState?(): InteractiveSessionState; + } + + export interface InteractiveSessionRequestArgs { + command: string; + args: any; + } + + export interface InteractiveRequest { + session: InteractiveSession; + message: string; + } + + export interface InteractiveResponse { + content: string; + followups?: string[]; + } + + export interface InteractiveResponseForProgress { + followups?: string[]; + } + + export interface InteractiveProgress { + content: string; + } + + export interface InteractiveSessionProvider { + provideInitialSuggestions?(token: CancellationToken): ProviderResult; + prepareSession(initialState: InteractiveSessionState | undefined, token: CancellationToken): ProviderResult; + resolveRequest(session: InteractiveSession, context: InteractiveSessionRequestArgs | string, token: CancellationToken): ProviderResult; + provideResponse?(request: InteractiveRequest, token: CancellationToken): ProviderResult; + provideResponseWithProgress?(request: InteractiveRequest, progress: Progress, token: CancellationToken): ProviderResult; + } + + export namespace interactive { + // current version of the proposal. + export const _version: 1 | number; + + export function registerInteractiveSessionProvider(id: string, provider: InteractiveSessionProvider): Disposable; + export function addInteractiveRequest(context: InteractiveSessionRequestArgs): void; + } +}