diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 343d2f16a4b..6a05eac3c78 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -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; + } } diff --git a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts index 92dd6cd1d87..36c1cce5158 100644 --- a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts +++ b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts @@ -21,18 +21,155 @@ class TestPromptInputModel extends PromptInputModel { suite('PromptInputModel', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + let promptInputModel: TestPromptInputModel; let xterm: Terminal; let onCommandStart: Emitter; let onCommandExecuted: Emitter; + async function writePromise(data: string) { + await new Promise(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(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('|'); }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts b/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts index 55cf3bf2b50..444a6717955 100644 --- a/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts @@ -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);