Skip to content

Commit 0c80e23

Browse files
authored
Merge pull request microsoft#210750 from microsoft/tyriar/update_cursor_still__suggest__2
Move terminal suggest to prompt input model instead of manual tracking
2 parents ba27ce3 + f3dcaf1 commit 0c80e23

File tree

3 files changed

+117
-159
lines changed

3 files changed

+117
-159
lines changed

src/vs/workbench/contrib/terminal/browser/terminal.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,9 +1241,4 @@ export interface ISuggestController {
12411241
selectNextPageSuggestion(): void;
12421242
acceptSelectedSuggestion(suggestion?: Pick<ISimpleSelectedSuggestion, 'item' | 'model'>): void;
12431243
hideSuggestWidget(): void;
1244-
/**
1245-
* Handle data written to the terminal outside of xterm.js which has no corresponding
1246-
* `Terminal.onData` event.
1247-
*/
1248-
handleNonXtermData(data: string): void;
12491244
}

src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo
6868
return;
6969
}
7070
if (this._terminalSuggestWidgetVisibleContextKey) {
71-
this._addon.value = this._instantiationService.createInstance(SuggestAddon, this._terminalSuggestWidgetVisibleContextKey);
71+
this._addon.value = this._instantiationService.createInstance(SuggestAddon, this._instance.capabilities, this._terminalSuggestWidgetVisibleContextKey);
7272
xterm.loadAddon(this._addon.value);
7373
this._addon.value.setPanel(dom.findParentWithClass(xterm.element!, 'panel')!);
7474
this._addon.value.setScreen(xterm.element!.querySelector('.xterm-screen')!);
@@ -77,9 +77,7 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo
7777
this._instance.focus();
7878
this._instance.sendText(text, false);
7979
}));
80-
this.add(this._instance.onDidSendText((text) => {
81-
this._addon.value?.handleNonXtermData(text);
82-
}));
80+
this.add(this._instance.onDidSendText(() => this._addon.value?.hideSuggestWidget()));
8381
}
8482
}
8583
}

src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts

Lines changed: 115 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { SimpleCompletionItem } from 'vs/workbench/services/suggest/browser/simp
88
import { LineContext, SimpleCompletionModel } from 'vs/workbench/services/suggest/browser/simpleCompletionModel';
99
import { ISimpleSelectedSuggestion, SimpleSuggestWidget } from 'vs/workbench/services/suggest/browser/simpleSuggestWidget';
1010
import { Codicon } from 'vs/base/common/codicons';
11-
import { Emitter } from 'vs/base/common/event';
12-
import { Disposable } from 'vs/base/common/lifecycle';
11+
import { Emitter, Event } from 'vs/base/common/event';
12+
import { combinedDisposable, Disposable, MutableDisposable } from 'vs/base/common/lifecycle';
1313
import { ThemeIcon } from 'vs/base/common/themables';
1414
import { editorSuggestWidgetSelectedBackground } from 'vs/editor/contrib/suggest/browser/suggestWidget';
1515
import { IContextKey } from 'vs/platform/contextkey/common/contextkey';
@@ -20,11 +20,9 @@ import { ISuggestController } from 'vs/workbench/contrib/terminal/browser/termin
2020
import { TerminalStorageKeys } from 'vs/workbench/contrib/terminal/common/terminalStorageKeys';
2121
import type { ITerminalAddon, Terminal } from '@xterm/xterm';
2222
import { getListStyles } from 'vs/platform/theme/browser/defaultStyles';
23-
24-
const enum ShellIntegrationOscPs {
25-
// TODO: Pull from elsewhere
26-
VSCode = 633
27-
}
23+
import { TerminalCapability, type ITerminalCapabilityStore } from 'vs/platform/terminal/common/capabilities/capabilities';
24+
import type { IPromptInputModel, IPromptInputModelState } from 'vs/platform/terminal/common/capabilities/commandDetection/promptInputModel';
25+
import { ShellIntegrationOscPs } from 'vs/platform/terminal/common/xterm/shellIntegrationAddon';
2826

2927
const enum VSCodeOscPt {
3028
Completions = 'Completions',
@@ -73,35 +71,59 @@ const pwshTypeToIconMap: { [type: string]: ThemeIcon | undefined } = {
7371

7472
export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggestController {
7573
private _terminal?: Terminal;
74+
75+
private _promptInputModel?: IPromptInputModel;
76+
private readonly _promptInputModelSubscriptions = this._register(new MutableDisposable());
77+
78+
private _mostRecentPromptInputState?: IPromptInputModelState;
79+
private _initialPromptInputState?: IPromptInputModelState;
80+
private _currentPromptInputState?: IPromptInputModelState;
81+
7682
private _panel?: HTMLElement;
7783
private _screen?: HTMLElement;
7884
private _suggestWidget?: SimpleSuggestWidget;
7985
private _enableWidget: boolean = true;
86+
87+
// TODO: Remove these in favor of prompt input state
8088
private _leadingLineContent?: string;
81-
private _additionalInput?: string;
8289
private _cursorIndexDelta: number = 0;
83-
private _inputQueue?: string[];
8490

8591
private readonly _onBell = this._register(new Emitter<void>());
8692
readonly onBell = this._onBell.event;
8793
private readonly _onAcceptedCompletion = this._register(new Emitter<string>());
8894
readonly onAcceptedCompletion = this._onAcceptedCompletion.event;
8995

9096
constructor(
97+
private readonly _capabilities: ITerminalCapabilityStore,
9198
private readonly _terminalSuggestWidgetVisibleContextKey: IContextKey<boolean>,
9299
@IInstantiationService private readonly _instantiationService: IInstantiationService
93100
) {
94101
super();
102+
103+
this._register(Event.runAndSubscribe(Event.any(
104+
this._capabilities.onDidAddCapabilityType,
105+
this._capabilities.onDidRemoveCapabilityType
106+
), () => {
107+
const commandDetection = this._capabilities.get(TerminalCapability.CommandDetection);
108+
if (commandDetection) {
109+
if (this._promptInputModel !== commandDetection.promptInputModel) {
110+
this._promptInputModel = commandDetection.promptInputModel;
111+
this._promptInputModelSubscriptions.value = combinedDisposable(
112+
this._promptInputModel.onDidChangeInput(e => this._sync(e)),
113+
this._promptInputModel.onDidFinishInput(() => this.hideSuggestWidget()),
114+
);
115+
}
116+
} else {
117+
this._promptInputModel = undefined;
118+
}
119+
}));
95120
}
96121

97122
activate(xterm: Terminal): void {
98123
this._terminal = xterm;
99124
this._register(xterm.parser.registerOscHandler(ShellIntegrationOscPs.VSCode, data => {
100125
return this._handleVSCodeSequence(data);
101126
}));
102-
this._register(xterm.onData(e => {
103-
this._handleTerminalInput(e);
104-
}));
105127
}
106128

107129
setPanel(panel: HTMLElement): void {
@@ -112,6 +134,45 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
112134
this._screen = screen;
113135
}
114136

137+
private _sync(promptInputState: IPromptInputModelState): void {
138+
this._mostRecentPromptInputState = promptInputState;
139+
if (!this._promptInputModel || !this._terminal || !this._suggestWidget || !this._initialPromptInputState) {
140+
return;
141+
}
142+
143+
this._currentPromptInputState = promptInputState;
144+
145+
if (this._terminalSuggestWidgetVisibleContextKey.get()) {
146+
const inputBeforeCursor = this._currentPromptInputState.value.substring(0, this._currentPromptInputState.cursorIndex);
147+
this._cursorIndexDelta = this._currentPromptInputState.cursorIndex - this._initialPromptInputState.cursorIndex;
148+
149+
this._suggestWidget.setLineContext(new LineContext(inputBeforeCursor, this._cursorIndexDelta));
150+
}
151+
152+
// Hide and clear model if there are no more items
153+
if (!this._suggestWidget.hasCompletions()) {
154+
this.hideSuggestWidget();
155+
// TODO: Don't request every time; refine completions
156+
// this._onAcceptedCompletion.fire('\x1b[24~e');
157+
return;
158+
}
159+
160+
// TODO: Expose on xterm.js
161+
const dimensions = this._getTerminalDimensions();
162+
if (!dimensions.width || !dimensions.height) {
163+
return;
164+
}
165+
// TODO: What do frozen and auto do?
166+
const xtermBox = this._screen!.getBoundingClientRect();
167+
const panelBox = this._panel!.offsetParent!.getBoundingClientRect();
168+
169+
this._suggestWidget.showSuggestions(0, false, false, {
170+
left: (xtermBox.left - panelBox.left) + this._terminal.buffer.active.cursorX * dimensions.width,
171+
top: (xtermBox.top - panelBox.top) + this._terminal.buffer.active.cursorY * dimensions.height,
172+
height: dimensions.height
173+
});
174+
}
175+
115176
private _handleVSCodeSequence(data: string): boolean {
116177
if (!this._terminal) {
117178
return false;
@@ -277,7 +338,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
277338
}
278339

279340
private _handleCompletionModel(model: SimpleCompletionModel): void {
280-
if (model.items.length === 0 || !this._terminal?.element) {
341+
if (model.items.length === 0 || !this._terminal?.element || !this._promptInputModel) {
281342
return;
282343
}
283344
if (model.items.length === 1) {
@@ -288,29 +349,24 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
288349
return;
289350
}
290351
const suggestWidget = this._ensureSuggestWidget(this._terminal);
291-
this._additionalInput = undefined;
292352
const dimensions = this._getTerminalDimensions();
293353
if (!dimensions.width || !dimensions.height) {
294354
return;
295355
}
296356
// TODO: What do frozen and auto do?
297357
const xtermBox = this._screen!.getBoundingClientRect();
298358
const panelBox = this._panel!.offsetParent!.getBoundingClientRect();
359+
this._initialPromptInputState = {
360+
value: this._promptInputModel.value,
361+
cursorIndex: this._promptInputModel.cursorIndex,
362+
ghostTextIndex: this._promptInputModel.ghostTextIndex
363+
};
299364
suggestWidget.setCompletionModel(model);
300365
suggestWidget.showSuggestions(0, false, false, {
301366
left: (xtermBox.left - panelBox.left) + this._terminal.buffer.active.cursorX * dimensions.width,
302367
top: (xtermBox.top - panelBox.top) + this._terminal.buffer.active.cursorY * dimensions.height,
303368
height: dimensions.height
304369
});
305-
306-
// Flush the input queue if any characters were typed after a trigger character
307-
if (this._inputQueue) {
308-
const inputQueue = this._inputQueue;
309-
this._inputQueue = undefined;
310-
for (const data of inputQueue) {
311-
this._handleTerminalInput(data);
312-
}
313-
}
314370
}
315371

316372
private _ensureSuggestWidget(terminal: Terminal): SimpleSuggestWidget {
@@ -328,7 +384,14 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
328384
}));
329385
this._suggestWidget.onDidSelect(async e => this.acceptSelectedSuggestion(e));
330386
this._suggestWidget.onDidHide(() => this._terminalSuggestWidgetVisibleContextKey.set(false));
331-
this._suggestWidget.onDidShow(() => this._terminalSuggestWidgetVisibleContextKey.set(true));
387+
this._suggestWidget.onDidShow(() => {
388+
this._initialPromptInputState = {
389+
value: this._promptInputModel!.value,
390+
cursorIndex: this._promptInputModel!.cursorIndex,
391+
ghostTextIndex: this._promptInputModel!.ghostTextIndex
392+
};
393+
this._terminalSuggestWidgetVisibleContextKey.set(true);
394+
});
332395
}
333396
return this._suggestWidget;
334397
}
@@ -353,137 +416,39 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
353416
if (!suggestion) {
354417
suggestion = this._suggestWidget?.getFocusedItem();
355418
}
356-
if (suggestion && this._leadingLineContent) {
357-
this._suggestWidget?.hide();
358-
359-
// Send the completion
360-
this._onAcceptedCompletion.fire([
361-
// Disable suggestions
362-
'\x1b[24~y',
363-
// Right arrow to the end of the additional input
364-
'\x1b[C'.repeat(Math.max((this._additionalInput?.length ?? 0) - this._cursorIndexDelta, 0)),
365-
// Backspace to remove additional input
366-
'\x7F'.repeat(this._additionalInput?.length ?? 0),
367-
// Backspace to remove the replacement
368-
'\x7F'.repeat(suggestion.model.replacementLength),
369-
// Write the completion
370-
suggestion.item.completion.label,
371-
// Enable suggestions
372-
'\x1b[24~z',
373-
].join(''));
419+
const initialPromptInputState = this._initialPromptInputState ?? this._mostRecentPromptInputState;
420+
if (!suggestion || !initialPromptInputState) {
421+
return;
374422
}
375-
}
376-
377-
hideSuggestWidget(): void {
378423
this._suggestWidget?.hide();
379-
}
380424

381-
handleNonXtermData(data: string): void {
382-
this._handleTerminalInput(data, true);
425+
const currentPromptInputState = this._currentPromptInputState ?? initialPromptInputState;
426+
const additionalInput = currentPromptInputState.value.substring(initialPromptInputState.cursorIndex, currentPromptInputState.cursorIndex);
427+
428+
// We could start from a common prefix to reduce the number of characters we need to send
429+
const initialInput = initialPromptInputState.value.substring(0, initialPromptInputState.cursorIndex);
430+
const lastSpaceIndex = initialInput.lastIndexOf(' ');
431+
const finalCompletion = suggestion.item.completion.label.substring(initialPromptInputState.cursorIndex - (lastSpaceIndex === -1 ? 0 : lastSpaceIndex + 1));
432+
433+
// Send the completion
434+
this._onAcceptedCompletion.fire([
435+
// Disable suggestions
436+
'\x1b[24~y',
437+
// Backspace to remove all additional input
438+
'\x7F'.repeat(additionalInput.length),
439+
// Write the completion
440+
finalCompletion,
441+
// Enable suggestions
442+
'\x1b[24~z',
443+
].join(''));
444+
445+
this.hideSuggestWidget();
383446
}
384447

385-
private _handleTerminalInput(data: string, nonUserInput?: boolean): void {
386-
if (!this._terminal || !this._enableWidget || !this._terminalSuggestWidgetVisibleContextKey.get()) {
387-
// HACK: Buffer any input to be evaluated when the completions come in, this is needed
388-
// because conpty may "render" the completion request after input characters that
389-
// actually come after it. This can happen when typing quickly after a trigger
390-
// character, especially on a freshly launched session.
391-
if (data === '-') {
392-
this._inputQueue = [];
393-
} else {
394-
this._inputQueue?.push(data);
395-
}
396-
397-
return;
398-
}
399-
let handled = false;
400-
let handledCursorDelta = 0;
401-
402-
// Backspace
403-
if (data === '\x7f') {
404-
if (this._additionalInput && this._additionalInput.length > 0 && this._cursorIndexDelta > 0) {
405-
handled = true;
406-
this._additionalInput = this._additionalInput.substring(0, this._cursorIndexDelta - 1) + this._additionalInput.substring(this._cursorIndexDelta);
407-
this._cursorIndexDelta--;
408-
handledCursorDelta--;
409-
}
410-
}
411-
// Delete
412-
if (data === '\x1b[3~') {
413-
if (this._additionalInput && this._additionalInput.length > 0 && this._cursorIndexDelta < this._additionalInput.length - 1) {
414-
handled = true;
415-
this._additionalInput = this._additionalInput.substring(0, this._cursorIndexDelta) + this._additionalInput.substring(this._cursorIndexDelta + 1);
416-
}
417-
}
418-
// Left
419-
else if (data === '\x1b[D') {
420-
// If left goes beyond where the completion was requested, hide
421-
if (this._cursorIndexDelta > 0) {
422-
handled = true;
423-
this._cursorIndexDelta--;
424-
handledCursorDelta--;
425-
}
426-
}
427-
// Right
428-
else if (data === '\x1b[C') {
429-
// If right requests beyond where the completion was requested (potentially accepting a shell completion), hide
430-
if (this._additionalInput?.length !== this._cursorIndexDelta) {
431-
handled = true;
432-
this._cursorIndexDelta++;
433-
handledCursorDelta++;
434-
}
435-
}
436-
// Other CSI sequence (ignore)
437-
else if (data.match(/^\x1b\[.+[a-z@\^`{\|}~]$/i)) {
438-
handled = true;
439-
}
440-
if (data.match(/^[a-z0-9]$/i)) {
441-
442-
// TODO: There is a race here where the completions may come through after new character presses because of conpty's rendering!
443-
444-
handled = true;
445-
if (this._additionalInput === undefined) {
446-
this._additionalInput = '';
447-
}
448-
this._additionalInput += data;
449-
this._cursorIndexDelta++;
450-
handledCursorDelta++;
451-
}
452-
if (handled) {
453-
// typed -> moved cursor RIGHT -> update UI
454-
if (this._terminalSuggestWidgetVisibleContextKey.get()) {
455-
this._suggestWidget?.setLineContext(new LineContext(this._leadingLineContent! + (this._additionalInput ?? ''), this._additionalInput?.length ?? 0));
456-
}
457-
458-
// Hide and clear model if there are no more items
459-
if (!this._suggestWidget?.hasCompletions() || nonUserInput) {
460-
this._additionalInput = undefined;
461-
this.hideSuggestWidget();
462-
// TODO: Don't request every time; refine completions
463-
// this._onAcceptedCompletion.fire('\x1b[24~e');
464-
return;
465-
}
466-
467-
// TODO: Expose on xterm.js
468-
const dimensions = this._getTerminalDimensions();
469-
if (!dimensions.width || !dimensions.height) {
470-
return;
471-
}
472-
// TODO: What do frozen and auto do?
473-
const xtermBox = this._screen!.getBoundingClientRect();
474-
const panelBox = this._panel!.offsetParent!.getBoundingClientRect();
475-
476-
this._suggestWidget?.showSuggestions(0, false, false, {
477-
left: (xtermBox.left - panelBox.left) + (this._terminal.buffer.active.cursorX + handledCursorDelta) * dimensions.width,
478-
top: (xtermBox.top - panelBox.top) + this._terminal.buffer.active.cursorY * dimensions.height,
479-
height: dimensions.height
480-
});
481-
} else {
482-
this._additionalInput = undefined;
483-
this.hideSuggestWidget();
484-
// TODO: Don't request every time; refine completions
485-
// this._onAcceptedCompletion.fire('\x1b[24~e');
486-
}
448+
hideSuggestWidget(): void {
449+
this._initialPromptInputState = undefined;
450+
this._currentPromptInputState = undefined;
451+
this._suggestWidget?.hide();
487452
}
488453
}
489454

0 commit comments

Comments
 (0)