From b233e67b0122d309df76e2710440b6496c339cd1 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 19 Apr 2024 09:12:55 -0700 Subject: [PATCH 1/7] Add PromptInputModel.onDidInterrupt Part of #210757 --- .../commandDetection/promptInputModel.ts | 31 ++++++++++++++----- .../commandDetection/promptInputModel.test.ts | 24 ++++++++++++-- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 178a77c3a72..0c8a85cd46f 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -19,10 +19,18 @@ const enum PromptInputState { Execute, } +/** + * 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,11 +84,12 @@ 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())); @@ -121,11 +134,11 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._state = PromptInputState.Execute; this._cursorIndex = -1; - this._onDidFinishInput.fire(this._createStateObject()); - } - - private _handleInput(data: string) { - this._sync(); + const event = this._createStateObject(); + if (this._lastUserInput === '\u0003') { // ETX end of text (ctrl+c) + this._onDidInterrupt.fire(event); + } + this._onDidFinishInput.fire(event); } @throttle(0) @@ -207,6 +220,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/test/common/capabilities/commandDetection/promptInputModel.test.ts b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts index 593b297ec33..6ad7e355a87 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 @@ -14,7 +14,7 @@ import { Terminal } from '@xterm/headless'; import { notDeepStrictEqual, strictEqual } from 'assert'; import { timeout } from 'vs/base/common/async'; -suite('PromptInputModel', () => { +suite.only('PromptInputModel', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let promptInputModel: PromptInputModel; @@ -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(); From 9e072d56583994b358ccb8bebb122a7b33c05b8f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 19 Apr 2024 09:35:24 -0700 Subject: [PATCH 2/7] Add tooltip and executing spinner to devmode status bar item --- .../developer/browser/terminal.developer.contribution.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) { From c238ded24838fa71102c166925668df3fdc58f37 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 19 Apr 2024 09:36:35 -0700 Subject: [PATCH 3/7] Detect interrupts in terminal --- .../commandDetection/promptInputModel.ts | 17 +++++++++++++++-- .../capabilities/commandDetectionCapability.ts | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 0c8a85cd46f..57e0994ccfc 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -17,6 +17,7 @@ const enum PromptInputState { Unknown, Input, Execute, + Interrupt, } /** @@ -125,6 +126,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() { @@ -132,13 +134,15 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { return; } - this._state = PromptInputState.Execute; this._cursorIndex = -1; const event = this._createStateObject(); - if (this._lastUserInput === '\u0003') { // ETX end of text (ctrl+c) + if (this._state === PromptInputState.Interrupt) { this._onDidInterrupt.fire(event); } + + this._state = PromptInputState.Execute; this._onDidFinishInput.fire(event); + this._onDidChangeInput.fire(event); } @throttle(0) @@ -212,6 +216,15 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._logService.trace(`PromptInputModel#_sync: ${this.getCombinedString()}`); } + // Check for an interrupt, this is verified using both the last user input and the current + // input. + if (this._lastUserInput === '\u0003') { // ETX end of text (ctrl+c) + if (this._value.endsWith('^C')) { + this._state = PromptInputState.Interrupt; + return; + } + } + if (this._value !== value || this._cursorIndex !== cursorIndex || this._ghostTextIndex !== ghostTextIndex) { this._value = value; this._cursorIndex = cursorIndex; 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 => { From da3a13a3fc525c793efe9e9cafa433a898c49f68 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 19 Apr 2024 10:03:34 -0700 Subject: [PATCH 4/7] Improve reliability of interrupt and log all events in trace --- .../commandDetection/promptInputModel.ts | 30 ++++++++++--------- .../commandDetection/promptInputModel.test.ts | 2 +- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 57e0994ccfc..3c39a495a92 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -14,10 +14,9 @@ import { throttle } from 'vs/base/common/decorators'; import type { Terminal, IMarker, IBufferCell, IBufferLine, IBuffer } from '@xterm/headless'; const enum PromptInputState { - Unknown, - Input, - Execute, - Interrupt, + Unknown = 0, + Input = 1, + Execute = 2, } /** @@ -94,6 +93,18 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._register(onCommandStart(e => this._handleCommandStart(e as { marker: IMarker }))); this._register(onCommandExecuted(() => this._handleCommandExecuted())); + + this.onDidStartInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidStartInput')); + this.onDidChangeInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidChangeInput')); + this.onDidFinishInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidFinishInput')); + 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 { @@ -136,7 +147,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._cursorIndex = -1; const event = this._createStateObject(); - if (this._state === PromptInputState.Interrupt) { + if (this._lastUserInput !== '\u0003') { this._onDidInterrupt.fire(event); } @@ -216,15 +227,6 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._logService.trace(`PromptInputModel#_sync: ${this.getCombinedString()}`); } - // Check for an interrupt, this is verified using both the last user input and the current - // input. - if (this._lastUserInput === '\u0003') { // ETX end of text (ctrl+c) - if (this._value.endsWith('^C')) { - this._state = PromptInputState.Interrupt; - return; - } - } - if (this._value !== value || this._cursorIndex !== cursorIndex || this._ghostTextIndex !== ghostTextIndex) { this._value = value; this._cursorIndex = cursorIndex; 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 6ad7e355a87..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 @@ -14,7 +14,7 @@ import { Terminal } from '@xterm/headless'; import { notDeepStrictEqual, strictEqual } from 'assert'; import { timeout } from 'vs/base/common/async'; -suite.only('PromptInputModel', () => { +suite('PromptInputModel', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let promptInputModel: PromptInputModel; From 47869440668dbb6135db10431a6aaec65ef96d1e Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 19 Apr 2024 10:06:00 -0700 Subject: [PATCH 5/7] Reset ghostTextIndex on execute --- .../common/capabilities/commandDetection/promptInputModel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 3c39a495a92..423b561a49d 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -146,6 +146,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { } this._cursorIndex = -1; + this._ghostTextIndex = -1; const event = this._createStateObject(); if (this._lastUserInput !== '\u0003') { this._onDidInterrupt.fire(event); From 2c9202adfbc2f2e4b91870dc2f55e77dd9488943 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 19 Apr 2024 10:08:03 -0700 Subject: [PATCH 6/7] Register new listeners --- .../capabilities/commandDetection/promptInputModel.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 423b561a49d..bde86bdab78 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -94,10 +94,10 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._register(onCommandStart(e => this._handleCommandStart(e as { marker: IMarker }))); this._register(onCommandExecuted(() => this._handleCommandExecuted())); - this.onDidStartInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidStartInput')); - this.onDidChangeInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidChangeInput')); - this.onDidFinishInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidFinishInput')); - this.onDidInterrupt(() => this._logCombinedStringIfTrace('PromptInputModel#onDidInterrupt')); + 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) { From b7b8b9d5fe233df61c4a44795751736d4e007512 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 19 Apr 2024 10:31:19 -0700 Subject: [PATCH 7/7] Fix tests --- .../common/capabilities/commandDetection/promptInputModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index bde86bdab78..c0b46dc6a5e 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -148,7 +148,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._cursorIndex = -1; this._ghostTextIndex = -1; const event = this._createStateObject(); - if (this._lastUserInput !== '\u0003') { + if (this._lastUserInput === '\u0003') { this._onDidInterrupt.fire(event); }