diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 4e15cd82405..e2fadfb2e21 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -25,7 +25,7 @@ import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; import { EditResponse, EmptyResponse, ErrorResponse, ExpansionState, IInlineChatSessionService, MarkdownResponse, Session, SessionExchange, SessionPrompt } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; -import { EditModeStrategy, LivePreviewStrategy, LiveStrategy, PreviewStrategy } from 'vs/workbench/contrib/inlineChat/browser/inlineChatStrategies'; +import { EditModeStrategy, LivePreviewStrategy, LiveStrategy, PreviewStrategy, ProgressingEditsOptions } from 'vs/workbench/contrib/inlineChat/browser/inlineChatStrategies'; import { InlineChatZoneWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatWidget'; import { CTX_INLINE_CHAT_HAS_ACTIVE_REQUEST, CTX_INLINE_CHAT_LAST_FEEDBACK, IInlineChatRequest, IInlineChatResponse, INLINE_CHAT_ID, EditMode, InlineChatResponseFeedbackKind, CTX_INLINE_CHAT_LAST_RESPONSE_TYPE, InlineChatResponseType, CTX_INLINE_CHAT_DID_EDIT, CTX_INLINE_CHAT_HAS_STASHED_SESSION, InlineChateResponseTypes, CTX_INLINE_CHAT_RESPONSE_TYPES, CTX_INLINE_CHAT_USER_DID_EDIT, IInlineChatProgressItem } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { IChatAccessibilityService, IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; @@ -38,6 +38,7 @@ import { TextEdit } from 'vs/editor/common/languages'; import { ISelection, Selection } from 'vs/editor/common/core/selection'; import { onUnexpectedError } from 'vs/base/common/errors'; import { MarkdownString } from 'vs/base/common/htmlContent'; +import { MovingAverage } from 'vs/base/common/numbers'; export const enum State { CREATE_SESSION = 'CREATE_SESSION', @@ -247,8 +248,8 @@ export class InlineChatController implements IEditorContribution { } } if (this._activeSession) { - this._zone.value.updateBackgroundColor(widgetPosition, this._activeSession.wholeRange.value); widgetPosition = this._strategy?.getWidgetPosition() ?? widgetPosition; + this._zone.value.updateBackgroundColor(widgetPosition, this._activeSession.wholeRange.value); } this._zone.value.show(widgetPosition); } @@ -556,6 +557,9 @@ export class InlineChatController implements IEditorContribution { const progressEdits: TextEdit[][] = []; const markdownContents = new MarkdownString('', { supportThemeIcons: true, supportHtml: true, isTrusted: false }); + const avgDuration = new MovingAverage(); + let round = 0; + let t1 = Date.now(); const progress = new AsyncProgress(async data => { this._log('received chunk', data, request); if (data.message) { @@ -573,8 +577,11 @@ export class InlineChatController implements IEditorContribution { throw new Error('Progress in NOT supported in non-live mode'); } progressEdits.push(data.edits); - await this._makeChanges(data.edits, true); - await this._strategy?.renderProgressChanges(); + avgDuration.update(Date.now() - t1); + await this._makeChanges(data.edits, true, { duration: avgDuration.value, round: round++ }); + t1 = Date.now(); // don't measure how long `_makeChanges` takes + // TODO@jrieken this still isn't the true speed because the progress itself is async which + // makes an artifical queue, so that towards the end duration (now() -t1) is almost 0 } if (data.markdownFragment) { markdownContents.appendMarkdown(data.markdownFragment); @@ -601,7 +608,7 @@ export class InlineChatController implements IEditorContribution { } else if (reply) { const editResponse = new EditResponse(this._activeSession.textModelN.uri, modelAltVersionIdNow, reply, progressEdits); for (let i = progressEdits.length; i < editResponse.allLocalEdits.length; i++) { - await this._makeChanges(editResponse.allLocalEdits[i], true); + await this._makeChanges(editResponse.allLocalEdits[i], true, undefined); } response = editResponse; } else { @@ -656,7 +663,7 @@ export class InlineChatController implements IEditorContribution { return State.SHOW_RESPONSE; } - private async _makeChanges(lastEdits: TextEdit[], computeMoreMinimalEdits: boolean) { + private async _makeChanges(lastEdits: TextEdit[], computeMoreMinimalEdits: boolean, opts: ProgressingEditsOptions | undefined) { assertType(this._activeSession); assertType(this._strategy); @@ -673,7 +680,11 @@ export class InlineChatController implements IEditorContribution { try { this._ignoreModelContentChanged = true; this._activeSession.wholeRange.trackEdits(editOperations); - await this._strategy.makeChanges(editOperations); + if (opts) { + await this._strategy.makeProgressiveChanges(editOperations, opts); + } else { + await this._strategy.makeChanges(editOperations); + } this._ctxDidEdit.set(this._activeSession.hasChangedText); } finally { this._ignoreModelContentChanged = false; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatLivePreviewWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatLivePreviewWidget.ts index c95b92258bc..321f358f16c 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatLivePreviewWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatLivePreviewWidget.ts @@ -48,6 +48,7 @@ export class InlineChatLivePreviewWidget extends ZoneWidget { private _dim: Dimension | undefined; private _isVisible: boolean = false; + private _lineRanges: readonly LineRangeMapping[] | undefined; constructor( editor: ICodeEditor, @@ -145,6 +146,7 @@ export class InlineChatLivePreviewWidget extends ZoneWidget { this._cleanupFullDiff(); super.hide(); this._isVisible = false; + this._lineRanges = undefined; } override show(): void { @@ -154,6 +156,7 @@ export class InlineChatLivePreviewWidget extends ZoneWidget { showForChanges(changes: readonly LineRangeMapping[]): void { const hasFocus = this._diffEditor.hasTextFocus(); this._isVisible = true; + this._lineRanges = changes; const onlyInserts = changes.every(change => change.original.isEmpty); @@ -178,6 +181,9 @@ export class InlineChatLivePreviewWidget extends ZoneWidget { } } + get startLine(): number | undefined { + return this._lineRanges?.[0]?.modified.startLineNumber; + } private _renderInsertWithHighlight(changes: readonly LineRangeMapping[]) { assertType(this.editor.hasModel()); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts index a3447e3b5a9..232f72d70f9 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts @@ -4,24 +4,27 @@ *--------------------------------------------------------------------------------------------*/ import { equals, tail } from 'vs/base/common/arrays'; +import { AsyncIterableObject, DeferredAsyncIterableObject } from 'vs/base/common/async'; import { Event } from 'vs/base/common/event'; import { Lazy } from 'vs/base/common/lazy'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { IBulkEditService } from 'vs/editor/browser/services/bulkEditService'; -import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; +import { EditOperation, ISingleEditOperation } from 'vs/editor/common/core/editOperation'; import { Position } from 'vs/editor/common/core/position'; +import { IRange } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; import { LineRangeMapping } from 'vs/editor/common/diff/rangeMapping'; import { IEditorDecorationsCollection } from 'vs/editor/common/editorCommon'; import { TextEdit } from 'vs/editor/common/languages'; -import { ICursorStateComputer, IModelDecorationOptions, IModelDeltaDecoration, ITextModel, IValidEditOperation } from 'vs/editor/common/model'; +import { ICursorStateComputer, IIdentifiedSingleEditOperation, IModelDecorationOptions, IModelDeltaDecoration, ITextModel, IValidEditOperation, TrackedRangeStickiness } from 'vs/editor/common/model'; import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker'; import { localize } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { IStorageService } from 'vs/platform/storage/common/storage'; +import { countWords, getNWords } from 'vs/workbench/contrib/chat/common/chatWordCounter'; import { InlineChatFileCreatePreviewWidget, InlineChatLivePreviewWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatLivePreviewWidget'; import { EditResponse, Session } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; import { InlineChatWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatWidget'; @@ -38,12 +41,12 @@ export abstract class EditModeStrategy { abstract cancel(): Promise; + abstract makeProgressiveChanges(edits: ISingleEditOperation[], timings: { duration: number }): Promise; + abstract makeChanges(edits: ISingleEditOperation[]): Promise; abstract undoChanges(altVersionId: number): Promise; - abstract renderProgressChanges(): Promise; - abstract renderChanges(response: EditResponse): Promise; abstract hasFocus(): boolean; @@ -126,7 +129,7 @@ export class PreviewStrategy extends EditModeStrategy { // nothing to do } - override async renderProgressChanges(): Promise { + override async makeProgressiveChanges(): Promise { // nothing to do } @@ -225,6 +228,11 @@ class InlineDiffDecorations { } } +export interface ProgressingEditsOptions { + duration: number; + round: number; +} + export class LiveStrategy extends EditModeStrategy { protected _diffEnabled: boolean = false; @@ -322,8 +330,19 @@ export class LiveStrategy extends EditModeStrategy { LiveStrategy._undoModelUntil(textModelN, altVersionId); } - override async renderProgressChanges(): Promise { - // nothing to do + override async makeProgressiveChanges(edits: ISingleEditOperation[], opts: ProgressingEditsOptions): Promise { + + if (opts.round === 0) { + this._session.textModelN.pushStackElement(); + } + + const durationInSec = opts.duration / 1000; + for (const edit of edits) { + const wordCount = countWords(edit.text ?? ''); + const speed = wordCount / durationInSec; + // console.log({ durationInSec, wordCount, speed: wordCount / durationInSec }); + await performAsyncTextEdit(this._session.textModelN, asProgressiveEdit(edit, speed)); + } } override async renderChanges(response: EditResponse) { @@ -474,8 +493,9 @@ export class LivePreviewStrategy extends LiveStrategy { } } - override async renderProgressChanges(): Promise { - return this._renderDiffZones(); + override async makeProgressiveChanges(edits: ISingleEditOperation[], timings: { duration: number; round: number }): Promise { + await super.makeProgressiveChanges(edits, timings); + await this._renderDiffZones(); } override async renderChanges(response: EditResponse) { @@ -496,9 +516,15 @@ export class LivePreviewStrategy extends LiveStrategy { override getWidgetPosition(): Position | undefined { for (let i = this._diffZonePool.length - 1; i >= 0; i--) { const zone = this._diffZonePool[i]; - if (zone.isVisible && zone.position) { + if (zone.isVisible) { // above last view zone - return zone.position; + if (zone.position) { + // can be undefined when the zone isn't attached yet + return zone.position; + } + if (zone.startLine) { + return new Position(zone.startLine, 1); + } } } return undefined; @@ -511,3 +537,72 @@ function showSingleCreateFile(accessor: ServicesAccessor, edit: EditResponse) { editorService.openEditor({ resource: edit.singleCreateFileEdit.uri }, SIDE_GROUP); } } + +export interface AsyncTextEdit { + readonly range: IRange; + readonly newText: AsyncIterable; +} + +export async function performAsyncTextEdit(model: ITextModel, edit: AsyncTextEdit) { + + const [id] = model.deltaDecorations([], [{ + range: edit.range, + options: { + description: 'asyncTextEdit', + stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges + } + }]); + + let first = true; + for await (const part of edit.newText) { + + if (model.isDisposed()) { + break; + } + + const range = model.getDecorationRange(id); + if (!range) { + throw new Error('FAILED to perform async replace edit because the anchor decoration was removed'); + } + + const edit = first + ? EditOperation.replace(range, part) // first edit needs to override the "anchor" + : EditOperation.insert(range.getEndPosition(), part); + + model.pushEditOperations(null, [edit], () => null); + first = false; + } +} + +export function asAsyncEdit(edit: IIdentifiedSingleEditOperation): AsyncTextEdit { + return { + range: edit.range, + newText: AsyncIterableObject.fromArray([edit.text ?? '']) + } satisfies AsyncTextEdit; +} + +export function asProgressiveEdit(edit: IIdentifiedSingleEditOperation, wordsPerSec: number): AsyncTextEdit { + + wordsPerSec = Math.max(10, wordsPerSec); + + const stream = new DeferredAsyncIterableObject(); + let newText = edit.text ?? ''; + // const wordCount = countWords(newText); + + const handle = setInterval(() => { + + const r = getNWords(newText, 1); + stream.emit(r.value); + newText = newText.substring(r.value.length); + if (r.isFullString) { + clearInterval(handle); + stream.complete(); + } + + }, 1000 / wordsPerSec); + + return { + range: edit.range, + newText: stream.asyncIterable + }; +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts index a4223bb8d8d..b94166b5110 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts @@ -923,6 +923,7 @@ export class InlineChatZoneWidget extends ZoneWidget { } override show(position: Position): void { + position = position.lineNumber === 1 ? position.delta(-1) : position; super.show(position, this._computeHeightInLines()); this.widget.focus(); this._ctxVisible.set(true);