Merge pull request #308797 from microsoft/joh/private-fields-async-fix

build: fix async private method token fusion; inlineChat: adopt native private fields
This commit is contained in:
Johannes Rieken
2026-04-10 12:49:00 +02:00
committed by GitHub
10 changed files with 758 additions and 621 deletions

View File

@@ -115,7 +115,7 @@ export function convertPrivateFields(code: string, filename: string): ConvertPri
lastEnd = edit.end;
}
parts.push(code.substring(lastEnd));
return { code: parts.join(''), classCount, fieldCount: fieldCount, editCount: edits.length, elapsed: Date.now() - t1, edits };
return { code: parts.join(''), classCount, fieldCount: fieldCount, editCount: edits.length, elapsed: Date.now() - t1, edits };
// --- AST walking ---
@@ -209,10 +209,15 @@ return { code: parts.join(''), classCount, fieldCount: fieldCount, editCount: ed
if (ts.isPrivateIdentifier(child)) {
const resolved = resolvePrivateName(child.text);
if (resolved !== undefined) {
const start = child.getStart(sourceFile);
edits.push({
start: child.getStart(sourceFile),
start,
end: child.getEnd(),
newText: resolved
// In minified code, `async#run()` has no space before `#`.
// The `#` naturally starts a new token, but `$` does not —
// `async$a` would fuse into one identifier. Insert a space
// when the preceding character is an identifier character.
newText: (start > 0 && isIdentifierChar(code.charCodeAt(start - 1))) ? ' ' + resolved : resolved
});
}
return;
@@ -234,6 +239,11 @@ return { code: parts.join(''), classCount, fieldCount: fieldCount, editCount: ed
}
}
function isIdentifierChar(ch: number): boolean {
// a-z, A-Z, 0-9, _, $
return (ch >= 97 && ch <= 122) || (ch >= 65 && ch <= 90) || (ch >= 48 && ch <= 57) || ch === 95 || ch === 36;
}
/**
* Adjusts a source map to account for text edits applied to the generated JS.
*

View File

@@ -292,6 +292,54 @@ suite('convertPrivateFields', () => {
assert.deepStrictEqual(result.edits, []);
});
test('async private method — replacement must not merge with async keyword', async () => {
// In minified output, there is no space between `async` and `#method`:
// class Foo{async#run(){await Promise.resolve(1)}}
// Replacing `#run` with `$a` naively produces `async$a()` which is a
// single identifier, not `async $a()`. The `await` inside then becomes
// invalid because the method is no longer async.
const code = 'class Foo{async#run(){return await Promise.resolve(1)}call(){return this.#run()}}';
const result = convertPrivateFields(code, 'test.js');
assert.ok(!result.code.includes('#run'), 'should replace #run');
// The replacement must NOT fuse with `async` into a single token
assert.doesNotThrow(() => new Function(result.code), 'transformed code must be valid JS');
// Verify it actually executes (the async method should still work)
const exec = new Function(`
${result.code}
return new Foo().call();
`);
const val = await exec();
assert.strictEqual(val, 1);
});
test('async private method — space inserted in declaration and not in usage', () => {
// More readable version: ensure that `async #method()` becomes
// `async $a()` (with space), while `this.#method()` becomes
// `this.$a()` (no extra space needed since `.` separates tokens).
const code = [
'class Foo {',
' async #doWork() { return await 42; }',
' run() { return this.#doWork(); }',
'}',
].join('\n');
const result = convertPrivateFields(code, 'test.js');
assert.ok(!result.code.includes('#doWork'), 'should replace #doWork');
assert.doesNotThrow(() => new Function(result.code), 'transformed code must be valid JS');
});
test('static async private method — no token fusion', async () => {
const code = 'class Foo{static async#init(){return await Promise.resolve(1)}static go(){return Foo.#init()}}';
const result = convertPrivateFields(code, 'test.js');
assert.doesNotThrow(() => new Function(result.code),
'static async private method must produce valid JS, got:\n' + result.code);
const exec = new Function(`
${result.code}
return Foo.go();
`);
const value = await exec();
assert.strictEqual(value, 1);
});
test('heritage clause — extends expression resolves outer private field, not inner', () => {
const code = [
'class Outer {',

View File

@@ -112,64 +112,94 @@ export class InlineChatController implements IEditorContribution {
* Stores the user's explicitly chosen model (qualified name) from a previous inline chat request in the same session.
* When set, this takes priority over the inlineChat.defaultModel setting.
*/
private static _userSelectedModel: string | undefined;
static #userSelectedModel: string | undefined;
private readonly _store = new DisposableStore();
private readonly _isActiveController = observableValue(this, false);
private readonly _renderMode: IObservable<'zone' | 'hover'>;
private readonly _zone: Lazy<InlineChatZoneWidget>;
readonly #store = new DisposableStore();
readonly #isActiveController = observableValue(this, false);
readonly #renderMode: IObservable<'zone' | 'hover'>;
readonly #zone: Lazy<InlineChatZoneWidget>;
readonly inputOverlayWidget: InlineChatAffordance;
private readonly _inputWidget: InlineChatInputWidget;
readonly #inputWidget: InlineChatInputWidget;
private readonly _currentSession: IObservable<IInlineChatSession2 | undefined>;
readonly #currentSession: IObservable<IInlineChatSession2 | undefined>;
readonly #editor: ICodeEditor;
readonly #instaService: IInstantiationService;
readonly #notebookEditorService: INotebookEditorService;
readonly #inlineChatSessionService: IInlineChatSessionService;
readonly #configurationService: IConfigurationService;
readonly #webContentExtractorService: ISharedWebContentExtractorService;
readonly #fileService: IFileService;
readonly #chatAttachmentResolveService: IChatAttachmentResolveService;
readonly #editorService: IEditorService;
readonly #markerDecorationsService: IMarkerDecorationsService;
readonly #languageModelService: ILanguageModelsService;
readonly #logService: ILogService;
readonly #chatEditingService: IChatEditingService;
readonly #chatService: IChatService;
get widget(): EditorBasedInlineChatWidget {
return this._zone.value.widget;
return this.#zone.value.widget;
}
get isActive() {
return Boolean(this._currentSession.get());
return Boolean(this.#currentSession.get());
}
get inputWidget(): InlineChatInputWidget {
return this._inputWidget;
return this.#inputWidget;
}
constructor(
private readonly _editor: ICodeEditor,
@IInstantiationService private readonly _instaService: IInstantiationService,
@INotebookEditorService private readonly _notebookEditorService: INotebookEditorService,
@IInlineChatSessionService private readonly _inlineChatSessionService: IInlineChatSessionService,
editor: ICodeEditor,
@IInstantiationService instaService: IInstantiationService,
@INotebookEditorService notebookEditorService: INotebookEditorService,
@IInlineChatSessionService inlineChatSessionService: IInlineChatSessionService,
@ICodeEditorService codeEditorService: ICodeEditorService,
@IContextKeyService contextKeyService: IContextKeyService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@ISharedWebContentExtractorService private readonly _webContentExtractorService: ISharedWebContentExtractorService,
@IFileService private readonly _fileService: IFileService,
@IChatAttachmentResolveService private readonly _chatAttachmentResolveService: IChatAttachmentResolveService,
@IEditorService private readonly _editorService: IEditorService,
@IMarkerDecorationsService private readonly _markerDecorationsService: IMarkerDecorationsService,
@ILanguageModelsService private readonly _languageModelService: ILanguageModelsService,
@ILogService private readonly _logService: ILogService,
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
@IChatService private readonly _chatService: IChatService,
@IConfigurationService configurationService: IConfigurationService,
@ISharedWebContentExtractorService webContentExtractorService: ISharedWebContentExtractorService,
@IFileService fileService: IFileService,
@IChatAttachmentResolveService chatAttachmentResolveService: IChatAttachmentResolveService,
@IEditorService editorService: IEditorService,
@IMarkerDecorationsService markerDecorationsService: IMarkerDecorationsService,
@ILanguageModelsService languageModelService: ILanguageModelsService,
@ILogService logService: ILogService,
@IChatEditingService chatEditingService: IChatEditingService,
@IChatService chatService: IChatService,
) {
const editorObs = observableCodeEditor(_editor);
this.#editor = editor;
this.#instaService = instaService;
this.#notebookEditorService = notebookEditorService;
this.#inlineChatSessionService = inlineChatSessionService;
this.#configurationService = configurationService;
this.#webContentExtractorService = webContentExtractorService;
this.#fileService = fileService;
this.#chatAttachmentResolveService = chatAttachmentResolveService;
this.#editorService = editorService;
this.#markerDecorationsService = markerDecorationsService;
this.#languageModelService = languageModelService;
this.#logService = logService;
this.#chatEditingService = chatEditingService;
this.#chatService = chatService;
const editorObs = observableCodeEditor(editor);
const ctxInlineChatVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService);
const ctxFileBelongsToChat = CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.bindTo(contextKeyService);
const ctxPendingConfirmation = CTX_INLINE_CHAT_PENDING_CONFIRMATION.bindTo(contextKeyService);
const ctxTerminated = CTX_INLINE_CHAT_TERMINATED.bindTo(contextKeyService);
const notebookAgentConfig = observableConfigValue(InlineChatConfigKeys.notebookAgent, false, this._configurationService);
this._renderMode = observableConfigValue(InlineChatConfigKeys.RenderMode, 'zone', this._configurationService);
const notebookAgentConfig = observableConfigValue(InlineChatConfigKeys.notebookAgent, false, this.#configurationService);
this.#renderMode = observableConfigValue(InlineChatConfigKeys.RenderMode, 'zone', this.#configurationService);
// Track whether the current editor's file is being edited by any chat editing session
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
const model = editorObs.model.read(r);
if (!model) {
ctxFileBelongsToChat.set(false);
return;
}
const sessions = this._chatEditingService.editingSessionsObs.read(r);
const sessions = this.#chatEditingService.editingSessionsObs.read(r);
let hasEdits = false;
for (const session of sessions) {
const entries = session.entries.read(r);
@@ -186,25 +216,25 @@ export class InlineChatController implements IEditorContribution {
ctxFileBelongsToChat.set(hasEdits);
}));
const overlayWidget = this._inputWidget = this._store.add(this._instaService.createInstance(InlineChatInputWidget, editorObs));
const sessionOverlayWidget = this._store.add(this._instaService.createInstance(InlineChatSessionOverlayWidget, editorObs));
this.inputOverlayWidget = this._store.add(this._instaService.createInstance(InlineChatAffordance, this._editor, overlayWidget));
const overlayWidget = this.#inputWidget = this.#store.add(this.#instaService.createInstance(InlineChatInputWidget, editorObs));
const sessionOverlayWidget = this.#store.add(this.#instaService.createInstance(InlineChatSessionOverlayWidget, editorObs));
this.inputOverlayWidget = this.#store.add(this.#instaService.createInstance(InlineChatAffordance, this.#editor, overlayWidget));
this._zone = new Lazy<InlineChatZoneWidget>(() => {
this.#zone = new Lazy<InlineChatZoneWidget>(() => {
assertType(this._editor.hasModel(), '[Illegal State] widget should only be created when the editor has a model');
assertType(this.#editor.hasModel(), '[Illegal State] widget should only be created when the editor has a model');
const location: IChatWidgetLocationOptions = {
location: ChatAgentLocation.EditorInline,
resolveData: () => {
assertType(this._editor.hasModel());
const wholeRange = this._editor.getSelection();
const document = this._editor.getModel().uri;
assertType(this.#editor.hasModel());
const wholeRange = this.#editor.getSelection();
const document = this.#editor.getModel().uri;
return {
type: ChatAgentLocation.EditorInline,
id: getEditorId(this._editor, this._editor.getModel()),
selection: this._editor.getSelection(),
id: getEditorId(this.#editor, this.#editor.getModel()),
selection: this.#editor.getSelection(),
document,
wholeRange
};
@@ -214,22 +244,22 @@ export class InlineChatController implements IEditorContribution {
// inline chat in notebooks
// check if this editor is part of a notebook editor
// if so, update the location and use the notebook specific widget
const notebookEditor = this._notebookEditorService.getNotebookForPossibleCell(this._editor);
const notebookEditor = this.#notebookEditorService.getNotebookForPossibleCell(this.#editor);
if (!!notebookEditor) {
location.location = ChatAgentLocation.Notebook;
if (notebookAgentConfig.get()) {
location.resolveData = () => {
assertType(this._editor.hasModel());
assertType(this.#editor.hasModel());
return {
type: ChatAgentLocation.Notebook,
sessionInputUri: this._editor.getModel().uri,
sessionInputUri: this.#editor.getModel().uri,
};
};
}
}
const result = this._instaService.createInstance(InlineChatZoneWidget,
const result = this.#instaService.createInstance(InlineChatZoneWidget,
location,
{
enableWorkingSet: 'implicit',
@@ -249,33 +279,33 @@ export class InlineChatController implements IEditorContribution {
},
defaultMode: ChatMode.Ask
},
{ editor: this._editor, notebookEditor },
{ editor: this.#editor, notebookEditor },
() => Promise.resolve(),
);
this._store.add(result);
this.#store.add(result);
result.domNode.classList.add('inline-chat-2');
return result;
});
const sessionsSignal = observableSignalFromEvent(this, _inlineChatSessionService.onDidChangeSessions);
const sessionsSignal = observableSignalFromEvent(this, inlineChatSessionService.onDidChangeSessions);
this._currentSession = derived(r => {
this.#currentSession = derived(r => {
sessionsSignal.read(r);
const model = editorObs.model.read(r);
const session = model && _inlineChatSessionService.getSessionByTextModel(model.uri);
const session = model && inlineChatSessionService.getSessionByTextModel(model.uri);
return session ?? undefined;
});
let lastSession: IInlineChatSession2 | undefined = undefined;
this._store.add(autorun(r => {
const session = this._currentSession.read(r);
this.#store.add(autorun(r => {
const session = this.#currentSession.read(r);
if (!session) {
this._isActiveController.set(false, undefined);
this.#isActiveController.set(false, undefined);
if (lastSession && !lastSession.chatModel.hasRequests) {
const state = lastSession.chatModel.inputModel.state.read(undefined);
@@ -291,23 +321,24 @@ export class InlineChatController implements IEditorContribution {
let foundOne = false;
for (const editor of codeEditorService.listCodeEditors()) {
if (Boolean(InlineChatController.get(editor)?._isActiveController.read(undefined))) {
const ctrl = InlineChatController.get(editor);
if (ctrl && ctrl.#isActiveController.read(undefined)) {
foundOne = true;
break;
}
}
if (!foundOne && editorObs.isFocused.read(r)) {
this._isActiveController.set(true, undefined);
this.#isActiveController.set(true, undefined);
}
}));
const visibleSessionObs = observableValue<IInlineChatSession2 | undefined>(this, undefined);
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
const model = editorObs.model.read(r);
const session = this._currentSession.read(r);
const isActive = this._isActiveController.read(r);
const session = this.#currentSession.read(r);
const isActive = this.#isActiveController.read(r);
if (!session || !isActive || !model) {
visibleSessionObs.set(undefined, undefined);
@@ -322,42 +353,42 @@ export class InlineChatController implements IEditorContribution {
: localize('placeholderWithSelection', "Modify selected code");
});
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
const session = visibleSessionObs.read(r);
ctxTerminated.set(!!session?.terminationState.read(r));
}));
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
// HIDE/SHOW
const session = visibleSessionObs.read(r);
const renderMode = this._renderMode.read(r);
const renderMode = this.#renderMode.read(r);
if (!session) {
this._zone.rawValue?.hide();
this._zone.rawValue?.widget.chatWidget.setModel(undefined);
_editor.focus();
this.#zone.rawValue?.hide();
this.#zone.rawValue?.widget.chatWidget.setModel(undefined);
editor.focus();
ctxInlineChatVisible.reset();
} else if (renderMode === 'hover') {
// hover mode: no zone widget needed, keep focus in editor
ctxInlineChatVisible.set(true);
} else {
ctxInlineChatVisible.set(true);
this._zone.value.widget.chatWidget.setModel(session.chatModel);
if (!this._zone.value.position) {
this._zone.value.widget.chatWidget.setInputPlaceholder(defaultPlaceholderObs.read(r));
this._zone.value.widget.chatWidget.input.renderAttachedContext(); // TODO - fights layout bug
this._zone.value.show(session.initialPosition);
this.#zone.value.widget.chatWidget.setModel(session.chatModel);
if (!this.#zone.value.position) {
this.#zone.value.widget.chatWidget.setInputPlaceholder(defaultPlaceholderObs.read(r));
this.#zone.value.widget.chatWidget.input.renderAttachedContext(); // TODO - fights layout bug
this.#zone.value.show(session.initialPosition);
}
this._zone.value.reveal(this._zone.value.position!);
this._zone.value.widget.focus();
this.#zone.value.reveal(this.#zone.value.position!);
this.#zone.value.widget.focus();
}
}));
// Show progress overlay widget in hover mode when a request is in progress or edits are not yet settled
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
const session = visibleSessionObs.read(r);
const renderMode = this._renderMode.read(r);
const renderMode = this.#renderMode.read(r);
if (!session || renderMode !== 'hover') {
ctxPendingConfirmation.set(false);
sessionOverlayWidget.hide();
@@ -380,7 +411,7 @@ export class InlineChatController implements IEditorContribution {
}
}));
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
const session = visibleSessionObs.read(r);
if (session) {
const entries = session.editingSession.entries.read(r);
@@ -398,7 +429,7 @@ export class InlineChatController implements IEditorContribution {
for (const entry of otherEntries) {
// OPEN other modified files in side group. This is a workaround, temp-solution until we have no more backend
// that modifies other files
this._editorService.openEditor({ resource: entry.modifiedURI }, SIDE_GROUP).catch(onUnexpectedError);
this.#editorService.openEditor({ resource: entry.modifiedURI }, SIDE_GROUP).catch(onUnexpectedError);
}
}
}));
@@ -419,40 +450,40 @@ export class InlineChatController implements IEditorContribution {
});
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
const session = visibleSessionObs.read(r);
const response = lastResponseObs.read(r);
const terminationState = session?.terminationState.read(r);
this._zone.rawValue?.widget.updateInfo('');
this.#zone.rawValue?.widget.updateInfo('');
if (!response?.isInProgress.read(r)) {
if (response?.result?.errorDetails) {
// ERROR case
this._zone.rawValue?.widget.updateInfo(`$(error) ${response.result.errorDetails.message}`);
this.#zone.rawValue?.widget.updateInfo(`$(error) ${response.result.errorDetails.message}`);
alert(response.result.errorDetails.message);
} else if (terminationState) {
this._zone.rawValue?.widget.updateInfo(`$(info) ${renderAsPlaintext(terminationState)}`);
this.#zone.rawValue?.widget.updateInfo(`$(info) ${renderAsPlaintext(terminationState)}`);
}
// no response or not in progress
this._zone.rawValue?.widget.domNode.classList.toggle('request-in-progress', false);
this._zone.rawValue?.widget.chatWidget.setInputPlaceholder(defaultPlaceholderObs.read(r));
this.#zone.rawValue?.widget.domNode.classList.toggle('request-in-progress', false);
this.#zone.rawValue?.widget.chatWidget.setInputPlaceholder(defaultPlaceholderObs.read(r));
} else {
this._zone.rawValue?.widget.domNode.classList.toggle('request-in-progress', true);
this.#zone.rawValue?.widget.domNode.classList.toggle('request-in-progress', true);
let placeholder = response.request?.message.text;
const lastProgress = lastResponseProgressObs.read(r);
if (lastProgress) {
placeholder = renderAsPlaintext(lastProgress.content);
}
this._zone.rawValue?.widget.chatWidget.setInputPlaceholder(placeholder || localize('loading', "Working..."));
this.#zone.rawValue?.widget.chatWidget.setInputPlaceholder(placeholder || localize('loading', "Working..."));
}
}));
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
const session = visibleSessionObs.read(r);
if (!session) {
return;
@@ -465,25 +496,25 @@ export class InlineChatController implements IEditorContribution {
}));
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
const session = visibleSessionObs.read(r);
const entry = session?.editingSession.readEntry(session.uri, r);
// make sure there is an editor integration
const pane = this._editorService.visibleEditorPanes.find(candidate => candidate.getControl() === this._editor || isNotebookWithCellEditor(candidate, this._editor));
const pane = this.#editorService.visibleEditorPanes.find(candidate => candidate.getControl() === this.#editor || isNotebookWithCellEditor(candidate, this.#editor));
if (pane && entry) {
entry?.getEditorIntegration(pane);
}
// make sure the ZONE isn't inbetween a diff and move above if so
if (entry?.diffInfo && this._zone.rawValue?.position) {
const { position } = this._zone.rawValue;
if (entry?.diffInfo && this.#zone.rawValue?.position) {
const { position } = this.#zone.rawValue;
const diff = entry.diffInfo.read(r);
for (const change of diff.changes) {
if (change.modified.contains(position.lineNumber)) {
this._zone.rawValue?.updatePositionAndHeight(new Position(change.modified.startLineNumber - 1, 1));
this.#zone.rawValue?.updatePositionAndHeight(new Position(change.modified.startLineNumber - 1, 1));
break;
}
}
@@ -492,35 +523,35 @@ export class InlineChatController implements IEditorContribution {
}
dispose(): void {
this._store.dispose();
this.#store.dispose();
}
getWidgetPosition(): Position | undefined {
return this._zone.rawValue?.position;
return this.#zone.rawValue?.position;
}
focus() {
this._zone.rawValue?.widget.focus();
this.#zone.rawValue?.widget.focus();
}
async run(arg?: InlineChatRunOptions): Promise<boolean> {
assertType(this._editor.hasModel());
const uri = this._editor.getModel().uri;
assertType(this.#editor.hasModel());
const uri = this.#editor.getModel().uri;
const existingSession = this._inlineChatSessionService.getSessionByTextModel(uri);
const existingSession = this.#inlineChatSessionService.getSessionByTextModel(uri);
if (existingSession) {
await existingSession.editingSession.accept();
existingSession.dispose();
}
this._isActiveController.set(true, undefined);
this.#isActiveController.set(true, undefined);
const session = this._inlineChatSessionService.createSession(this._editor);
const session = this.#inlineChatSessionService.createSession(this.#editor);
if (this._renderMode.get() === 'hover') {
return this._runHover(session, arg);
if (this.#renderMode.get() === 'hover') {
return this.#runHover(session, arg);
} else {
return this._runZone(session, arg);
return this.#runZone(session, arg);
}
}
@@ -528,40 +559,40 @@ export class InlineChatController implements IEditorContribution {
* Hover mode: submit requests directly via IChatService.sendRequest without
* instantiating the zone widget.
*/
private async _runHover(session: IInlineChatSession2, arg?: InlineChatRunOptions): Promise<boolean> {
assertType(this._editor.hasModel());
const uri = this._editor.getModel().uri;
async #runHover(session: IInlineChatSession2, arg?: InlineChatRunOptions): Promise<boolean> {
assertType(this.#editor.hasModel());
const uri = this.#editor.getModel().uri;
// Apply editor adjustments from args
if (arg && InlineChatRunOptions.isInlineChatRunOptions(arg)) {
if (arg.initialRange) {
this._editor.revealRange(arg.initialRange);
this.#editor.revealRange(arg.initialRange);
}
if (arg.initialSelection) {
this._editor.setSelection(arg.initialSelection);
this.#editor.setSelection(arg.initialSelection);
}
}
// Build location data (after selection adjustments)
const { location, locationData } = this._buildLocationData();
const { location, locationData } = this.#buildLocationData();
// Resolve model
let userSelectedModelId: string | undefined;
if (arg?.modelSelector) {
userSelectedModelId = (await this._languageModelService.selectLanguageModels(arg.modelSelector)).sort().at(0);
userSelectedModelId = (await this.#languageModelService.selectLanguageModels(arg.modelSelector)).sort().at(0);
if (!userSelectedModelId) {
throw new Error(`No language models found matching selector: ${JSON.stringify(arg.modelSelector)}.`);
}
} else {
userSelectedModelId = await this._resolveModelId(location);
userSelectedModelId = await this.#resolveModelId(location);
}
// Collect attachments
const attachedContext: IChatRequestVariableEntry[] = [];
if (arg?.attachments) {
for (const attachment of arg.attachments) {
const resolved = await this._chatAttachmentResolveService.resolveImageEditorAttachContext(attachment);
const resolved = await this.#chatAttachmentResolveService.resolveImageEditorAttachContext(attachment);
if (resolved) {
attachedContext.push(resolved);
}
@@ -570,7 +601,7 @@ export class InlineChatController implements IEditorContribution {
// Send the request directly
if (arg?.message && arg.autoSend) {
await this._chatService.sendRequest(
await this.#chatService.sendRequest(
session.chatModel.sessionResource,
arg.message,
{
@@ -606,15 +637,15 @@ export class InlineChatController implements IEditorContribution {
/**
* Zone mode: use the full zone widget and chat widget for request submission.
*/
private async _runZone(session: IInlineChatSession2, arg?: InlineChatRunOptions): Promise<boolean> {
assertType(this._editor.hasModel());
const uri = this._editor.getModel().uri;
async #runZone(session: IInlineChatSession2, arg?: InlineChatRunOptions): Promise<boolean> {
assertType(this.#editor.hasModel());
const uri = this.#editor.getModel().uri;
// Store for tracking model changes during this session
const sessionStore = new DisposableStore();
try {
await this._applyModelDefaults(session, sessionStore);
await this.#applyModelDefaults(session, sessionStore);
if (arg) {
arg.attachDiagnostics ??= true;
@@ -623,52 +654,52 @@ export class InlineChatController implements IEditorContribution {
// ADD diagnostics (only when explicitly requested)
if (arg?.attachDiagnostics) {
const entries: IChatRequestVariableEntry[] = [];
for (const [range, marker] of this._markerDecorationsService.getLiveMarkers(uri)) {
if (range.intersectRanges(this._editor.getSelection())) {
for (const [range, marker] of this.#markerDecorationsService.getLiveMarkers(uri)) {
if (range.intersectRanges(this.#editor.getSelection())) {
const filter = IDiagnosticVariableEntryFilterData.fromMarker(marker);
entries.push(IDiagnosticVariableEntryFilterData.toEntry(filter));
}
}
if (entries.length > 0) {
this._zone.value.widget.chatWidget.attachmentModel.addContext(...entries);
this.#zone.value.widget.chatWidget.attachmentModel.addContext(...entries);
const msg = entries.length > 1
? localize('fixN', "Fix the attached problems")
: localize('fix1', "Fix the attached problem");
this._zone.value.widget.chatWidget.input.setValue(msg, true);
this.#zone.value.widget.chatWidget.input.setValue(msg, true);
arg.message = msg;
this._zone.value.widget.chatWidget.inputEditor.setSelection(new Selection(1, 1, Number.MAX_SAFE_INTEGER, 1));
this.#zone.value.widget.chatWidget.inputEditor.setSelection(new Selection(1, 1, Number.MAX_SAFE_INTEGER, 1));
}
}
// Check args
if (arg && InlineChatRunOptions.isInlineChatRunOptions(arg)) {
if (arg.initialRange) {
this._editor.revealRange(arg.initialRange);
this.#editor.revealRange(arg.initialRange);
}
if (arg.initialSelection) {
this._editor.setSelection(arg.initialSelection);
this.#editor.setSelection(arg.initialSelection);
}
if (arg.attachments) {
await Promise.all(arg.attachments.map(async attachment => {
await this._zone.value.widget.chatWidget.attachmentModel.addFile(attachment);
await this.#zone.value.widget.chatWidget.attachmentModel.addFile(attachment);
}));
delete arg.attachments;
}
if (arg.modelSelector) {
const id = (await this._languageModelService.selectLanguageModels(arg.modelSelector)).sort().at(0);
const id = (await this.#languageModelService.selectLanguageModels(arg.modelSelector)).sort().at(0);
if (!id) {
throw new Error(`No language models found matching selector: ${JSON.stringify(arg.modelSelector)}.`);
}
const model = this._languageModelService.lookupLanguageModel(id);
const model = this.#languageModelService.lookupLanguageModel(id);
if (!model) {
throw new Error(`Language model not loaded: ${id}.`);
}
this._zone.value.widget.chatWidget.input.setCurrentLanguageModel({ metadata: model, identifier: id });
this.#zone.value.widget.chatWidget.input.setCurrentLanguageModel({ metadata: model, identifier: id });
}
if (arg.message) {
this._zone.value.widget.chatWidget.setInput(arg.message);
this.#zone.value.widget.chatWidget.setInput(arg.message);
if (arg.autoSend) {
await this._zone.value.widget.chatWidget.acceptInput();
await this.#zone.value.widget.chatWidget.acceptInput();
}
}
}
@@ -694,7 +725,7 @@ export class InlineChatController implements IEditorContribution {
}
async acceptSession() {
const session = this._currentSession.get();
const session = this.#currentSession.get();
if (!session) {
return;
}
@@ -703,26 +734,26 @@ export class InlineChatController implements IEditorContribution {
}
async rejectSession() {
const session = this._currentSession.get();
const session = this.#currentSession.get();
if (!session) {
return;
}
await this._chatService.cancelCurrentRequestForSession(session.chatModel.sessionResource, 'inlineChatReject');
await this.#chatService.cancelCurrentRequestForSession(session.chatModel.sessionResource, 'inlineChatReject');
await session.editingSession.reject();
session.dispose();
}
async continueSessionInChat(): Promise<void> {
const session = this._currentSession.get();
const session = this.#currentSession.get();
if (!session) {
return;
}
await this._instaService.invokeFunction(continueInPanelChat, session);
await this.#instaService.invokeFunction(continueInPanelChat, session);
}
async rephraseSession(): Promise<void> {
const session = this._currentSession.get();
const session = this.#currentSession.get();
if (!session) {
return;
}
@@ -734,21 +765,21 @@ export class InlineChatController implements IEditorContribution {
return;
}
const selection = this._editor.getSelection();
const selection = this.#editor.getSelection();
const placeholder = selection && !selection.isEmpty()
? localize('placeholderWithSelectionHover', "Describe how to change this")
: localize('placeholderNoSelectionHover', "Describe what to generate");
await this.inputOverlayWidget.showMenuAtSelection(placeholder, requestText);
}
private async _selectVendorDefaultModel(session: IInlineChatSession2): Promise<void> {
const model = this._zone.value.widget.chatWidget.input.selectedLanguageModel.get();
async #selectVendorDefaultModel(session: IInlineChatSession2): Promise<void> {
const model = this.#zone.value.widget.chatWidget.input.selectedLanguageModel.get();
if (model && !model.metadata.isDefaultForLocation[session.chatModel.initialLocation]) {
const ids = await this._languageModelService.selectLanguageModels({ vendor: model.metadata.vendor });
const ids = await this.#languageModelService.selectLanguageModels({ vendor: model.metadata.vendor });
for (const identifier of ids) {
const candidate = this._languageModelService.lookupLanguageModel(identifier);
const candidate = this.#languageModelService.lookupLanguageModel(identifier);
if (candidate?.isDefaultForLocation[session.chatModel.initialLocation]) {
this._zone.value.widget.chatWidget.input.setCurrentLanguageModel({ metadata: candidate, identifier });
this.#zone.value.widget.chatWidget.input.setCurrentLanguageModel({ metadata: candidate, identifier });
break;
}
}
@@ -761,32 +792,32 @@ export class InlineChatController implements IEditorContribution {
*
* Priority: user session choice > inlineChat.defaultModel setting > vendor default for location
*/
private async _resolveModelId(location: ChatAgentLocation): Promise<string | undefined> {
const userSelectedModel = InlineChatController._userSelectedModel;
const defaultModelSetting = this._configurationService.getValue<string>(InlineChatConfigKeys.DefaultModel);
async #resolveModelId(location: ChatAgentLocation): Promise<string | undefined> {
const userSelectedModel = InlineChatController.#userSelectedModel;
const defaultModelSetting = this.#configurationService.getValue<string>(InlineChatConfigKeys.DefaultModel);
// 1. Try user's explicitly chosen model from a previous inline chat
if (userSelectedModel) {
const match = this._languageModelService.lookupLanguageModelByQualifiedName(userSelectedModel);
const match = this.#languageModelService.lookupLanguageModelByQualifiedName(userSelectedModel);
if (match) {
return match.identifier;
}
// Previously selected model is no longer available
InlineChatController._userSelectedModel = undefined;
InlineChatController.#userSelectedModel = undefined;
}
// 2. Try inlineChat.defaultModel setting
if (defaultModelSetting) {
const match = this._languageModelService.lookupLanguageModelByQualifiedName(defaultModelSetting);
const match = this.#languageModelService.lookupLanguageModelByQualifiedName(defaultModelSetting);
if (match) {
return match.identifier;
}
this._logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`);
this.#logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`);
}
// 3. Fall back to vendor default for the given location
for (const id of this._languageModelService.getLanguageModelIds()) {
const metadata = this._languageModelService.lookupLanguageModel(id);
for (const id of this.#languageModelService.getLanguageModelIds()) {
const metadata = this.#languageModelService.lookupLanguageModel(id);
if (metadata?.isDefaultForLocation[location]) {
return id;
}
@@ -798,18 +829,18 @@ export class InlineChatController implements IEditorContribution {
/**
* Builds location data for chat requests without going through the zone widget.
*/
private _buildLocationData(): { location: ChatAgentLocation; locationData: IChatLocationData } {
assertType(this._editor.hasModel());
#buildLocationData(): { location: ChatAgentLocation; locationData: IChatLocationData } {
assertType(this.#editor.hasModel());
const notebookEditor = this._notebookEditorService.getNotebookForPossibleCell(this._editor);
const notebookEditor = this.#notebookEditorService.getNotebookForPossibleCell(this.#editor);
if (notebookEditor) {
const useNotebookAgent = this._configurationService.getValue<boolean>(InlineChatConfigKeys.notebookAgent);
const useNotebookAgent = this.#configurationService.getValue<boolean>(InlineChatConfigKeys.notebookAgent);
if (useNotebookAgent) {
return {
location: ChatAgentLocation.Notebook,
locationData: {
type: ChatAgentLocation.Notebook,
sessionInputUri: this._editor.getModel().uri,
sessionInputUri: this.#editor.getModel().uri,
}
};
}
@@ -819,10 +850,10 @@ export class InlineChatController implements IEditorContribution {
location: ChatAgentLocation.Notebook,
locationData: {
type: ChatAgentLocation.EditorInline,
id: getEditorId(this._editor, this._editor.getModel()),
selection: this._editor.getSelection(),
document: this._editor.getModel().uri,
wholeRange: this._editor.getSelection(),
id: getEditorId(this.#editor, this.#editor.getModel()),
selection: this.#editor.getSelection(),
document: this.#editor.getModel().uri,
wholeRange: this.#editor.getSelection(),
}
};
}
@@ -831,10 +862,10 @@ export class InlineChatController implements IEditorContribution {
location: ChatAgentLocation.EditorInline,
locationData: {
type: ChatAgentLocation.EditorInline,
id: getEditorId(this._editor, this._editor.getModel()),
selection: this._editor.getSelection(),
document: this._editor.getModel().uri,
wholeRange: this._editor.getSelection(),
id: getEditorId(this.#editor, this.#editor.getModel()),
selection: this.#editor.getSelection(),
document: this.#editor.getModel().uri,
wholeRange: this.#editor.getSelection(),
}
};
}
@@ -843,39 +874,39 @@ export class InlineChatController implements IEditorContribution {
* Applies model defaults based on settings and tracks user model changes.
* Prioritization: user session choice > inlineChat.defaultModel setting > vendor default
*/
private async _applyModelDefaults(session: IInlineChatSession2, sessionStore: DisposableStore): Promise<void> {
const userSelectedModel = InlineChatController._userSelectedModel;
const defaultModelSetting = this._configurationService.getValue<string>(InlineChatConfigKeys.DefaultModel);
async #applyModelDefaults(session: IInlineChatSession2, sessionStore: DisposableStore): Promise<void> {
const userSelectedModel = InlineChatController.#userSelectedModel;
const defaultModelSetting = this.#configurationService.getValue<string>(InlineChatConfigKeys.DefaultModel);
let modelApplied = false;
// 1. Try user's explicitly chosen model from a previous inline chat in the same session
if (userSelectedModel) {
modelApplied = this._zone.value.widget.chatWidget.input.switchModelByQualifiedName([userSelectedModel]);
modelApplied = this.#zone.value.widget.chatWidget.input.switchModelByQualifiedName([userSelectedModel]);
if (!modelApplied) {
// User's previously selected model is no longer available, clear it
InlineChatController._userSelectedModel = undefined;
InlineChatController.#userSelectedModel = undefined;
}
}
// 2. Try inlineChat.defaultModel setting
if (!modelApplied && defaultModelSetting) {
modelApplied = this._zone.value.widget.chatWidget.input.switchModelByQualifiedName([defaultModelSetting]);
modelApplied = this.#zone.value.widget.chatWidget.input.switchModelByQualifiedName([defaultModelSetting]);
if (!modelApplied) {
this._logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`);
this.#logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`);
}
}
// 3. Fall back to vendor default
if (!modelApplied) {
await this._selectVendorDefaultModel(session);
await this.#selectVendorDefaultModel(session);
}
// Track model changes - store user's explicit choice in the given sessions.
// NOTE: This currently detects any model change, not just user-initiated ones.
let initialModelId: string | undefined;
sessionStore.add(autorun(r => {
const newModel = this._zone.value.widget.chatWidget.input.selectedLanguageModel.read(r);
const newModel = this.#zone.value.widget.chatWidget.input.selectedLanguageModel.read(r);
if (!newModel) {
return;
}
@@ -885,25 +916,25 @@ export class InlineChatController implements IEditorContribution {
}
if (initialModelId !== newModel.identifier) {
// User explicitly changed model, store their choice as qualified name
InlineChatController._userSelectedModel = ILanguageModelChatMetadata.asQualifiedName(newModel.metadata);
InlineChatController.#userSelectedModel = ILanguageModelChatMetadata.asQualifiedName(newModel.metadata);
initialModelId = newModel.identifier;
}
}));
}
async createImageAttachment(attachment: URI): Promise<IChatRequestVariableEntry | undefined> {
const value = this._currentSession.get();
const value = this.#currentSession.get();
if (!value) {
return undefined;
}
if (attachment.scheme === Schemas.file) {
if (await this._fileService.canHandleResource(attachment)) {
return await this._chatAttachmentResolveService.resolveImageEditorAttachContext(attachment);
if (await this.#fileService.canHandleResource(attachment)) {
return await this.#chatAttachmentResolveService.resolveImageEditorAttachContext(attachment);
}
} else if (attachment.scheme === Schemas.http || attachment.scheme === Schemas.https) {
const extractedImages = await this._webContentExtractorService.readImage(attachment, CancellationToken.None);
const extractedImages = await this.#webContentExtractorService.readImage(attachment, CancellationToken.None);
if (extractedImages) {
return await this._chatAttachmentResolveService.resolveImageEditorAttachContext(attachment, extractedImages);
return await this.#chatAttachmentResolveService.resolveImageEditorAttachContext(attachment, extractedImages);
}
}
return undefined;

View File

@@ -33,12 +33,13 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j
class QuickFixActionViewItem extends MenuEntryActionViewItem {
private readonly _lightBulbStore = this._store.add(new MutableDisposable<DisposableStore>());
private _currentTitle: string | undefined;
readonly #lightBulbStore = this._store.add(new MutableDisposable<DisposableStore>());
#currentTitle: string | undefined;
readonly #editor: ICodeEditor;
constructor(
action: MenuItemAction,
private readonly _editor: ICodeEditor,
editor: ICodeEditor,
@IKeybindingService keybindingService: IKeybindingService,
@INotificationService notificationService: INotificationService,
@IContextKeyService contextKeyService: IContextKeyService,
@@ -55,7 +56,7 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem {
elementGetter: () => HTMLElement | undefined = () => undefined;
override async run(...args: unknown[]): Promise<void> {
const controller = CodeActionController.get(_editor);
const controller = CodeActionController.get(editor);
const info = controller?.lightBulbState.get();
const element = this.elementGetter();
if (controller && info && element) {
@@ -67,26 +68,27 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem {
super(wrappedAction, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService);
this.#editor = editor;
wrappedAction.elementGetter = () => this.element;
}
override render(container: HTMLElement): void {
super.render(container);
this._updateFromLightBulb();
this.#updateFromLightBulb();
}
protected override getTooltip(): string {
return this._currentTitle ?? super.getTooltip();
return this.#currentTitle ?? super.getTooltip();
}
private _updateFromLightBulb(): void {
const controller = CodeActionController.get(this._editor);
#updateFromLightBulb(): void {
const controller = CodeActionController.get(this.#editor);
if (!controller) {
return;
}
const store = new DisposableStore();
this._lightBulbStore.value = store;
this.#lightBulbStore.value = store;
store.add(autorun(reader => {
const info = controller.lightBulbState.read(reader);
@@ -99,7 +101,7 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem {
}
// Update tooltip
this._currentTitle = info?.title;
this.#currentTitle = info?.title;
this.updateTooltip();
}));
}
@@ -107,7 +109,7 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem {
class LabelWithKeybindingActionViewItem extends MenuEntryActionViewItem {
private readonly _kbLabel: string | undefined;
readonly #kbLabel: string | undefined;
constructor(
action: MenuItemAction,
@@ -121,14 +123,14 @@ class LabelWithKeybindingActionViewItem extends MenuEntryActionViewItem {
super(action, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService);
this.options.label = true;
this.options.icon = false;
this._kbLabel = keybindingService.lookupKeybinding(action.id)?.getLabel() ?? undefined;
this.#kbLabel = keybindingService.lookupKeybinding(action.id)?.getLabel() ?? undefined;
}
protected override updateLabel(): void {
if (this.label) {
dom.reset(this.label,
this.action.label,
...(this._kbLabel ? [dom.$('span.inline-chat-keybinding', undefined, this._kbLabel)] : [])
...(this.#kbLabel ? [dom.$('span.inline-chat-keybinding', undefined, this.#kbLabel)] : [])
);
}
}
@@ -140,38 +142,42 @@ class LabelWithKeybindingActionViewItem extends MenuEntryActionViewItem {
*/
export class InlineChatEditorAffordance extends Disposable implements IContentWidget {
private static _idPool = 0;
static #idPool = 0;
private readonly _id = `inline-chat-content-widget-${InlineChatEditorAffordance._idPool++}`;
private readonly _domNode: HTMLElement;
private _position: IContentWidgetPosition | null = null;
private _isVisible = false;
readonly #id = `inline-chat-content-widget-${InlineChatEditorAffordance.#idPool++}`;
readonly #domNode: HTMLElement;
#position: IContentWidgetPosition | null = null;
#isVisible = false;
private readonly _onDidRunAction = this._store.add(new Emitter<string>());
readonly onDidRunAction: Event<string> = this._onDidRunAction.event;
readonly #onDidRunAction = this._store.add(new Emitter<string>());
readonly onDidRunAction: Event<string> = this.#onDidRunAction.event;
readonly allowEditorOverflow = true;
readonly suppressMouseDown = false;
readonly #editor: ICodeEditor;
constructor(
private readonly _editor: ICodeEditor,
editor: ICodeEditor,
selection: IObservable<Selection | undefined>,
@IInstantiationService instantiationService: IInstantiationService,
) {
super();
this.#editor = editor;
// Create the widget DOM
this._domNode = dom.$('.inline-chat-content-widget');
this.#domNode = dom.$('.inline-chat-content-widget');
// Create toolbar with the inline chat start action
const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._domNode, MenuId.InlineChatEditorAffordance, {
const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this.#domNode, MenuId.InlineChatEditorAffordance, {
telemetrySource: 'inlineChatEditorAffordance',
hiddenItemStrategy: HiddenItemStrategy.Ignore,
menuOptions: { renderShortTitle: true },
toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true },
actionViewItemProvider: (action: IAction) => {
if (action instanceof MenuItemAction && action.id === quickFixCommandId) {
return instantiationService.createInstance(QuickFixActionViewItem, action, this._editor);
return instantiationService.createInstance(QuickFixActionViewItem, action, this.#editor);
}
if (action instanceof MenuItemAction && (action.id === ACTION_START || action.id === ACTION_ASK_IN_CHAT || action.id === 'inlineChat.fixDiagnostics')) {
return instantiationService.createInstance(LabelWithKeybindingActionViewItem, action);
@@ -180,50 +186,50 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi
}
}));
this._store.add(toolbar.actionRunner.onDidRun((e) => {
this._onDidRunAction.fire(e.action.id);
this._hide();
this.#onDidRunAction.fire(e.action.id);
this.#hide();
}));
this._store.add(autorun(r => {
const sel = selection.read(r);
if (sel) {
this._show(sel);
this.#show(sel);
} else {
this._hide();
this.#hide();
}
}));
this._store.add(this._editor.onDidScrollChange(() => {
this._store.add(this.#editor.onDidScrollChange(() => {
const sel = selection.get();
if (!sel) {
return;
}
const isInViewport = this._isPositionInViewport();
if (isInViewport && !this._isVisible) {
this._show(sel);
} else if (!isInViewport && this._isVisible) {
this._hide();
const isInViewport = this.#isPositionInViewport();
if (isInViewport && !this.#isVisible) {
this.#show(sel);
} else if (!isInViewport && this.#isVisible) {
this.#hide();
}
}));
}
private _show(selection: Selection): void {
#show(selection: Selection): void {
if (selection.isEmpty()) {
this._showAtLineStart(selection.getPosition().lineNumber);
this.#showAtLineStart(selection.getPosition().lineNumber);
} else {
this._showAtSelection(selection);
this.#showAtSelection(selection);
}
if (this._isVisible) {
this._editor.layoutContentWidget(this);
if (this.#isVisible) {
this.#editor.layoutContentWidget(this);
} else {
this._editor.addContentWidget(this);
this._isVisible = true;
this.#editor.addContentWidget(this);
this.#isVisible = true;
}
}
private _showAtSelection(selection: Selection): void {
#showAtSelection(selection: Selection): void {
const cursorPosition = selection.getPosition();
const direction = selection.getDirection();
@@ -231,20 +237,20 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi
? ContentWidgetPositionPreference.ABOVE
: ContentWidgetPositionPreference.BELOW;
this._position = {
this.#position = {
position: cursorPosition,
preference: [preference],
};
}
private _showAtLineStart(lineNumber: number): void {
const model = this._editor.getModel();
#showAtLineStart(lineNumber: number): void {
const model = this.#editor.getModel();
if (!model) {
return;
}
const tabSize = model.getOptions().tabSize;
const fontInfo = this._editor.getOptions().get(EditorOption.fontInfo);
const fontInfo = this.#editor.getOptions().get(EditorOption.fontInfo);
const lineContent = model.getLineContent(lineNumber);
const indent = computeIndentLevel(lineContent, tabSize);
const lineHasSpace = indent < 0 ? true : fontInfo.spaceWidth * indent > 22;
@@ -267,20 +273,20 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi
const effectiveColumnNumber = /^\S\s*$/.test(model.getLineContent(effectiveLineNumber)) ? 2 : 1;
this._position = {
this.#position = {
position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },
preference: [ContentWidgetPositionPreference.EXACT],
};
}
private _isPositionInViewport(): boolean {
const widgetPosition = this._position?.position;
#isPositionInViewport(): boolean {
const widgetPosition = this.#position?.position;
if (!widgetPosition) {
return false;
}
// Check vertical visibility
const visibleRanges = this._editor.getVisibleRanges();
const visibleRanges = this.#editor.getVisibleRanges();
const isLineVisible = visibleRanges.some(range =>
widgetPosition.lineNumber >= range.startLineNumber && widgetPosition.lineNumber <= range.endLineNumber
);
@@ -289,45 +295,45 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi
}
// Check horizontal visibility
const scrolledPos = this._editor.getScrolledVisiblePosition(widgetPosition);
const scrolledPos = this.#editor.getScrolledVisiblePosition(widgetPosition);
if (!scrolledPos) {
return false;
}
const layoutInfo = this._editor.getOptions().get(EditorOption.layoutInfo);
const layoutInfo = this.#editor.getOptions().get(EditorOption.layoutInfo);
return scrolledPos.left >= 0 && scrolledPos.left <= layoutInfo.width;
}
private _hide(): void {
if (this._isVisible) {
this._isVisible = false;
this._editor.removeContentWidget(this);
#hide(): void {
if (this.#isVisible) {
this.#isVisible = false;
this.#editor.removeContentWidget(this);
}
}
getId(): string {
return this._id;
return this.#id;
}
getDomNode(): HTMLElement {
return this._domNode;
return this.#domNode;
}
getPosition(): IContentWidgetPosition | null {
return this._position;
return this.#position;
}
beforeRender(): IDimension | null {
const position = this._editor.getPosition();
const lineHeight = position ? this._editor.getLineHeightForPosition(position) : this._editor.getOption(EditorOption.lineHeight);
const position = this.#editor.getPosition();
const lineHeight = position ? this.#editor.getLineHeightForPosition(position) : this.#editor.getOption(EditorOption.lineHeight);
this._domNode.style.setProperty('--vscode-inline-chat-affordance-height', `${lineHeight}px`);
this.#domNode.style.setProperty('--vscode-inline-chat-affordance-height', `${lineHeight}px`);
return null;
}
override dispose(): void {
if (this._isVisible) {
this._editor.removeContentWidget(this);
if (this.#isVisible) {
this.#editor.removeContentWidget(this);
}
super.dispose();
}

View File

@@ -27,14 +27,17 @@ const _capacity = 50;
export class InlineChatHistoryService extends Disposable implements IInlineChatHistoryService {
declare readonly _serviceBrand: undefined;
private readonly _history: HistoryNavigator2<string>;
readonly #history: HistoryNavigator2<string>;
readonly #storageService: IStorageService;
constructor(
@IStorageService private readonly _storageService: IStorageService,
@IStorageService storageService: IStorageService,
) {
super();
const raw = this._storageService.get(_storageKey, StorageScope.PROFILE);
this.#storageService = storageService;
const raw = this.#storageService.get(_storageKey, StorageScope.PROFILE);
let entries: string[] = [''];
if (raw) {
try {
@@ -51,44 +54,44 @@ export class InlineChatHistoryService extends Disposable implements IInlineChatH
}
}
this._history = new HistoryNavigator2<string>(entries, _capacity);
this.#history = new HistoryNavigator2<string>(entries, _capacity);
this._store.add(this._storageService.onWillSaveState(() => {
this._saveToStorage();
this._store.add(this.#storageService.onWillSaveState(() => {
this.#saveToStorage();
}));
}
private _saveToStorage(): void {
const values = [...this._history].filter(v => v.length > 0);
#saveToStorage(): void {
const values = [...this.#history].filter(v => v.length > 0);
if (values.length === 0) {
this._storageService.remove(_storageKey, StorageScope.PROFILE);
this.#storageService.remove(_storageKey, StorageScope.PROFILE);
} else {
this._storageService.store(_storageKey, JSON.stringify(values), StorageScope.PROFILE, StorageTarget.USER);
this.#storageService.store(_storageKey, JSON.stringify(values), StorageScope.PROFILE, StorageTarget.USER);
}
}
addToHistory(value: string): void {
this._history.replaceLast(value);
this._history.add('');
this.#history.replaceLast(value);
this.#history.add('');
}
previousValue(): string | undefined {
return this._history.previous();
return this.#history.previous();
}
nextValue(): string | undefined {
return this._history.next();
return this.#history.next();
}
isAtEnd(): boolean {
return this._history.isAtEnd();
return this.#history.isAtEnd();
}
replaceLast(value: string): void {
this._history.replaceLast(value);
this.#history.replaceLast(value);
}
resetCursor(): void {
this._history.resetCursor();
this.#history.resetCursor();
}
}

View File

@@ -14,7 +14,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js
export class InlineChatNotebookContribution {
private readonly _store = new DisposableStore();
readonly #store = new DisposableStore();
constructor(
@IInlineChatSessionService sessionService: IInlineChatSessionService,
@@ -22,7 +22,7 @@ export class InlineChatNotebookContribution {
@INotebookEditorService notebookEditorService: INotebookEditorService,
) {
this._store.add(sessionService.onWillStartSession(newSessionEditor => {
this.#store.add(sessionService.onWillStartSession(newSessionEditor => {
const candidate = CellUri.parse(newSessionEditor.getModel().uri);
if (!candidate) {
return;
@@ -51,6 +51,6 @@ export class InlineChatNotebookContribution {
}
dispose(): void {
this._store.dispose();
this.#store.dispose();
}
}

View File

@@ -50,52 +50,58 @@ import { IInlineChatHistoryService } from './inlineChatHistoryService.js';
*/
export class InlineChatInputWidget extends Disposable {
private readonly _domNode: HTMLElement;
private readonly _container: HTMLElement;
private readonly _inputContainer: HTMLElement;
private readonly _toolbarContainer: HTMLElement;
private readonly _input: IActiveCodeEditor;
private readonly _position = observableValue<IOverlayWidgetPosition | null>(this, null);
readonly position: IObservable<IOverlayWidgetPosition | null> = this._position;
readonly #domNode: HTMLElement;
readonly #container: HTMLElement;
readonly #inputContainer: HTMLElement;
readonly #toolbarContainer: HTMLElement;
readonly #input: IActiveCodeEditor;
readonly #position = observableValue<IOverlayWidgetPosition | null>(this, null);
readonly position: IObservable<IOverlayWidgetPosition | null> = this.#position;
private readonly _showStore = this._store.add(new DisposableStore());
private readonly _stickyScrollHeight: IObservable<number>;
private readonly _layoutData: IObservable<{ totalWidth: number; toolbarWidth: number; height: number; editorPad: number }>;
private _anchorLineNumber: number = 0;
private _anchorLeft: number = 0;
private _anchorAbove: boolean = false;
readonly #showStore = this._store.add(new DisposableStore());
readonly #stickyScrollHeight: IObservable<number>;
readonly #layoutData: IObservable<{ totalWidth: number; toolbarWidth: number; height: number; editorPad: number }>;
#anchorLineNumber: number = 0;
#anchorLeft: number = 0;
#anchorAbove: boolean = false;
readonly #editorObs: ObservableCodeEditor;
readonly #historyService: IInlineChatHistoryService;
constructor(
private readonly _editorObs: ObservableCodeEditor,
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
@IMenuService private readonly _menuService: IMenuService,
editorObs: ObservableCodeEditor,
@IContextKeyService contextKeyService: IContextKeyService,
@IMenuService menuService: IMenuService,
@IInstantiationService instantiationService: IInstantiationService,
@IModelService modelService: IModelService,
@IConfigurationService configurationService: IConfigurationService,
@IInlineChatHistoryService private readonly _historyService: IInlineChatHistoryService,
@IInlineChatHistoryService historyService: IInlineChatHistoryService,
) {
super();
this.#editorObs = editorObs;
this.#historyService = historyService;
// Create container
this._domNode = dom.$('.inline-chat-gutter-menu');
this.#domNode = dom.$('.inline-chat-gutter-menu');
// Create inner container (background + focus border)
this._container = dom.append(this._domNode, dom.$('.inline-chat-gutter-container'));
this.#container = dom.append(this.#domNode, dom.$('.inline-chat-gutter-container'));
// Create input editor container
this._inputContainer = dom.append(this._container, dom.$('.input'));
this.#inputContainer = dom.append(this.#container, dom.$('.input'));
// Create toolbar container
this._toolbarContainer = dom.append(this._container, dom.$('.toolbar'));
this.#toolbarContainer = dom.append(this.#container, dom.$('.toolbar'));
// Create vertical actions bar below the input container
const actionsContainer = dom.append(this._domNode, dom.$('.inline-chat-gutter-actions'));
const actionsContainer = dom.append(this.#domNode, dom.$('.inline-chat-gutter-actions'));
const actionBar = this._store.add(instantiationService.createInstance(WorkbenchActionBar, actionsContainer, {
orientation: ActionsOrientation.VERTICAL,
preventLoopNavigation: true,
telemetrySource: 'inlineChatInput.actionBar',
}));
const actionsMenu = this._store.add(this._menuService.createMenu(MenuId.ChatEditorInlineMenu, this._contextKeyService));
const actionsMenu = this._store.add(menuService.createMenu(MenuId.ChatEditorInlineMenu, contextKeyService));
const updateActions = () => {
const actions = getFlatActionBarActions(actionsMenu.getActions({ shouldForwardArgs: true }));
actionBar.clear();
@@ -130,13 +136,13 @@ export class InlineChatInputWidget extends Disposable {
])
};
this._input = this._store.add(instantiationService.createInstance(CodeEditorWidget, this._inputContainer, options, codeEditorWidgetOptions)) as IActiveCodeEditor;
this.#input = this._store.add(instantiationService.createInstance(CodeEditorWidget, this.#inputContainer, options, codeEditorWidgetOptions)) as IActiveCodeEditor;
const model = this._store.add(modelService.createModel('', null, URI.parse(`gutter-input:${Date.now()}`), true));
this._input.setModel(model);
this.#input.setModel(model);
// Create toolbar
const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._toolbarContainer, MenuId.InlineChatInput, {
const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this.#toolbarContainer, MenuId.InlineChatInput, {
telemetrySource: 'inlineChatInput.toolbar',
hiddenItemStrategy: HiddenItemStrategy.NoHide,
toolbarOptions: {
@@ -146,8 +152,8 @@ export class InlineChatInputWidget extends Disposable {
}));
// Initialize sticky scroll height observable
const stickyScrollController = StickyScrollController.get(this._editorObs.editor);
this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0);
const stickyScrollController = StickyScrollController.get(this.#editorObs.editor);
this.#stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0);
// Track toolbar width changes
const toolbarWidth = observableValue<number>(this, 0);
@@ -157,17 +163,17 @@ export class InlineChatInputWidget extends Disposable {
this._store.add(resizeObserver);
this._store.add(resizeObserver.observe(toolbar.getElement()));
const contentWidth = observableFromEvent(this, this._input.onDidChangeModelContent, () => this._input.getContentWidth());
const contentHeight = observableFromEvent(this, this._input.onDidContentSizeChange, () => this._input.getContentHeight());
const contentWidth = observableFromEvent(this, this.#input.onDidChangeModelContent, () => this.#input.getContentWidth());
const contentHeight = observableFromEvent(this, this.#input.onDidContentSizeChange, () => this.#input.getContentHeight());
this._layoutData = derived(r => {
this.#layoutData = derived(r => {
const editorPad = 6;
const totalWidth = contentWidth.read(r) + editorPad + toolbarWidth.read(r);
const minWidth = 220;
const maxWidth = 600;
const midWidth = Math.round(maxWidth / 1.618);
let clampedWidth: number;
if (this._input.getOption(EditorOption.wordWrap) === 'on') {
if (this.#input.getOption(EditorOption.wordWrap) === 'on') {
clampedWidth = maxWidth;
} else if (totalWidth <= minWidth) {
clampedWidth = minWidth;
@@ -177,12 +183,12 @@ export class InlineChatInputWidget extends Disposable {
clampedWidth = maxWidth;
}
const lineHeight = this._input.getOption(EditorOption.lineHeight);
const lineHeight = this.#input.getOption(EditorOption.lineHeight);
const clampedHeight = Math.min(contentHeight.read(r), (3 * lineHeight));
if (totalWidth > clampedWidth) {
// enable word wrap
this._input.updateOptions({ wordWrap: 'on', });
this.#input.updateOptions({ wordWrap: 'on', });
}
return {
@@ -195,55 +201,55 @@ export class InlineChatInputWidget extends Disposable {
// Update container width and editor layout when width changes
this._store.add(autorun(r => {
const { editorPad, toolbarWidth, totalWidth, height } = this._layoutData.read(r);
const { editorPad, toolbarWidth, totalWidth, height } = this.#layoutData.read(r);
const inputWidth = totalWidth - toolbarWidth - editorPad;
this._container.style.width = `${totalWidth}px`;
this._inputContainer.style.width = `${inputWidth}px`;
this._input.layout({ width: inputWidth, height });
if (this._position.read(undefined) !== null) {
this._updatePosition();
this.#container.style.width = `${totalWidth}px`;
this.#inputContainer.style.width = `${inputWidth}px`;
this.#input.layout({ width: inputWidth, height });
if (this.#position.read(undefined) !== null) {
this.#updatePosition();
}
}));
// Toggle focus class on the container
this._store.add(this._input.onDidFocusEditorText(() => this._container.classList.add('focused')));
this._store.add(this._input.onDidBlurEditorText(() => this._container.classList.remove('focused')));
this._store.add(this.#input.onDidFocusEditorText(() => this.#container.classList.add('focused')));
this._store.add(this.#input.onDidBlurEditorText(() => this.#container.classList.remove('focused')));
// Toggle scroll decoration on the toolbar
this._store.add(this._input.onDidScrollChange(e => {
this._toolbarContainer.classList.toggle('fake-scroll-decoration', e.scrollTop > 0);
this._store.add(this.#input.onDidScrollChange(e => {
this.#toolbarContainer.classList.toggle('fake-scroll-decoration', e.scrollTop > 0);
}));
// Track input text for context key and adjust width based on content
const inputHasText = CTX_INLINE_CHAT_INPUT_HAS_TEXT.bindTo(this._contextKeyService);
this._store.add(this._input.onDidChangeModelContent(() => {
inputHasText.set(this._input.getModel().getValue().trim().length > 0);
const inputHasText = CTX_INLINE_CHAT_INPUT_HAS_TEXT.bindTo(contextKeyService);
this._store.add(this.#input.onDidChangeModelContent(() => {
inputHasText.set(this.#input.getModel().getValue().trim().length > 0);
}));
this._store.add(toDisposable(() => inputHasText.reset()));
// Track focus state
const inputWidgetFocused = CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED.bindTo(this._contextKeyService);
this._store.add(this._input.onDidFocusEditorText(() => inputWidgetFocused.set(true)));
this._store.add(this._input.onDidBlurEditorText(() => inputWidgetFocused.set(false)));
const inputWidgetFocused = CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED.bindTo(contextKeyService);
this._store.add(this.#input.onDidFocusEditorText(() => inputWidgetFocused.set(true)));
this._store.add(this.#input.onDidBlurEditorText(() => inputWidgetFocused.set(false)));
this._store.add(toDisposable(() => inputWidgetFocused.reset()));
// Handle key events: ArrowUp/ArrowDown for history navigation and action bar focus
this._store.add(this._input.onKeyDown(e => {
this._store.add(this.#input.onKeyDown(e => {
if (e.keyCode === KeyCode.UpArrow) {
const position = this._input.getPosition();
const position = this.#input.getPosition();
if (position && position.lineNumber === 1) {
this._showPreviousHistoryValue();
this.#showPreviousHistoryValue();
e.preventDefault();
e.stopPropagation();
}
} else if (e.keyCode === KeyCode.DownArrow) {
const model = this._input.getModel();
const position = this._input.getPosition();
const model = this.#input.getModel();
const position = this.#input.getPosition();
if (position && position.lineNumber === model.getLineCount()) {
if (!this._historyService.isAtEnd()) {
this._showNextHistoryValue();
if (!this.#historyService.isAtEnd()) {
this.#showNextHistoryValue();
e.preventDefault();
e.stopPropagation();
} else if (!actionBar.isEmpty()) {
@@ -268,41 +274,41 @@ export class InlineChatInputWidget extends Disposable {
if (firstItem?.element && dom.isAncestorOfActiveElement(firstItem.element)) {
event.preventDefault();
event.stopPropagation();
this._input.focus();
this.#input.focus();
}
}
}, true));
// Track focus - hide when focus leaves
const focusTracker = this._store.add(dom.trackFocus(this._domNode));
const focusTracker = this._store.add(dom.trackFocus(this.#domNode));
this._store.add(focusTracker.onDidBlur(() => this.hide()));
}
get value(): string {
return this._input.getModel().getValue().trim();
return this.#input.getModel().getValue().trim();
}
addToHistory(value: string): void {
this._historyService.addToHistory(value);
this.#historyService.addToHistory(value);
}
private _showPreviousHistoryValue(): void {
if (this._historyService.isAtEnd()) {
this._historyService.replaceLast(this._input.getModel().getValue());
#showPreviousHistoryValue(): void {
if (this.#historyService.isAtEnd()) {
this.#historyService.replaceLast(this.#input.getModel().getValue());
}
const value = this._historyService.previousValue();
const value = this.#historyService.previousValue();
if (value !== undefined) {
this._input.getModel().setValue(value);
this.#input.getModel().setValue(value);
}
}
private _showNextHistoryValue(): void {
if (this._historyService.isAtEnd()) {
#showNextHistoryValue(): void {
if (this.#historyService.isAtEnd()) {
return;
}
const value = this._historyService.nextValue();
const value = this.#historyService.nextValue();
if (value !== undefined) {
this._input.getModel().setValue(value);
this.#input.getModel().setValue(value);
}
}
@@ -313,109 +319,105 @@ export class InlineChatInputWidget extends Disposable {
* @param anchorAbove Whether to anchor above the position (widget grows upward)
*/
show(lineNumber: number, left: number, anchorAbove: boolean, placeholder: string, value?: string): void {
this._showStore.clear();
this.#showStore.clear();
// Reset history cursor to the end (current uncommitted text)
this._historyService.resetCursor();
this.#historyService.resetCursor();
// Clear input state
this._input.updateOptions({ wordWrap: 'off', placeholder });
this._input.getModel().setValue(value ?? '');
this.#input.updateOptions({ wordWrap: 'off', placeholder });
this.#input.getModel().setValue(value ?? '');
// Store anchor info for scroll updates
this._anchorLineNumber = lineNumber;
this._anchorLeft = left;
this._anchorAbove = anchorAbove;
this.#anchorLineNumber = lineNumber;
this.#anchorLeft = left;
this.#anchorAbove = anchorAbove;
// Set initial position
this._updatePosition();
this.#updatePosition();
// Create overlay widget via observable pattern
this._showStore.add(this._editorObs.createOverlayWidget({
domNode: this._domNode,
position: this._position,
this.#showStore.add(this.#editorObs.createOverlayWidget({
domNode: this.#domNode,
position: this.#position,
minContentWidthInPx: constObservable(0),
allowEditorOverflow: true,
}));
// Re-adjust position after render to account for widget dimensions (offsetWidth/offsetHeight
// are only available after the widget is added to the DOM)
this._updatePosition();
this.#updatePosition();
// Update position on scroll, hide if anchor line is out of view (only when input is empty)
this._showStore.add(this._editorObs.editor.onDidScrollChange(() => {
const visibleRanges = this._editorObs.editor.getVisibleRanges();
this.#showStore.add(this.#editorObs.editor.onDidScrollChange(() => {
const visibleRanges = this.#editorObs.editor.getVisibleRanges();
const isLineVisible = visibleRanges.some(range =>
this._anchorLineNumber >= range.startLineNumber && this._anchorLineNumber <= range.endLineNumber
this.#anchorLineNumber >= range.startLineNumber && this.#anchorLineNumber <= range.endLineNumber
);
const hasContent = !!this._input.getModel().getValue();
const hasContent = !!this.#input.getModel().getValue();
if (!isLineVisible && !hasContent) {
this.hide();
} else {
this._updatePosition();
this.#updatePosition();
}
}));
// Update position when the editor resizes (e.g. sidebar toggle, window resize)
this._showStore.add(this._editorObs.editor.onDidLayoutChange(() => {
this._updatePosition();
this.#showStore.add(this.#editorObs.editor.onDidLayoutChange(() => {
this.#updatePosition();
}));
// Focus the input editor
setTimeout(() => {
this._input.focus();
this.#input.focus();
if (value) {
this._input.setSelection(this._input.getModel().getFullModelRange());
this.#input.setSelection(this.#input.getModel().getFullModelRange());
}
}, 0);
}
private _updatePosition(): void {
const editor = this._editorObs.editor;
#updatePosition(): void {
const editor = this.#editorObs.editor;
const lineHeight = editor.getOption(EditorOption.lineHeight);
const top = editor.getTopForLineNumber(this._anchorLineNumber) - editor.getScrollTop();
const top = editor.getTopForLineNumber(this.#anchorLineNumber) - editor.getScrollTop();
let adjustedTop = top;
if (this._anchorAbove) {
const widgetHeight = this._domNode.offsetHeight;
if (this.#anchorAbove) {
const widgetHeight = this.#domNode.offsetHeight;
adjustedTop = top - widgetHeight;
} else {
adjustedTop = top + lineHeight;
}
// Clamp to viewport bounds when anchor line is out of view
const stickyScrollHeight = this._stickyScrollHeight.get();
const stickyScrollHeight = this.#stickyScrollHeight.get();
const layoutInfo = editor.getLayoutInfo();
const widgetHeight = this._domNode.offsetHeight;
const widgetWidth = this._domNode.offsetWidth;
const widgetHeight = this.#domNode.offsetHeight;
const widgetWidth = this.#domNode.offsetWidth;
const minTop = stickyScrollHeight;
const maxTop = layoutInfo.height - widgetHeight;
const padding = 8;
const maxLeft = layoutInfo.width - layoutInfo.verticalScrollbarWidth - layoutInfo.minimap.minimapWidth - widgetWidth - padding;
const clampedTop = Math.max(minTop, Math.min(adjustedTop, maxTop));
const clampedLeft = Math.max(0, Math.min(this._anchorLeft, maxLeft));
const isClamped = clampedTop !== adjustedTop || clampedLeft !== this._anchorLeft;
this._domNode.classList.toggle('clamped', isClamped);
const clampedLeft = Math.max(0, Math.min(this.#anchorLeft, maxLeft));
const isClamped = clampedTop !== adjustedTop || clampedLeft !== this.#anchorLeft;
this.#domNode.classList.toggle('clamped', isClamped);
this._position.set({
this.#position.set({
preference: { top: clampedTop, left: clampedLeft },
stackOrdinal: 10000,
}, undefined);
}
/**
* Hide the widget (removes from editor but does not dispose).
*/
hide(): void {
// Focus editor if focus is still within the editor's DOM
const editorDomNode = this._editorObs.editor.getDomNode();
const editorDomNode = this.#editorObs.editor.getDomNode();
if (editorDomNode && dom.isAncestorOfActiveElement(editorDomNode)) {
this._editorObs.editor.focus();
this.#editorObs.editor.focus();
}
this._position.set(null, undefined);
this._input.getModel().setValue('');
this._showStore.clear();
this.#position.set(null, undefined);
this.#input.getModel().setValue('');
this.#showStore.clear();
}
}
@@ -424,73 +426,83 @@ export class InlineChatInputWidget extends Disposable {
*/
export class InlineChatSessionOverlayWidget extends Disposable {
private readonly _domNode: HTMLElement = document.createElement('div');
private readonly _container: HTMLElement;
private readonly _markdownContainer: HTMLElement;
private readonly _markdownMessage: HTMLElement;
private readonly _markdownScrollable: DomScrollableElement;
private readonly _contentRow: HTMLElement;
private readonly _statusNode: HTMLElement;
private readonly _icon: HTMLElement;
private readonly _message: HTMLElement;
private readonly _toolbarNode: HTMLElement;
readonly #domNode: HTMLElement = document.createElement('div');
readonly #container: HTMLElement;
readonly #markdownContainer: HTMLElement;
readonly #markdownMessage: HTMLElement;
readonly #markdownScrollable: DomScrollableElement;
readonly #contentRow: HTMLElement;
readonly #statusNode: HTMLElement;
readonly #icon: HTMLElement;
readonly #message: HTMLElement;
readonly #toolbarNode: HTMLElement;
private readonly _showStore = this._store.add(new DisposableStore());
private readonly _position = observableValue<IOverlayWidgetPosition | null>(this, null);
private readonly _minContentWidthInPx = constObservable(0);
readonly #showStore = this._store.add(new DisposableStore());
readonly #position = observableValue<IOverlayWidgetPosition | null>(this, null);
readonly #minContentWidthInPx = constObservable(0);
private readonly _stickyScrollHeight: IObservable<number>;
readonly #stickyScrollHeight: IObservable<number>;
readonly #editorObs: ObservableCodeEditor;
readonly #instaService: IInstantiationService;
readonly #keybindingService: IKeybindingService;
readonly #logService: ILogService;
constructor(
private readonly _editorObs: ObservableCodeEditor,
@IInstantiationService private readonly _instaService: IInstantiationService,
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@ILogService private readonly _logService: ILogService,
editorObs: ObservableCodeEditor,
@IInstantiationService instaService: IInstantiationService,
@IKeybindingService keybindingService: IKeybindingService,
@ILogService logService: ILogService,
) {
super();
this._domNode.classList.add('inline-chat-session-overlay-widget');
this.#editorObs = editorObs;
this.#instaService = instaService;
this.#keybindingService = keybindingService;
this.#logService = logService;
this._container = document.createElement('div');
this._domNode.appendChild(this._container);
this._container.classList.add('inline-chat-session-overlay-container');
this.#domNode.classList.add('inline-chat-session-overlay-widget');
this._markdownContainer = document.createElement('div');
this._markdownContainer.classList.add('markdown-scroll-container');
this.#container = document.createElement('div');
this.#domNode.appendChild(this.#container);
this.#container.classList.add('inline-chat-session-overlay-container');
this._markdownMessage = document.createElement('div');
this._markdownMessage.classList.add('markdown-message');
this._markdownContainer.appendChild(this._markdownMessage);
this._markdownScrollable = this._store.add(new DomScrollableElement(this._markdownContainer, {
this.#markdownContainer = document.createElement('div');
this.#markdownContainer.classList.add('markdown-scroll-container');
this.#markdownMessage = document.createElement('div');
this.#markdownMessage.classList.add('markdown-message');
this.#markdownContainer.appendChild(this.#markdownMessage);
this.#markdownScrollable = this._store.add(new DomScrollableElement(this.#markdownContainer, {
consumeMouseWheelIfScrollbarIsNeeded: true,
horizontal: ScrollbarVisibility.Hidden,
vertical: ScrollbarVisibility.Auto,
}));
this._container.appendChild(this._markdownScrollable.getDomNode());
this.#container.appendChild(this.#markdownScrollable.getDomNode());
this._contentRow = document.createElement('div');
this._contentRow.classList.add('content-row');
this._container.appendChild(this._contentRow);
this.#contentRow = document.createElement('div');
this.#contentRow.classList.add('content-row');
this.#container.appendChild(this.#contentRow);
// Create status node with icon and message
this._statusNode = document.createElement('div');
this._statusNode.classList.add('status');
this._icon = dom.append(this._statusNode, dom.$('span'));
this._message = dom.append(this._statusNode, dom.$('span.message'));
this._contentRow.appendChild(this._statusNode);
this.#statusNode = document.createElement('div');
this.#statusNode.classList.add('status');
this.#icon = dom.append(this.#statusNode, dom.$('span'));
this.#message = dom.append(this.#statusNode, dom.$('span.message'));
this.#contentRow.appendChild(this.#statusNode);
// Create toolbar node
this._toolbarNode = document.createElement('div');
this._toolbarNode.classList.add('toolbar');
this.#toolbarNode = document.createElement('div');
this.#toolbarNode.classList.add('toolbar');
// Initialize sticky scroll height observable
const stickyScrollController = StickyScrollController.get(this._editorObs.editor);
this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0);
const stickyScrollController = StickyScrollController.get(this.#editorObs.editor);
this.#stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0);
}
show(session: IInlineChatSession2): void {
assertType(this._editorObs.editor.hasModel());
this._showStore.clear();
assertType(this.#editorObs.editor.hasModel());
this.#showStore.clear();
// Derived entry observable for this session
const entry = derived(r => session.editingSession.readEntry(session.uri, r));
@@ -558,71 +570,71 @@ export class InlineChatSessionOverlayWidget extends Disposable {
}
});
const markdownStore = this._showStore.add(new DisposableStore());
const markdownStore = this.#showStore.add(new DisposableStore());
this._showStore.add(autorun(r => {
this.#showStore.add(autorun(r => {
const value = requestMessage.read(r);
if (value) {
if (value.message && value.icon) {
this._message.innerText = renderAsPlaintext(value.message);
this._icon.className = '';
this._icon.classList.add(...ThemeIcon.asClassNameArray(value.icon));
this._statusNode.classList.remove('hidden');
this._contentRow.classList.remove('status-hidden');
this.#message.innerText = renderAsPlaintext(value.message);
this.#icon.className = '';
this.#icon.classList.add(...ThemeIcon.asClassNameArray(value.icon));
this.#statusNode.classList.remove('hidden');
this.#contentRow.classList.remove('status-hidden');
} else {
this._message.innerText = '';
this._icon.className = '';
this._statusNode.classList.add('hidden');
this._contentRow.classList.add('status-hidden');
this.#message.innerText = '';
this.#icon.className = '';
this.#statusNode.classList.add('hidden');
this.#contentRow.classList.add('status-hidden');
}
markdownStore.clear();
this._markdownMessage.replaceChildren();
this.#markdownMessage.replaceChildren();
if (value.markdown) {
this._markdownScrollable.getDomNode().classList.remove('hidden');
this.#markdownScrollable.getDomNode().classList.remove('hidden');
const markdown = typeof value.markdown === 'string' ? new MarkdownString(value.markdown) : value.markdown;
const rendered = markdownStore.add(renderMarkdown(markdown));
this._markdownMessage.appendChild(rendered.element);
this._markdownScrollable.scanDomNode();
this.#markdownMessage.appendChild(rendered.element);
this.#markdownScrollable.scanDomNode();
} else {
this._markdownScrollable.getDomNode().classList.add('hidden');
this.#markdownScrollable.getDomNode().classList.add('hidden');
}
} else {
this._message.innerText = '';
this._icon.className = '';
this._statusNode.classList.add('hidden');
this._contentRow.classList.add('status-hidden');
this.#message.innerText = '';
this.#icon.className = '';
this.#statusNode.classList.add('hidden');
this.#contentRow.classList.add('status-hidden');
markdownStore.clear();
this._markdownMessage.replaceChildren();
this._markdownScrollable.getDomNode().classList.add('hidden');
this.#markdownMessage.replaceChildren();
this.#markdownScrollable.getDomNode().classList.add('hidden');
}
}));
// Log when pending confirmation changes
this._showStore.add(autorun(r => {
this.#showStore.add(autorun(r => {
const response = session.chatModel.lastRequestObs.read(r)?.response;
const pending = response?.isPendingConfirmation.read(r);
if (pending) {
this._logService.info(`[InlineChat] UNEXPECTED approval needed: ${pending.detail ?? 'unknown'}`);
this.#logService.info(`[InlineChat] UNEXPECTED approval needed: ${pending.detail ?? 'unknown'}`);
}
}));
// Add toolbar
this._contentRow.appendChild(this._toolbarNode);
this._showStore.add(toDisposable(() => this._toolbarNode.remove()));
this.#contentRow.appendChild(this.#toolbarNode);
this.#showStore.add(toDisposable(() => this.#toolbarNode.remove()));
const that = this;
// Focus the owning editor before running any toolbar action so that
// EditorAction2-based actions resolve the correct editor instance
// even when the user has clicked into a different editor.
const actionRunner = this._showStore.add(new class extends ActionRunner {
const actionRunner = this.#showStore.add(new class extends ActionRunner {
protected override async runAction(action: IAction, context?: unknown): Promise<void> {
that._editorObs.editor.focus();
that.#editorObs.editor.focus();
return super.runAction(action, context);
}
});
this._showStore.add(this._instaService.createInstance(MenuWorkbenchToolBar, this._toolbarNode, MenuId.ChatEditorInlineExecute, {
this.#showStore.add(this.#instaService.createInstance(MenuWorkbenchToolBar, this.#toolbarNode, MenuId.ChatEditorInlineExecute, {
telemetrySource: 'inlineChatProgress.overlayToolbar',
hiddenItemStrategy: HiddenItemStrategy.Ignore,
actionRunner,
@@ -639,56 +651,56 @@ export class InlineChatSessionOverlayWidget extends Disposable {
return undefined; // use default action view item with label
}
return new ChatEditingAcceptRejectActionViewItem(action, { ...options, keybinding: undefined }, entry, undefined, that._keybindingService, primaryActions);
return new ChatEditingAcceptRejectActionViewItem(action, { ...options, keybinding: undefined }, entry, undefined, that.#keybindingService, primaryActions);
}
}));
// Position in top right of editor, below sticky scroll
const lineHeight = this._editorObs.getOption(EditorOption.lineHeight);
const lineHeight = this.#editorObs.getOption(EditorOption.lineHeight);
// Track widget width changes
const widgetWidth = observableValue<number>(this, 0);
const resizeObserver = new dom.DisposableResizeObserver(() => {
widgetWidth.set(this._domNode.offsetWidth, undefined);
widgetWidth.set(this.#domNode.offsetWidth, undefined);
});
this._showStore.add(resizeObserver);
this._showStore.add(resizeObserver.observe(this._domNode));
this.#showStore.add(resizeObserver);
this.#showStore.add(resizeObserver.observe(this.#domNode));
this._showStore.add(autorun(r => {
const layoutInfo = this._editorObs.layoutInfo.read(r);
const stickyScrollHeight = this._stickyScrollHeight.read(r);
this.#showStore.add(autorun(r => {
const layoutInfo = this.#editorObs.layoutInfo.read(r);
const stickyScrollHeight = this.#stickyScrollHeight.read(r);
const width = widgetWidth.read(r);
const padding = Math.round(lineHeight.read(r) * 2 / 3);
// Cap max-width to the editor viewport (content area)
const maxWidth = Math.min(400, layoutInfo.contentWidth - 2 * padding);
const maxHeight = Math.min(150, Math.floor(layoutInfo.height / 3));
this._domNode.style.maxWidth = `${maxWidth}px`;
this._markdownScrollable.getDomNode().style.maxHeight = `${maxHeight}px`;
this._markdownContainer.style.maxHeight = `${maxHeight}px`;
this._markdownScrollable.scanDomNode();
this.#domNode.style.maxWidth = `${maxWidth}px`;
this.#markdownScrollable.getDomNode().style.maxHeight = `${maxHeight}px`;
this.#markdownContainer.style.maxHeight = `${maxHeight}px`;
this.#markdownScrollable.scanDomNode();
// Position: top right, below sticky scroll with padding, left of minimap and scrollbar
const top = stickyScrollHeight + padding;
const left = layoutInfo.width - width - layoutInfo.verticalScrollbarWidth - layoutInfo.minimap.minimapWidth - padding;
this._position.set({
this.#position.set({
preference: { top, left },
stackOrdinal: 10000,
}, undefined);
}));
// Create overlay widget
this._showStore.add(this._editorObs.createOverlayWidget({
domNode: this._domNode,
position: this._position,
minContentWidthInPx: this._minContentWidthInPx,
this.#showStore.add(this.#editorObs.createOverlayWidget({
domNode: this.#domNode,
position: this.#position,
minContentWidthInPx: this.#minContentWidthInPx,
allowEditorOverflow: false,
}));
}
hide(): void {
this._position.set(null, undefined);
this._showStore.clear();
this.#position.set(null, undefined);
this.#showStore.clear();
}
}

View File

@@ -41,56 +41,59 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService {
declare _serviceBrand: undefined;
private readonly _store = new DisposableStore();
private readonly _sessions = new ResourceMap<IInlineChatSession2>();
readonly #store = new DisposableStore();
readonly #sessions = new ResourceMap<IInlineChatSession2>();
private readonly _onWillStartSession = this._store.add(new Emitter<IActiveCodeEditor>());
readonly onWillStartSession: Event<IActiveCodeEditor> = this._onWillStartSession.event;
readonly #onWillStartSession = this.#store.add(new Emitter<IActiveCodeEditor>());
readonly onWillStartSession: Event<IActiveCodeEditor> = this.#onWillStartSession.event;
private readonly _onDidChangeSessions = this._store.add(new Emitter<this>());
readonly onDidChangeSessions: Event<this> = this._onDidChangeSessions.event;
readonly #onDidChangeSessions = this.#store.add(new Emitter<this>());
readonly onDidChangeSessions: Event<this> = this.#onDidChangeSessions.event;
readonly #chatService: IChatService;
constructor(
@IChatService private readonly _chatService: IChatService,
@IChatService chatService: IChatService,
@IChatAgentService chatAgentService: IChatAgentService,
) {
this.#chatService = chatService;
// Listen for agent changes and dispose all sessions when there is no agent
const agentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.EditorInline));
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
const agent = agentObs.read(r);
if (!agent) {
// No agent available, dispose all sessions
dispose(this._sessions.values());
this._sessions.clear();
dispose(this.#sessions.values());
this.#sessions.clear();
}
}));
}
dispose() {
this._store.dispose();
this.#store.dispose();
}
createSession(editor: IActiveCodeEditor): IInlineChatSession2 {
const uri = editor.getModel().uri;
if (this._sessions.has(uri)) {
if (this.#sessions.has(uri)) {
throw new Error('Session already exists');
}
this._onWillStartSession.fire(editor);
this.#onWillStartSession.fire(editor);
const chatModelRef = this._chatService.startNewLocalSession(ChatAgentLocation.EditorInline, { canUseTools: false /* SEE https://github.com/microsoft/vscode/issues/279946 */ });
const chatModelRef = this.#chatService.startNewLocalSession(ChatAgentLocation.EditorInline, { canUseTools: false /* SEE https://github.com/microsoft/vscode/issues/279946 */ });
const chatModel = chatModelRef.object;
chatModel.startEditingSession(false);
const terminationState = observableValue<InlineChatSessionTerminationState | undefined>(this, undefined);
const store = new DisposableStore();
store.add(toDisposable(() => {
void this._chatService.cancelCurrentRequestForSession(chatModel.sessionResource, 'inlineChatSession');
void this.#chatService.cancelCurrentRequestForSession(chatModel.sessionResource, 'inlineChatSession');
chatModel.editingSession?.reject();
this._sessions.delete(uri);
this._onDidChangeSessions.fire(this);
this.#sessions.delete(uri);
this.#onDidChangeSessions.fire(this);
}));
store.add(chatModelRef);
@@ -105,7 +108,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService {
if (state === ModifiedFileEntryState.Accepted || state === ModifiedFileEntryState.Rejected) {
const response = chatModel.getRequests().at(-1)?.response;
if (response) {
this._chatService.notifyUserAction({
this.#chatService.notifyUserAction({
sessionResource: response.session.sessionResource,
requestId: response.requestId,
agentId: response.agent?.id,
@@ -140,20 +143,20 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService {
terminationState,
setTerminationState: state => {
terminationState.set(state, undefined);
this._onDidChangeSessions.fire(this);
this.#onDidChangeSessions.fire(this);
},
dispose: store.dispose.bind(store)
};
this._sessions.set(uri, result);
this._onDidChangeSessions.fire(this);
this.#sessions.set(uri, result);
this.#onDidChangeSessions.fire(this);
return result;
}
getSessionByTextModel(uri: URI): IInlineChatSession2 | undefined {
let result = this._sessions.get(uri);
let result = this.#sessions.get(uri);
if (!result) {
// no direct session, try to find an editing session which has a file entry for the uri
for (const [_, candidate] of this._sessions) {
for (const [_, candidate] of this.#sessions) {
const entry = candidate.editingSession.getEntry(uri);
if (entry) {
result = candidate;
@@ -165,7 +168,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService {
}
getSessionBySessionUri(sessionResource: URI): IInlineChatSession2 | undefined {
for (const session of this._sessions.values()) {
for (const session of this.#sessions.values()) {
if (isEqual(session.chatModel.sessionResource, sessionResource)) {
return session;
}
@@ -178,11 +181,11 @@ export class InlineChatEnabler {
static Id = 'inlineChat.enabler';
private readonly _ctxHasProvider2: IContextKey<boolean>;
private readonly _ctxHasNotebookProvider: IContextKey<boolean>;
private readonly _ctxPossible: IContextKey<boolean>;
readonly #ctxHasProvider2: IContextKey<boolean>;
readonly #ctxHasNotebookProvider: IContextKey<boolean>;
readonly #ctxPossible: IContextKey<boolean>;
private readonly _store = new DisposableStore();
readonly #store = new DisposableStore();
constructor(
@IContextKeyService contextKeyService: IContextKeyService,
@@ -190,41 +193,41 @@ export class InlineChatEnabler {
@IEditorService editorService: IEditorService,
@IConfigurationService configService: IConfigurationService,
) {
this._ctxHasProvider2 = CTX_INLINE_CHAT_HAS_AGENT2.bindTo(contextKeyService);
this._ctxHasNotebookProvider = CTX_INLINE_CHAT_HAS_NOTEBOOK_AGENT.bindTo(contextKeyService);
this._ctxPossible = CTX_INLINE_CHAT_POSSIBLE.bindTo(contextKeyService);
this.#ctxHasProvider2 = CTX_INLINE_CHAT_HAS_AGENT2.bindTo(contextKeyService);
this.#ctxHasNotebookProvider = CTX_INLINE_CHAT_HAS_NOTEBOOK_AGENT.bindTo(contextKeyService);
this.#ctxPossible = CTX_INLINE_CHAT_POSSIBLE.bindTo(contextKeyService);
const agentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.EditorInline));
const notebookAgentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.Notebook));
const notebookAgentConfigObs = observableConfigValue(InlineChatConfigKeys.notebookAgent, false, configService);
this._store.add(autorun(r => {
this.#store.add(autorun(r => {
const agent = agentObs.read(r);
if (!agent) {
this._ctxHasProvider2.reset();
this.#ctxHasProvider2.reset();
} else {
this._ctxHasProvider2.set(true);
this.#ctxHasProvider2.set(true);
}
}));
this._store.add(autorun(r => {
this._ctxHasNotebookProvider.set(notebookAgentConfigObs.read(r) && !!notebookAgentObs.read(r));
this.#store.add(autorun(r => {
this.#ctxHasNotebookProvider.set(notebookAgentConfigObs.read(r) && !!notebookAgentObs.read(r));
}));
const updateEditor = () => {
const ctrl = editorService.activeEditorPane?.getControl();
const isCodeEditorLike = isCodeEditor(ctrl) || isDiffEditor(ctrl) || isCompositeEditor(ctrl);
this._ctxPossible.set(isCodeEditorLike);
this.#ctxPossible.set(isCodeEditorLike);
};
this._store.add(editorService.onDidActiveEditorChange(updateEditor));
this.#store.add(editorService.onDidActiveEditorChange(updateEditor));
updateEditor();
}
dispose() {
this._ctxPossible.reset();
this._ctxHasProvider2.reset();
this._store.dispose();
this.#ctxPossible.reset();
this.#ctxHasProvider2.reset();
this.#store.dispose();
}
}
@@ -235,7 +238,7 @@ export class InlineChatEscapeToolContribution extends Disposable {
static readonly DONT_ASK_AGAIN_KEY = 'inlineChat.dontAskMoveToPanelChat';
private static readonly _data: IToolData = {
static readonly #data: IToolData = {
id: 'inline_chat_exit',
source: ToolDataSource.Internal,
canBeReferencedInPrompt: false,
@@ -269,7 +272,7 @@ export class InlineChatEscapeToolContribution extends Disposable {
super();
this._store.add(lmTools.registerTool(InlineChatEscapeToolContribution._data, {
this._store.add(lmTools.registerTool(InlineChatEscapeToolContribution.#data, {
invoke: async (invocation, _tokenCountFn, _progress, _token) => {
const sessionResource = invocation.context?.sessionResource;

View File

@@ -93,38 +93,58 @@ export class InlineChatWidget {
protected readonly _store = new DisposableStore();
private readonly _ctxInputEditorFocused: IContextKey<boolean>;
private readonly _ctxResponseFocused: IContextKey<boolean>;
readonly #ctxInputEditorFocused: IContextKey<boolean>;
readonly #ctxResponseFocused: IContextKey<boolean>;
private readonly _chatWidget: ChatWidget;
readonly #chatWidget: ChatWidget;
protected readonly _onDidChangeHeight = this._store.add(new Emitter<void>());
readonly onDidChangeHeight: Event<void> = Event.filter(this._onDidChangeHeight.event, _ => !this._isLayouting);
readonly onDidChangeHeight: Event<void> = Event.filter(this._onDidChangeHeight.event, _ => !this.#isLayouting);
private readonly _requestInProgress = observableValue(this, false);
readonly requestInProgress: IObservable<boolean> = this._requestInProgress;
readonly #requestInProgress = observableValue(this, false);
readonly requestInProgress: IObservable<boolean> = this.#requestInProgress;
private _isLayouting: boolean = false;
#isLayouting: boolean = false;
readonly scopedContextKeyService: IContextKeyService;
readonly #options: IInlineChatWidgetConstructionOptions;
readonly #keybindingService: IKeybindingService;
readonly #accessibilityService: IAccessibilityService;
readonly #configurationService: IConfigurationService;
readonly #accessibleViewService: IAccessibleViewService;
readonly #modelService: IModelService;
readonly #chatService: IChatService;
readonly #chatEntitlementService: IChatEntitlementService;
readonly #markdownRendererService: IMarkdownRendererService;
constructor(
location: IChatWidgetLocationOptions,
private readonly _options: IInlineChatWidgetConstructionOptions,
options: IInlineChatWidgetConstructionOptions,
@IInstantiationService protected readonly _instantiationService: IInstantiationService,
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@IAccessibilityService private readonly _accessibilityService: IAccessibilityService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService,
@IContextKeyService contextKeyService: IContextKeyService,
@IKeybindingService keybindingService: IKeybindingService,
@IAccessibilityService accessibilityService: IAccessibilityService,
@IConfigurationService configurationService: IConfigurationService,
@IAccessibleViewService accessibleViewService: IAccessibleViewService,
@ITextModelService protected readonly _textModelResolverService: ITextModelService,
@IModelService private readonly _modelService: IModelService,
@IChatService private readonly _chatService: IChatService,
@IHoverService private readonly _hoverService: IHoverService,
@IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService,
@IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService,
@IModelService modelService: IModelService,
@IChatService chatService: IChatService,
@IHoverService hoverService: IHoverService,
@IChatEntitlementService chatEntitlementService: IChatEntitlementService,
@IMarkdownRendererService markdownRendererService: IMarkdownRendererService,
) {
this.scopedContextKeyService = this._store.add(_contextKeyService.createScoped(this._elements.chatWidget));
this.#options = options;
this.#keybindingService = keybindingService;
this.#accessibilityService = accessibilityService;
this.#configurationService = configurationService;
this.#accessibleViewService = accessibleViewService;
this.#modelService = modelService;
this.#chatService = chatService;
this.#chatEntitlementService = chatEntitlementService;
this.#markdownRendererService = markdownRendererService;
this.scopedContextKeyService = this._store.add(contextKeyService.createScoped(this._elements.chatWidget));
const scopedInstaService = _instantiationService.createChild(
new ServiceCollection([
IContextKeyService,
@@ -133,7 +153,7 @@ export class InlineChatWidget {
this._store
);
this._chatWidget = scopedInstaService.createInstance(
this.#chatWidget = scopedInstaService.createInstance(
ChatWidget,
location,
{ isInlineChat: true },
@@ -153,14 +173,14 @@ export class InlineChatWidget {
if (emptyResponse) {
return false;
}
if (item.response.value.every(item => item.kind === 'textEditGroup' && _options.chatWidgetViewOptions?.rendererOptions?.renderTextEditsAsSummary?.(item.uri))) {
if (item.response.value.every(item => item.kind === 'textEditGroup' && options.chatWidgetViewOptions?.rendererOptions?.renderTextEditsAsSummary?.(item.uri))) {
return false;
}
return true;
},
dndContainer: this._elements.root,
defaultMode: ChatMode.Ask,
..._options.chatWidgetViewOptions
...options.chatWidgetViewOptions
},
{
listForeground: inlineChatForeground,
@@ -170,11 +190,11 @@ export class InlineChatWidget {
resultEditorBackground: editorBackground
}
);
this._elements.root.classList.toggle('in-zone-widget', !!_options.inZoneWidget);
this._chatWidget.render(this._elements.chatWidget);
this._elements.root.classList.toggle('in-zone-widget', !!options.inZoneWidget);
this.#chatWidget.render(this._elements.chatWidget);
this._elements.chatWidget.style.setProperty(asCssVariableName(chatRequestBackground), asCssVariable(inlineChatBackground));
this._chatWidget.setVisible(true);
this._store.add(this._chatWidget);
this.#chatWidget.setVisible(true);
this._store.add(this.#chatWidget);
const ctxResponse = ChatContextKeys.isResponse.bindTo(this.scopedContextKeyService);
const ctxResponseVote = ChatContextKeys.responseVote.bindTo(this.scopedContextKeyService);
@@ -183,10 +203,10 @@ export class InlineChatWidget {
const ctxResponseErrorFiltered = ChatContextKeys.responseIsFiltered.bindTo(this.scopedContextKeyService);
const viewModelStore = this._store.add(new DisposableStore());
this._store.add(this._chatWidget.onDidChangeViewModel(() => {
this._store.add(this.#chatWidget.onDidChangeViewModel(() => {
viewModelStore.clear();
const viewModel = this._chatWidget.viewModel;
const viewModel = this.#chatWidget.viewModel;
if (!viewModel) {
return;
}
@@ -202,7 +222,7 @@ export class InlineChatWidget {
viewModelStore.add(viewModel.onDidChange(() => {
this._requestInProgress.set(viewModel.model.requestInProgress.get(), undefined);
this.#requestInProgress.set(viewModel.model.requestInProgress.get(), undefined);
const last = viewModel.getItems().at(-1);
toolbar2.context = last;
@@ -223,22 +243,22 @@ export class InlineChatWidget {
}));
// context keys
this._ctxResponseFocused = CTX_INLINE_CHAT_RESPONSE_FOCUSED.bindTo(this._contextKeyService);
this.#ctxResponseFocused = CTX_INLINE_CHAT_RESPONSE_FOCUSED.bindTo(contextKeyService);
const tracker = this._store.add(trackFocus(this.domNode));
this._store.add(tracker.onDidBlur(() => this._ctxResponseFocused.set(false)));
this._store.add(tracker.onDidFocus(() => this._ctxResponseFocused.set(true)));
this._store.add(tracker.onDidBlur(() => this.#ctxResponseFocused.set(false)));
this._store.add(tracker.onDidFocus(() => this.#ctxResponseFocused.set(true)));
this._ctxInputEditorFocused = CTX_INLINE_CHAT_FOCUSED.bindTo(_contextKeyService);
this._store.add(this._chatWidget.inputEditor.onDidFocusEditorWidget(() => this._ctxInputEditorFocused.set(true)));
this._store.add(this._chatWidget.inputEditor.onDidBlurEditorWidget(() => this._ctxInputEditorFocused.set(false)));
this.#ctxInputEditorFocused = CTX_INLINE_CHAT_FOCUSED.bindTo(contextKeyService);
this._store.add(this.#chatWidget.inputEditor.onDidFocusEditorWidget(() => this.#ctxInputEditorFocused.set(true)));
this._store.add(this.#chatWidget.inputEditor.onDidBlurEditorWidget(() => this.#ctxInputEditorFocused.set(false)));
const statusMenuId = _options.statusMenuId instanceof MenuId ? _options.statusMenuId : _options.statusMenuId.menu;
const statusMenuId = options.statusMenuId instanceof MenuId ? options.statusMenuId : options.statusMenuId.menu;
// BUTTON bar
const statusMenuOptions = _options.statusMenuId instanceof MenuId ? undefined : _options.statusMenuId.options;
const statusMenuOptions = options.statusMenuId instanceof MenuId ? undefined : options.statusMenuId.options;
const statusButtonBar = scopedInstaService.createInstance(MenuWorkbenchButtonBar, this._elements.toolbar1, statusMenuId, {
toolbarOptions: { primaryGroup: '0_main' },
telemetrySource: _options.chatWidgetViewOptions?.menus?.telemetrySource,
telemetrySource: options.chatWidgetViewOptions?.menus?.telemetrySource,
menuOptions: { renderShortTitle: true },
...statusMenuOptions,
});
@@ -246,8 +266,8 @@ export class InlineChatWidget {
this._store.add(statusButtonBar);
// secondary toolbar
const toolbar2 = scopedInstaService.createInstance(MenuWorkbenchToolBar, this._elements.toolbar2, _options.secondaryMenuId ?? MenuId.for(''), {
telemetrySource: _options.chatWidgetViewOptions?.menus?.telemetrySource,
const toolbar2 = scopedInstaService.createInstance(MenuWorkbenchToolBar, this._elements.toolbar2, options.secondaryMenuId ?? MenuId.for(''), {
telemetrySource: options.chatWidgetViewOptions?.menus?.telemetrySource,
menuOptions: { renderShortTitle: true, shouldForwardArgs: true },
actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => {
return createActionViewItem(scopedInstaService, action, options);
@@ -257,60 +277,60 @@ export class InlineChatWidget {
this._store.add(toolbar2);
this._store.add(this._configurationService.onDidChangeConfiguration(e => {
this._store.add(this.#configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(AccessibilityVerbositySettingId.InlineChat)) {
this._updateAriaLabel();
this.#updateAriaLabel();
}
}));
this._elements.root.tabIndex = 0;
this._elements.statusLabel.tabIndex = 0;
this._updateAriaLabel();
this._setupDisclaimer();
this.#updateAriaLabel();
this.#setupDisclaimer();
this._store.add(this._hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this._elements.statusLabel, () => {
this._store.add(hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this._elements.statusLabel, () => {
return this._elements.statusLabel.dataset['title'];
}));
this._store.add(this._chatService.onDidPerformUserAction(e => {
if (isEqual(e.sessionResource, this._chatWidget.viewModel?.model.sessionResource) && e.action.kind === 'vote') {
this._store.add(this.#chatService.onDidPerformUserAction(e => {
if (isEqual(e.sessionResource, this.#chatWidget.viewModel?.model.sessionResource) && e.action.kind === 'vote') {
this.updateStatus(localize('feedbackThanks', "Thank you for your feedback!"), { resetAfter: 1250 });
}
}));
}
private _updateAriaLabel(): void {
#updateAriaLabel(): void {
this._elements.root.ariaLabel = this._accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.InlineChat);
this._elements.root.ariaLabel = this.#accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.InlineChat);
if (this._accessibilityService.isScreenReaderOptimized()) {
if (this.#accessibilityService.isScreenReaderOptimized()) {
let label = defaultAriaLabel;
if (this._configurationService.getValue<boolean>(AccessibilityVerbositySettingId.InlineChat)) {
const kbLabel = this._keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel();
if (this.#configurationService.getValue<boolean>(AccessibilityVerbositySettingId.InlineChat)) {
const kbLabel = this.#keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel();
label = kbLabel
? localize('inlineChat.accessibilityHelp', "Inline Chat Input, Use {0} for Inline Chat Accessibility Help.", kbLabel)
: localize('inlineChat.accessibilityHelpNoKb', "Inline Chat Input, Run the Inline Chat Accessibility Help command for more information.");
}
this._chatWidget.inputEditor.updateOptions({ ariaLabel: label });
this.#chatWidget.inputEditor.updateOptions({ ariaLabel: label });
}
}
private _setupDisclaimer(): void {
#setupDisclaimer(): void {
const disposables = this._store.add(new DisposableStore());
this._store.add(autorun(reader => {
disposables.clear();
reset(this._elements.disclaimerLabel);
const sentiment = this._chatEntitlementService.sentimentObs.read(reader);
const anonymous = this._chatEntitlementService.anonymousObs.read(reader);
const requestInProgress = this._chatService.requestInProgressObs.read(reader);
const sentiment = this.#chatEntitlementService.sentimentObs.read(reader);
const anonymous = this.#chatEntitlementService.anonymousObs.read(reader);
const requestInProgress = this.#chatService.requestInProgressObs.read(reader);
const showDisclaimer = !sentiment.completed && anonymous && !requestInProgress;
this._elements.disclaimerLabel.classList.toggle('hidden', !showDisclaimer);
if (showDisclaimer) {
const renderedMarkdown = disposables.add(this._markdownRendererService.render(new MarkdownString(localize({ key: 'termsDisclaimer', comment: ['{Locked="]({2})"}', '{Locked="]({3})"}'] }, "By continuing with {0} Copilot, you agree to {1}'s [Terms]({2}) and [Privacy Statement]({3})", product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.termsStatementUrl ?? '', product.defaultChatAgent?.privacyStatementUrl ?? ''), { isTrusted: true })));
const renderedMarkdown = disposables.add(this.#markdownRendererService.render(new MarkdownString(localize({ key: 'termsDisclaimer', comment: ['{Locked="]({2})"}', '{Locked="]({3})"}'] }, "By continuing with {0} Copilot, you agree to {1}'s [Terms]({2}) and [Privacy Statement]({3})", product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.termsStatementUrl ?? '', product.defaultChatAgent?.privacyStatementUrl ?? ''), { isTrusted: true })));
this._elements.disclaimerLabel.appendChild(renderedMarkdown.element);
}
@@ -327,20 +347,20 @@ export class InlineChatWidget {
}
get chatWidget(): ChatWidget {
return this._chatWidget;
return this.#chatWidget;
}
saveState() {
this._chatWidget.saveState();
this.#chatWidget.saveState();
}
layout(widgetDim: Dimension) {
const contentHeight = this.contentHeight;
this._isLayouting = true;
this.#isLayouting = true;
try {
this._doLayout(widgetDim);
} finally {
this._isLayouting = false;
this.#isLayouting = false;
if (this.contentHeight !== contentHeight) {
this._onDidChangeHeight.fire();
@@ -357,7 +377,7 @@ export class InlineChatWidget {
this._elements.root.style.height = `${dimension.height - extraHeight}px`;
this._elements.root.style.width = `${dimension.width}px`;
this._chatWidget.layout(
this.#chatWidget.layout(
dimension.height - statusHeight - extraHeight,
dimension.width
);
@@ -368,7 +388,7 @@ export class InlineChatWidget {
*/
get contentHeight(): number {
const data = {
chatWidgetContentHeight: this._chatWidget.contentHeight,
chatWidgetContentHeight: this.#chatWidget.contentHeight,
statusHeight: getTotalHeight(this._elements.status),
extraHeight: this._getExtraHeight()
};
@@ -381,7 +401,7 @@ export class InlineChatWidget {
// at least "maxWidgetHeight" high and at most the content height.
let maxWidgetOutputHeight = 100;
for (const item of this._chatWidget.viewModel?.getItems() ?? []) {
for (const item of this.#chatWidget.viewModel?.getItems() ?? []) {
if (isResponseVM(item) && item.response.value.some(r => r.kind === 'textEditGroup' && !r.state?.applied)) {
maxWidgetOutputHeight = 270;
break;
@@ -389,29 +409,29 @@ export class InlineChatWidget {
}
let value = this.contentHeight;
value -= this._chatWidget.contentHeight;
value += Math.min(this._chatWidget.input.height.get() + maxWidgetOutputHeight, this._chatWidget.contentHeight);
value -= this.#chatWidget.contentHeight;
value += Math.min(this.#chatWidget.input.height.get() + maxWidgetOutputHeight, this.#chatWidget.contentHeight);
return value;
}
protected _getExtraHeight(): number {
return this._options.inZoneWidget ? 1 : (2 /*border*/ + 4 /*shadow*/);
return this.#options.inZoneWidget ? 1 : (2 /*border*/ + 4 /*shadow*/);
}
get value(): string {
return this._chatWidget.getInput();
return this.#chatWidget.getInput();
}
set value(value: string) {
this._chatWidget.setInput(value);
this.#chatWidget.setInput(value);
}
selectAll() {
this._chatWidget.inputEditor.setSelection(new Selection(1, 1, Number.MAX_SAFE_INTEGER, 1));
this.#chatWidget.inputEditor.setSelection(new Selection(1, 1, Number.MAX_SAFE_INTEGER, 1));
}
set placeholder(value: string) {
this._chatWidget.setInputPlaceholder(value);
this.#chatWidget.setInputPlaceholder(value);
}
toggleStatus(show: boolean) {
@@ -432,7 +452,7 @@ export class InlineChatWidget {
}
async getCodeBlockInfo(codeBlockIndex: number): Promise<ITextModel | undefined> {
const { viewModel } = this._chatWidget;
const { viewModel } = this.#chatWidget;
if (!viewModel) {
return undefined;
}
@@ -443,10 +463,10 @@ export class InlineChatWidget {
}
// Look for the code block in the rendered response
const codeBlocks = this._chatWidget.getCodeBlockInfosForResponse(item);
const codeBlocks = this.#chatWidget.getCodeBlockInfosForResponse(item);
const info = codeBlocks[codeBlockIndex];
if (info?.uri) {
return this._modelService.getModel(info.uri) ?? undefined;
return this.#modelService.getModel(info.uri) ?? undefined;
}
// Fallback: if the code block hasn't been rendered yet (e.g. due to
@@ -471,25 +491,25 @@ export class InlineChatWidget {
}
if (foundText !== undefined && currentCodeBlockIndex === codeBlockIndex) {
return this._modelService.createModel(foundText, null, undefined, true);
return this.#modelService.createModel(foundText, null, undefined, true);
}
return undefined;
}
get responseContent(): string | undefined {
const requests = this._chatWidget.viewModel?.model.getRequests();
const requests = this.#chatWidget.viewModel?.model.getRequests();
return requests?.at(-1)?.response?.response.toString();
}
getChatModel(): IChatModel | undefined {
return this._chatWidget.viewModel?.model;
return this.#chatWidget.viewModel?.model;
}
setChatModel(chatModel: IChatModel) {
chatModel.inputModel.setState({ inputText: '', selections: [] });
this._chatWidget.setModel(chatModel);
this.#chatWidget.setModel(chatModel);
}
updateInfo(message: string): void {
@@ -528,8 +548,8 @@ export class InlineChatWidget {
}
reset() {
this._chatWidget.attachmentModel.clear(true);
this._chatWidget.saveState();
this.#chatWidget.attachmentModel.clear(true);
this.#chatWidget.saveState();
reset(this._elements.statusLabel);
this._elements.statusLabel.classList.toggle('hidden', true);
@@ -542,7 +562,7 @@ export class InlineChatWidget {
}
focus() {
this._chatWidget.focusInput();
this.#chatWidget.focusInput();
}
hasFocus() {

View File

@@ -28,7 +28,7 @@ import { EditorBasedInlineChatWidget } from './inlineChatWidget.js';
export class InlineChatZoneWidget extends ZoneWidget {
private static readonly _options: IOptions = {
static readonly #options: IOptions = {
showFrame: true,
frameWidth: 1,
// frameColor: 'var(--vscode-inlineChat-border)',
@@ -43,30 +43,34 @@ export class InlineChatZoneWidget extends ZoneWidget {
readonly widget: EditorBasedInlineChatWidget;
private readonly _ctxCursorPosition: IContextKey<'above' | 'below' | ''>;
private _dimension?: Dimension;
readonly #ctxCursorPosition: IContextKey<'above' | 'below' | ''>;
#dimension?: Dimension;
private notebookEditor?: INotebookEditor;
readonly #logService: ILogService;
constructor(
location: IChatWidgetLocationOptions,
options: IChatWidgetViewOptions | undefined,
editors: { editor: ICodeEditor; notebookEditor?: INotebookEditor },
/** @deprecated should go away with inline2 */
clearDelegate: () => Promise<void>,
@IInstantiationService private readonly _instaService: IInstantiationService,
@ILogService private _logService: ILogService,
@IInstantiationService instaService: IInstantiationService,
@ILogService logService: ILogService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super(editors.editor, InlineChatZoneWidget._options);
super(editors.editor, InlineChatZoneWidget.#options);
this.notebookEditor = editors.notebookEditor;
this._ctxCursorPosition = CTX_INLINE_CHAT_OUTER_CURSOR_POSITION.bindTo(contextKeyService);
this.#logService = logService;
this.#ctxCursorPosition = CTX_INLINE_CHAT_OUTER_CURSOR_POSITION.bindTo(contextKeyService);
this._disposables.add(toDisposable(() => {
this._ctxCursorPosition.reset();
this.#ctxCursorPosition.reset();
}));
this.widget = this._instaService.createInstance(EditorBasedInlineChatWidget, location, this.editor, {
this.widget = instaService.createInstance(EditorBasedInlineChatWidget, location, this.editor, {
statusMenuId: {
menu: MENU_INLINE_CHAT_WIDGET_STATUS,
options: {
@@ -105,14 +109,14 @@ export class InlineChatZoneWidget extends ZoneWidget {
let revealFn: (() => void) | undefined;
this._disposables.add(this.widget.chatWidget.onWillMaybeChangeHeight(() => {
if (this.position) {
revealFn = this._createZoneAndScrollRestoreFn(this.position);
revealFn = this.#createZoneAndScrollRestoreFn(this.position);
}
}));
this._disposables.add(this.widget.onDidChangeHeight(() => {
if (this.position && !this._usesResizeHeight) {
// only relayout when visible
revealFn ??= this._createZoneAndScrollRestoreFn(this.position);
const height = this._computeHeight();
revealFn ??= this.#createZoneAndScrollRestoreFn(this.position);
const height = this.#computeHeight();
this._relayout(height.linesValue);
revealFn?.();
revealFn = undefined;
@@ -136,13 +140,13 @@ export class InlineChatZoneWidget extends ZoneWidget {
// todo@jrieken listen ONLY when showing
const updateCursorIsAboveContextKey = () => {
if (!this.position || !this.editor.hasModel()) {
this._ctxCursorPosition.reset();
this.#ctxCursorPosition.reset();
} else if (this.position.lineNumber === this.editor.getPosition().lineNumber) {
this._ctxCursorPosition.set('above');
this.#ctxCursorPosition.set('above');
} else if (this.position.lineNumber + 1 === this.editor.getPosition().lineNumber) {
this._ctxCursorPosition.set('below');
this.#ctxCursorPosition.set('below');
} else {
this._ctxCursorPosition.reset();
this.#ctxCursorPosition.reset();
}
};
this._disposables.add(this.editor.onDidChangeCursorPosition(e => updateCursorIsAboveContextKey()));
@@ -159,17 +163,17 @@ export class InlineChatZoneWidget extends ZoneWidget {
protected override _doLayout(heightInPixel: number): void {
this._updatePadding();
this.#updatePadding();
const info = this.editor.getLayoutInfo();
const width = info.contentWidth - info.verticalScrollbarWidth;
// width = Math.min(850, width);
this._dimension = new Dimension(width, heightInPixel);
this.widget.layout(this._dimension);
this.#dimension = new Dimension(width, heightInPixel);
this.widget.layout(this.#dimension);
}
private _computeHeight(): { linesValue: number; pixelsValue: number } {
#computeHeight(): { linesValue: number; pixelsValue: number } {
const chatContentHeight = this.widget.contentHeight;
const editorHeight = this.notebookEditor?.getLayoutInfo().height ?? this.editor.getLayoutInfo().height;
@@ -192,25 +196,25 @@ export class InlineChatZoneWidget extends ZoneWidget {
}
protected override _onWidth(_widthInPixel: number): void {
if (this._dimension) {
this._doLayout(this._dimension.height);
if (this.#dimension) {
this._doLayout(this.#dimension.height);
}
}
override show(position: Position): void {
assertType(this.container);
this._updatePadding();
this.#updatePadding();
const revealZone = this._createZoneAndScrollRestoreFn(position);
super.show(position, this._computeHeight().linesValue);
const revealZone = this.#createZoneAndScrollRestoreFn(position);
super.show(position, this.#computeHeight().linesValue);
this.widget.chatWidget.setVisible(true);
this.widget.focus();
revealZone();
}
private _updatePadding() {
#updatePadding() {
assertType(this.container);
const info = this.editor.getLayoutInfo();
@@ -226,12 +230,12 @@ export class InlineChatZoneWidget extends ZoneWidget {
}
override updatePositionAndHeight(position: Position): void {
const revealZone = this._createZoneAndScrollRestoreFn(position);
super.updatePositionAndHeight(position, !this._usesResizeHeight ? this._computeHeight().linesValue : undefined);
const revealZone = this.#createZoneAndScrollRestoreFn(position);
super.updatePositionAndHeight(position, !this._usesResizeHeight ? this.#computeHeight().linesValue : undefined);
revealZone();
}
private _createZoneAndScrollRestoreFn(position: Position): () => void {
#createZoneAndScrollRestoreFn(position: Position): () => void {
const scrollState = StableEditorBottomScrollState.capture(this.editor);
@@ -242,7 +246,7 @@ export class InlineChatZoneWidget extends ZoneWidget {
const scrollTop = this.editor.getScrollTop();
const lineTop = this.editor.getTopForLineNumber(lineNumber);
const zoneTop = lineTop - this._computeHeight().pixelsValue;
const zoneTop = lineTop - this.#computeHeight().pixelsValue;
const editorHeight = this.editor.getLayoutInfo().height;
const lineBottom = this.editor.getBottomForLineNumber(lineNumber);
@@ -257,7 +261,7 @@ export class InlineChatZoneWidget extends ZoneWidget {
}
if (newScrollTop < scrollTop || forceScrollTop) {
this._logService.trace('[IE] REVEAL zone', { zoneTop, lineTop, lineBottom, scrollTop, newScrollTop, forceScrollTop });
this.#logService.trace('[IE] REVEAL zone', { zoneTop, lineTop, lineBottom, scrollTop, newScrollTop, forceScrollTop });
this.editor.setScrollTop(newScrollTop, ScrollType.Immediate);
}
};
@@ -269,7 +273,7 @@ export class InlineChatZoneWidget extends ZoneWidget {
override hide(): void {
const scrollState = StableEditorBottomScrollState.capture(this.editor);
this._ctxCursorPosition.reset();
this.#ctxCursorPosition.reset();
this.widget.chatWidget.setVisible(false);
super.hide();
aria.status(localize('inlineChatClosed', 'Closed inline chat widget'));