mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-05 21:07:16 +01:00
Support wide and emoji chars in prompt input model
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+146
-38
@@ -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',
|
||||
'[34m\r\n[38;2;17;17;17m[44m03:13:47 [34m[41m [38;2;17;17;17mvscode [31m[43m [38;2;17;17;17m tyriar/prompt_input_model [33m[46m [38;2;17;17;17m$⇡ [36m[49m [mvia [32m[1m v18.18.2 \r\n❯[m ',
|
||||
]);
|
||||
onCommandStart.fire({ marker: xterm.registerMarker() } as ITerminalCommand);
|
||||
promptInputModel.forceSync();
|
||||
fireCommandStart();
|
||||
assertPromptInput('|');
|
||||
|
||||
await replayEvents([
|
||||
@@ -84,7 +203,6 @@ suite('PromptInputModel', () => {
|
||||
'[?25l[93m[3;3Hfoo[?25h',
|
||||
'[m',
|
||||
]);
|
||||
promptInputModel.forceSync();
|
||||
assertPromptInput('foo|');
|
||||
});
|
||||
test('input with accepted and run ghost text', async () => {
|
||||
@@ -97,72 +215,62 @@ suite('PromptInputModel', () => {
|
||||
'[34m\r\n[38;2;17;17;17m[44m03:41:36 [34m[41m [38;2;17;17;17mvscode [31m[43m [38;2;17;17;17m tyriar/prompt_input_model [33m[46m [38;2;17;17;17m$ [36m[49m [mvia [32m[1m v18.18.2 \r\n❯[m ',
|
||||
]);
|
||||
promptInputModel.setContinuationPrompt('∙ ');
|
||||
onCommandStart.fire({ marker: xterm.registerMarker() } as ITerminalCommand);
|
||||
promptInputModel.forceSync();
|
||||
fireCommandStart();
|
||||
assertPromptInput('|');
|
||||
|
||||
await replayEvents([
|
||||
'[?25l[93me[97m[2m[3mcho "hello world"[3;4H[?25h',
|
||||
'[m',
|
||||
]);
|
||||
promptInputModel.forceSync();
|
||||
assertPromptInput('e|cho "hello world"');
|
||||
|
||||
await replayEvents([
|
||||
'[?25l[93mec[97m[2m[3mho "hello world"[3;5H[?25h',
|
||||
'[m',
|
||||
]);
|
||||
promptInputModel.forceSync();
|
||||
assertPromptInput('ec|ho "hello world"');
|
||||
|
||||
await replayEvents([
|
||||
'[?25l[93m[3;3Hech[97m[2m[3mo "hello world"[3;6H[?25h',
|
||||
'[m',
|
||||
]);
|
||||
promptInputModel.forceSync();
|
||||
assertPromptInput('ech|o "hello world"');
|
||||
|
||||
await replayEvents([
|
||||
'[?25l[93m[3;3Hecho[97m[2m[3m "hello world"[3;7H[?25h',
|
||||
'[m',
|
||||
]);
|
||||
promptInputModel.forceSync();
|
||||
assertPromptInput('echo| "hello world"');
|
||||
|
||||
await replayEvents([
|
||||
'[?25l[93m[3;3Hecho [97m[2m[3m"hello world"[3;8H[?25h',
|
||||
'[m',
|
||||
]);
|
||||
promptInputModel.forceSync();
|
||||
assertPromptInput('echo |"hello world"');
|
||||
|
||||
await replayEvents([
|
||||
'[?25l[93m[3;3Hecho [36m"hello world"[?25h',
|
||||
'[m',
|
||||
]);
|
||||
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',
|
||||
'[34m\r\n[38;2;17;17;17m[44m03:41:42 [34m[41m [38;2;17;17;17mvscode [31m[43m [38;2;17;17;17m tyriar/prompt_input_model [33m[46m [38;2;17;17;17m$ [36m[49m [mvia [32m[1m v18.18.2 \r\n❯[m ',
|
||||
]);
|
||||
onCommandStart.fire({ marker: xterm.registerMarker() } as ITerminalCommand);
|
||||
promptInputModel.forceSync();
|
||||
fireCommandStart();
|
||||
assertPromptInput('|');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user