Skip to content

Commit

Permalink
Merge pull request #210615 from microsoft/tyriar/prompt_input_model
Browse files Browse the repository at this point in the history
Introduce PromptInputModel that gives a more reliable and testable view of the current command line
  • Loading branch information
Tyriar committed Apr 18, 2024
2 parents aa2068d + 9c873bf commit 00c528c
Show file tree
Hide file tree
Showing 10 changed files with 550 additions and 36 deletions.
3 changes: 3 additions & 0 deletions src/vs/platform/terminal/common/capabilities/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { Event } from 'vs/base/common/event';
import { IDisposable } from 'vs/base/common/lifecycle';
import type { IPromptInputModel } from 'vs/platform/terminal/common/capabilities/commandDetection/promptInputModel';
import { ICurrentPartialCommand } from 'vs/platform/terminal/common/capabilities/commandDetection/terminalCommand';
import { ITerminalOutputMatch, ITerminalOutputMatcher } from 'vs/platform/terminal/common/terminal';
import { ReplayEntry } from 'vs/platform/terminal/common/terminalProcess';
Expand Down Expand Up @@ -161,6 +162,7 @@ export interface IBufferMarkCapability {

export interface ICommandDetectionCapability {
readonly type: TerminalCapability.CommandDetection;
readonly promptInputModel: IPromptInputModel;
readonly commands: readonly ITerminalCommand[];
/** The command currently being executed, otherwise undefined. */
readonly executingCommand: string | undefined;
Expand All @@ -178,6 +180,7 @@ export interface ICommandDetectionCapability {
readonly onCommandExecuted: Event<ITerminalCommand>;
readonly onCommandInvalidated: Event<ITerminalCommand[]>;
readonly onCurrentCommandInvalidated: Event<ICommandInvalidationRequest>;
setContinuationPrompt(value: string): void;
setCwd(value: string): void;
setIsWindowsPty(value: boolean): void;
setIsCommandStorageDisabled(): void;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

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, IBufferLine, IBuffer } from '@xterm/headless';

const enum PromptInputState {
Unknown,
Input,
Execute,
}

export interface IPromptInputModel {
readonly onDidStartInput: Event<void>;
readonly onDidChangeInput: Event<void>;
readonly onDidFinishInput: Event<void>;

readonly value: string;
readonly cursorIndex: number;
}

export class PromptInputModel extends Disposable implements IPromptInputModel {
private _state: PromptInputState = PromptInputState.Unknown;

private _commandStartMarker: IMarker | undefined;
private _commandStartX: number = 0;
private _continuationPrompt: string | undefined;

private _value: string = '';
get value() { return this._value; }

private _cursorIndex: number = 0;
get cursorIndex() { return this._cursorIndex; }

private readonly _onDidStartInput = this._register(new Emitter<void>());
readonly onDidStartInput = this._onDidStartInput.event;
private readonly _onDidChangeInput = this._register(new Emitter<void>());
readonly onDidChangeInput = this._onDidChangeInput.event;
private readonly _onDidFinishInput = this._register(new Emitter<void>());
readonly onDidFinishInput = this._onDidFinishInput.event;

constructor(
private readonly _xterm: Terminal,
onCommandStart: Event<ITerminalCommand>,
onCommandExecuted: Event<ITerminalCommand>,
@ILogService private readonly _logService: ILogService
) {
super();

this._register(this._xterm.onData(e => this._handleInput(e)));
this._register(this._xterm.onCursorMove(() => this._sync()));

this._register(onCommandStart(e => this._handleCommandStart(e as { marker: IMarker })));
this._register(onCommandExecuted(() => this._handleCommandExecuted()));
}

setContinuationPrompt(value: string): void {
this._continuationPrompt = value;
}

private _handleCommandStart(command: { marker: IMarker }) {
if (this._state === PromptInputState.Input) {
return;
}

this._state = PromptInputState.Input;
this._commandStartMarker = command.marker;
this._commandStartX = this._xterm.buffer.active.cursorX;
this._value = '';
this._cursorIndex = 0;
this._onDidStartInput.fire();
}

private _handleCommandExecuted() {
if (this._state === PromptInputState.Execute) {
return;
}

this._state = PromptInputState.Execute;
this._onDidFinishInput.fire();
}

private _handleInput(data: string) {
this._sync();
}

@debounce(50)
private _sync() {
this._syncNow();
}

protected _syncNow() {
if (this._state !== PromptInputState.Input) {
return;
}

const commandStartY = this._commandStartMarker?.line;
if (commandStartY === undefined) {
return;
}

const buffer = this._xterm.buffer.active;
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;

// 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
for (let y = commandStartY + 1; y <= absoluteCursorY; y++) {
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}`;
this._cursorIndex += (absoluteCursorY === y
? this._getRelativeCursorIndex(this._getContinuationPromptCellWidth(line, lineText), buffer, line)
: lineText.length + 1);
} else {
break;
}
}
}

// Below cursor line
for (let y = absoluteCursorY + 1; y < buffer.baseY + this._xterm.rows; y++) {
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 {
break;
}
}
}

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

this._onDidChangeInput.fire();
}

private _trimContinuationPrompt(lineText: string): string {
if (this._lineContainsContinuationPrompt(lineText)) {
lineText = lineText.substring(this._continuationPrompt!.length);
}
return lineText;
}

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 = '';
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import { Disposable, MandatoryMutableDisposable, MutableDisposable } from 'vs/ba
import { ILogService } from 'vs/platform/log/common/log';
import { CommandInvalidationReason, ICommandDetectionCapability, ICommandInvalidationRequest, IHandleCommandOptions, ISerializedCommandDetectionCapability, ISerializedTerminalCommand, ITerminalCommand, IXtermMarker, TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities';
import { ITerminalOutputMatcher } from 'vs/platform/terminal/common/terminal';
import { ICurrentPartialCommand, PartialTerminalCommand, TerminalCommand } from 'vs/platform/terminal/common/capabilities/commandDetection/terminalCommand';
import { PromptInputModel, type IPromptInputModel } from 'vs/platform/terminal/common/capabilities/commandDetection/promptInputModel';

// Importing types is safe in any layer
// eslint-disable-next-line local/code-import-patterns
import type { IBuffer, IDisposable, IMarker, Terminal } from '@xterm/headless';
import { ICurrentPartialCommand, PartialTerminalCommand, TerminalCommand } from 'vs/platform/terminal/common/capabilities/commandDetection/terminalCommand';

interface ITerminalDimensions {
cols: number;
Expand All @@ -24,6 +25,9 @@ interface ITerminalDimensions {
export class CommandDetectionCapability extends Disposable implements ICommandDetectionCapability {
readonly type = TerminalCapability.CommandDetection;

private readonly _promptInputModel: PromptInputModel;
get promptInputModel(): IPromptInputModel { return this._promptInputModel; }

protected _commands: TerminalCommand[] = [];
private _cwd: string | undefined;
private _currentCommand: PartialTerminalCommand = new PartialTerminalCommand(this._terminal);
Expand Down Expand Up @@ -87,6 +91,8 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe
) {
super();

this._promptInputModel = this._register(new PromptInputModel(this._terminal, this.onCommandStarted, this.onCommandFinished, this._logService));

// Pull command line from the buffer if it was not set explicitly
this._register(this.onCommandExecuted(command => {
if (command.commandLineConfidence !== 'high') {
Expand Down Expand Up @@ -193,6 +199,10 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe
}
}

setContinuationPrompt(value: string): void {
this._promptInputModel.setContinuationPrompt(value);
}

setCwd(value: string) {
this._cwd = value;
}
Expand Down
18 changes: 18 additions & 0 deletions src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ const enum VSCodeOscPt {
* - `IsWindows` - Indicates whether the terminal is using a Windows backend like winpty or
* conpty. This may be used to enable additional heuristics as the positioning of the shell
* integration sequences are not guaranteed to be correct. Valid values: `True`, `False`.
* - `ContinuationPrompt` - Reports the continuation prompt that is printed at the start of
* multi-line inputs.
*
* WARNING: Any other properties may be changed and are not guaranteed to work in the future.
*/
Expand Down Expand Up @@ -379,6 +381,15 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati
return true;
}
switch (key) {
case 'ContinuationPrompt': {
// Exclude escape sequences and values between \[ and \]
const sanitizedValue = (value
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/\\\[.*?\\\]/g, '')
);
this._updateContinuationPrompt(sanitizedValue);
return true;
}
case 'Cwd': {
this._updateCwd(value);
return true;
Expand All @@ -404,6 +415,13 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati
return false;
}

private _updateContinuationPrompt(value: string) {
if (!this._terminal) {
return;
}
this._createOrGetCommandDetection(this._terminal).setContinuationPrompt(value);
}

private _updateCwd(value: string) {
value = sanitizeCwd(value);
this._createOrGetCwdDetection().updateCwd(value);
Expand Down

0 comments on commit 00c528c

Please sign in to comment.