Skip to content

Commit

Permalink
Merge pull request #138886 from microsoft/tyriar/133524
Browse files Browse the repository at this point in the history
Create terminal auto responder concept
  • Loading branch information
Tyriar authored Dec 14, 2021
2 parents 7565751 + 8b8de90 commit 745ef8b
Show file tree
Hide file tree
Showing 17 changed files with 282 additions and 20 deletions.
4 changes: 4 additions & 0 deletions src/vs/platform/terminal/common/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export const enum TerminalSettingId {
InheritEnv = 'terminal.integrated.inheritEnv',
ShowLinkHover = 'terminal.integrated.showLinkHover',
IgnoreProcessNames = 'terminal.integrated.ignoreProcessNames',
AutoReplies = 'terminal.integrated.autoReplies',
}

export enum WindowsShellType {
Expand Down Expand Up @@ -278,6 +279,9 @@ export interface IPtyService {
orphanQuestionReply(id: number): Promise<void>;
updateTitle(id: number, title: string, titleSource: TitleEventSource): Promise<void>;
updateIcon(id: number, icon: TerminalIcon, color?: string): Promise<void>;
installAutoReply(match: string, reply: string): Promise<void>;
uninstallAllAutoReplies(): Promise<void>;
uninstallAutoReply(match: string): Promise<void>;
getDefaultSystemShell(osOverride?: OperatingSystem): Promise<string>;
getProfiles?(workspaceId: string, profiles: unknown, defaultProfile: unknown, includeDetectedProfiles?: boolean): Promise<ITerminalProfile[]>;
getEnvironment(): Promise<IProcessEnvironment>;
Expand Down
73 changes: 73 additions & 0 deletions src/vs/platform/terminal/common/terminalAutoResponder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { timeout } from 'vs/base/common/async';
import { Disposable } from 'vs/base/common/lifecycle';
import { isWindows } from 'vs/base/common/platform';
import { ITerminalChildProcess } from 'vs/platform/terminal/common/terminal';

/**
* Tracks a terminal process's data stream and responds immediately when a matching string is
* received. This is done in a low overhead way and is ideally run on the same process as the
* where the process is handled to minimize latency.
*/
export class TerminalAutoResponder extends Disposable {
private _pointer = 0;
private _paused = false;

/**
* Each reply is throttled by a second to avoid resource starvation and responding to screen
* reprints on Winodws.
*/
private _throttled = false;

constructor(
proc: ITerminalChildProcess,
matchWord: string,
response: string
) {
super();

this._register(proc.onProcessData(e => {
if (this._paused || this._throttled) {
return;
}
const data = typeof e === 'string' ? e : e.data;
console.log('data ' + data);
for (let i = 0; i < data.length; i++) {
if (data[i] === matchWord[this._pointer]) {
this._pointer++;
} else {
this._reset();
}
// Auto reply and reset
if (this._pointer === matchWord.length) {
proc.input(response);
this._throttled = true;
timeout(1000).then(() => this._throttled = false);
this._reset();
}
}
}));
}

private _reset() {
this._pointer = 0;
}

/**
* No auto response will happen after a resize on Windows in case the resize is a result of
* reprinting the screen.
*/
handleResize() {
if (isWindows) {
this._paused = true;
}
}

handleInput() {
this._paused = false;
}
}
10 changes: 10 additions & 0 deletions src/vs/platform/terminal/node/ptyHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,16 @@ export class PtyHostService extends Disposable implements IPtyService {
return this._proxy.orphanQuestionReply(id);
}

installAutoReply(match: string, reply: string): Promise<void> {
return this._proxy.installAutoReply(match, reply);
}
uninstallAllAutoReplies(): Promise<void> {
return this._proxy.uninstallAllAutoReplies();
}
uninstallAutoReply(match: string): Promise<void> {
return this._proxy.uninstallAutoReply(match);
}

getDefaultSystemShell(osOverride?: OperatingSystem): Promise<string> {
return this._proxy.getDefaultSystemShell(osOverride);
}
Expand Down
60 changes: 60 additions & 0 deletions src/vs/platform/terminal/node/ptyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { getWindowsBuildNumber } from 'vs/platform/terminal/node/terminalEnviron
import { TerminalProcess } from 'vs/platform/terminal/node/terminalProcess';
import { localize } from 'vs/nls';
import { ignoreProcessNames } from 'vs/platform/terminal/node/childProcessMonitor';
import { TerminalAutoResponder } from 'vs/platform/terminal/common/terminalAutoResponder';

type WorkspaceId = string;

Expand All @@ -36,6 +37,7 @@ export class PtyService extends Disposable implements IPtyService {
private readonly _workspaceLayoutInfos = new Map<WorkspaceId, ISetTerminalLayoutInfoArgs>();
private readonly _detachInstanceRequestStore: RequestStore<IProcessDetails | undefined, { workspaceId: string, instanceId: number }>;
private readonly _revivedPtyIdMap: Map<number, { newId: number, state: ISerializedTerminalState }> = new Map();
private readonly _autoReplies: Map<string, string> = new Map();

private readonly _onHeartbeat = this._register(new Emitter<void>());
readonly onHeartbeat = this._onHeartbeat.event;
Expand Down Expand Up @@ -205,6 +207,11 @@ export class PtyService extends Disposable implements IPtyService {
persistentProcess.onProcessReady(event => this._onProcessReady.fire({ id, event }));
persistentProcess.onProcessOrphanQuestion(() => this._onProcessOrphanQuestion.fire({ id }));
persistentProcess.onDidChangeProperty(property => this._onDidChangeProperty.fire({ id, property }));
persistentProcess.onPersistentProcessReady(() => {
for (const e of this._autoReplies.entries()) {
persistentProcess.installAutoReply(e[0], e[1]);
}
});
this._ptys.set(id, persistentProcess);
return id;
}
Expand Down Expand Up @@ -292,6 +299,26 @@ export class PtyService extends Disposable implements IPtyService {
return this._throwIfNoPty(id).orphanQuestionReply();
}

async installAutoReply(match: string, reply: string) {
this._autoReplies.set(match, reply);
// If the auto reply exists on any existing terminals it will be overridden
for (const p of this._ptys.values()) {
p.installAutoReply(match, reply);
}
}
async uninstallAllAutoReplies() {
for (const match of this._autoReplies.keys()) {
for (const p of this._ptys.values()) {
p.uninstallAutoReply(match);
}
}
}
async uninstallAutoReply(match: string) {
for (const p of this._ptys.values()) {
p.uninstallAutoReply(match);
}
}

async getDefaultSystemShell(osOverride: OperatingSystem = OS): Promise<string> {
return getSystemShell(osOverride, process.env);
}
Expand Down Expand Up @@ -399,6 +426,7 @@ interface IPersistentTerminalProcessLaunchOptions {
export class PersistentTerminalProcess extends Disposable {

private readonly _bufferer: TerminalDataBufferer;
private readonly _autoReplies: Map<string, TerminalAutoResponder> = new Map();

private readonly _pendingCommands = new Map<number, { resolve: (data: any) => void; reject: (err: any) => void; }>();

Expand All @@ -415,6 +443,9 @@ export class PersistentTerminalProcess extends Disposable {
readonly onProcessReplay = this._onProcessReplay.event;
private readonly _onProcessReady = this._register(new Emitter<IProcessReadyEvent>());
readonly onProcessReady = this._onProcessReady.event;
private readonly _onPersistentProcessReady = this._register(new Emitter<void>());
/** Fired when the persistent process has a ready process and has finished its replay. */
readonly onPersistentProcessReady = this._onPersistentProcessReady.event;
private readonly _onProcessData = this._register(new Emitter<string>());
readonly onProcessData = this._onProcessData.event;
private readonly _onProcessOrphanQuestion = this._register(new Emitter<void>());
Expand Down Expand Up @@ -513,6 +544,14 @@ export class PersistentTerminalProcess extends Disposable {

// Data recording for reconnect
this._register(this.onProcessData(e => this._serializer.handleData(e)));

// Clean up other disposables
this._register(toDisposable(() => {
for (const e of this._autoReplies.values()) {
e.dispose();
}
this._autoReplies.clear();
}));
}

attach(): void {
Expand Down Expand Up @@ -561,6 +600,8 @@ export class PersistentTerminalProcess extends Disposable {
// be attached yet). https://github.com/microsoft/terminal/issues/11213
if (this._wasRevived) {
this.triggerReplay();
} else {
this._onPersistentProcessReady.fire();
}
} else {
this._onProcessReady.fire({ pid: this._pid, cwd: this._cwd, capabilities: this._terminalProcess.capabilities, requiresWindowsMode: isWindows && getWindowsBuildNumber() < 21376 });
Expand All @@ -578,6 +619,9 @@ export class PersistentTerminalProcess extends Disposable {
if (this._inReplay) {
return;
}
for (const listener of this._autoReplies.values()) {
listener.handleInput();
}
return this._terminalProcess.input(data);
}
writeBinary(data: string): Promise<void> {
Expand All @@ -591,6 +635,10 @@ export class PersistentTerminalProcess extends Disposable {

// Buffered events should flush when a resize occurs
this._bufferer.flushBuffer(this._persistentProcessId);

for (const listener of this._autoReplies.values()) {
listener.handleResize();
}
return this._terminalProcess.resize(cols, rows);
}
setUnicodeVersion(version: '6' | '11'): void {
Expand Down Expand Up @@ -624,6 +672,18 @@ export class PersistentTerminalProcess extends Disposable {
this._logService.info(`Persistent process "${this._persistentProcessId}": Replaying ${dataLength} chars and ${ev.events.length} size events`);
this._onProcessReplay.fire(ev);
this._terminalProcess.clearUnacknowledgedChars();
this._onPersistentProcessReady.fire();
}

installAutoReply(match: string, reply: string) {
this._autoReplies.get(match)?.dispose();
this._autoReplies.set(match, new TerminalAutoResponder(this._terminalProcess, match, reply));
}

uninstallAutoReply(match: string) {
const autoReply = this._autoReplies.get(match);
autoReply?.dispose();
this._autoReplies.delete(match);
}

sendCommandResult(reqId: number, isError: boolean, serializedPayload: any): void {
Expand Down
2 changes: 2 additions & 0 deletions src/vs/server/remoteTerminalChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export class RemoteTerminalChannel extends Disposable implements IServerChannel<
case '$processBinary': return this._ptyService.processBinary.apply(this._ptyService, args);

case '$sendCommandResult': return this._sendCommandResult(args[0], args[1], args[2]);
case '$installAutoReply': return this._ptyService.installAutoReply.apply(this._ptyService, args);
case '$uninstallAllAutoReplies': return this._ptyService.uninstallAllAutoReplies.apply(this._ptyService, args);
case '$getDefaultSystemShell': return this._getDefaultSystemShell.apply(this, args);
case '$getProfiles': return this._getProfiles.apply(this, args);
case '$getEnvironment': return this._getEnvironment();
Expand Down
16 changes: 16 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,22 @@ class RemoteTerminalBackend extends Disposable implements ITerminalBackend {
const result = await Promise.all(resolveCalls);
channel.acceptPtyHostResolvedVariables(e.requestId, result);
}));

// Listen for config changes
const initialConfig = this._configurationService.getValue<ITerminalConfiguration>(TERMINAL_CONFIG_SECTION);
for (const match of Object.keys(initialConfig.autoReplies)) {
channel.installAutoReply(match, initialConfig.autoReplies[match]);
}
// TODO: Could simplify update to a single call
this._register(this._configurationService.onDidChangeConfiguration(async e => {
if (e.affectsConfiguration(TerminalSettingId.AutoReplies)) {
channel.uninstallAllAutoReplies();
const config = this._configurationService.getValue<ITerminalConfiguration>(TERMINAL_CONFIG_SECTION);
for (const match of Object.keys(config.autoReplies)) {
await channel.installAutoReply(match, config.autoReplies[match]);
}
}
}));
} else {
this._remoteTerminalChannel = null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,12 @@ export class RemoteTerminalChannelClient {
return this._channel.call('$sendCommandResult', [reqId, isError, payload]);
}

installAutoReply(match: string, reply: string): Promise<void> {
return this._channel.call('$installAutoReply', [match, reply]);
}
uninstallAllAutoReplies(): Promise<void> {
return this._channel.call('$uninstallAllAutoReplies', []);
}
getDefaultSystemShell(osOverride?: OperatingSystem): Promise<string> {
return this._channel.call('$getDefaultSystemShell', [osOverride]);
}
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/terminal/common/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ export interface ITerminalConfiguration {
customGlyphs: boolean;
persistentSessionReviveProcess: 'onExit' | 'onExitAndWindowClose' | 'never';
ignoreProcessNames: string[];
autoReplies: { [key: string]: string };
}

export const DEFAULT_LOCAL_ECHO_EXCLUDE: ReadonlyArray<string> = ['vim', 'vi', 'nano', 'tmux'];
Expand Down
11 changes: 11 additions & 0 deletions src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,17 @@ const terminalConfiguration: IConfigurationNode = {
description: localize('terminal.integrated.customGlyphs', "Whether to draw custom glyphs for block element and box drawing characters instead of using the font, which typically yields better rendering with continuous lines. Note that this doesn't work with the DOM renderer"),
type: 'boolean',
default: true
},
[TerminalSettingId.AutoReplies]: {
description: localize('terminal.integrated.autoReplies', "A set of messages that when encountered in the terminal will be automatically responded to. Provided the message is specific enough, this can help automate away common responses. Note that the message includes escape sequences so the reply might not happen with styled text. Each reply can only happen once every second."),
type: 'object',
additionalProperties: {
type: 'string',
description: localize('terminal.integrated.autoReplies.reply', "The reply to send to the process.")
},
default: {
'Terminate batch job (Y/N)': 'Y\r'
}
}
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,20 @@ import { IProcessEnvironment, OperatingSystem } from 'vs/base/common/platform';
import { withNullAsUndefined } from 'vs/base/common/types';
import { URI } from 'vs/base/common/uri';
import { localize } from 'vs/nls';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ILabelService } from 'vs/platform/label/common/label';
import { ILogService } from 'vs/platform/log/common/log';
import { INotificationHandle, INotificationService, IPromptChoice, Severity } from 'vs/platform/notification/common/notification';
import { Registry } from 'vs/platform/registry/common/platform';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { IProcessPropertyMap, IShellLaunchConfig, ITerminalChildProcess, ITerminalsLayoutInfo, ITerminalsLayoutInfoById, ProcessPropertyType, TitleEventSource } from 'vs/platform/terminal/common/terminal';
import { IProcessPropertyMap, IShellLaunchConfig, ITerminalChildProcess, ITerminalsLayoutInfo, ITerminalsLayoutInfoById, ProcessPropertyType, TerminalSettingId, TitleEventSource } from 'vs/platform/terminal/common/terminal';
import { IGetTerminalLayoutInfoArgs, IProcessDetails, ISetTerminalLayoutInfoArgs } from 'vs/platform/terminal/common/terminalProcess';
import { ILocalPtyService } from 'vs/platform/terminal/electron-sandbox/terminal';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
import { ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal';
import { ITerminalBackend, ITerminalBackendRegistry, TerminalExtensions } from 'vs/workbench/contrib/terminal/common/terminal';
import { ITerminalBackend, ITerminalBackendRegistry, ITerminalConfiguration, TerminalExtensions, TERMINAL_CONFIG_SECTION } from 'vs/workbench/contrib/terminal/common/terminal';
import { TerminalStorageKeys } from 'vs/workbench/contrib/terminal/common/terminalStorageKeys';
import { LocalPty } from 'vs/workbench/contrib/terminal/electron-sandbox/localPty';
import { IConfigurationResolverService } from 'vs/workbench/services/configurationResolver/common/configurationResolver';
Expand Down Expand Up @@ -65,6 +66,7 @@ class LocalTerminalBackend extends Disposable implements ITerminalBackend {
@IStorageService private readonly _storageService: IStorageService,
@IConfigurationResolverService configurationResolverService: IConfigurationResolverService,
@IHistoryService historyService: IHistoryService,
@IConfigurationService configurationService: IConfigurationService,
) {
super();

Expand Down Expand Up @@ -137,6 +139,22 @@ class LocalTerminalBackend extends Disposable implements ITerminalBackend {
this._localPtyService.acceptPtyHostResolvedVariables?.(e.requestId, result);
}));
}

// Listen for config changes
const initialConfig = configurationService.getValue<ITerminalConfiguration>(TERMINAL_CONFIG_SECTION);
for (const match of Object.keys(initialConfig.autoReplies)) {
this._localPtyService.installAutoReply(match, initialConfig.autoReplies[match]);
}
// TODO: Could simplify update to a single call
this._register(configurationService.onDidChangeConfiguration(async e => {
if (e.affectsConfiguration(TerminalSettingId.AutoReplies)) {
this._localPtyService.uninstallAllAutoReplies();
const config = configurationService.getValue<ITerminalConfiguration>(TERMINAL_CONFIG_SECTION);
for (const match of Object.keys(config.autoReplies)) {
await this._localPtyService.installAutoReply(match, config.autoReplies[match]);
}
}
}));
}

async requestDetachInstance(workspaceId: string, instanceId: number): Promise<IProcessDetails | undefined> {
Expand Down
Loading

0 comments on commit 745ef8b

Please sign in to comment.