Skip to content

Commit 3be6cd6

Browse files
authored
Merge pull request microsoft#210690 from microsoft/tyriar/210662
Detect ghost text in PromptInputModel
2 parents bc25233 + 558ccac commit 3be6cd6

File tree

3 files changed

+102
-17
lines changed

3 files changed

+102
-17
lines changed

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

Lines changed: 78 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,15 @@ 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+
// Ghost text in pwsh only appears to happen on the cursor line
153+
this._ghostTextIndex = this._scanForGhostText(buffer, line);
154+
}
125155

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.
128156
// IDEA: Detect line continuation if it's not set
129157

130158
// From command start line to cursor line
@@ -160,12 +188,53 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
160188
}
161189

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

166194
this._onDidChangeInput.fire();
167195
}
168196

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

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

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,14 @@ suite('PromptInputModel', () => {
4646

4747
promptInputModel.forceSync();
4848

49-
const actualValueWithCursor = promptInputModel.value.substring(0, promptInputModel.cursorIndex) + '|' + promptInputModel.value.substring(promptInputModel.cursorIndex);
49+
const actualValueWithCursor = promptInputModel.getCombinedString();
5050
strictEqual(
51-
actualValueWithCursor.replaceAll('\n', '\u23CE'),
51+
actualValueWithCursor,
5252
valueWithCursor.replaceAll('\n', '\u23CE')
5353
);
5454

5555
// This is required to ensure the cursor index is correctly resolved for non-ascii characters
56-
const value = valueWithCursor.replace('|', '');
56+
const value = valueWithCursor.replace(/[\|\[\]]/g, '');
5757
const cursorIndex = valueWithCursor.indexOf('|');
5858
strictEqual(promptInputModel.value, value);
5959
strictEqual(promptInputModel.cursorIndex, cursorIndex, `value=${promptInputModel.value}`);
@@ -110,6 +110,18 @@ suite('PromptInputModel', () => {
110110
assertPromptInput('foo bar|');
111111
});
112112

113+
test('ghost text', async () => {
114+
await writePromise('$ ');
115+
fireCommandStart();
116+
assertPromptInput('|');
117+
118+
await writePromise('foo\x1b[2m bar\x1b[0m\x1b[4D');
119+
assertPromptInput('foo|[ bar]');
120+
121+
await writePromise('\x1b[2D');
122+
assertPromptInput('f|oo[ bar]');
123+
});
124+
113125
test('wide input (Korean)', async () => {
114126
await writePromise('$ ');
115127
fireCommandStart();
@@ -222,31 +234,31 @@ suite('PromptInputModel', () => {
222234
'[?25lecho "hello world"[?25h',
223235
'',
224236
]);
225-
assertPromptInput('e|cho "hello world"');
237+
assertPromptInput('e|[cho "hello world"]');
226238

227239
await replayEvents([
228240
'[?25lecho "hello world"[?25h',
229241
'',
230242
]);
231-
assertPromptInput('ec|ho "hello world"');
243+
assertPromptInput('ec|[ho "hello world"]');
232244

233245
await replayEvents([
234246
'[?25lecho "hello world"[?25h',
235247
'',
236248
]);
237-
assertPromptInput('ech|o "hello world"');
249+
assertPromptInput('ech|[o "hello world"]');
238250

239251
await replayEvents([
240252
'[?25lecho "hello world"[?25h',
241253
'',
242254
]);
243-
assertPromptInput('echo| "hello world"');
255+
assertPromptInput('echo|[ "hello world"]');
244256

245257
await replayEvents([
246258
'[?25lecho "hello world"[?25h',
247259
'',
248260
]);
249-
assertPromptInput('echo |"hello world"');
261+
assertPromptInput('echo |["hello world"]');
250262

251263
await replayEvents([
252264
'[?25lecho "hello world"[?25h',

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)