ctrlK now switches tabs + UI + remove async

This commit is contained in:
Andrew Pareles 2024-12-31 15:41:24 -08:00
parent 954e611fda
commit d3b61867f0
5 changed files with 338 additions and 76 deletions

View file

@ -7,7 +7,10 @@ import { IRange } from '../../../common/core/range.js';
import { EditorOption } from '../../../common/config/editorOptions.js';
// THIS FILE IS OLD!!!
// THIS FILE IS OLD + UNUSED!!!
// SEE inlineDiffsService.ts INSTEAD.
export interface IInlineDiffService {
readonly _serviceBrand: undefined;
addDiff(editor: ICodeEditor, originalText: string, modifiedRange: IRange): void;

View file

@ -9,7 +9,6 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in
// lets you add a "consistent" item to a Model (aka URI), instead of just to a single editor
type AddItemInputs = { uri: URI; fn: (editor: ICodeEditor) => (() => void); }
export interface IConsistentItemService {
@ -56,6 +55,7 @@ export class ConsistentItemService extends Disposable {
}
// when editor switches tabs (models)
const addTabSwitchListeners = (editor: ICodeEditor) => {
this._register(
editor.onDidChangeModel(e => {
@ -65,6 +65,7 @@ export class ConsistentItemService extends Disposable {
)
}
// when editor is disposed
const addDisposeListener = (editor: ICodeEditor) => {
this._register(editor.onDidDispose(() => {
// anything on the editor has been disposed already
@ -176,3 +177,243 @@ export class ConsistentItemService extends Disposable {
registerSingleton(IConsistentItemService, ConsistentItemService, InstantiationType.Eager);
// mostly generated by o1 (almost the same as above, but just for 1 editor)
export interface IConsistentEditorItemService {
readonly _serviceBrand: undefined;
addToEditor(editor: ICodeEditor, fn: () => () => void): string;
removeFromEditor(itemId: string): void;
}
export const IConsistentEditorItemService = createDecorator<IConsistentEditorItemService>('ConsistentEditorItemService');
export class ConsistentEditorItemService extends Disposable {
readonly _serviceBrand: undefined;
/**
* For each editorId, we track the set of itemIds that have been "added" to that editor.
* This does *not* necessarily mean they're currently mounted (the user may have switched models).
*/
private readonly itemIdsByEditorId: Record<string, Set<string>> = {};
/**
* For each itemId, we store relevant info (the fn to call on the editor, the editorId, the uri, and the current dispose function).
*/
private readonly itemInfoById: Record<
string,
{
editorId: string;
uriFsPath: string;
fn: (editor: ICodeEditor) => () => void;
disposeFn?: () => void;
}
> = {};
constructor(
@ICodeEditorService private readonly _editorService: ICodeEditorService,
) {
super();
//
// Wire up listeners to watch for new editors, removed editors, etc.
//
// Initialize any already-existing editors
for (const editor of this._editorService.listCodeEditors()) {
this._initializeEditor(editor);
}
// When an editor is added, track it
this._register(
this._editorService.onCodeEditorAdd((editor) => {
this._initializeEditor(editor);
})
);
// When an editor is removed, remove all items associated with that editor
this._register(
this._editorService.onCodeEditorRemove((editor) => {
this._removeAllItemsFromEditor(editor);
})
);
}
/**
* Sets up listeners on the provided editor so that:
* - If the editor changes models, we remove items and re-mount only if the new model matches.
* - If the editor is disposed, we do the needed cleanup.
*/
private _initializeEditor(editor: ICodeEditor) {
const editorId = editor.getId();
//
// Listen for model changes
//
this._register(
editor.onDidChangeModel((e) => {
this._removeAllItemsFromEditor(editor);
if (!e.newModelUrl) {
return;
}
// Re-mount any items that belong to this editor and match the new URI
const itemsForEditor = this.itemIdsByEditorId[editorId];
if (itemsForEditor) {
for (const itemId of itemsForEditor) {
const itemInfo = this.itemInfoById[itemId];
if (itemInfo && itemInfo.uriFsPath === e.newModelUrl.fsPath) {
this._mountItemOnEditor(editor, itemId);
}
}
}
})
);
//
// When the editor is disposed, remove all items from it
//
this._register(
editor.onDidDispose(() => {
this._removeAllItemsFromEditor(editor);
})
);
//
// If the editor already has a model (e.g. on initial load), try mounting items
//
const uri = editor.getModel()?.uri;
if (!uri) {
return;
}
const itemsForEditor = this.itemIdsByEditorId[editorId];
if (itemsForEditor) {
for (const itemId of itemsForEditor) {
const itemInfo = this.itemInfoById[itemId];
if (itemInfo && itemInfo.uriFsPath === uri.fsPath) {
this._mountItemOnEditor(editor, itemId);
}
}
}
}
/**
* Actually calls the item-creation function `fn(editor)` and saves the resulting disposeFn
* so we can later clean it up.
*/
private _mountItemOnEditor(editor: ICodeEditor, itemId: string) {
const info = this.itemInfoById[itemId];
if (!info) {
return;
}
const { fn } = info;
const disposeFn = fn(editor);
info.disposeFn = disposeFn;
}
/**
* Removes a single item from an editor (calling its `disposeFn` if present).
*/
private _removeItemFromEditor(editor: ICodeEditor, itemId: string) {
const info = this.itemInfoById[itemId];
if (info?.disposeFn) {
info.disposeFn();
info.disposeFn = undefined;
}
}
/**
* Removes *all* items from the given editor. Typically called when the editor changes model or is disposed.
*/
private _removeAllItemsFromEditor(editor: ICodeEditor) {
const editorId = editor.getId();
const itemsForEditor = this.itemIdsByEditorId[editorId];
if (!itemsForEditor) {
return;
}
for (const itemId of itemsForEditor) {
this._removeItemFromEditor(editor, itemId);
}
}
/**
* Public API: Adds an item to an *individual* editor (determined by editor ID),
* but only when that editor is showing the same model (uri.fsPath).
*/
addToEditor(editor: ICodeEditor, fn: () => () => void): string {
const uri = editor.getModel()?.uri
if (!uri) {
throw new Error('No URI on the provided editor or in AddItemInputs.');
}
const editorId = editor.getId();
// Create an ID for this item
const itemId = generateUuid();
// Record the info
this.itemInfoById[itemId] = {
editorId,
uriFsPath: uri.fsPath,
fn,
};
// Add to the editor's known items
if (!this.itemIdsByEditorId[editorId]) {
this.itemIdsByEditorId[editorId] = new Set();
}
this.itemIdsByEditorId[editorId].add(itemId);
// If the editor's current URI matches, mount it now
if (editor.getModel()?.uri.fsPath === uri.fsPath) {
this._mountItemOnEditor(editor, itemId);
}
return itemId;
}
/**
* Public API: Removes an item from the *specific* editor. We look up which editor
* had this item and remove it from that editor.
*/
removeFromEditor(itemId: string): void {
const info = this.itemInfoById[itemId];
if (!info) {
// Nothing to remove
return;
}
const { editorId } = info;
// Find the editor in question
const editor = this._editorService.listCodeEditors().find(
(ed) => ed.getId() === editorId
);
if (editor) {
// Dispose on that editor
this._removeItemFromEditor(editor, itemId);
}
// Clean up references
this.itemIdsByEditorId[editorId]?.delete(itemId);
delete this.itemInfoById[itemId];
}
}
registerSingleton(IConsistentEditorItemService, ConsistentEditorItemService, InstantiationType.Eager);

View file

@ -26,7 +26,7 @@ import { ILanguageService } from '../../../../editor/common/languages/language.j
import * as dom from '../../../../base/browser/dom.js';
import { Widget } from '../../../../base/browser/ui/widget.js';
import { URI } from '../../../../base/common/uri.js';
import { IConsistentItemService } from './helperServices/consistentItemService.js';
import { IConsistentEditorItemService, IConsistentItemService } from './helperServices/consistentItemService.js';
import { ctrlKStream_prefixAndSuffix, ctrlKStream_prompt, ctrlKStream_systemMessage, ctrlLStream_prompt, ctrlLStream_systemMessage } from './prompt/prompts.js';
import { ILLMMessageService } from '../../../../platform/void/common/llmMessageService.js';
import { IPosition } from '../../../../editor/common/core/position.js';
@ -117,7 +117,7 @@ type CtrlKZone = {
editorId: string; // the editor the input lives on
_mountInfo: null | {
inputBox: InputBox; // the input box that lives in the zone
inputBox: InputBox | null; // the input box that lives in the zone
dispose: () => void;
refresh: () => void;
}
@ -195,6 +195,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
@ILLMMessageService private readonly _llmMessageService: ILLMMessageService,
@IConsistentItemService private readonly _consistentItemService: IConsistentItemService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IConsistentEditorItemService private readonly _consistentEditorItemService: IConsistentEditorItemService,
) {
super();
@ -319,74 +320,78 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
mostRecentTextOfCtrlKZoneId: Record<string, string | undefined> = {}
private _addCtrlKZoneInput = async (ctrlKZone: CtrlKZone) => {
private _addCtrlKZoneInput = (ctrlKZone: CtrlKZone) => {
const { editorId } = ctrlKZone
const editor = this._editorService.listCodeEditors().find(e => e.getId() === editorId)
if (!editor) {
console.error('editor not found')
return null
}
if (!editor) { return null }
const domNode = document.createElement('div');
domNode.style.zIndex = '1'
const viewZone: IViewZone = {
afterLineNumber: ctrlKZone.startLine - 1,
domNode: domNode,
heightInPx: 0,
suppressMouseDown: false,
};
// mount zone
let zoneId: string | null = null
editor.changeViewZones(accessor => {
zoneId = accessor.addZone(viewZone)
})
let viewZone_: IViewZone | null = null
let inputBox_: InputBox | null = null
const itemId = this._consistentEditorItemService.addToEditor(editor, () => {
const domNode = document.createElement('div');
domNode.style.zIndex = '1'
const viewZone: IViewZone = {
afterLineNumber: ctrlKZone.startLine - 1,
domNode: domNode,
heightInPx: 52,
suppressMouseDown: false,
};
viewZone_ = viewZone
let res_: (inputBox: InputBox) => void
const inputBoxPromise: Promise<InputBox> = new Promise((res, rej) => { res_ = res })
// mount zone
editor.changeViewZones(accessor => {
zoneId = accessor.addZone(viewZone)
})
// mount react
this._instantiationService.invokeFunction(accessor => {
const props: QuickEditPropsType = {
diffareaid: ctrlKZone.diffareaid,
onGetInputBox(inputBox) {
res_(inputBox)
// not sure why this requries a timeout
setTimeout(() => inputBox.focus(), 0)
},
onChangeHeight(height) {
if (height === undefined) return
viewZone.heightInPx = height
// re-render with this new height
editor.changeViewZones(accessor => {
if (zoneId) {
accessor.layoutZone(zoneId)
// mount react
this._instantiationService.invokeFunction(accessor => {
mountCtrlK(domNode, accessor, {
diffareaid: ctrlKZone.diffareaid,
onGetInputBox: (inputBox) => {
inputBox_ = inputBox
// if it's mounting for the first time, focus it
if (!(ctrlKZone.diffareaid in this.mostRecentTextOfCtrlKZoneId)) { // detect first mount this way (a hack)
this.mostRecentTextOfCtrlKZoneId[ctrlKZone.diffareaid] = undefined
setTimeout(() => inputBox.focus(), 0)
}
})
},
onUserUpdateText: (text) => { this.mostRecentTextOfCtrlKZoneId[ctrlKZone.diffareaid] = text; },
initText: this.mostRecentTextOfCtrlKZoneId[ctrlKZone.diffareaid] ?? null,
}
mountCtrlK(domNode, accessor, props)
},
onChangeHeight(height) {
if (height === undefined) return
viewZone.heightInPx = height
// re-render with this new height
editor.changeViewZones(accessor => {
if (zoneId) {
accessor.layoutZone(zoneId)
}
})
},
onUserUpdateText: (text) => { this.mostRecentTextOfCtrlKZoneId[ctrlKZone.diffareaid] = text; },
initText: this.mostRecentTextOfCtrlKZoneId[ctrlKZone.diffareaid] ?? null,
} satisfies QuickEditPropsType)
})
return () => editor.changeViewZones(accessor => {
if (zoneId)
accessor.removeZone(zoneId)
})
})
const inputBox = await inputBoxPromise
return {
inputBox,
inputBox: inputBox_,
refresh: () => editor.changeViewZones(accessor => {
if (zoneId && viewZone) {
viewZone.afterLineNumber = ctrlKZone.startLine - 1
if (zoneId && viewZone_) {
viewZone_.afterLineNumber = ctrlKZone.startLine - 1
accessor.layoutZone(zoneId)
}
}),
dispose: () => {
editor.changeViewZones(accessor => {
if (zoneId)
accessor.removeZone(zoneId)
})
this._consistentEditorItemService.removeFromEditor(itemId)
},
}
}
@ -398,12 +403,11 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
const diffArea = this.diffAreaOfId[diffareaid]
if (diffArea.type !== 'CtrlKZone') continue
if (!diffArea._mountInfo) {
diffArea._mountInfo = await this._addCtrlKZoneInput(diffArea)
diffArea._mountInfo = this._addCtrlKZoneInput(diffArea)
}
else {
diffArea._mountInfo.refresh()
}
}
}
@ -882,7 +886,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
if (diffArea.type !== 'CtrlKZone') continue
const noOverlap = diffArea.startLine > endLine || diffArea.endLine < startLine
if (!noOverlap) {
diffArea._mountInfo?.inputBox.focus()
setTimeout(() => diffArea._mountInfo?.inputBox?.focus(), 0)
return
}
}
@ -967,8 +971,8 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
startLine = startLine_
endLine = endLine_
if (!_mountInfo) return
userMessage = _mountInfo.inputBox.value
if (!_mountInfo?.inputBox) return
userMessage = _mountInfo.inputBox?.value
}
else {
throw new Error(`Void: diff.type not recognized on: ${featureName}`)
@ -1101,7 +1105,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
}
// call this outside undo/redo (it calls undo)
// call this outside undo/redo (it calls undo). this is only for aborting a diffzone stream
interruptStreaming(diffareaid: number) {
const diffArea = this.diffAreaOfId[diffareaid]

View file

@ -334,7 +334,8 @@ export const ctrlKStream_prompt = ({ selection, prefix, suffix, userMessage }: {
<${preTag}>
/* Original Selection:
${selection}*/
/* Instructions: ${userMessage}*/
/* Instructions:
${userMessage}*/
${prefix}</${preTag}>
<${sufTag}>${suffix}</${sufTag}>
<${midTag}>`

View file

@ -11,6 +11,7 @@ import { getCmdKey } from '../../../helpers/getCmdKey.js';
import { VoidInputBox } from '../util/inputs.js';
import { QuickEditPropsType } from '../../../quickEditActions.js';
import { ButtonStop, ButtonSubmit } from '../sidebar-tsx/SidebarChat.js';
import { ModelDropdown } from '../void-settings-tsx/ModelDropdown.js';
export const CtrlKChat = ({ diffareaid, onGetInputBox, onUserUpdateText, onChangeHeight, initText }: QuickEditPropsType) => {
@ -60,6 +61,7 @@ export const CtrlKChat = ({ diffareaid, onGetInputBox, onUserUpdateText, onChang
const onInterrupt = useCallback(() => {
if (currentlyStreamingIdRef.current !== undefined)
inlineDiffsService.interruptStreaming(currentlyStreamingIdRef.current)
inputBoxRef.current?.enable()
setIsStreaming(false)
}, [inlineDiffsService])
@ -126,22 +128,33 @@ export const CtrlKChat = ({ diffareaid, onGetInputBox, onUserUpdateText, onChang
/>
</div>
{/* right (button) */}
<div className='flex flex-row items-end'>
{/* submit / stop button */}
{isStreaming ?
// stop button
<ButtonStop
onClick={onInterrupt}
/>
:
// submit button (up arrow)
<ButtonSubmit
disabled={isDisabled}
/>
}
</div>
{/* bottom row */}
<div
className='flex flex-row justify-between items-end gap-1'
>
{/* submit options */}
<div className='max-w-[150px]
@@[&_select]:!void-border-none
@@[&_select]:!void-outline-none'
>
<ModelDropdown featureName='Ctrl+L' />
</div>
{/* submit / stop button */}
{isStreaming ?
// stop button
<ButtonStop
onClick={onInterrupt}
/>
:
// submit button (up arrow)
<ButtonSubmit
disabled={isDisabled}
/>
}
</div>
</div>