Improve chat input history (#215593)

* Fixes to history navigation with dynamic variables
Prevent ChatDynamicVariableModel deleting editor text while in history navigation

* Try to add history item for current input, doesn't quite work

* Use HistoryNavigator2 in chat

* Update context attachments for new input state model

* Add SetWithKey and use it in HistoryNavigator2

* Fix history for 2 widget inline chat

* Add log action, clean up

* Add tests for SetWithKey

* comment

* Fix
This commit is contained in:
Rob Lourens 2024-06-14 16:38:22 -07:00 committed by GitHub
parent be838c0af7
commit 35ac2d01a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 321 additions and 74 deletions

View file

@ -80,3 +80,61 @@ export function intersection<T>(setA: Set<T>, setB: Iterable<T>): Set<T> {
}
return result;
}
export class SetWithKey<T> implements Set<T> {
private _map = new Map<any, T>();
constructor(values: T[], private toKey: (t: T) => any) {
for (const value of values) {
this.add(value);
}
}
get size(): number {
return this._map.size;
}
add(value: T): this {
const key = this.toKey(value);
this._map.set(key, value);
return this;
}
delete(value: T): boolean {
return this._map.delete(this.toKey(value));
}
has(value: T): boolean {
return this._map.has(this.toKey(value));
}
*entries(): IterableIterator<[T, T]> {
for (const entry of this._map.values()) {
yield [entry, entry];
}
}
keys(): IterableIterator<T> {
return this.values();
}
*values(): IterableIterator<T> {
for (const entry of this._map.values()) {
yield entry;
}
}
clear(): void {
this._map.clear();
}
forEach(callbackfn: (value: T, value2: T, set: Set<T>) => void, thisArg?: any): void {
this._map.forEach(entry => callbackfn.call(thisArg, entry, entry, this));
}
[Symbol.iterator](): IterableIterator<T> {
return this.values();
}
[Symbol.toStringTag]: string = 'SetWithKey';
}

View file

@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { SetWithKey } from 'vs/base/common/collections';
import { ArrayNavigator, INavigator } from 'vs/base/common/navigator';
export class HistoryNavigator<T> implements INavigator<T> {
@ -114,6 +115,10 @@ interface HistoryNode<T> {
next: HistoryNode<T> | undefined;
}
/**
* The right way to use HistoryNavigator2 is for the last item in the list to be the user's uncommitted current text. eg empty string, or whatever has been typed. Then
* the user can navigate away from the last item through the list, and back to it. When updating the last item, call replaceLast.
*/
export class HistoryNavigator2<T> {
private valueSet: Set<T>;
@ -123,7 +128,7 @@ export class HistoryNavigator2<T> {
private _size: number;
get size(): number { return this._size; }
constructor(history: readonly T[], private capacity: number = 10) {
constructor(history: readonly T[], private capacity: number = 10, private identityFn: (t: T) => any = t => t) {
if (history.length < 1) {
throw new Error('not supported');
}
@ -135,7 +140,7 @@ export class HistoryNavigator2<T> {
next: undefined
};
this.valueSet = new Set<T>([history[0]]);
this.valueSet = new SetWithKey<T>([history[0]], identityFn);
for (let i = 1; i < history.length; i++) {
this.add(history[i]);
}
@ -172,7 +177,7 @@ export class HistoryNavigator2<T> {
* @returns old last value
*/
replaceLast(value: T): T {
if (this.tail.value === value) {
if (this.identityFn(this.tail.value) === this.identityFn(value)) {
return value;
}
@ -252,8 +257,9 @@ export class HistoryNavigator2<T> {
private _deleteFromList(value: T): void {
let temp = this.head;
const valueKey = this.identityFn(value);
while (temp !== this.tail) {
if (temp.value === value) {
if (this.identityFn(temp.value) === valueKey) {
if (temp === this.head) {
this.head = this.head.next!;
this.head.previous = undefined;

View file

@ -32,4 +32,70 @@ suite('Collections', () => {
assert.strictEqual(grouped[group2].length, 1);
assert.strictEqual(grouped[group2][0].value, value3);
});
suite('SetWithKey', () => {
let setWithKey: collections.SetWithKey<{ someProp: string }>;
const initialValues = ['a', 'b', 'c'].map(s => ({ someProp: s }));
setup(() => {
setWithKey = new collections.SetWithKey<{ someProp: string }>(initialValues, value => value.someProp);
});
test('size', () => {
assert.strictEqual(setWithKey.size, 3);
});
test('add', () => {
setWithKey.add({ someProp: 'd' });
assert.strictEqual(setWithKey.size, 4);
assert.strictEqual(setWithKey.has({ someProp: 'd' }), true);
});
test('delete', () => {
assert.strictEqual(setWithKey.has({ someProp: 'b' }), true);
setWithKey.delete({ someProp: 'b' });
assert.strictEqual(setWithKey.size, 2);
assert.strictEqual(setWithKey.has({ someProp: 'b' }), false);
});
test('has', () => {
assert.strictEqual(setWithKey.has({ someProp: 'a' }), true);
assert.strictEqual(setWithKey.has({ someProp: 'b' }), true);
});
test('entries', () => {
const entries = Array.from(setWithKey.entries());
assert.deepStrictEqual(entries, initialValues.map(value => [value, value]));
});
test('keys and values', () => {
const keys = Array.from(setWithKey.keys());
const values = Array.from(setWithKey.values());
assert.deepStrictEqual(keys, initialValues);
assert.deepStrictEqual(values, initialValues);
});
test('clear', () => {
setWithKey.clear();
assert.strictEqual(setWithKey.size, 0);
});
test('forEach', () => {
const values: any[] = [];
setWithKey.forEach(value => values.push(value));
assert.deepStrictEqual(values, initialValues);
});
test('iterator', () => {
const values: any[] = [];
for (const value of setWithKey) {
values.push(value);
}
assert.deepStrictEqual(values, initialValues);
});
test('toStringTag', () => {
assert.strictEqual(setWithKey[Symbol.toStringTag], 'SetWithKey');
});
});
});

View file

@ -0,0 +1,35 @@
/*---------------------------------------------------------------------------------------------
* 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 { ServicesAccessor } from 'vs/editor/browser/editorExtensions';
import { localize2 } from 'vs/nls';
import { Categories } from 'vs/platform/action/common/actionCommonCategories';
import { Action2, registerAction2 } from 'vs/platform/actions/common/actions';
import { IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat';
export function registerChatDeveloperActions() {
registerAction2(LogChatInputHistoryAction);
}
class LogChatInputHistoryAction extends Action2 {
static readonly ID = 'workbench.action.chat.logInputHistory';
constructor() {
super({
id: LogChatInputHistoryAction.ID,
title: localize2('workbench.action.chat.logInputHistory.label', "Log Chat Input History"),
icon: Codicon.attach,
category: Categories.Developer,
f1: true
});
}
override async run(accessor: ServicesAccessor, ...args: any[]): Promise<void> {
const chatWidgetService = accessor.get(IChatWidgetService);
chatWidgetService.lastFocusedWidget?.logInputHistory();
}
}

View file

@ -58,6 +58,7 @@ import { IEditorResolverService, RegisteredEditorPriority } from 'vs/workbench/s
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
import '../common/chatColors';
import { registerChatContextActions } from 'vs/workbench/contrib/chat/browser/actions/chatContextActions';
import { registerChatDeveloperActions } from 'vs/workbench/contrib/chat/browser/actions/chatDeveloperActions';
// Register configuration
const configurationRegistry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration);
@ -250,6 +251,7 @@ registerChatExportActions();
registerMoveActions();
registerNewChatActions();
registerChatContextActions();
registerChatDeveloperActions();
registerSingleton(IChatService, ChatService, InstantiationType.Delayed);
registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delayed);

View file

@ -157,6 +157,7 @@ export interface IChatWidget {
getFocus(): ChatTreeItem | undefined;
setInput(query?: string): void;
getInput(): string;
logInputHistory(): void;
acceptInput(query?: string): Promise<IChatResponseModel | undefined>;
acceptInputWithPrefix(prefix: string): void;
setInputPlaceholder(placeholder: string): void;

View file

@ -6,14 +6,16 @@
import * as dom from 'vs/base/browser/dom';
import { DEFAULT_FONT_FAMILY } from 'vs/base/browser/fonts';
import { IHistoryNavigationWidget } from 'vs/base/browser/history';
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import * as aria from 'vs/base/browser/ui/aria/aria';
import { Range } from 'vs/editor/common/core/range';
import { Button } from 'vs/base/browser/ui/button/button';
import { IAction } from 'vs/base/common/actions';
import { Codicon } from 'vs/base/common/codicons';
import { Emitter } from 'vs/base/common/event';
import { HistoryNavigator } from 'vs/base/common/history';
import { HistoryNavigator2 } from 'vs/base/common/history';
import { KeyCode } from 'vs/base/common/keyCodes';
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { basename, dirname } from 'vs/base/common/path';
import { isMacintosh } from 'vs/base/common/platform';
import { URI } from 'vs/base/common/uri';
import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration';
@ -21,6 +23,7 @@ import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions';
import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget';
import { IDimension } from 'vs/editor/common/core/dimension';
import { IPosition } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { ITextModel } from 'vs/editor/common/model';
import { IModelService } from 'vs/editor/common/services/model';
import { HoverController } from 'vs/editor/contrib/hover/browser/hoverController';
@ -38,6 +41,7 @@ import { registerAndCreateHistoryNavigationContext } from 'vs/platform/history/b
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { ILogService } from 'vs/platform/log/common/log';
import { INotificationService } from 'vs/platform/notification/common/notification';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { ResourceLabels } from 'vs/workbench/browser/labels';
@ -53,9 +57,6 @@ import { IChatFollowup } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatResponseViewModel } from 'vs/workbench/contrib/chat/common/chatViewModel';
import { IChatHistoryEntry, IChatWidgetHistoryService } from 'vs/workbench/contrib/chat/common/chatWidgetHistoryService';
import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions';
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { KeyCode } from 'vs/base/common/keyCodes';
import { basename, dirname } from 'vs/base/common/path';
const $ = dom.$;
@ -130,10 +131,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
return this._inputEditor;
}
private history: HistoryNavigator<IChatHistoryEntry>;
private history: HistoryNavigator2<IChatHistoryEntry>;
private historyNavigationBackwardsEnablement!: IContextKey<boolean>;
private historyNavigationForewardsEnablement!: IContextKey<boolean>;
private onHistoryEntry = false;
private inHistoryNavigation = false;
private inputModel: ITextModel | undefined;
private inputEditorHasText: IContextKey<boolean>;
@ -156,6 +156,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
@IConfigurationService private readonly configurationService: IConfigurationService,
@IKeybindingService private readonly keybindingService: IKeybindingService,
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
@ILogService private readonly logService: ILogService,
) {
super();
@ -165,8 +166,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this.chatCursorAtTop = CONTEXT_CHAT_INPUT_CURSOR_AT_TOP.bindTo(contextKeyService);
this.inputEditorHasFocus = CONTEXT_CHAT_INPUT_HAS_FOCUS.bindTo(contextKeyService);
this.history = new HistoryNavigator([], 5);
this._register(this.historyService.onDidClearHistory(() => this.history.clear()));
this.history = this.loadHistory();
this._register(this.historyService.onDidClearHistory(() => this.history = new HistoryNavigator2([{ text: '' }], 50, historyKeyFn)));
this._register(this.configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(AccessibilityVerbositySettingId.Chat)) {
@ -175,6 +176,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}));
}
private loadHistory(): HistoryNavigator2<IChatHistoryEntry> {
const history = this.historyService.getHistory(this.location);
if (history.length === 0) {
history.push({ text: '' });
}
return new HistoryNavigator2(history, 50, historyKeyFn);
}
private _getAriaLabel(): string {
const verbose = this.configurationService.getValue<boolean>(AccessibilityVerbositySettingId.Chat);
if (verbose) {
@ -184,13 +194,35 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
return localize('chatInput', "Chat Input");
}
setState(inputValue: string | undefined): void {
const history = this.historyService.getHistory(this.location);
this.history = new HistoryNavigator(history, 50);
if (typeof inputValue === 'string') {
this.setValue(inputValue);
updateState(inputState: Object): void {
if (this.inHistoryNavigation) {
return;
}
const newEntry = { text: this._inputEditor.getValue(), state: inputState };
if (this.history.isAtEnd()) {
// The last history entry should always be the current input value
this.history.replaceLast(newEntry);
} else {
// Added a reference while in the middle of history navigation, it's a new entry
this.history.replaceLast(newEntry);
this.history.resetCursor();
}
}
initForNewChatModel(inputValue: string | undefined, inputState: Object): void {
this.history = this.loadHistory();
this.history.add({ text: inputValue ?? this.history.current().text, state: inputState });
if (inputValue) {
this.setValue(inputValue, false);
}
}
logInputHistory(): void {
const historyStr = [...this.history].map(entry => JSON.stringify(entry)).join('\n');
this.logService.info(`[${this.location}] Chat input history:`, historyStr);
}
setVisible(visible: boolean): void {
@ -202,24 +234,39 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}
showPreviousValue(): void {
if (this.history.isAtEnd()) {
this.saveCurrentValue();
} else {
if (!this.history.has({ text: this._inputEditor.getValue(), state: this.history.current().state })) {
this.saveCurrentValue();
this.history.resetCursor();
}
}
this.navigateHistory(true);
}
showNextValue(): void {
if (this.history.isAtEnd()) {
return;
} else {
if (!this.history.has({ text: this._inputEditor.getValue(), state: this.history.current().state })) {
this.saveCurrentValue();
this.history.resetCursor();
}
}
this.navigateHistory(false);
}
private navigateHistory(previous: boolean): void {
const historyEntry = (previous ?
(this.history.previous() ?? this.history.first()) : this.history.next())
?? { text: '' };
this.onHistoryEntry = previous || this.history.current() !== null;
const historyEntry = previous ?
this.history.previous() : this.history.next();
aria.status(historyEntry.text);
this.inHistoryNavigation = true;
this.setValue(historyEntry.text);
this.setValue(historyEntry.text, true);
this.inHistoryNavigation = false;
this._onDidLoadInputState.fire(historyEntry.state);
@ -235,10 +282,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}
}
setValue(value: string): void {
setValue(value: string, transient: boolean): void {
this.inputEditor.setValue(value);
// always leave cursor at the end
this.inputEditor.setPosition({ lineNumber: 1, column: value.length + 1 });
if (!transient) {
this.saveCurrentValue();
}
}
private saveCurrentValue(): void {
const newEntry = { text: this._inputEditor.getValue(), state: this.history.current().state };
this.history.replaceLast(newEntry);
}
focus() {
@ -253,17 +309,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
* Reset the input and update history.
* @param userQuery If provided, this will be added to the history. Followups and programmatic queries should not be passed.
*/
async acceptInput(userQuery?: string, inputState?: any): Promise<void> {
if (userQuery) {
let element = this.history.getHistory().find(candidate => candidate.text === userQuery);
if (!element) {
element = { text: userQuery, state: inputState };
} else {
element.state = inputState;
}
this.history.add(element);
async acceptInput(isUserQuery?: boolean): Promise<void> {
if (isUserQuery) {
const userQuery = this._inputEditor.getValue();
const entry: IChatHistoryEntry = { text: userQuery, state: this.history.current().state };
this.history.replaceLast(entry);
this.history.add({ text: '' });
}
this._onDidLoadInputState.fire({});
if (this.accessibilityService.isScreenReaderOptimized() && isMacintosh) {
this._acceptInputForVoiceover();
} else {
@ -343,21 +397,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this._onDidChangeHeight.fire();
}
// Only allow history navigation when the input is empty.
// (If this model change happened as a result of a history navigation, this is canceled out by a call in this.navigateHistory)
const model = this._inputEditor.getModel();
const inputHasText = !!model && model.getValue().trim().length > 0;
this.inputEditorHasText.set(inputHasText);
// If the user is typing on a history entry, then reset the onHistoryEntry flag so that history navigation can be disabled
if (!this.inHistoryNavigation) {
this.onHistoryEntry = false;
}
if (!this.onHistoryEntry) {
this.historyNavigationForewardsEnablement.set(!inputHasText);
this.historyNavigationBackwardsEnablement.set(!inputHasText);
}
}));
this._register(this._inputEditor.onDidFocusEditorText(() => {
this.inputEditorHasFocus.set(true);
@ -379,10 +421,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
const atTop = e.position.column === 1 && e.position.lineNumber === 1;
this.chatCursorAtTop.set(atTop);
if (this.onHistoryEntry) {
this.historyNavigationBackwardsEnablement.set(atTop);
this.historyNavigationForewardsEnablement.set(e.position.equals(getLastPosition(model)));
}
this.historyNavigationBackwardsEnablement.set(atTop);
this.historyNavigationForewardsEnablement.set(e.position.equals(getLastPosition(model)));
}));
this.toolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, inputContainer, this.options.menus.executeToolbar, {
@ -568,11 +608,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}
saveState(): void {
const inputHistory = this.history.getHistory();
const inputHistory = [...this.history];
this.historyService.saveHistory(this.location, inputHistory);
}
}
const historyKeyFn = (entry: IChatHistoryEntry) => JSON.stringify(entry);
function getLastPosition(model: ITextModel): IPosition {
return { lineNumber: model.getLineCount(), column: model.getLineLength(model.getLineCount()) + 1 };
}

View file

@ -69,6 +69,8 @@ export interface IChatWidgetContrib extends IDisposable {
*/
getInputState?(): any;
onDidChangeInputState?: Event<void>;
/**
* Called with the result of getInputState when navigating input history.
*/
@ -111,7 +113,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
private readonly _onDidChangeContentHeight = new Emitter<void>();
readonly onDidChangeContentHeight: Event<void> = this._onDidChangeContentHeight.event;
private contribs: IChatWidgetContrib[] = [];
private contribs: ReadonlyArray<IChatWidgetContrib> = [];
private tree!: WorkbenchObjectTree<ChatTreeItem>;
private renderer!: ChatListItemRenderer;
@ -311,6 +313,15 @@ export class ChatWidget extends Disposable implements IChatWidget {
return undefined;
}
}).filter(isDefined);
this.contribs.forEach(c => {
if (c.onDidChangeInputState) {
this._register(c.onDidChangeInputState(() => {
const state = this.collectInputState();
this.inputPart.updateState(state);
}));
}
});
}
getContrib<T extends IChatWidgetContrib>(id: string): T | undefined {
@ -552,8 +563,9 @@ export class ChatWidget extends Disposable implements IChatWidget {
this._register(this.inputPart.onDidLoadInputState(state => {
this.contribs.forEach(c => {
if (c.setInputState && typeof state === 'object' && state?.[c.id]) {
c.setInputState(state[c.id]);
if (c.setInputState) {
const contribState = (typeof state === 'object' && state?.[c.id]) ?? {};
c.setInputState(contribState);
}
});
}));
@ -646,7 +658,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
this.viewModel = undefined;
this.onDidChangeItems();
}));
this.inputPart.setState(viewState.inputValue);
this.inputPart.initForNewChatModel(viewState.inputValue, viewState.inputState ?? this.collectInputState());
this.contribs.forEach(c => {
if (c.setInputState && viewState.inputState?.[c.id]) {
c.setInputState(viewState.inputState?.[c.id]);
@ -692,13 +704,17 @@ export class ChatWidget extends Disposable implements IChatWidget {
}
setInput(value = ''): void {
this.inputPart.setValue(value);
this.inputPart.setValue(value, false);
}
getInput(): string {
return this.inputPart.inputEditor.getValue();
}
logInputHistory(): void {
this.inputPart.logInputHistory();
}
async acceptInput(query?: string): Promise<IChatResponseModel | undefined> {
return this._acceptInput(query ? { query } : undefined);
}
@ -731,9 +747,9 @@ export class ChatWidget extends Disposable implements IChatWidget {
if (result) {
this.inputPart.attachedContext.clear();
const inputState = this.collectInputState();
this.inputPart.acceptInput(isUserQuery ? input : undefined, isUserQuery ? inputState : undefined);
this.inputPart.acceptInput(isUserQuery);
this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand });
this.inputPart.updateState(this.collectInputState());
result.responseCompletePromise.then(() => {
const responses = this.viewModel?.getItems().filter(isResponseVM);
const lastResponse = responses?.[responses.length - 1];

View file

@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import { IChatWidget } from 'vs/workbench/contrib/chat/browser/chat';
import { ChatWidget, IChatWidgetContrib } from 'vs/workbench/contrib/chat/browser/chatWidget';
@ -12,6 +13,9 @@ export class ChatContextAttachments extends Disposable implements IChatWidgetCon
private _attachedContext = new Set<IChatRequestVariableEntry>();
private readonly _onDidChangeInputState = this._register(new Emitter<void>());
readonly onDidChangeInputState = this._onDidChangeInputState.event;
public static readonly ID = 'chatContextAttachments';
get id() {
@ -30,13 +34,18 @@ export class ChatContextAttachments extends Disposable implements IChatWidgetCon
}));
}
getInputState?() {
getInputState(): IChatRequestVariableEntry[] {
return [...this._attachedContext.values()];
}
setInputState?(s: any): void {
setInputState(s: any): void {
if (!Array.isArray(s)) {
return;
s = [];
}
this._attachedContext.clear();
for (const attachment of s) {
this._attachedContext.add(attachment);
}
this.widget.setContext(true, ...s);
@ -55,10 +64,12 @@ export class ChatContextAttachments extends Disposable implements IChatWidgetCon
}
this.widget.setContext(overwrite, ...attachments);
this._onDidChangeInputState.fire();
}
private _removeContext(attachment: IChatRequestVariableEntry) {
this._attachedContext.delete(attachment);
this._onDidChangeInputState.fire();
}
private _clearAttachedContext() {

View file

@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { coalesce } from 'vs/base/common/arrays';
import { Emitter } from 'vs/base/common/event';
import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent';
import { Disposable } from 'vs/base/common/lifecycle';
import { basename } from 'vs/base/common/resources';
@ -38,24 +39,30 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC
return ChatDynamicVariableModel.ID;
}
private _onDidChangeInputState = this._register(new Emitter<void>());
readonly onDidChangeInputState = this._onDidChangeInputState.event;
constructor(
private readonly widget: IChatWidget,
@ILabelService private readonly labelService: ILabelService,
@ILogService private readonly logService: ILogService,
) {
super();
this._register(widget.inputEditor.onDidChangeModelContent(e => {
e.changes.forEach(c => {
// Don't mutate entries in _variables, since they will be returned from the getter
const originalNumVariables = this._variables.length;
this._variables = coalesce(this._variables.map(ref => {
const intersection = Range.intersectRanges(ref.range, c.range);
if (intersection && !intersection.isEmpty()) {
// The reference text was changed, it's broken
const rangeToDelete = new Range(ref.range.startLineNumber, ref.range.startColumn, ref.range.endLineNumber, ref.range.endColumn - 1);
this.widget.inputEditor.executeEdits(this.id, [{
range: rangeToDelete,
text: '',
}]);
// The reference text was changed, it's broken.
// But if the whole reference range was deleted (eg history navigation) then don't try to change the editor.
if (!Range.containsRange(c.range, ref.range)) {
const rangeToDelete = new Range(ref.range.startLineNumber, ref.range.startColumn, ref.range.endLineNumber, ref.range.endColumn - 1);
this.widget.inputEditor.executeEdits(this.id, [{
range: rangeToDelete,
text: '',
}]);
}
return null;
} else if (Range.compareRangesUsingStarts(ref.range, c.range) > 0) {
const delta = c.text.length - c.rangeLength;
@ -72,6 +79,10 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC
return ref;
}));
if (this._variables.length !== originalNumVariables) {
this._onDidChangeInputState.fire();
}
});
this.updateDecorations();
@ -84,9 +95,7 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC
setInputState(s: any): void {
if (!Array.isArray(s)) {
// Something went wrong
this.logService.warn('ChatDynamicVariableModel.setInputState called with invalid state: ' + JSON.stringify(s));
return;
s = [];
}
this._variables = s;
@ -96,6 +105,7 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC
addReference(ref: IDynamicVariable): void {
this._variables.push(ref);
this.updateDecorations();
this._onDidChangeInputState.fire();
}
private updateDecorations(): void {

View file

@ -464,7 +464,7 @@ export class InlineChatWidget {
*/
addToHistory(input: string) {
if (this._chatWidget.viewModel?.model === this._defaultChatModel) {
this._chatWidget.input.acceptInput(input);
this._chatWidget.input.acceptInput(true);
}
}

View file

@ -134,7 +134,7 @@ class SCMInput extends Disposable implements ISCMInput {
}
if (!transient) {
this.historyNavigator.add(this._value);
this.historyNavigator.replaceLast(this._value);
this.historyNavigator.add(value);
this.didChangeHistory = true;
}