diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 178a77c3a72..c0b46dc6a5e 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -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; readonly onDidChangeInput: Event; readonly onDidFinishInput: Event; + /** + * Fires immediately before {@link onDidFinishInput} when a SIGINT/Ctrl+C/^C is detected. + */ + readonly onDidInterrupt: Event; 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()); readonly onDidFinishInput = this._onDidFinishInput.event; + private readonly _onDidInterrupt = this._register(new Emitter()); + 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. diff --git a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts index 6efbebd22a7..f948ec10ccc 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts @@ -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 => { 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 593b297ec33..1295ef48d91 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 @@ -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(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(); 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 744cf3622c3..692c2588c4a 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 @@ -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) {