Merge pull request #210766 from microsoft/tyriar/210757_interrupt

PromptInputModel: Detect interrupts and show executing in dev mode
This commit is contained in:
Daniel Imms
2024-04-19 10:48:21 -07:00
committed by GitHub
4 changed files with 68 additions and 13 deletions
@@ -14,15 +14,23 @@ import { throttle } from 'vs/base/common/decorators';
import type { Terminal, IMarker, IBufferCell, IBufferLine, IBuffer } from '@xterm/headless';
const enum PromptInputState {
Unknown,
Input,
Execute,
Unknown = 0,
Input = 1,
Execute = 2,
}
/**
* A model of the prompt input state using shell integration and analyzing the terminal buffer. This
* may not be 100% accurate but provides a best guess.
*/
export interface IPromptInputModel {
readonly onDidStartInput: Event<IPromptInputModelState>;
readonly onDidChangeInput: Event<IPromptInputModelState>;
readonly onDidFinishInput: Event<IPromptInputModelState>;
/**
* Fires immediately before {@link onDidFinishInput} when a SIGINT/Ctrl+C/^C is detected.
*/
readonly onDidInterrupt: Event<IPromptInputModelState>;
readonly value: string;
readonly cursorIndex: number;
@@ -48,6 +56,8 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
private _commandStartX: number = 0;
private _continuationPrompt: string | undefined;
private _lastUserInput: string = '';
private _value: string = '';
get value() { return this._value; }
@@ -63,6 +73,8 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
readonly onDidChangeInput = this._onDidChangeInput.event;
private readonly _onDidFinishInput = this._register(new Emitter<IPromptInputModelState>());
readonly onDidFinishInput = this._onDidFinishInput.event;
private readonly _onDidInterrupt = this._register(new Emitter<IPromptInputModelState>());
readonly onDidInterrupt = this._onDidInterrupt.event;
constructor(
private readonly _xterm: Terminal,
@@ -72,14 +84,27 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
) {
super();
this._register(this._xterm.onData(e => this._handleInput(e)));
this._register(Event.any(
this._xterm.onWriteParsed,
this._xterm.onCursorMove,
this._xterm.onData,
this._xterm.onWriteParsed,
)(() => this._sync()));
this._register(this._xterm.onData(e => this._handleUserInput(e)));
this._register(onCommandStart(e => this._handleCommandStart(e as { marker: IMarker })));
this._register(onCommandExecuted(() => this._handleCommandExecuted()));
this._register(this.onDidStartInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidStartInput')));
this._register(this.onDidChangeInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidChangeInput')));
this._register(this.onDidFinishInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidFinishInput')));
this._register(this.onDidInterrupt(() => this._logCombinedStringIfTrace('PromptInputModel#onDidInterrupt')));
}
private _logCombinedStringIfTrace(message: string) {
// Only generate the combined string if trace
if (this._logService.getLevel() === LogLevel.Trace) {
this._logService.trace(message, this.getCombinedString());
}
}
setContinuationPrompt(value: string): void {
@@ -112,6 +137,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
this._value = '';
this._cursorIndex = 0;
this._onDidStartInput.fire(this._createStateObject());
this._onDidChangeInput.fire(this._createStateObject());
}
private _handleCommandExecuted() {
@@ -119,13 +145,16 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
return;
}
this._state = PromptInputState.Execute;
this._cursorIndex = -1;
this._onDidFinishInput.fire(this._createStateObject());
}
this._ghostTextIndex = -1;
const event = this._createStateObject();
if (this._lastUserInput === '\u0003') {
this._onDidInterrupt.fire(event);
}
private _handleInput(data: string) {
this._sync();
this._state = PromptInputState.Execute;
this._onDidFinishInput.fire(event);
this._onDidChangeInput.fire(event);
}
@throttle(0)
@@ -207,6 +236,10 @@ export class PromptInputModel extends Disposable implements IPromptInputModel {
}
}
private _handleUserInput(e: string) {
this._lastUserInput = e;
}
/**
* Detect ghost text by looking for italic or dim text in or after the cursor and
* non-italic/dim text in the cell closest non-whitespace cell before the cursor.
@@ -91,7 +91,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe
) {
super();
this._promptInputModel = this._register(new PromptInputModel(this._terminal, this.onCommandStarted, this.onCommandFinished, this._logService));
this._promptInputModel = this._register(new PromptInputModel(this._terminal, this.onCommandStarted, this.onCommandExecuted, this._logService));
// Pull command line from the buffer if it was not set explicitly
this._register(this.onCommandExecuted(command => {
@@ -78,7 +78,7 @@ suite('PromptInputModel', () => {
await assertPromptInput('|');
});
test('should not fire events when nothing changes', async () => {
test('should not fire onDidChangeInput events when nothing changes', async () => {
const events: IPromptInputModelState[] = [];
store.add(promptInputModel.onDidChangeInput(e => events.push(e)));
@@ -108,6 +108,26 @@ suite('PromptInputModel', () => {
}
});
test('should fire onDidInterrupt followed by onDidFinish when ctrl+c is pressed', async () => {
await writePromise('$ ');
fireCommandStart();
await assertPromptInput('|');
await writePromise('foo');
await assertPromptInput('foo|');
await new Promise<void>(r => {
store.add(promptInputModel.onDidInterrupt(() => {
// Fire onDidFinishInput immediately after onDidInterrupt
store.add(promptInputModel.onDidFinishInput(() => {
r();
}));
}));
xterm.input('\x03');
writePromise('^C').then(() => fireCommandExecuted());
});
});
test('cursor navigation', async () => {
await writePromise('$ ');
fireCommandStart();
@@ -246,10 +246,12 @@ class DevModeContribution extends Disposable implements ITerminalContribution {
const promptInputModel = commandDetection.promptInputModel;
if (promptInputModel) {
const name = localize('terminalDevMode', 'Terminal Dev Mode');
const isExecuting = promptInputModel.cursorIndex === -1;
this._statusbarEntry = {
name,
text: `$(terminal) ${promptInputModel.getCombinedString()}`,
text: `$(${isExecuting ? 'loading~spin' : 'terminal'}) ${promptInputModel.getCombinedString()}`,
ariaLabel: name,
tooltip: 'The detected terminal prompt input',
kind: 'prominent'
};
if (!this._statusbarEntryAccessor.value) {