auto accept changes (#238043)

* add setting to auto apply AI changes

* fix rounding error

* tweak animation
This commit is contained in:
Johannes Rieken
2025-01-16 11:51:56 +01:00
committed by GitHub
parent eaba97f995
commit 4d44668917
8 changed files with 209 additions and 59 deletions

View File

@@ -128,6 +128,12 @@ configurationRegistry.registerConfiguration({
markdownDescription: nls.localize('chat.editing.alwaysSaveWithGeneratedChanges', "Whether files that have changes made by chat can be saved without confirmation."),
default: false,
},
'chat.editing.automaticallyAcceptChanges': {
type: 'boolean',
scope: ConfigurationScope.APPLICATION,
markdownDescription: nls.localize('chat.editing.automaticallyAcceptChanges', "Whether changes made by chat are automatically accepted without prior review."),
default: false,
},
'chat.editing.confirmEditRequestRemoval': {
type: 'boolean',
scope: ConfigurationScope.APPLICATION,

View File

@@ -7,7 +7,7 @@ import { RunOnceScheduler } from '../../../../../base/common/async.js';
import { Emitter } from '../../../../../base/common/event.js';
import { Disposable, IReference, toDisposable } from '../../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../../base/common/network.js';
import { autorun, IObservable, ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js';
import { autorun, derived, IObservable, ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js';
import { themeColorFromId } from '../../../../../base/common/themables.js';
import { URI } from '../../../../../base/common/uri.js';
import { EditOperation, ISingleEditOperation } from '../../../../../editor/common/core/editOperation.js';
@@ -91,6 +91,12 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie
return this._maxLineNumberObs;
}
private readonly _reviewModeTempObs = observableValue<true | undefined>(this, undefined);
readonly reviewMode: IObservable<boolean>;
private readonly _autoAcceptCountdown = observableValue<number | undefined>(this, undefined);
readonly autoAcceptCountdown: IObservable<number | undefined> = this._autoAcceptCountdown;
private _isFirstEditAfterStartOrSnapshot: boolean = true;
private _edit: OffsetEdit = OffsetEdit.empty;
private _isEditFromUs: boolean = false;
@@ -199,6 +205,14 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie
this._diffTrimWhitespace.read(r);
this._updateDiffInfoSeq();
}));
// review mode depends on setting and temporary override
const autoAccept = observableConfigValue('chat.editing.automaticallyAcceptChanges', false, configService);
this.reviewMode = derived(r => {
const configuredValue = !autoAccept.read(r);
const tempValue = this._reviewModeTempObs.read(r);
return tempValue || configuredValue;
});
}
override dispose(): void {
@@ -212,6 +226,22 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie
return this;
}
enableReviewModeUntilSettled(): void {
this._reviewModeTempObs.set(true, undefined);
const cleanup = autorun(r => {
// reset config when settled
const resetConfig = this.state.read(r) !== WorkingSetEntryState.Modified;
if (resetConfig) {
this._store.delete(cleanup);
this._reviewModeTempObs.set(undefined, undefined);
}
});
this._store.add(cleanup);
}
private _clearCurrentEditLineDecoration() {
this._editDecorations = this.doc.deltaDecorations(this._editDecorations, []);
}
@@ -258,6 +288,31 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie
this._isCurrentlyBeingModifiedObs.set(false, tx);
this._rewriteRatioObs.set(0, tx);
this._clearCurrentEditLineDecoration();
// AUTO accept mode
if (!this.reviewMode.get()) {
const future = Date.now() + 10000; // 10secs
const update = () => {
const reviewMode = this.reviewMode.get();
if (reviewMode) {
// switched back to review mode
this._autoAcceptCountdown.set(undefined, undefined);
return;
}
const remain = Math.round((future - Date.now()) / 1000);
if (remain <= 0) {
this.accept(undefined);
this._autoAcceptCountdown.set(undefined, undefined);
} else {
this._autoAcceptCountdown.set(remain, undefined);
setTimeout(update, 1000);
}
};
update();
}
}
private _mirrorEdits(event: IModelContentChangedEvent) {

View File

@@ -3,14 +3,14 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js';
import { localize2 } from '../../../../nls.js';
import { localize, localize2 } from '../../../../nls.js';
import { EditorAction2, ServicesAccessor } from '../../../../editor/browser/editorExtensions.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
import { CHAT_CATEGORY } from './actions/chatActions.js';
import { ChatEditorController, ctxHasEditorModification, ctxHasRequestInProgress } from './chatEditorController.js';
import { ChatEditorController, ctxHasEditorModification, ctxReviewModeEnabled } from './chatEditorController.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';
import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js';
@@ -45,6 +45,7 @@ abstract class NavigateAction extends Action2 {
id: MenuId.ChatEditingEditorContent,
group: 'navigate',
order: !next ? 2 : 3,
when: ctxReviewModeEnabled
}
});
}
@@ -130,7 +131,7 @@ abstract class AcceptDiscardAction extends Action2 {
? localize2('accept2', 'Accept')
: localize2('discard2', 'Discard'),
category: CHAT_CATEGORY,
precondition: ContextKeyExpr.and(ctxHasRequestInProgress.negate(), hasUndecidedChatEditingResourceContextKey),
precondition: ContextKeyExpr.and(hasUndecidedChatEditingResourceContextKey),
icon: accept
? Codicon.check
: Codicon.discard,
@@ -146,6 +147,7 @@ abstract class AcceptDiscardAction extends Action2 {
id: MenuId.ChatEditingEditorContent,
group: 'a_resolve',
order: accept ? 0 : 1,
when: !accept ? ctxReviewModeEnabled : undefined
}
});
}
@@ -272,6 +274,7 @@ class OpenDiffAction extends EditorAction2 {
id: MenuId.ChatEditingEditorContent,
group: 'a_resolve',
order: 2,
when: ctxReviewModeEnabled
}]
});
}
@@ -281,12 +284,55 @@ class OpenDiffAction extends EditorAction2 {
}
}
export class ReviewChangesAction extends EditorAction2 {
constructor() {
super({
id: 'chatEditor.action.reviewChanges',
title: localize2('review', "Review"),
menu: [{
id: MenuId.ChatEditingEditorContent,
group: 'a_resolve',
order: 3,
when: ctxReviewModeEnabled.negate(),
}]
});
}
override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor) {
const chatEditingService = accessor.get(IChatEditingService);
if (!editor.hasModel()) {
return;
}
const session = chatEditingService.editingSessionsObs.get().find(session => session.getEntry(editor.getModel().uri));
const entry = session?.getEntry(editor.getModel().uri);
entry?.enableReviewModeUntilSettled();
}
}
export function registerChatEditorActions() {
registerAction2(class NextAction extends NavigateAction { constructor() { super(true); } });
registerAction2(class PrevAction extends NavigateAction { constructor() { super(false); } });
registerAction2(ReviewChangesAction);
registerAction2(AcceptAction);
registerAction2(AcceptHunkAction);
registerAction2(RejectAction);
registerAction2(RejectHunkAction);
registerAction2(OpenDiffAction);
}
export const navigationBearingFakeActionId = 'chatEditor.navigation.bearings';
MenuRegistry.appendMenuItem(MenuId.ChatEditingEditorContent, {
command: {
id: navigationBearingFakeActionId,
title: localize('label', "Navigation Status"),
precondition: ContextKeyExpr.false(),
},
group: 'navigate',
order: -1,
when: ctxReviewModeEnabled,
});

View File

@@ -39,6 +39,7 @@ import { ChatEditorOverlayWidget } from './chatEditorOverlay.js';
export const ctxHasEditorModification = new RawContextKey<boolean>('chat.hasEditorModifications', undefined, localize('chat.hasEditorModifications', "The current editor contains chat modifications"));
export const ctxHasRequestInProgress = new RawContextKey<boolean>('chat.ctxHasRequestInProgress', false, localize('chat.ctxHasRequestInProgress', "The current editor shows a file from an edit session which is still in progress"));
export const ctxReviewModeEnabled = new RawContextKey<boolean>('chat.ctxReviewModeEnabled', true, localize('chat.ctxReviewModeEnabled', "Review mode for chat changes is enabled"));
export class ChatEditorController extends Disposable implements IEditorContribution {
@@ -57,6 +58,7 @@ export class ChatEditorController extends Disposable implements IEditorContribut
private readonly _ctxHasEditorModification: IContextKey<boolean>;
private readonly _ctxRequestInProgress: IContextKey<boolean>;
private readonly _ctxReviewModelEnabled: IContextKey<boolean>;
static get(editor: ICodeEditor): ChatEditorController | null {
const controller = editor.getContribution<ChatEditorController>(ChatEditorController.ID);
@@ -84,13 +86,13 @@ export class ChatEditorController extends Disposable implements IEditorContribut
this._overlayWidget = _instantiationService.createInstance(ChatEditorOverlayWidget, _editor);
this._ctxHasEditorModification = ctxHasEditorModification.bindTo(contextKeyService);
this._ctxRequestInProgress = ctxHasRequestInProgress.bindTo(contextKeyService);
this._ctxReviewModelEnabled = ctxReviewModeEnabled.bindTo(contextKeyService);
const editorObs = observableCodeEditor(this._editor);
const fontInfoObs = editorObs.getOption(EditorOption.fontInfo);
const lineHeightObs = editorObs.getOption(EditorOption.lineHeight);
const modelObs = editorObs.model;
this._store.add(autorun(r => {
let isStreamingEdits = false;
for (const session of _chatEditingService.editingSessionsObs.read(r)) {
@@ -139,11 +141,13 @@ export class ChatEditorController extends Disposable implements IEditorContribut
const { session, entries, idx, entry } = currentEditorEntry;
this._ctxReviewModelEnabled.set(entry.reviewMode.read(r));
// context
this._currentEntryIndex.set(idx, undefined);
// overlay widget
if (entry.state.read(r) === WorkingSetEntryState.Accepted || entry.state.read(r) === WorkingSetEntryState.Rejected) {
if (entry.state.read(r) !== WorkingSetEntryState.Modified) {
this._overlayWidget.hide();
} else {
this._overlayWidget.show(session, entry, entries[(idx + 1) % entries.length]);
@@ -172,9 +176,11 @@ export class ChatEditorController extends Disposable implements IEditorContribut
// Add line decorations (just markers, no UI) for diff navigation
this._updateDiffLineDecorations(diff);
const reviewMode = entry.reviewMode.read(r);
// Add diff decoration to the UI (unless in diff editor)
if (!this._editor.getOption(EditorOption.inDiffEditor)) {
this._updateDiffRendering(entry, diff);
this._updateDiffRendering(entry, diff, reviewMode);
} else {
this._clearDiffRendering();
}
@@ -246,6 +252,7 @@ export class ChatEditorController extends Disposable implements IEditorContribut
this._currentChangeIndex.set(undefined, undefined);
this._currentEntryIndex.set(undefined, undefined);
this._ctxHasEditorModification.reset();
this._ctxReviewModelEnabled.reset();
}
private _clearDiffRendering() {
@@ -260,7 +267,7 @@ export class ChatEditorController extends Disposable implements IEditorContribut
this._scrollLock = false;
}
private _updateDiffRendering(entry: IModifiedFileEntry, diff: IDocumentDiff): void {
private _updateDiffRendering(entry: IModifiedFileEntry, diff: IDocumentDiff, reviewMode: boolean): void {
const originalModel = entry.originalModel;
@@ -309,18 +316,21 @@ export class ChatEditorController extends Disposable implements IEditorContribut
mightContainRTL,
);
const decorations: InlineDecoration[] = [];
for (const i of diffEntry.innerChanges || []) {
decorations.push(new InlineDecoration(
i.originalRange.delta(-(diffEntry.original.startLineNumber - 1)),
diffDeleteDecoration.className!,
InlineDecorationType.Regular
));
// If the original range is empty, the start line number is 1 and the new range spans the entire file, don't draw an Added decoration
if (!(i.originalRange.isEmpty() && i.originalRange.startLineNumber === 1 && i.modifiedRange.endLineNumber === editorLineCount) && !i.modifiedRange.isEmpty()) {
modifiedVisualDecorations.push({
range: i.modifiedRange, options: chatDiffAddDecoration
});
if (reviewMode) {
for (const i of diffEntry.innerChanges || []) {
decorations.push(new InlineDecoration(
i.originalRange.delta(-(diffEntry.original.startLineNumber - 1)),
diffDeleteDecoration.className!,
InlineDecorationType.Regular
));
// If the original range is empty, the start line number is 1 and the new range spans the entire file, don't draw an Added decoration
if (!(i.originalRange.isEmpty() && i.originalRange.startLineNumber === 1 && i.modifiedRange.endLineNumber === editorLineCount) && !i.modifiedRange.isEmpty()) {
modifiedVisualDecorations.push({
range: i.modifiedRange, options: chatDiffAddDecoration
});
}
}
}
@@ -354,33 +364,38 @@ export class ChatEditorController extends Disposable implements IEditorContribut
options: modifiedDecoration
});
}
const domNode = document.createElement('div');
domNode.className = 'chat-editing-original-zone view-lines line-delete monaco-mouse-cursor-text';
const result = renderLines(source, renderOptions, decorations, domNode);
if (!isCreatedContent) {
const viewZoneData: IViewZone = {
afterLineNumber: diffEntry.modified.startLineNumber - 1,
heightInLines: result.heightInLines,
domNode,
ordinal: 50000 + 2 // more than https://github.com/microsoft/vscode/blob/bf52a5cfb2c75a7327c9adeaefbddc06d529dcad/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts#L42
};
if (reviewMode) {
const domNode = document.createElement('div');
domNode.className = 'chat-editing-original-zone view-lines line-delete monaco-mouse-cursor-text';
const result = renderLines(source, renderOptions, decorations, domNode);
this._viewZones.push(viewZoneChangeAccessor.addZone(viewZoneData));
}
if (!isCreatedContent) {
// Add content widget for each diff change
const widget = this._instantiationService.createInstance(DiffHunkWidget, entry, diffEntry, this._editor.getModel()!.getVersionId(), this._editor, isCreatedContent ? 0 : result.heightInLines);
widget.layout(diffEntry.modified.startLineNumber);
const viewZoneData: IViewZone = {
afterLineNumber: diffEntry.modified.startLineNumber - 1,
heightInLines: result.heightInLines,
domNode,
ordinal: 50000 + 2 // more than https://github.com/microsoft/vscode/blob/bf52a5cfb2c75a7327c9adeaefbddc06d529dcad/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts#L42
};
this._diffHunkWidgets.push(widget);
diffHunkDecorations.push({
range: diffEntry.modified.toInclusiveRange() ?? new Range(diffEntry.modified.startLineNumber, 1, diffEntry.modified.startLineNumber, Number.MAX_SAFE_INTEGER),
options: {
description: 'diff-hunk-widget',
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges
this._viewZones.push(viewZoneChangeAccessor.addZone(viewZoneData));
}
});
// Add content widget for each diff change
const widget = this._instantiationService.createInstance(DiffHunkWidget, entry, diffEntry, this._editor.getModel()!.getVersionId(), this._editor, isCreatedContent ? 0 : result.heightInLines);
widget.layout(diffEntry.modified.startLineNumber);
this._diffHunkWidgets.push(widget);
diffHunkDecorations.push({
range: diffEntry.modified.toInclusiveRange() ?? new Range(diffEntry.modified.startLineNumber, 1, diffEntry.modified.startLineNumber, Number.MAX_SAFE_INTEGER),
options: {
description: 'diff-hunk-widget',
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges
}
});
}
}
this._diffVisualDecorations.set(modifiedVisualDecorations);

View File

@@ -9,7 +9,7 @@ import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, OverlayWidgetPosit
import { HiddenItemStrategy, MenuWorkbenchToolBar, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IChatEditingSession, IModifiedFileEntry } from '../common/chatEditingService.js';
import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js';
import { MenuId } from '../../../../platform/actions/common/actions.js';
import { ActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js';
import { Range } from '../../../../editor/common/core/range.js';
@@ -20,8 +20,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { assertType } from '../../../../base/common/types.js';
import { localize } from '../../../../nls.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { AcceptAction, RejectAction } from './chatEditorActions.js';
import { AcceptAction, navigationBearingFakeActionId, RejectAction } from './chatEditorActions.js';
import { ChatEditorController } from './chatEditorController.js';
import './media/chatEditorOverlay.css';
import { findDiffEditorContainingCodeEditor } from '../../../../editor/browser/widget/diffEditor/commands.js';
@@ -123,6 +122,25 @@ export class ChatEditorOverlayWidget implements IOverlayWidget {
constructor() {
super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true });
}
override render(container: HTMLElement): void {
super.render(container);
if (action.id === AcceptAction.ID) {
assertType(this.label);
assertType(this.element);
this._store.add(autorun(r => {
const value = that._entry.read(r)?.entry.autoAcceptCountdown.read(r);
this.label!.innerText = value === undefined
? this.action.label
: localize('pattern', "{0} ({1})", this.action.label, value);
this.element?.classList.toggle('auto', !!value);
}));
}
}
override set actionRunner(actionRunner: IActionRunner) {
super.actionRunner = actionRunner;
@@ -258,15 +276,3 @@ export class ChatEditorOverlayWidget implements IOverlayWidget {
}
}
}
export const navigationBearingFakeActionId = 'chatEditor.navigation.bearings';
MenuRegistry.appendMenuItem(MenuId.ChatEditingEditorContent, {
command: {
id: navigationBearingFakeActionId,
title: localize('label', "Navigation Status"),
precondition: ContextKeyExpr.false(),
},
group: 'navigate',
order: -1
});

View File

@@ -89,3 +89,22 @@
color: var(--vscode-button-foreground);
opacity: 1;
}
.chat-editor-overlay-widget .action-item.auto > .action-label {
animation: textShift 5s linear infinite alternate;
background: linear-gradient(90deg,
var(--vscode-button-foreground) 0%,
var(--vscode-button-foreground) 45%,
var(--vscode-editorGutter-addedBackground) 50%,
var(--vscode-button-foreground) 55%,
var(--vscode-button-foreground) 100%);
background-size: 200% 100%;
color: transparent;
-webkit-background-clip: text;
background-clip: text;
}
@keyframes textShift {
0% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}

View File

@@ -138,6 +138,10 @@ export interface IModifiedFileEntry {
readonly lastModifyingRequestId: string;
accept(transaction: ITransaction | undefined): Promise<void>;
reject(transaction: ITransaction | undefined): Promise<void>;
reviewMode: IObservable<boolean>;
autoAcceptCountdown: IObservable<number | undefined>;
enableReviewModeUntilSettled(): void;
}
export interface IChatEditingSessionStream {

View File

@@ -18,8 +18,7 @@ import { autorun, autorunWithStore, IObservable, ISettableObservable, observable
import { isEqual } from '../../../../../../base/common/resources.js';
import { CellDiffInfo } from '../../diff/notebookDiffViewModel.js';
import { INotebookDeletedCellDecorator } from './notebookCellDecorators.js';
import { AcceptAction, RejectAction } from '../../../../chat/browser/chatEditorActions.js';
import { navigationBearingFakeActionId } from '../../../../chat/browser/chatEditorOverlay.js';
import { AcceptAction, navigationBearingFakeActionId, RejectAction } from '../../../../chat/browser/chatEditorActions.js';
export class NotebookChatActionsOverlayController extends Disposable {
constructor(