Skip to content

Commit 74f2888

Browse files
committed
Detect ghost text in input
Part of microsoft#210662
1 parent 00c528c commit 74f2888

File tree

2 files changed

+79
-9
lines changed

2 files changed

+79
-9
lines changed

src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { debounce } from 'vs/base/common/decorators';
1111

1212
// Importing types is safe in any layer
1313
// eslint-disable-next-line local/code-import-patterns
14-
import type { Terminal, IMarker, IBufferLine, IBuffer } from '@xterm/headless';
14+
import type { Terminal, IMarker, IBufferCell, IBufferLine, IBuffer } from '@xterm/headless';
1515

1616
const enum PromptInputState {
1717
Unknown,
@@ -26,6 +26,13 @@ export interface IPromptInputModel {
2626

2727
readonly value: string;
2828
readonly cursorIndex: number;
29+
readonly ghostTextIndex: number;
30+
31+
/**
32+
* Gets the prompt input as a user-friendly string where `|` is the cursor position and `[` and
33+
* `]` wrap any ghost text.
34+
*/
35+
getCombinedString(): string;
2936
}
3037

3138
export class PromptInputModel extends Disposable implements IPromptInputModel {
@@ -41,6 +48,9 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
4148
private _cursorIndex: number = 0;
4249
get cursorIndex() { return this._cursorIndex; }
4350

51+
private _ghostTextIndex: number = -1;
52+
get ghostTextIndex() { return this._ghostTextIndex; }
53+
4454
private readonly _onDidStartInput = this._register(new Emitter<void>());
4555
readonly onDidStartInput = this._onDidStartInput.event;
4656
private readonly _onDidChangeInput = this._register(new Emitter<void>());
@@ -67,6 +77,18 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
6777
this._continuationPrompt = value;
6878
}
6979

80+
getCombinedString(): string {
81+
const value = this._value.replaceAll('\n', '\u23CE');
82+
let result = `${value.substring(0, this.cursorIndex)}|`;
83+
if (this.ghostTextIndex !== -1) {
84+
result += `${value.substring(this.cursorIndex, this.ghostTextIndex)}[`;
85+
result += `${value.substring(this.ghostTextIndex)}]`;
86+
} else {
87+
result += value.substring(this.cursorIndex);
88+
}
89+
return result;
90+
}
91+
7092
private _handleCommandStart(command: { marker: IMarker }) {
7193
if (this._state === PromptInputState.Input) {
7294
return;
@@ -111,7 +133,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
111133
const buffer = this._xterm.buffer.active;
112134
let line = buffer.getLine(commandStartY);
113135
const commandLine = line?.translateToString(true, this._commandStartX);
114-
if (!commandLine || !line) {
136+
if (!line || commandLine === undefined) {
115137
this._logService.trace(`PromptInputModel#_sync: no line`);
116138
return;
117139
}
@@ -122,9 +144,14 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
122144
// Get cursor index
123145
const absoluteCursorY = buffer.baseY + buffer.cursorY;
124146
this._cursorIndex = absoluteCursorY === commandStartY ? this._getRelativeCursorIndex(this._commandStartX, buffer, line) : commandLine.length + 1;
147+
this._ghostTextIndex = -1;
148+
149+
// Detect ghost text by looking for italic or dim text in or after the cursor and
150+
// non-italic/dim text in the cell closest non-whitespace cell before the cursor
151+
if (absoluteCursorY === commandStartY && buffer.cursorX > 1) {
152+
this._ghostTextIndex = this._scanForGhostText(buffer, line);
153+
}
125154

126-
// IDEA: Detect ghost text based on SGR and cursor. This might work by checking for italic
127-
// or dim only to avoid false positives from shells that do immediate coloring.
128155
// IDEA: Detect line continuation if it's not set
129156

130157
// From command start line to cursor line
@@ -160,12 +187,51 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
160187
}
161188

162189
if (this._logService.getLevel() === LogLevel.Trace) {
163-
this._logService.trace(`PromptInputModel#_sync: Input="${this._value.substring(0, this._cursorIndex)}|${this.value.substring(this._cursorIndex)}"`);
190+
this._logService.trace(`PromptInputModel#_sync: ${this.getCombinedString()}`);
164191
}
165192

166193
this._onDidChangeInput.fire();
167194
}
168195

196+
/**
197+
* Detect ghost text by looking for italic or dim text in or after the cursor and
198+
* non-italic/dim text in the cell closest non-whitespace cell before the cursor.
199+
*/
200+
private _scanForGhostText(buffer: IBuffer, line: IBufferLine): number {
201+
// Check last non-whitespace character has non-ghost text styles
202+
let ghostTextIndex = -1;
203+
let proceedWithGhostTextCheck = false;
204+
let x = buffer.cursorX;
205+
while (x > 1) {
206+
const cell = line.getCell(--x);
207+
if (!cell) {
208+
break;
209+
}
210+
if (cell.getChars().trim().length > 0) {
211+
proceedWithGhostTextCheck = !this._isCellStyledLikeGhostText(cell);
212+
break;
213+
}
214+
}
215+
216+
// Check to the end of the line for possible ghost text. For example pwsh's ghost text
217+
// can look like this `Get-|Ch[ildItem]`
218+
if (proceedWithGhostTextCheck) {
219+
let x = buffer.cursorX;
220+
while (x < line.length) {
221+
const cell = line.getCell(x++);
222+
if (!cell || cell.getCode() === 0) {
223+
break;
224+
}
225+
if (this._isCellStyledLikeGhostText(cell)) {
226+
ghostTextIndex = this._cursorIndex;
227+
break;
228+
}
229+
}
230+
}
231+
232+
return ghostTextIndex;
233+
}
234+
169235
private _trimContinuationPrompt(lineText: string): string {
170236
if (this._lineContainsContinuationPrompt(lineText)) {
171237
lineText = lineText.substring(this._continuationPrompt!.length);
@@ -192,4 +258,8 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
192258
private _getRelativeCursorIndex(startCellX: number, buffer: IBuffer, line: IBufferLine): number {
193259
return line?.translateToString(true, startCellX, buffer.cursorX).length ?? 0;
194260
}
261+
262+
private _isCellStyledLikeGhostText(cell: IBufferCell): boolean {
263+
return !!(cell.isItalic() || cell.isDim());
264+
}
195265
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,11 +245,11 @@ class DevModeContribution extends Disposable implements ITerminalContribution {
245245
private _updatePromptInputStatusBar(commandDetection: ICommandDetectionCapability) {
246246
const promptInputModel = commandDetection.promptInputModel;
247247
if (promptInputModel) {
248-
const promptInput = promptInputModel.value.replaceAll('\n', '\u23CE');
248+
const name = localize('terminalDevMode', 'Terminal Dev Mode');
249249
this._statusbarEntry = {
250-
name: localize('terminalDevMode', 'Terminal Dev Mode'),
251-
text: `$(terminal) ${promptInput.substring(0, promptInputModel.cursorIndex)}|${promptInput.substring(promptInputModel.cursorIndex)}`,
252-
ariaLabel: localize('terminalDevMode', 'Terminal Dev Mode'),
250+
name,
251+
text: `$(terminal) ${promptInputModel.getCombinedString()}`,
252+
ariaLabel: name,
253253
kind: 'prominent'
254254
};
255255
if (!this._statusbarEntryAccessor.value) {

0 commit comments

Comments
 (0)