Skip to content

Commit 00c528c

Browse files
authored
Merge pull request microsoft#210615 from microsoft/tyriar/prompt_input_model
Introduce PromptInputModel that gives a more reliable and testable view of the current command line
2 parents aa2068d + 9c873bf commit 00c528c

File tree

10 files changed

+550
-36
lines changed

10 files changed

+550
-36
lines changed

src/vs/platform/terminal/common/capabilities/capabilities.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { Event } from 'vs/base/common/event';
77
import { IDisposable } from 'vs/base/common/lifecycle';
8+
import type { IPromptInputModel } from 'vs/platform/terminal/common/capabilities/commandDetection/promptInputModel';
89
import { ICurrentPartialCommand } from 'vs/platform/terminal/common/capabilities/commandDetection/terminalCommand';
910
import { ITerminalOutputMatch, ITerminalOutputMatcher } from 'vs/platform/terminal/common/terminal';
1011
import { ReplayEntry } from 'vs/platform/terminal/common/terminalProcess';
@@ -161,6 +162,7 @@ export interface IBufferMarkCapability {
161162

162163
export interface ICommandDetectionCapability {
163164
readonly type: TerminalCapability.CommandDetection;
165+
readonly promptInputModel: IPromptInputModel;
164166
readonly commands: readonly ITerminalCommand[];
165167
/** The command currently being executed, otherwise undefined. */
166168
readonly executingCommand: string | undefined;
@@ -178,6 +180,7 @@ export interface ICommandDetectionCapability {
178180
readonly onCommandExecuted: Event<ITerminalCommand>;
179181
readonly onCommandInvalidated: Event<ITerminalCommand[]>;
180182
readonly onCurrentCommandInvalidated: Event<ICommandInvalidationRequest>;
183+
setContinuationPrompt(value: string): void;
181184
setCwd(value: string): void;
182185
setIsWindowsPty(value: boolean): void;
183186
setIsCommandStorageDisabled(): void;
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { Emitter, type Event } from 'vs/base/common/event';
7+
import { Disposable } from 'vs/base/common/lifecycle';
8+
import { ILogService, LogLevel } from 'vs/platform/log/common/log';
9+
import type { ITerminalCommand } from 'vs/platform/terminal/common/capabilities/capabilities';
10+
import { debounce } from 'vs/base/common/decorators';
11+
12+
// Importing types is safe in any layer
13+
// eslint-disable-next-line local/code-import-patterns
14+
import type { Terminal, IMarker, IBufferLine, IBuffer } from '@xterm/headless';
15+
16+
const enum PromptInputState {
17+
Unknown,
18+
Input,
19+
Execute,
20+
}
21+
22+
export interface IPromptInputModel {
23+
readonly onDidStartInput: Event<void>;
24+
readonly onDidChangeInput: Event<void>;
25+
readonly onDidFinishInput: Event<void>;
26+
27+
readonly value: string;
28+
readonly cursorIndex: number;
29+
}
30+
31+
export class PromptInputModel extends Disposable implements IPromptInputModel {
32+
private _state: PromptInputState = PromptInputState.Unknown;
33+
34+
private _commandStartMarker: IMarker | undefined;
35+
private _commandStartX: number = 0;
36+
private _continuationPrompt: string | undefined;
37+
38+
private _value: string = '';
39+
get value() { return this._value; }
40+
41+
private _cursorIndex: number = 0;
42+
get cursorIndex() { return this._cursorIndex; }
43+
44+
private readonly _onDidStartInput = this._register(new Emitter<void>());
45+
readonly onDidStartInput = this._onDidStartInput.event;
46+
private readonly _onDidChangeInput = this._register(new Emitter<void>());
47+
readonly onDidChangeInput = this._onDidChangeInput.event;
48+
private readonly _onDidFinishInput = this._register(new Emitter<void>());
49+
readonly onDidFinishInput = this._onDidFinishInput.event;
50+
51+
constructor(
52+
private readonly _xterm: Terminal,
53+
onCommandStart: Event<ITerminalCommand>,
54+
onCommandExecuted: Event<ITerminalCommand>,
55+
@ILogService private readonly _logService: ILogService
56+
) {
57+
super();
58+
59+
this._register(this._xterm.onData(e => this._handleInput(e)));
60+
this._register(this._xterm.onCursorMove(() => this._sync()));
61+
62+
this._register(onCommandStart(e => this._handleCommandStart(e as { marker: IMarker })));
63+
this._register(onCommandExecuted(() => this._handleCommandExecuted()));
64+
}
65+
66+
setContinuationPrompt(value: string): void {
67+
this._continuationPrompt = value;
68+
}
69+
70+
private _handleCommandStart(command: { marker: IMarker }) {
71+
if (this._state === PromptInputState.Input) {
72+
return;
73+
}
74+
75+
this._state = PromptInputState.Input;
76+
this._commandStartMarker = command.marker;
77+
this._commandStartX = this._xterm.buffer.active.cursorX;
78+
this._value = '';
79+
this._cursorIndex = 0;
80+
this._onDidStartInput.fire();
81+
}
82+
83+
private _handleCommandExecuted() {
84+
if (this._state === PromptInputState.Execute) {
85+
return;
86+
}
87+
88+
this._state = PromptInputState.Execute;
89+
this._onDidFinishInput.fire();
90+
}
91+
92+
private _handleInput(data: string) {
93+
this._sync();
94+
}
95+
96+
@debounce(50)
97+
private _sync() {
98+
this._syncNow();
99+
}
100+
101+
protected _syncNow() {
102+
if (this._state !== PromptInputState.Input) {
103+
return;
104+
}
105+
106+
const commandStartY = this._commandStartMarker?.line;
107+
if (commandStartY === undefined) {
108+
return;
109+
}
110+
111+
const buffer = this._xterm.buffer.active;
112+
let line = buffer.getLine(commandStartY);
113+
const commandLine = line?.translateToString(true, this._commandStartX);
114+
if (!commandLine || !line) {
115+
this._logService.trace(`PromptInputModel#_sync: no line`);
116+
return;
117+
}
118+
119+
// Command start line
120+
this._value = commandLine;
121+
122+
// Get cursor index
123+
const absoluteCursorY = buffer.baseY + buffer.cursorY;
124+
this._cursorIndex = absoluteCursorY === commandStartY ? this._getRelativeCursorIndex(this._commandStartX, buffer, line) : commandLine.length + 1;
125+
126+
// IDEA: Detect ghost text based on SGR and cursor. This might work by checking for italic
127+
// or dim only to avoid false positives from shells that do immediate coloring.
128+
// IDEA: Detect line continuation if it's not set
129+
130+
// From command start line to cursor line
131+
for (let y = commandStartY + 1; y <= absoluteCursorY; y++) {
132+
line = buffer.getLine(y);
133+
let lineText = line?.translateToString(true);
134+
if (lineText && line) {
135+
// Verify continuation prompt if we have it, if this line doesn't have it then the
136+
// user likely just pressed enter
137+
if (this._continuationPrompt === undefined || this._lineContainsContinuationPrompt(lineText)) {
138+
lineText = this._trimContinuationPrompt(lineText);
139+
this._value += `\n${lineText}`;
140+
this._cursorIndex += (absoluteCursorY === y
141+
? this._getRelativeCursorIndex(this._getContinuationPromptCellWidth(line, lineText), buffer, line)
142+
: lineText.length + 1);
143+
} else {
144+
break;
145+
}
146+
}
147+
}
148+
149+
// Below cursor line
150+
for (let y = absoluteCursorY + 1; y < buffer.baseY + this._xterm.rows; y++) {
151+
line = buffer.getLine(y);
152+
const lineText = line?.translateToString(true);
153+
if (lineText && line) {
154+
if (this._continuationPrompt === undefined || this._lineContainsContinuationPrompt(lineText)) {
155+
this._value += `\n${this._trimContinuationPrompt(lineText)}`;
156+
} else {
157+
break;
158+
}
159+
}
160+
}
161+
162+
if (this._logService.getLevel() === LogLevel.Trace) {
163+
this._logService.trace(`PromptInputModel#_sync: Input="${this._value.substring(0, this._cursorIndex)}|${this.value.substring(this._cursorIndex)}"`);
164+
}
165+
166+
this._onDidChangeInput.fire();
167+
}
168+
169+
private _trimContinuationPrompt(lineText: string): string {
170+
if (this._lineContainsContinuationPrompt(lineText)) {
171+
lineText = lineText.substring(this._continuationPrompt!.length);
172+
}
173+
return lineText;
174+
}
175+
176+
private _lineContainsContinuationPrompt(lineText: string): boolean {
177+
return !!(this._continuationPrompt && lineText.startsWith(this._continuationPrompt));
178+
}
179+
180+
private _getContinuationPromptCellWidth(line: IBufferLine, lineText: string): number {
181+
if (!this._continuationPrompt || !lineText.startsWith(this._continuationPrompt)) {
182+
return 0;
183+
}
184+
let buffer = '';
185+
let x = 0;
186+
while (buffer !== this._continuationPrompt) {
187+
buffer += line.getCell(x++)!.getChars();
188+
}
189+
return x;
190+
}
191+
192+
private _getRelativeCursorIndex(startCellX: number, buffer: IBuffer, line: IBufferLine): number {
193+
return line?.translateToString(true, startCellX, buffer.cursorX).length ?? 0;
194+
}
195+
}

src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ import { Disposable, MandatoryMutableDisposable, MutableDisposable } from 'vs/ba
1010
import { ILogService } from 'vs/platform/log/common/log';
1111
import { CommandInvalidationReason, ICommandDetectionCapability, ICommandInvalidationRequest, IHandleCommandOptions, ISerializedCommandDetectionCapability, ISerializedTerminalCommand, ITerminalCommand, IXtermMarker, TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities';
1212
import { ITerminalOutputMatcher } from 'vs/platform/terminal/common/terminal';
13+
import { ICurrentPartialCommand, PartialTerminalCommand, TerminalCommand } from 'vs/platform/terminal/common/capabilities/commandDetection/terminalCommand';
14+
import { PromptInputModel, type IPromptInputModel } from 'vs/platform/terminal/common/capabilities/commandDetection/promptInputModel';
1315

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

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

28+
private readonly _promptInputModel: PromptInputModel;
29+
get promptInputModel(): IPromptInputModel { return this._promptInputModel; }
30+
2731
protected _commands: TerminalCommand[] = [];
2832
private _cwd: string | undefined;
2933
private _currentCommand: PartialTerminalCommand = new PartialTerminalCommand(this._terminal);
@@ -87,6 +91,8 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe
8791
) {
8892
super();
8993

94+
this._promptInputModel = this._register(new PromptInputModel(this._terminal, this.onCommandStarted, this.onCommandFinished, this._logService));
95+
9096
// Pull command line from the buffer if it was not set explicitly
9197
this._register(this.onCommandExecuted(command => {
9298
if (command.commandLineConfidence !== 'high') {
@@ -193,6 +199,10 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe
193199
}
194200
}
195201

202+
setContinuationPrompt(value: string): void {
203+
this._promptInputModel.setContinuationPrompt(value);
204+
}
205+
196206
setCwd(value: string) {
197207
this._cwd = value;
198208
}

src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ const enum VSCodeOscPt {
160160
* - `IsWindows` - Indicates whether the terminal is using a Windows backend like winpty or
161161
* conpty. This may be used to enable additional heuristics as the positioning of the shell
162162
* integration sequences are not guaranteed to be correct. Valid values: `True`, `False`.
163+
* - `ContinuationPrompt` - Reports the continuation prompt that is printed at the start of
164+
* multi-line inputs.
163165
*
164166
* WARNING: Any other properties may be changed and are not guaranteed to work in the future.
165167
*/
@@ -379,6 +381,15 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati
379381
return true;
380382
}
381383
switch (key) {
384+
case 'ContinuationPrompt': {
385+
// Exclude escape sequences and values between \[ and \]
386+
const sanitizedValue = (value
387+
.replace(/\x1b\[[0-9;]*m/g, '')
388+
.replace(/\\\[.*?\\\]/g, '')
389+
);
390+
this._updateContinuationPrompt(sanitizedValue);
391+
return true;
392+
}
382393
case 'Cwd': {
383394
this._updateCwd(value);
384395
return true;
@@ -404,6 +415,13 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati
404415
return false;
405416
}
406417

418+
private _updateContinuationPrompt(value: string) {
419+
if (!this._terminal) {
420+
return;
421+
}
422+
this._createOrGetCommandDetection(this._terminal).setContinuationPrompt(value);
423+
}
424+
407425
private _updateCwd(value: string) {
408426
value = sanitizeCwd(value);
409427
this._createOrGetCwdDetection().updateCwd(value);

0 commit comments

Comments
 (0)