Support wide and emoji chars in prompt input model

This commit is contained in:
Daniel Imms
2024-04-18 07:48:27 -07:00
parent c2a14c8496
commit e1d3e77d92
3 changed files with 188 additions and 59 deletions
@@ -7,11 +7,11 @@ import { Emitter, type Event } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import { ILogService, LogLevel } from 'vs/platform/log/common/log';
import type { ITerminalCommand } from 'vs/platform/terminal/common/capabilities/capabilities';
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 } from '@xterm/headless';
import { debounce } from 'vs/base/common/decorators';
import type { Terminal, IMarker, IBufferLine, IBuffer } from '@xterm/headless';
const enum PromptInputState {
Unknown,
@@ -75,6 +75,8 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
this._state = PromptInputState.Input;
this._commandStartMarker = command.marker;
this._commandStartX = this._xterm.buffer.active.cursorX;
this._value = '';
this._cursorIndex = 0;
this._onDidStartInput.fire();
}
@@ -102,40 +104,43 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
}
const commandStartY = this._commandStartMarker?.line;
if (!commandStartY) {
if (commandStartY === undefined) {
return;
}
const buffer = this._xterm.buffer.active;
const commandLine = buffer.getLine(commandStartY)?.translateToString(true);
if (!commandLine) {
let line = buffer.getLine(commandStartY);
const commandLine = line?.translateToString(true, this._commandStartX);
if (!commandLine || !line) {
this._logService.trace(`PromptInputModel#_sync: no line`);
return;
}
// Command start line
this._value = commandLine.substring(this._commandStartX);
this._cursorIndex = Math.max(buffer.cursorX - this._commandStartX, 0);
this._value = commandLine;
// IDEA: Reinforce knowledge of prompt to avoid incorrect commandStart
// IDEA: Detect ghost text based on SGR and cursor
// Get cursor index
const absoluteCursorY = buffer.baseY + buffer.cursorY;
this._cursorIndex = absoluteCursorY === commandStartY ? this._getRelativeCursorIndex(this._commandStartX, buffer, line) : commandLine.length + 1;
// 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
const absoluteCursorY = buffer.baseY + buffer.cursorY;
for (let y = commandStartY + 1; y <= absoluteCursorY; y++) {
let lineText = buffer.getLine(y)?.translateToString(true);
if (lineText) {
line = buffer.getLine(y);
let lineText = line?.translateToString(true);
if (lineText && line) {
// Verify continuation prompt if we have it, if this line doesn't have it then the
// user likely just pressed enter
if (this._continuationPrompt === undefined || this._lineContainsContinuationPrompt(lineText)) {
lineText = this._trimContinuationPrompt(lineText);
this._value += `\n${lineText}`;
if (y === absoluteCursorY) {
// TODO: Wide/emoji length support
this._cursorIndex = Math.max(this._value.length - lineText.length - (this._continuationPrompt?.length ?? 0) + buffer.cursorX, 0);
}
this._cursorIndex += (absoluteCursorY === y
? this._getRelativeCursorIndex(this._getContinuationPromptCellWidth(line, lineText), buffer, line)
: lineText.length + 1);
} else {
this._cursorIndex = this._value.length;
break;
}
}
@@ -143,8 +148,9 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
// Below cursor line
for (let y = absoluteCursorY + 1; y < buffer.baseY + this._xterm.rows; y++) {
const lineText = buffer.getLine(y)?.translateToString(true);
if (lineText) {
line = buffer.getLine(y);
const lineText = line?.translateToString(true);
if (lineText && line) {
if (this._continuationPrompt === undefined || this._lineContainsContinuationPrompt(lineText)) {
this._value += `\n${this._trimContinuationPrompt(lineText)}`;
} else {
@@ -161,7 +167,6 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
}
private _trimContinuationPrompt(lineText: string): string {
// TODO: Detect line continuation if it's not set
if (this._lineContainsContinuationPrompt(lineText)) {
lineText = lineText.substring(this._continuationPrompt!.length);
}
@@ -171,4 +176,20 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
private _lineContainsContinuationPrompt(lineText: string): boolean {
return !!(this._continuationPrompt && lineText.startsWith(this._continuationPrompt));
}
private _getContinuationPromptCellWidth(line: IBufferLine, lineText: string): number {
if (!this._continuationPrompt || !lineText.startsWith(this._continuationPrompt)) {
return 0;
}
let buffer: string = '';
let x = 0;
while (buffer !== this._continuationPrompt) {
buffer += line.getCell(x++)!.getChars();
}
return x;
}
private _getRelativeCursorIndex(startCellX: number, buffer: IBuffer, line: IBufferLine): number {
return line?.translateToString(true, startCellX, buffer.cursorX).length ?? 0;
}
}
@@ -21,18 +21,155 @@ class TestPromptInputModel extends PromptInputModel {
suite('PromptInputModel', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
let promptInputModel: TestPromptInputModel;
let xterm: Terminal;
let onCommandStart: Emitter<ITerminalCommand>;
let onCommandExecuted: Emitter<ITerminalCommand>;
async function writePromise(data: string) {
await new Promise<void>(r => xterm.write(data, r));
}
function fireCommandStart() {
onCommandStart.fire({ marker: xterm.registerMarker() } as ITerminalCommand);
}
function fireCommandExecuted() {
onCommandExecuted.fire(null!);
}
function assertPromptInput(valueWithCursor: string) {
if (!valueWithCursor.includes('|')) {
throw new Error('assertPromptInput must contain | character');
}
promptInputModel.forceSync();
const actualValueWithCursor = promptInputModel.value.substring(0, promptInputModel.cursorIndex) + '|' + promptInputModel.value.substring(promptInputModel.cursorIndex);
strictEqual(
actualValueWithCursor.replaceAll('\n', '\u23CE'),
valueWithCursor.replaceAll('\n', '\u23CE')
);
// This is required to ensure the cursor index is correctly resolved for non-ascii characters
const value = valueWithCursor.replace('|', '');
const cursorIndex = valueWithCursor.indexOf('|');
strictEqual(promptInputModel.value, value);
strictEqual(promptInputModel.cursorIndex, cursorIndex, `value=${promptInputModel.value}`);
}
setup(() => {
xterm = new Terminal({ allowProposedApi: true });
onCommandStart = new Emitter();
onCommandExecuted = new Emitter();
xterm = store.add(new Terminal({ allowProposedApi: true }));
onCommandStart = store.add(new Emitter());
onCommandExecuted = store.add(new Emitter());
promptInputModel = store.add(new TestPromptInputModel(xterm, onCommandStart.event, onCommandExecuted.event, new NullLogService));
});
test('basic input and execute', async () => {
await writePromise('$ ');
fireCommandStart();
assertPromptInput('|');
await writePromise('foo bar');
assertPromptInput('foo bar|');
await writePromise('\r\n');
fireCommandExecuted();
assertPromptInput('foo bar|');
await writePromise('(command output)\r\n$ ');
fireCommandStart();
assertPromptInput('|');
});
test('cursor navigation', async () => {
await writePromise('$ ');
fireCommandStart();
assertPromptInput('|');
await writePromise('foo bar');
assertPromptInput('foo bar|');
await writePromise('\x1b[3D');
assertPromptInput('foo |bar');
await writePromise('\x1b[4D');
assertPromptInput('|foo bar');
await writePromise('\x1b[3C');
assertPromptInput('foo| bar');
await writePromise('\x1b[4C');
assertPromptInput('foo bar|');
await writePromise('\x1b[D');
assertPromptInput('foo ba|r');
await writePromise('\x1b[C');
assertPromptInput('foo bar|');
});
test('wide input (Korean)', async () => {
await writePromise('$ ');
fireCommandStart();
assertPromptInput('|');
await writePromise('안');
assertPromptInput('안|');
await writePromise('\r\n영');
assertPromptInput('안\n영|');
await writePromise('\r\n이');
assertPromptInput('안\n영\n이|');
await writePromise('\x1b[G');
assertPromptInput('안\n영\n|이');
await writePromise('\x1b[A');
assertPromptInput('안\n|영\n이');
await writePromise('\x1b[C');
assertPromptInput('안\n영|\n이');
await writePromise('\x1b[1;4H');
assertPromptInput('안|\n영\n이');
await writePromise('\x1b[D');
assertPromptInput('|안\n영\n이');
});
test('emoji input', async () => {
await writePromise('$ ');
fireCommandStart();
assertPromptInput('|');
await writePromise('👋');
assertPromptInput('👋|');
await writePromise('\r\n👍');
assertPromptInput('👋\n👍|');
await writePromise('\r\n✌️');
assertPromptInput('👋\n👍\n✌️|');
await writePromise('\x1b[G');
assertPromptInput('👋\n👍\n|✌️');
await writePromise('\x1b[A');
assertPromptInput('👋\n|👍\n✌️');
await writePromise('\x1b[C');
assertPromptInput('👋\n👍|\n✌️');
await writePromise('\x1b[1;4H');
assertPromptInput('👋|\n👍\n✌️');
await writePromise('\x1b[D');
assertPromptInput('|👋\n👍\n✌️');
});
// To "record a session" for these tests:
// - Enable debug logging
// - Open and clear Terminal output channel
@@ -40,28 +177,11 @@ suite('PromptInputModel', () => {
// - Extract all "parsing data" lines from the terminal
suite('recorded sessions', () => {
async function replayEvents(events: string[]) {
for (const e of events) {
await new Promise<void>(r => xterm.write(e, r));
for (const data of events) {
await writePromise(data);
}
}
function assertPromptInput(valueWithCursor: string) {
if (!valueWithCursor.includes('|')) {
throw new Error('assertPromptInput must contain | character');
}
const actualValueWithCursor = promptInputModel.value.substring(0, promptInputModel.cursorIndex) + '|' + promptInputModel.value.substring(promptInputModel.cursorIndex);
strictEqual(
actualValueWithCursor.replaceAll('\n', '\u23CE'),
valueWithCursor.replaceAll('\n', '\u23CE')
);
// This shouldn't be needed but include as a sanity check
const value = valueWithCursor.replace('|', '');
const cursorIndex = valueWithCursor.indexOf('|');
strictEqual(promptInputModel.value, value);
strictEqual(promptInputModel.cursorIndex, cursorIndex,);
}
suite('Windows 11 (10.0.22621.3447), pwsh 7.4.2, starship prompt 1.10.2', () => {
test('input with ignored ghost text', async () => {
await replayEvents([
@@ -72,8 +192,7 @@ suite('PromptInputModel', () => {
']633;A]633;P;Cwd=C:\x5cGithub\x5cmicrosoft\x5cvscode]633;B',
'\r\n03:13:47  vscode   tyriar/prompt_input_model  $⇡  via  v18.18.2 \r\n ',
]);
onCommandStart.fire({ marker: xterm.registerMarker() } as ITerminalCommand);
promptInputModel.forceSync();
fireCommandStart();
assertPromptInput('|');
await replayEvents([
@@ -84,7 +203,6 @@ suite('PromptInputModel', () => {
'[?25lfoo[?25h',
'',
]);
promptInputModel.forceSync();
assertPromptInput('foo|');
});
test('input with accepted and run ghost text', async () => {
@@ -97,72 +215,62 @@ suite('PromptInputModel', () => {
'\r\n03:41:36  vscode   tyriar/prompt_input_model  $  via  v18.18.2 \r\n ',
]);
promptInputModel.setContinuationPrompt('∙ ');
onCommandStart.fire({ marker: xterm.registerMarker() } as ITerminalCommand);
promptInputModel.forceSync();
fireCommandStart();
assertPromptInput('|');
await replayEvents([
'[?25lecho "hello world"[?25h',
'',
]);
promptInputModel.forceSync();
assertPromptInput('e|cho "hello world"');
await replayEvents([
'[?25lecho "hello world"[?25h',
'',
]);
promptInputModel.forceSync();
assertPromptInput('ec|ho "hello world"');
await replayEvents([
'[?25lecho "hello world"[?25h',
'',
]);
promptInputModel.forceSync();
assertPromptInput('ech|o "hello world"');
await replayEvents([
'[?25lecho "hello world"[?25h',
'',
]);
promptInputModel.forceSync();
assertPromptInput('echo| "hello world"');
await replayEvents([
'[?25lecho "hello world"[?25h',
'',
]);
promptInputModel.forceSync();
assertPromptInput('echo |"hello world"');
await replayEvents([
'[?25lecho "hello world"[?25h',
'',
]);
promptInputModel.forceSync();
assertPromptInput('echo "hello world"|');
await replayEvents([
']633;E;echo "hello world";ff464d39-bc80-4bae-9ead-b1cafc4adf6f]633;C',
]);
onCommandExecuted.fire(null!);
promptInputModel.forceSync();
fireCommandExecuted();
assertPromptInput('echo "hello world"|');
await replayEvents([
'\r\n',
'hello world\r\n',
]);
promptInputModel.forceSync();
assertPromptInput('echo "hello world"|');
await replayEvents([
']633;D;0]633;A]633;P;Cwd=C:\x5cGithub\x5cmicrosoft\x5cvscode]633;B',
'\r\n03:41:42  vscode   tyriar/prompt_input_model  $  via  v18.18.2 \r\n ',
]);
onCommandStart.fire({ marker: xterm.registerMarker() } as ITerminalCommand);
promptInputModel.forceSync();
fireCommandStart();
assertPromptInput('|');
});
});
@@ -250,7 +250,7 @@ class DevModeContribution extends Disposable implements ITerminalContribution {
name: localize('terminalDevMode', 'Terminal Dev Mode'),
text: `$(terminal) ${promptInput.substring(0, promptInputModel.cursorIndex)}|${promptInput.substring(promptInputModel.cursorIndex)}`,
ariaLabel: localize('terminalDevMode', 'Terminal Dev Mode'),
kind: 'warning'
kind: 'prominent'
};
if (!this._statusbarEntryAccessor.value) {
this._statusbarEntryAccessor.value = this._statusbarService.addEntry(this._statusbarEntry, `terminal.promptInput.${this._instance.instanceId}`, StatusbarAlignment.LEFT);