Skip to content

Commit

Permalink
Merge pull request #210690 from microsoft/tyriar/210662
Browse files Browse the repository at this point in the history
Detect ghost text in PromptInputModel
  • Loading branch information
Tyriar committed Apr 18, 2024
2 parents bc25233 + 558ccac commit 3be6cd6
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { debounce } from 'vs/base/common/decorators';

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

const enum PromptInputState {
Unknown,
Expand All @@ -26,6 +26,13 @@ export interface IPromptInputModel {

readonly value: string;
readonly cursorIndex: number;
readonly ghostTextIndex: number;

/**
* Gets the prompt input as a user-friendly string where `|` is the cursor position and `[` and
* `]` wrap any ghost text.
*/
getCombinedString(): string;
}

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

private _ghostTextIndex: number = -1;
get ghostTextIndex() { return this._ghostTextIndex; }

private readonly _onDidStartInput = this._register(new Emitter<void>());
readonly onDidStartInput = this._onDidStartInput.event;
private readonly _onDidChangeInput = this._register(new Emitter<void>());
Expand All @@ -67,6 +77,18 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
this._continuationPrompt = value;
}

getCombinedString(): string {
const value = this._value.replaceAll('\n', '\u23CE');
let result = `${value.substring(0, this.cursorIndex)}|`;
if (this.ghostTextIndex !== -1) {
result += `${value.substring(this.cursorIndex, this.ghostTextIndex)}[`;
result += `${value.substring(this.ghostTextIndex)}]`;
} else {
result += value.substring(this.cursorIndex);
}
return result;
}

private _handleCommandStart(command: { marker: IMarker }) {
if (this._state === PromptInputState.Input) {
return;
Expand Down Expand Up @@ -111,7 +133,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
const buffer = this._xterm.buffer.active;
let line = buffer.getLine(commandStartY);
const commandLine = line?.translateToString(true, this._commandStartX);
if (!commandLine || !line) {
if (!line || commandLine === undefined) {
this._logService.trace(`PromptInputModel#_sync: no line`);
return;
}
Expand All @@ -122,9 +144,15 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
// Get cursor index
const absoluteCursorY = buffer.baseY + buffer.cursorY;
this._cursorIndex = absoluteCursorY === commandStartY ? this._getRelativeCursorIndex(this._commandStartX, buffer, line) : commandLine.length + 1;
this._ghostTextIndex = -1;

// Detect ghost text by looking for italic or dim text in or after the cursor and
// non-italic/dim text in the cell closest non-whitespace cell before the cursor
if (absoluteCursorY === commandStartY && buffer.cursorX > 1) {
// Ghost text in pwsh only appears to happen on the cursor line
this._ghostTextIndex = this._scanForGhostText(buffer, line);
}

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

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

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

this._onDidChangeInput.fire();
}

/**
* Detect ghost text by looking for italic or dim text in or after the cursor and
* non-italic/dim text in the cell closest non-whitespace cell before the cursor.
*/
private _scanForGhostText(buffer: IBuffer, line: IBufferLine): number {
// Check last non-whitespace character has non-ghost text styles
let ghostTextIndex = -1;
let proceedWithGhostTextCheck = false;
let x = buffer.cursorX;
while (x > 0) {
const cell = line.getCell(--x);
if (!cell) {
break;
}
if (cell.getChars().trim().length > 0) {
proceedWithGhostTextCheck = !this._isCellStyledLikeGhostText(cell);
break;
}
}

// Check to the end of the line for possible ghost text. For example pwsh's ghost text
// can look like this `Get-|Ch[ildItem]`
if (proceedWithGhostTextCheck) {
let potentialGhostIndexOffset = 0;
let x = buffer.cursorX;
while (x < line.length) {
const cell = line.getCell(x++);
if (!cell || cell.getCode() === 0) {
break;
}
if (this._isCellStyledLikeGhostText(cell)) {
ghostTextIndex = this._cursorIndex + potentialGhostIndexOffset;
break;
}
potentialGhostIndexOffset += cell.getChars().length;
}
}

return ghostTextIndex;
}

private _trimContinuationPrompt(lineText: string): string {
if (this._lineContainsContinuationPrompt(lineText)) {
lineText = lineText.substring(this._continuationPrompt!.length);
Expand All @@ -192,4 +261,8 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
private _getRelativeCursorIndex(startCellX: number, buffer: IBuffer, line: IBufferLine): number {
return line?.translateToString(true, startCellX, buffer.cursorX).length ?? 0;
}

private _isCellStyledLikeGhostText(cell: IBufferCell): boolean {
return !!(cell.isItalic() || cell.isDim());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,14 @@ suite('PromptInputModel', () => {

promptInputModel.forceSync();

const actualValueWithCursor = promptInputModel.value.substring(0, promptInputModel.cursorIndex) + '|' + promptInputModel.value.substring(promptInputModel.cursorIndex);
const actualValueWithCursor = promptInputModel.getCombinedString();
strictEqual(
actualValueWithCursor.replaceAll('\n', '\u23CE'),
actualValueWithCursor,
valueWithCursor.replaceAll('\n', '\u23CE')
);

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

test('ghost text', async () => {
await writePromise('$ ');
fireCommandStart();
assertPromptInput('|');

await writePromise('foo\x1b[2m bar\x1b[0m\x1b[4D');
assertPromptInput('foo|[ bar]');

await writePromise('\x1b[2D');
assertPromptInput('f|oo[ bar]');
});

test('wide input (Korean)', async () => {
await writePromise('$ ');
fireCommandStart();
Expand Down Expand Up @@ -222,31 +234,31 @@ suite('PromptInputModel', () => {
'[?25lecho "hello world"[?25h',
'',
]);
assertPromptInput('e|cho "hello world"');
assertPromptInput('e|[cho "hello world"]');

await replayEvents([
'[?25lecho "hello world"[?25h',
'',
]);
assertPromptInput('ec|ho "hello world"');
assertPromptInput('ec|[ho "hello world"]');

await replayEvents([
'[?25lecho "hello world"[?25h',
'',
]);
assertPromptInput('ech|o "hello world"');
assertPromptInput('ech|[o "hello world"]');

await replayEvents([
'[?25lecho "hello world"[?25h',
'',
]);
assertPromptInput('echo| "hello world"');
assertPromptInput('echo|[ "hello world"]');

await replayEvents([
'[?25lecho "hello world"[?25h',
'',
]);
assertPromptInput('echo |"hello world"');
assertPromptInput('echo |["hello world"]');

await replayEvents([
'[?25lecho "hello world"[?25h',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,11 @@ class DevModeContribution extends Disposable implements ITerminalContribution {
private _updatePromptInputStatusBar(commandDetection: ICommandDetectionCapability) {
const promptInputModel = commandDetection.promptInputModel;
if (promptInputModel) {
const promptInput = promptInputModel.value.replaceAll('\n', '\u23CE');
const name = localize('terminalDevMode', 'Terminal Dev Mode');
this._statusbarEntry = {
name: localize('terminalDevMode', 'Terminal Dev Mode'),
text: `$(terminal) ${promptInput.substring(0, promptInputModel.cursorIndex)}|${promptInput.substring(promptInputModel.cursorIndex)}`,
ariaLabel: localize('terminalDevMode', 'Terminal Dev Mode'),
name,
text: `$(terminal) ${promptInputModel.getCombinedString()}`,
ariaLabel: name,
kind: 'prominent'
};
if (!this._statusbarEntryAccessor.value) {
Expand Down

0 comments on commit 3be6cd6

Please sign in to comment.