From f1575f6d8de2f4efa0805a2d11a4a421a1a8228f Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:03:51 -0700 Subject: [PATCH] feat(core): refactor shell execution to use node-pty (#6491) Co-authored-by: Jacob Richman --- esbuild.config.js | 11 +- package-lock.json | 139 ++++- package.json | 12 +- packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 11 + .../prompt-processors/shellProcessor.test.ts | 98 +--- .../prompt-processors/shellProcessor.ts | 13 +- .../ui/hooks/shellCommandProcessor.test.ts | 12 +- .../cli/src/ui/hooks/shellCommandProcessor.ts | 22 +- packages/core/package.json | 12 +- packages/core/src/config/config.ts | 7 + .../services/shellExecutionService.test.ts | 454 ++++++++++++++-- .../src/services/shellExecutionService.ts | 505 ++++++++++++------ packages/core/src/tools/shell.test.ts | 32 +- packages/core/src/tools/shell.ts | 28 +- packages/core/src/utils/getPty.ts | 34 ++ packages/vscode-ide-companion/esbuild.js | 1 + 17 files changed, 1064 insertions(+), 328 deletions(-) create mode 100644 packages/core/src/utils/getPty.ts diff --git a/esbuild.config.js b/esbuild.config.js index 0cb8e0fa..c716f6b7 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -21,7 +21,15 @@ esbuild outfile: 'bundle/gemini.js', platform: 'node', format: 'esm', - external: [], + external: [ + '@lydell/node-pty', + 'node-pty', + '@lydell/node-pty-darwin-arm64', + '@lydell/node-pty-darwin-x64', + '@lydell/node-pty-linux-x64', + '@lydell/node-pty-win32-arm64', + '@lydell/node-pty-win32-x64', + ], alias: { 'is-in-ci': path.resolve( __dirname, @@ -34,5 +42,6 @@ esbuild banner: { js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`, }, + loader: { '.node': 'file' }, }) .catch(() => process.exit(1)); diff --git a/package-lock.json b/package-lock.json index 7052ae8d..89ecc8a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "packages/*" ], "dependencies": { - "node-fetch": "^3.3.2" + "node-fetch": "^3.3.2", + "strip-ansi": "^7.1.0" }, "bin": { "gemini": "bundle/gemini.js" @@ -50,6 +51,15 @@ }, "engines": { "node": ">=20.0.0" + }, + "optionalDependencies": { + "@lydell/node-pty": "1.1.0", + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0", + "node-pty": "^1.0.0" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -1455,6 +1465,99 @@ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "license": "MIT" }, + "node_modules/@lydell/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", + "license": "MIT", + "optional": true, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-arm64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0" + } + }, + "node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz", + "integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz", + "integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz", + "integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz", + "integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lydell/node-pty-win32-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz", + "integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.15.1.tgz", @@ -3281,6 +3384,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/headless": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz", + "integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -8381,6 +8490,13 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nan": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8461,6 +8577,17 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -12524,6 +12651,7 @@ "@opentelemetry/sdk-node": "^0.52.0", "@types/glob": "^8.1.0", "@types/html-to-text": "^9.0.4", + "@xterm/headless": "5.5.0", "ajv": "^8.17.1", "chardet": "^2.1.0", "diff": "^7.0.0", @@ -12559,6 +12687,15 @@ }, "engines": { "node": ">=20" + }, + "optionalDependencies": { + "@lydell/node-pty": "1.1.0", + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0", + "node-pty": "^1.0.0" } }, "packages/core/node_modules/ajv": { diff --git a/package.json b/package.json index 8a40681e..6fcda594 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,16 @@ "yargs": "^17.7.2" }, "dependencies": { - "node-fetch": "^3.3.2" + "node-fetch": "^3.3.2", + "strip-ansi": "^7.1.0" + }, + "optionalDependencies": { + "@lydell/node-pty": "1.1.0", + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0", + "node-pty": "^1.0.0" } } diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index b66afcc8..d3b73e06 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -539,6 +539,7 @@ export async function loadCliConfig( folderTrust, interactive, trustedFolder, + shouldUseNodePtyShell: settings.shouldUseNodePtyShell, }); } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ebea3ad5..8c1b5191 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -277,6 +277,17 @@ export const SETTINGS_SCHEMA = { showInDialog: true, }, + shouldUseNodePtyShell: { + type: 'boolean', + label: 'Use node-pty for Shell Execution', + category: 'Shell', + requiresRestart: true, + default: false, + description: + 'Use node-pty for shell command execution. Fallback to child_process still applies.', + showInDialog: true, + }, + selectedAuthType: { type: 'string', label: 'Selected Auth Type', diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 49d30799..3f51e858 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -46,8 +46,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); const SUCCESS_RESULT = { - stdout: 'default shell output', - stderr: '', + output: 'default shell output', exitCode: 0, error: null, aborted: false, @@ -64,6 +63,7 @@ describe('ShellProcessor', () => { mockConfig = { getTargetDir: vi.fn().mockReturnValue('/test/dir'), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + getShouldUseNodePtyShell: vi.fn().mockReturnValue(false), }; context = createMockCommandContext({ @@ -120,7 +120,7 @@ describe('ShellProcessor', () => { disallowedCommands: [], }); mockShellExecute.mockReturnValue({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'On branch main' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'On branch main' }), }); const result = await processor.process(prompt, context); @@ -135,6 +135,7 @@ describe('ShellProcessor', () => { expect.any(String), expect.any(Function), expect.any(Object), + false, ); expect(result).toBe('The current status is: On branch main'); }); @@ -151,11 +152,11 @@ describe('ShellProcessor', () => { .mockReturnValueOnce({ result: Promise.resolve({ ...SUCCESS_RESULT, - stdout: 'On branch main', + output: 'On branch main', }), }) .mockReturnValueOnce({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: '/usr/home' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: '/usr/home' }), }); const result = await processor.process(prompt, context); @@ -188,7 +189,7 @@ describe('ShellProcessor', () => { // Override the approval mode for this test (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); mockShellExecute.mockReturnValue({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'deleted' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }), }); const result = await processor.process(prompt, context); @@ -199,6 +200,7 @@ describe('ShellProcessor', () => { expect.any(String), expect.any(Function), expect.any(Object), + false, ); expect(result).toBe('Do something dangerous: deleted'); }); @@ -324,10 +326,10 @@ describe('ShellProcessor', () => { mockShellExecute .mockReturnValueOnce({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'output1' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'output1' }), }) .mockReturnValueOnce({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'output2' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'output2' }), }); const result = await processor.process(prompt, context); @@ -361,7 +363,7 @@ describe('ShellProcessor', () => { disallowedCommands: [], }); mockShellExecute.mockReturnValue({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'total 0' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'total 0' }), }); await processor.process(prompt, context); @@ -376,6 +378,7 @@ describe('ShellProcessor', () => { expect.any(String), expect.any(Function), expect.any(Object), + false, ); }); @@ -398,7 +401,7 @@ describe('ShellProcessor', () => { const command = "awk '{print $1}' file.txt"; const prompt = `Output: !{${command}}`; mockShellExecute.mockReturnValue({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'result' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'result' }), }); const result = await processor.process(prompt, context); @@ -413,6 +416,7 @@ describe('ShellProcessor', () => { expect.any(String), expect.any(Function), expect.any(Object), + false, ); expect(result).toBe('Output: result'); }); @@ -422,7 +426,7 @@ describe('ShellProcessor', () => { const command = "echo '{{a},{b}}'"; const prompt = `!{${command}}`; mockShellExecute.mockReturnValue({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: '{{a},{b}}' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: '{{a},{b}}' }), }); const result = await processor.process(prompt, context); @@ -431,6 +435,7 @@ describe('ShellProcessor', () => { expect.any(String), expect.any(Function), expect.any(Object), + false, ); expect(result).toBe('{{a},{b}}'); }); @@ -455,45 +460,13 @@ describe('ShellProcessor', () => { }); describe('Error Reporting', () => { - it('should append stderr information if the command produces it', async () => { + it('should append exit code and command name on failure', async () => { const processor = new ShellProcessor('test-command'); const prompt = '!{cmd}'; mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, - stdout: 'some output', - stderr: 'some error', - }), - }); - - const result = await processor.process(prompt, context); - - expect(result).toBe('some output\n--- STDERR ---\nsome error'); - }); - - it('should handle stderr-only output correctly', async () => { - const processor = new ShellProcessor('test-command'); - const prompt = '!{cmd}'; - mockShellExecute.mockReturnValue({ - result: Promise.resolve({ - ...SUCCESS_RESULT, - stdout: '', - stderr: 'error only', - }), - }); - - const result = await processor.process(prompt, context); - - expect(result).toBe('--- STDERR ---\nerror only'); - }); - - it('should append exit code and command name if the command fails', async () => { - const processor = new ShellProcessor('test-command'); - const prompt = 'Run a failing command: !{exit 1}'; - mockShellExecute.mockReturnValue({ - result: Promise.resolve({ - ...SUCCESS_RESULT, - stdout: 'some error output', + output: 'some error output', stderr: '', exitCode: 1, }), @@ -502,7 +475,7 @@ describe('ShellProcessor', () => { const result = await processor.process(prompt, context); expect(result).toBe( - "Run a failing command: some error output\n[Shell command 'exit 1' exited with code 1]", + "some error output\n[Shell command 'cmd' exited with code 1]", ); }); @@ -512,7 +485,7 @@ describe('ShellProcessor', () => { mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, - stdout: 'output', + output: 'output', stderr: '', exitCode: null, signal: 'SIGTERM', @@ -526,25 +499,6 @@ describe('ShellProcessor', () => { ); }); - it('should append stderr and exit code information correctly', async () => { - const processor = new ShellProcessor('test-command'); - const prompt = '!{cmd}'; - mockShellExecute.mockReturnValue({ - result: Promise.resolve({ - ...SUCCESS_RESULT, - stdout: 'out', - stderr: 'err', - exitCode: 127, - }), - }); - - const result = await processor.process(prompt, context); - - expect(result).toBe( - "out\n--- STDERR ---\nerr\n[Shell command 'cmd' exited with code 127]", - ); - }); - it('should throw a detailed error if the shell fails to spawn', async () => { const processor = new ShellProcessor('test-command'); const prompt = '!{bad-command}'; @@ -572,7 +526,7 @@ describe('ShellProcessor', () => { mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, - stdout: 'partial output', + output: 'partial output', stderr: '', exitCode: null, error: spawnError, @@ -609,7 +563,7 @@ describe('ShellProcessor', () => { const processor = new ShellProcessor('test-command'); const prompt = 'Outside: {{args}}. Inside: !{echo "hello"}'; mockShellExecute.mockReturnValue({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'hello' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'hello' }), }); const result = await processor.process(prompt, context); @@ -621,7 +575,7 @@ describe('ShellProcessor', () => { const processor = new ShellProcessor('test-command'); const prompt = 'Command: !{grep {{args}} file.txt}'; mockShellExecute.mockReturnValue({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'match found' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'match found' }), }); const result = await processor.process(prompt, context); @@ -634,6 +588,7 @@ describe('ShellProcessor', () => { expect.any(String), expect.any(Function), expect.any(Object), + false, ); expect(result).toBe('Command: match found'); @@ -643,7 +598,7 @@ describe('ShellProcessor', () => { const processor = new ShellProcessor('test-command'); const prompt = 'User "({{args}})" requested search: !{search {{args}}}'; mockShellExecute.mockReturnValue({ - result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'results' }), + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'results' }), }); const result = await processor.process(prompt, context); @@ -655,6 +610,7 @@ describe('ShellProcessor', () => { expect.any(String), expect.any(Function), expect.any(Object), + false, ); expect(result).toBe(`User "(${rawArgs})" requested search: results`); @@ -718,6 +674,7 @@ describe('ShellProcessor', () => { expect.any(String), expect.any(Function), expect.any(Object), + false, ); }); @@ -745,6 +702,7 @@ describe('ShellProcessor', () => { expect.any(String), expect.any(Function), expect.any(Object), + false, ); }); }); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 7acf2415..5242fa7a 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -137,11 +137,12 @@ export class ShellProcessor implements IPromptProcessor { // Execute the resolved command (which already has ESCAPED input). if (injection.resolvedCommand) { - const { result } = ShellExecutionService.execute( + const { result } = await ShellExecutionService.execute( injection.resolvedCommand, config.getTargetDir(), () => {}, new AbortController().signal, + config.getShouldUseNodePtyShell(), ); const executionResult = await result; @@ -154,15 +155,7 @@ export class ShellProcessor implements IPromptProcessor { } // Append the output, making stderr explicit for the model. - if (executionResult.stdout) { - processedPrompt += executionResult.stdout; - } - if (executionResult.stderr) { - if (executionResult.stdout) { - processedPrompt += '\n'; - } - processedPrompt += `--- STDERR ---\n${executionResult.stderr}`; - } + processedPrompt += executionResult.output; // Append a status message if the command did not succeed. if (executionResult.aborted) { diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts index d5270aba..9c13c8ec 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts @@ -65,7 +65,10 @@ describe('useShellCommandProcessor', () => { setPendingHistoryItemMock = vi.fn(); onExecMock = vi.fn(); onDebugMessageMock = vi.fn(); - mockConfig = { getTargetDir: () => '/test/dir' } as Config; + mockConfig = { + getTargetDir: () => '/test/dir', + getShouldUseNodePtyShell: () => false, + } as Config; mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient; vi.mocked(os.platform).mockReturnValue('linux'); @@ -104,13 +107,12 @@ describe('useShellCommandProcessor', () => { ): ShellExecutionResult => ({ rawOutput: Buffer.from(overrides.output || ''), output: 'Success', - stdout: 'Success', - stderr: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, + executionMethod: 'child_process', ...overrides, }); @@ -141,6 +143,7 @@ describe('useShellCommandProcessor', () => { '/test/dir', expect.any(Function), expect.any(Object), + false, ); expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise)); }); @@ -223,7 +226,6 @@ describe('useShellCommandProcessor', () => { act(() => { mockShellOutputCallback({ type: 'data', - stream: 'stdout', chunk: 'hello', }); }); @@ -238,7 +240,6 @@ describe('useShellCommandProcessor', () => { act(() => { mockShellOutputCallback({ type: 'data', - stream: 'stdout', chunk: ' world', }); }); @@ -319,6 +320,7 @@ describe('useShellCommandProcessor', () => { '/test/dir', expect.any(Function), expect.any(Object), + false, ); }); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index 08df0a74..23f2bb29 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -101,10 +101,11 @@ export const useShellCommandProcessor = ( commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`; } - const execPromise = new Promise((resolve) => { + const executeCommand = async ( + resolve: (value: void | PromiseLike) => void, + ) => { let lastUpdateTime = Date.now(); let cumulativeStdout = ''; - let cumulativeStderr = ''; let isBinaryStream = false; let binaryBytesReceived = 0; @@ -134,7 +135,7 @@ export const useShellCommandProcessor = ( onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`); try { - const { pid, result } = ShellExecutionService.execute( + const { pid, result } = await ShellExecutionService.execute( commandToExecute, targetDir, (event) => { @@ -142,11 +143,7 @@ export const useShellCommandProcessor = ( case 'data': // Do not process text data if we've already switched to binary mode. if (isBinaryStream) break; - if (event.stream === 'stdout') { - cumulativeStdout += event.chunk; - } else { - cumulativeStderr += event.chunk; - } + cumulativeStdout += event.chunk; break; case 'binary_detected': isBinaryStream = true; @@ -172,9 +169,7 @@ export const useShellCommandProcessor = ( '[Binary output detected. Halting stream...]'; } } else { - currentDisplayOutput = - cumulativeStdout + - (cumulativeStderr ? `\n${cumulativeStderr}` : ''); + currentDisplayOutput = cumulativeStdout; } // Throttle pending UI updates to avoid excessive re-renders. @@ -192,6 +187,7 @@ export const useShellCommandProcessor = ( } }, abortSignal, + config.getShouldUseNodePtyShell(), ); executionPid = pid; @@ -295,6 +291,10 @@ export const useShellCommandProcessor = ( resolve(); // Resolve the promise to unblock `onExec` } + }; + + const execPromise = new Promise((resolve) => { + executeCommand(resolve); }); onExec(execPromise); diff --git a/packages/core/package.json b/packages/core/package.json index 0fc02e7b..abb529fa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -53,7 +53,17 @@ "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", "undici": "^7.10.0", - "ws": "^8.18.0" + "ws": "^8.18.0", + "@xterm/headless": "5.5.0" + }, + "optionalDependencies": { + "@lydell/node-pty": "1.1.0", + "node-pty": "^1.0.0", + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0" }, "devDependencies": { "@google/gemini-cli-test-utils": "file:../test-utils", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5ab39e83..8c95a99d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -202,6 +202,7 @@ export interface ConfigParameters { chatCompression?: ChatCompressionSettings; interactive?: boolean; trustedFolder?: boolean; + shouldUseNodePtyShell?: boolean; } export class Config { @@ -267,6 +268,7 @@ export class Config { private readonly chatCompression: ChatCompressionSettings | undefined; private readonly interactive: boolean; private readonly trustedFolder: boolean | undefined; + private readonly shouldUseNodePtyShell: boolean; private initialized: boolean = false; constructor(params: ConfigParameters) { @@ -334,6 +336,7 @@ export class Config { this.chatCompression = params.chatCompression; this.interactive = params.interactive ?? false; this.trustedFolder = params.trustedFolder; + this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -728,6 +731,10 @@ export class Config { return this.interactive; } + getShouldUseNodePtyShell(): boolean { + return this.shouldUseNodePtyShell; + } + async getGitService(): Promise { if (!this.gitService) { this.gitService = new GitService(this.targetDir); diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 47df12e8..604350aa 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -5,21 +5,6 @@ */ import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; -const mockSpawn = vi.hoisted(() => vi.fn()); -vi.mock('child_process', () => ({ - spawn: mockSpawn, -})); - -const mockGetShellConfiguration = vi.hoisted(() => vi.fn()); -let mockIsWindows = false; - -vi.mock('../utils/shell-utils.js', () => ({ - getShellConfiguration: mockGetShellConfiguration, - get isWindows() { - return mockIsWindows; - }, -})); - import EventEmitter from 'events'; import { Readable } from 'stream'; import { type ChildProcess } from 'child_process'; @@ -28,17 +13,43 @@ import { ShellOutputEvent, } from './shellExecutionService.js'; +// Hoisted Mocks +const mockPtySpawn = vi.hoisted(() => vi.fn()); +const mockCpSpawn = vi.hoisted(() => vi.fn()); const mockIsBinary = vi.hoisted(() => vi.fn()); +const mockPlatform = vi.hoisted(() => vi.fn()); +const mockGetPty = vi.hoisted(() => vi.fn()); + +// Top-level Mocks +vi.mock('@lydell/node-pty', () => ({ + spawn: mockPtySpawn, +})); +vi.mock('child_process', () => ({ + spawn: mockCpSpawn, +})); vi.mock('../utils/textUtils.js', () => ({ isBinary: mockIsBinary, })); - -const mockPlatform = vi.hoisted(() => vi.fn()); vi.mock('os', () => ({ default: { platform: mockPlatform, + constants: { + signals: { + SIGTERM: 15, + SIGKILL: 9, + }, + }, }, platform: mockPlatform, + constants: { + signals: { + SIGTERM: 15, + SIGKILL: 9, + }, + }, +})); +vi.mock('../utils/getPty.js', () => ({ + getPty: mockGetPty, })); const mockProcessKill = vi @@ -46,6 +57,262 @@ const mockProcessKill = vi .mockImplementation(() => true); describe('ShellExecutionService', () => { + let mockPtyProcess: EventEmitter & { + pid: number; + kill: Mock; + onData: Mock; + onExit: Mock; + }; + let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>; + + beforeEach(() => { + vi.clearAllMocks(); + + mockIsBinary.mockReturnValue(false); + mockPlatform.mockReturnValue('linux'); + mockGetPty.mockResolvedValue({ + module: { spawn: mockPtySpawn }, + name: 'mock-pty', + }); + + onOutputEventMock = vi.fn(); + + mockPtyProcess = new EventEmitter() as EventEmitter & { + pid: number; + kill: Mock; + onData: Mock; + onExit: Mock; + }; + mockPtyProcess.pid = 12345; + mockPtyProcess.kill = vi.fn(); + mockPtyProcess.onData = vi.fn(); + mockPtyProcess.onExit = vi.fn(); + + mockPtySpawn.mockReturnValue(mockPtyProcess); + }); + + // Helper function to run a standard execution simulation + const simulateExecution = async ( + command: string, + simulation: ( + ptyProcess: typeof mockPtyProcess, + ac: AbortController, + ) => void, + ) => { + const abortController = new AbortController(); + const handle = await ShellExecutionService.execute( + command, + '/test/dir', + onOutputEventMock, + abortController.signal, + true, + ); + + await new Promise((resolve) => setImmediate(resolve)); + simulation(mockPtyProcess, abortController); + const result = await handle.result; + return { result, handle, abortController }; + }; + + describe('Successful Execution', () => { + it('should execute a command and capture output', async () => { + const { result, handle } = await simulateExecution('ls -l', (pty) => { + pty.onData.mock.calls[0][0]('file1.txt\n'); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'bash', + ['-c', 'ls -l'], + expect.any(Object), + ); + expect(result.exitCode).toBe(0); + expect(result.signal).toBeNull(); + expect(result.error).toBeNull(); + expect(result.aborted).toBe(false); + expect(result.output).toBe('file1.txt'); + expect(handle.pid).toBe(12345); + + expect(onOutputEventMock).toHaveBeenCalledWith({ + type: 'data', + chunk: 'file1.txt', + }); + }); + + it('should strip ANSI codes from output', async () => { + const { result } = await simulateExecution('ls --color=auto', (pty) => { + pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword'); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + + expect(result.output).toBe('aredword'); + expect(onOutputEventMock).toHaveBeenCalledWith({ + type: 'data', + chunk: 'aredword', + }); + }); + + it('should correctly decode multi-byte characters split across chunks', async () => { + const { result } = await simulateExecution('echo "你好"', (pty) => { + const multiByteChar = '你好'; + pty.onData.mock.calls[0][0](multiByteChar.slice(0, 1)); + pty.onData.mock.calls[0][0](multiByteChar.slice(1)); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + expect(result.output).toBe('你好'); + }); + + it('should handle commands with no output', async () => { + const { result } = await simulateExecution('touch file', (pty) => { + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + + expect(result.output).toBe(''); + expect(onOutputEventMock).not.toHaveBeenCalled(); + }); + }); + + describe('Failed Execution', () => { + it('should capture a non-zero exit code', async () => { + const { result } = await simulateExecution('a-bad-command', (pty) => { + pty.onData.mock.calls[0][0]('command not found'); + pty.onExit.mock.calls[0][0]({ exitCode: 127, signal: null }); + }); + + expect(result.exitCode).toBe(127); + expect(result.output).toBe('command not found'); + expect(result.error).toBeNull(); + }); + + it('should capture a termination signal', async () => { + const { result } = await simulateExecution('long-process', (pty) => { + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: 15 }); + }); + + expect(result.exitCode).toBe(0); + expect(result.signal).toBe(15); + }); + + it('should handle a synchronous spawn error', async () => { + mockGetPty.mockImplementation(() => null); + + mockCpSpawn.mockImplementation(() => { + throw new Error('Simulated PTY spawn error'); + }); + + const handle = await ShellExecutionService.execute( + 'any-command', + '/test/dir', + onOutputEventMock, + new AbortController().signal, + true, + ); + const result = await handle.result; + + expect(result.error).toBeInstanceOf(Error); + expect(result.error?.message).toContain('Simulated PTY spawn error'); + expect(result.exitCode).toBe(1); + expect(result.output).toBe(''); + expect(handle.pid).toBeUndefined(); + }); + }); + + describe('Aborting Commands', () => { + it('should abort a running process and set the aborted flag', async () => { + const { result } = await simulateExecution( + 'sleep 10', + (pty, abortController) => { + abortController.abort(); + pty.onExit.mock.calls[0][0]({ exitCode: 1, signal: null }); + }, + ); + + expect(result.aborted).toBe(true); + expect(mockPtyProcess.kill).toHaveBeenCalled(); + }); + }); + + describe('Binary Output', () => { + it('should detect binary output and switch to progress events', async () => { + mockIsBinary.mockReturnValueOnce(true); + const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]); + + const { result } = await simulateExecution('cat image.png', (pty) => { + pty.onData.mock.calls[0][0](binaryChunk1); + pty.onData.mock.calls[0][0](binaryChunk2); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + + expect(result.rawOutput).toEqual( + Buffer.concat([binaryChunk1, binaryChunk2]), + ); + expect(onOutputEventMock).toHaveBeenCalledTimes(3); + expect(onOutputEventMock.mock.calls[0][0]).toEqual({ + type: 'binary_detected', + }); + expect(onOutputEventMock.mock.calls[1][0]).toEqual({ + type: 'binary_progress', + bytesReceived: 4, + }); + expect(onOutputEventMock.mock.calls[2][0]).toEqual({ + type: 'binary_progress', + bytesReceived: 8, + }); + }); + + it('should not emit data events after binary is detected', async () => { + mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00)); + + await simulateExecution('cat mixed_file', (pty) => { + pty.onData.mock.calls[0][0](Buffer.from('some text')); + pty.onData.mock.calls[0][0](Buffer.from([0x00, 0x01, 0x02])); + pty.onData.mock.calls[0][0](Buffer.from('more text')); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + + const eventTypes = onOutputEventMock.mock.calls.map( + (call: [ShellOutputEvent]) => call[0].type, + ); + expect(eventTypes).toEqual([ + 'data', + 'binary_detected', + 'binary_progress', + 'binary_progress', + ]); + }); + }); + + describe('Platform-Specific Behavior', () => { + it('should use cmd.exe on Windows', async () => { + mockPlatform.mockReturnValue('win32'); + await simulateExecution('dir "foo bar"', (pty) => + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), + ); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'cmd.exe', + ['/c', 'dir "foo bar"'], + expect.any(Object), + ); + }); + + it('should use bash on Linux', async () => { + mockPlatform.mockReturnValue('linux'); + await simulateExecution('ls "foo bar"', (pty) => + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), + ); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'bash', + ['-c', 'ls "foo bar"'], + expect.any(Object), + ); + }); + }); +}); + +describe('ShellExecutionService child_process fallback', () => { let mockChildProcess: EventEmitter & Partial; let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>; @@ -53,12 +320,8 @@ describe('ShellExecutionService', () => { vi.clearAllMocks(); mockIsBinary.mockReturnValue(false); - - mockGetShellConfiguration.mockReturnValue({ - executable: 'bash', - argsPrefix: ['-c'], - }); - mockIsWindows = false; + mockPlatform.mockReturnValue('linux'); + mockGetPty.mockResolvedValue(null); onOutputEventMock = vi.fn(); @@ -73,7 +336,7 @@ describe('ShellExecutionService', () => { configurable: true, }); - mockSpawn.mockReturnValue(mockChildProcess); + mockCpSpawn.mockReturnValue(mockChildProcess); }); // Helper function to run a standard execution simulation @@ -82,11 +345,12 @@ describe('ShellExecutionService', () => { simulation: (cp: typeof mockChildProcess, ac: AbortController) => void, ) => { const abortController = new AbortController(); - const handle = ShellExecutionService.execute( + const handle = await ShellExecutionService.execute( command, '/test/dir', onOutputEventMock, abortController.signal, + true, ); await new Promise((resolve) => setImmediate(resolve)); @@ -103,7 +367,7 @@ describe('ShellExecutionService', () => { cp.emit('exit', 0, null); }); - expect(mockSpawn).toHaveBeenCalledWith( + expect(mockCpSpawn).toHaveBeenCalledWith( 'ls -l', [], expect.objectContaining({ shell: 'bash' }), @@ -112,19 +376,15 @@ describe('ShellExecutionService', () => { expect(result.signal).toBeNull(); expect(result.error).toBeNull(); expect(result.aborted).toBe(false); - expect(result.stdout).toBe('file1.txt\n'); - expect(result.stderr).toBe('a warning'); - expect(result.output).toBe('file1.txt\n\na warning'); + expect(result.output).toBe('file1.txt\na warning'); expect(handle.pid).toBe(12345); expect(onOutputEventMock).toHaveBeenCalledWith({ type: 'data', - stream: 'stdout', chunk: 'file1.txt\n', }); expect(onOutputEventMock).toHaveBeenCalledWith({ type: 'data', - stream: 'stderr', chunk: 'a warning', }); }); @@ -135,10 +395,9 @@ describe('ShellExecutionService', () => { cp.emit('exit', 0, null); }); - expect(result.stdout).toBe('aredword'); + expect(result.output).toBe('aredword'); expect(onOutputEventMock).toHaveBeenCalledWith({ type: 'data', - stream: 'stdout', chunk: 'aredword', }); }); @@ -150,7 +409,7 @@ describe('ShellExecutionService', () => { cp.stdout?.emit('data', multiByteChar.slice(2)); cp.emit('exit', 0, null); }); - expect(result.stdout).toBe('你好'); + expect(result.output).toBe('你好'); }); it('should handle commands with no output', async () => { @@ -158,8 +417,6 @@ describe('ShellExecutionService', () => { cp.emit('exit', 0, null); }); - expect(result.stdout).toBe(''); - expect(result.stderr).toBe(''); expect(result.output).toBe(''); expect(onOutputEventMock).not.toHaveBeenCalled(); }); @@ -173,9 +430,7 @@ describe('ShellExecutionService', () => { }); expect(result.exitCode).toBe(127); - expect(result.stderr).toBe('command not found'); - expect(result.stdout).toBe(''); - expect(result.output).toBe('\ncommand not found'); + expect(result.output).toBe('command not found'); expect(result.error).toBeNull(); }); @@ -185,7 +440,7 @@ describe('ShellExecutionService', () => { }); expect(result.exitCode).toBeNull(); - expect(result.signal).toBe('SIGTERM'); + expect(result.signal).toBe(15); }); it('should handle a spawn error', async () => { @@ -247,7 +502,7 @@ describe('ShellExecutionService', () => { expectedSignal, ); } else { - expect(mockSpawn).toHaveBeenCalledWith(expectedCommand, [ + expect(mockCpSpawn).toHaveBeenCalledWith(expectedCommand, [ '/pid', String(mockChildProcess.pid), '/f', @@ -265,11 +520,12 @@ describe('ShellExecutionService', () => { // Don't await the result inside the simulation block for this specific test. // We need to control the timeline manually. const abortController = new AbortController(); - const handle = ShellExecutionService.execute( + const handle = await ShellExecutionService.execute( 'unresponsive_process', '/test/dir', onOutputEventMock, abortController.signal, + true, ); abortController.abort(); @@ -296,7 +552,7 @@ describe('ShellExecutionService', () => { vi.useRealTimers(); expect(result.aborted).toBe(true); - expect(result.signal).toBe('SIGKILL'); + expect(result.signal).toBe(9); // The individual kill calls were already asserted above. expect(mockProcessKill).toHaveBeenCalledTimes(2); }); @@ -341,7 +597,6 @@ describe('ShellExecutionService', () => { cp.emit('exit', 0, null); }); - // FIX: Provide explicit type for the 'call' parameter in the map function. const eventTypes = onOutputEventMock.mock.calls.map( (call: [ShellOutputEvent]) => call[0].type, ); @@ -361,7 +616,7 @@ describe('ShellExecutionService', () => { cp.emit('exit', 0, null), ); - expect(mockSpawn).toHaveBeenCalledWith( + expect(mockCpSpawn).toHaveBeenCalledWith( 'dir "foo bar"', [], expect.objectContaining({ @@ -375,7 +630,7 @@ describe('ShellExecutionService', () => { mockPlatform.mockReturnValue('linux'); await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null)); - expect(mockSpawn).toHaveBeenCalledWith( + expect(mockCpSpawn).toHaveBeenCalledWith( 'ls "foo bar"', [], expect.objectContaining({ @@ -386,3 +641,110 @@ describe('ShellExecutionService', () => { }); }); }); + +describe('ShellExecutionService execution method selection', () => { + let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>; + let mockPtyProcess: EventEmitter & { + pid: number; + kill: Mock; + onData: Mock; + onExit: Mock; + }; + let mockChildProcess: EventEmitter & Partial; + + beforeEach(() => { + vi.clearAllMocks(); + onOutputEventMock = vi.fn(); + + // Mock for pty + mockPtyProcess = new EventEmitter() as EventEmitter & { + pid: number; + kill: Mock; + onData: Mock; + onExit: Mock; + }; + mockPtyProcess.pid = 12345; + mockPtyProcess.kill = vi.fn(); + mockPtyProcess.onData = vi.fn(); + mockPtyProcess.onExit = vi.fn(); + mockPtySpawn.mockReturnValue(mockPtyProcess); + mockGetPty.mockResolvedValue({ + module: { spawn: mockPtySpawn }, + name: 'mock-pty', + }); + + // Mock for child_process + mockChildProcess = new EventEmitter() as EventEmitter & + Partial; + mockChildProcess.stdout = new EventEmitter() as Readable; + mockChildProcess.stderr = new EventEmitter() as Readable; + mockChildProcess.kill = vi.fn(); + Object.defineProperty(mockChildProcess, 'pid', { + value: 54321, + configurable: true, + }); + mockCpSpawn.mockReturnValue(mockChildProcess); + }); + + it('should use node-pty when shouldUseNodePty is true and pty is available', async () => { + const abortController = new AbortController(); + const handle = await ShellExecutionService.execute( + 'test command', + '/test/dir', + onOutputEventMock, + abortController.signal, + true, // shouldUseNodePty + ); + + // Simulate exit to allow promise to resolve + mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + const result = await handle.result; + + expect(mockGetPty).toHaveBeenCalled(); + expect(mockPtySpawn).toHaveBeenCalled(); + expect(mockCpSpawn).not.toHaveBeenCalled(); + expect(result.executionMethod).toBe('mock-pty'); + }); + + it('should use child_process when shouldUseNodePty is false', async () => { + const abortController = new AbortController(); + const handle = await ShellExecutionService.execute( + 'test command', + '/test/dir', + onOutputEventMock, + abortController.signal, + false, // shouldUseNodePty + ); + + // Simulate exit to allow promise to resolve + mockChildProcess.emit('exit', 0, null); + const result = await handle.result; + + expect(mockGetPty).not.toHaveBeenCalled(); + expect(mockPtySpawn).not.toHaveBeenCalled(); + expect(mockCpSpawn).toHaveBeenCalled(); + expect(result.executionMethod).toBe('child_process'); + }); + + it('should fall back to child_process if pty is not available even if shouldUseNodePty is true', async () => { + mockGetPty.mockResolvedValue(null); + + const abortController = new AbortController(); + const handle = await ShellExecutionService.execute( + 'test command', + '/test/dir', + onOutputEventMock, + abortController.signal, + true, // shouldUseNodePty + ); + + // Simulate exit to allow promise to resolve + mockChildProcess.emit('exit', 0, null); + const result = await handle.result; + + expect(mockGetPty).toHaveBeenCalled(); + expect(mockPtySpawn).not.toHaveBeenCalled(); + expect(mockCpSpawn).toHaveBeenCalled(); + expect(result.executionMethod).toBe('child_process'); + }); +}); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 3749fcf6..59e998bd 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -4,35 +4,47 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { spawn } from 'child_process'; +import { getPty, PtyImplementation } from '../utils/getPty.js'; +import { spawn as cpSpawn } from 'child_process'; import { TextDecoder } from 'util'; import os from 'os'; -import stripAnsi from 'strip-ansi'; import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js'; import { isBinary } from '../utils/textUtils.js'; +import pkg from '@xterm/headless'; +import stripAnsi from 'strip-ansi'; +const { Terminal } = pkg; const SIGKILL_TIMEOUT_MS = 200; +// @ts-expect-error getFullText is not a public API. +const getFullText = (terminal: Terminal) => { + const buffer = terminal.buffer.active; + const lines: string[] = []; + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + lines.push(line ? line.translateToString(true) : ''); + } + return lines.join('\n').trim(); +}; + /** A structured result from a shell command execution. */ export interface ShellExecutionResult { /** The raw, unprocessed output buffer. */ rawOutput: Buffer; - /** The combined, decoded stdout and stderr as a string. */ + /** The combined, decoded output as a string. */ output: string; - /** The decoded stdout as a string. */ - stdout: string; - /** The decoded stderr as a string. */ - stderr: string; /** The process exit code, or null if terminated by a signal. */ exitCode: number | null; /** The signal that terminated the process, if any. */ - signal: NodeJS.Signals | null; + signal: number | null; /** An error object if the process failed to spawn. */ error: Error | null; /** A boolean indicating if the command was aborted by the user. */ aborted: boolean; /** The process ID of the spawned shell. */ pid: number | undefined; + /** The method used to execute the shell command. */ + executionMethod: 'lydell-node-pty' | 'node-pty' | 'child_process' | 'none'; } /** A handle for an ongoing shell execution. */ @@ -50,8 +62,6 @@ export type ShellOutputEvent = | { /** The event contains a chunk of output data. */ type: 'data'; - /** The stream from which the data originated. */ - stream: 'stdout' | 'stderr'; /** The decoded string chunk. */ chunk: string; } @@ -73,7 +83,7 @@ export type ShellOutputEvent = */ export class ShellExecutionService { /** - * Executes a shell command using `spawn`, capturing all output and lifecycle events. + * Executes a shell command using `node-pty`, capturing all output and lifecycle events. * * @param commandToExecute The exact command string to run. * @param cwd The working directory to execute the command in. @@ -82,172 +92,369 @@ export class ShellExecutionService { * @returns An object containing the process ID (pid) and a promise that * resolves with the complete execution result. */ - static execute( + static async execute( + commandToExecute: string, + cwd: string, + onOutputEvent: (event: ShellOutputEvent) => void, + abortSignal: AbortSignal, + shouldUseNodePty: boolean, + terminalColumns?: number, + terminalRows?: number, + ): Promise { + if (shouldUseNodePty) { + const ptyInfo = await getPty(); + if (ptyInfo) { + try { + return this.executeWithPty( + commandToExecute, + cwd, + onOutputEvent, + abortSignal, + terminalColumns, + terminalRows, + ptyInfo, + ); + } catch (_e) { + // Fallback to child_process + } + } + } + + return this.childProcessFallback( + commandToExecute, + cwd, + onOutputEvent, + abortSignal, + ); + } + + private static childProcessFallback( commandToExecute: string, cwd: string, onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, ): ShellExecutionHandle { - const isWindows = os.platform() === 'win32'; + try { + const isWindows = os.platform() === 'win32'; - const child = spawn(commandToExecute, [], { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - // Use bash unless in Windows (since it doesn't support bash). - // For windows, just use the default. - shell: isWindows ? true : 'bash', - // Use process groups on non-Windows for robust killing. - // Windows process termination is handled by `taskkill /t`. - detached: !isWindows, - env: { - ...process.env, - GEMINI_CLI: '1', - }, - }); + const child = cpSpawn(commandToExecute, [], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + shell: isWindows ? true : 'bash', + detached: !isWindows, + env: { + ...process.env, + GEMINI_CLI: '1', + TERM: 'xterm-256color', + PAGER: 'cat', + }, + }); - const result = new Promise((resolve) => { - // Use decoders to handle multi-byte characters safely (for streaming output). - let stdoutDecoder: TextDecoder | null = null; - let stderrDecoder: TextDecoder | null = null; + const result = new Promise((resolve) => { + let stdoutDecoder: TextDecoder | null = null; + let stderrDecoder: TextDecoder | null = null; - let stdout = ''; - let stderr = ''; - const outputChunks: Buffer[] = []; - let error: Error | null = null; - let exited = false; + let stdout = ''; + let stderr = ''; + const outputChunks: Buffer[] = []; + let error: Error | null = null; + let exited = false; - let isStreamingRawContent = true; - const MAX_SNIFF_SIZE = 4096; - let sniffedBytes = 0; + let isStreamingRawContent = true; + const MAX_SNIFF_SIZE = 4096; + let sniffedBytes = 0; - const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => { - if (!stdoutDecoder || !stderrDecoder) { - const encoding = getCachedEncodingForBuffer(data); - try { - stdoutDecoder = new TextDecoder(encoding); - stderrDecoder = new TextDecoder(encoding); - } catch { - // If the encoding is not supported, fall back to utf-8. - // This can happen on some platforms for certain encodings like 'utf-32le'. - stdoutDecoder = new TextDecoder('utf-8'); - stderrDecoder = new TextDecoder('utf-8'); + const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => { + if (!stdoutDecoder || !stderrDecoder) { + const encoding = getCachedEncodingForBuffer(data); + try { + stdoutDecoder = new TextDecoder(encoding); + stderrDecoder = new TextDecoder(encoding); + } catch { + stdoutDecoder = new TextDecoder('utf-8'); + stderrDecoder = new TextDecoder('utf-8'); + } } - } - outputChunks.push(data); + outputChunks.push(data); - // Binary detection logic. This only runs until we've made a determination. - if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { - const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20)); - sniffedBytes = sniffBuffer.length; + if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { + const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20)); + sniffedBytes = sniffBuffer.length; - if (isBinary(sniffBuffer)) { - // Change state to stop streaming raw content. - isStreamingRawContent = false; - onOutputEvent({ type: 'binary_detected' }); + if (isBinary(sniffBuffer)) { + isStreamingRawContent = false; + onOutputEvent({ type: 'binary_detected' }); + } } + + const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder; + const decodedChunk = decoder.decode(data, { stream: true }); + const strippedChunk = stripAnsi(decodedChunk); + + if (stream === 'stdout') { + stdout += strippedChunk; + } else { + stderr += strippedChunk; + } + + if (isStreamingRawContent) { + onOutputEvent({ type: 'data', chunk: strippedChunk }); + } else { + const totalBytes = outputChunks.reduce( + (sum, chunk) => sum + chunk.length, + 0, + ); + onOutputEvent({ + type: 'binary_progress', + bytesReceived: totalBytes, + }); + } + }; + + const handleExit = ( + code: number | null, + signal: NodeJS.Signals | null, + ) => { + const { finalBuffer } = cleanup(); + // Ensure we don't add an extra newline if stdout already ends with one. + const separator = stdout.endsWith('\n') ? '' : '\n'; + const combinedOutput = + stdout + (stderr ? (stdout ? separator : '') + stderr : ''); + + resolve({ + rawOutput: finalBuffer, + output: combinedOutput.trim(), + exitCode: code, + signal: signal ? os.constants.signals[signal] : null, + error, + aborted: abortSignal.aborted, + pid: child.pid, + executionMethod: 'child_process', + }); + }; + + child.stdout.on('data', (data) => handleOutput(data, 'stdout')); + child.stderr.on('data', (data) => handleOutput(data, 'stderr')); + child.on('error', (err) => { + error = err; + handleExit(1, null); + }); + + const abortHandler = async () => { + if (child.pid && !exited) { + if (isWindows) { + cpSpawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']); + } else { + try { + process.kill(-child.pid, 'SIGTERM'); + await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); + if (!exited) { + process.kill(-child.pid, 'SIGKILL'); + } + } catch (_e) { + if (!exited) child.kill('SIGKILL'); + } + } + } + }; + + abortSignal.addEventListener('abort', abortHandler, { once: true }); + + child.on('exit', (code, signal) => { + handleExit(code, signal); + }); + + function cleanup() { + exited = true; + abortSignal.removeEventListener('abort', abortHandler); + if (stdoutDecoder) { + const remaining = stdoutDecoder.decode(); + if (remaining) { + stdout += stripAnsi(remaining); + } + } + if (stderrDecoder) { + const remaining = stderrDecoder.decode(); + if (remaining) { + stderr += stripAnsi(remaining); + } + } + + const finalBuffer = Buffer.concat(outputChunks); + + return { stdout, stderr, finalBuffer }; } + }); - const decodedChunk = - stream === 'stdout' - ? stdoutDecoder.decode(data, { stream: true }) - : stderrDecoder.decode(data, { stream: true }); - const strippedChunk = stripAnsi(decodedChunk); - - if (stream === 'stdout') { - stdout += strippedChunk; - } else { - stderr += strippedChunk; - } - - if (isStreamingRawContent) { - onOutputEvent({ type: 'data', stream, chunk: strippedChunk }); - } else { - const totalBytes = outputChunks.reduce( - (sum, chunk) => sum + chunk.length, - 0, - ); - onOutputEvent({ type: 'binary_progress', bytesReceived: totalBytes }); - } - }; - - child.stdout.on('data', (data) => handleOutput(data, 'stdout')); - child.stderr.on('data', (data) => handleOutput(data, 'stderr')); - child.on('error', (err) => { - const { stdout, stderr, finalBuffer } = cleanup(); - error = err; - resolve({ + return { pid: child.pid, result }; + } catch (e) { + const error = e as Error; + return { + pid: undefined, + result: Promise.resolve({ error, - stdout, - stderr, - rawOutput: finalBuffer, - output: stdout + (stderr ? `\n${stderr}` : ''), + rawOutput: Buffer.from(''), + output: '', exitCode: 1, signal: null, aborted: false, - pid: child.pid, - }); - }); - - const abortHandler = async () => { - if (child.pid && !exited) { - if (isWindows) { - spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']); - } else { - try { - // Kill the entire process group (negative PID). - // SIGTERM first, then SIGKILL if it doesn't die. - process.kill(-child.pid, 'SIGTERM'); - await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); - if (!exited) { - process.kill(-child.pid, 'SIGKILL'); - } - } catch (_e) { - // Fall back to killing just the main process if group kill fails. - if (!exited) child.kill('SIGKILL'); - } - } - } + pid: undefined, + executionMethod: 'none', + }), }; + } + } - abortSignal.addEventListener('abort', abortHandler, { once: true }); + private static executeWithPty( + commandToExecute: string, + cwd: string, + onOutputEvent: (event: ShellOutputEvent) => void, + abortSignal: AbortSignal, + terminalColumns: number | undefined, + terminalRows: number | undefined, + ptyInfo: PtyImplementation | undefined, + ): ShellExecutionHandle { + try { + const cols = terminalColumns ?? 80; + const rows = terminalRows ?? 30; + const isWindows = os.platform() === 'win32'; + const shell = isWindows ? 'cmd.exe' : 'bash'; + const args = isWindows + ? ['/c', commandToExecute] + : ['-c', commandToExecute]; - child.on('exit', (code: number, signal: NodeJS.Signals) => { - const { stdout, stderr, finalBuffer } = cleanup(); - - resolve({ - rawOutput: finalBuffer, - output: stdout + (stderr ? `\n${stderr}` : ''), - stdout, - stderr, - exitCode: code, - signal, - error, - aborted: abortSignal.aborted, - pid: child.pid, - }); + const ptyProcess = ptyInfo?.module.spawn(shell, args, { + cwd, + name: 'xterm-color', + cols, + rows, + env: { + ...process.env, + GEMINI_CLI: '1', + TERM: 'xterm-256color', + PAGER: 'cat', + }, + handleFlowControl: true, }); - /** - * Cleans up a process (and it's accompanying state) that is exiting or - * erroring and returns output formatted output buffers and strings - */ - function cleanup() { - exited = true; - abortSignal.removeEventListener('abort', abortHandler); - if (stdoutDecoder) { - stdout += stripAnsi(stdoutDecoder.decode()); - } - if (stderrDecoder) { - stderr += stripAnsi(stderrDecoder.decode()); - } + const result = new Promise((resolve) => { + const headlessTerminal = new Terminal({ + allowProposedApi: true, + cols, + rows, + }); + let processingChain = Promise.resolve(); + let decoder: TextDecoder | null = null; + let output = ''; + const outputChunks: Buffer[] = []; + const error: Error | null = null; + let exited = false; - const finalBuffer = Buffer.concat(outputChunks); + let isStreamingRawContent = true; + const MAX_SNIFF_SIZE = 4096; + let sniffedBytes = 0; - return { stdout, stderr, finalBuffer }; - } - }); + const handleOutput = (data: Buffer) => { + processingChain = processingChain.then( + () => + new Promise((resolve) => { + if (!decoder) { + const encoding = getCachedEncodingForBuffer(data); + try { + decoder = new TextDecoder(encoding); + } catch { + decoder = new TextDecoder('utf-8'); + } + } - return { pid: child.pid, result }; + outputChunks.push(data); + + if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { + const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20)); + sniffedBytes = sniffBuffer.length; + + if (isBinary(sniffBuffer)) { + isStreamingRawContent = false; + onOutputEvent({ type: 'binary_detected' }); + } + } + + if (isStreamingRawContent) { + const decodedChunk = decoder.decode(data, { stream: true }); + headlessTerminal.write(decodedChunk, () => { + const newStrippedOutput = getFullText(headlessTerminal); + output = newStrippedOutput; + onOutputEvent({ type: 'data', chunk: newStrippedOutput }); + resolve(); + }); + } else { + const totalBytes = outputChunks.reduce( + (sum, chunk) => sum + chunk.length, + 0, + ); + onOutputEvent({ + type: 'binary_progress', + bytesReceived: totalBytes, + }); + resolve(); + } + }), + ); + }; + + ptyProcess.onData((data: string) => { + const bufferData = Buffer.from(data, 'utf-8'); + handleOutput(bufferData); + }); + + ptyProcess.onExit( + ({ exitCode, signal }: { exitCode: number; signal?: number }) => { + exited = true; + abortSignal.removeEventListener('abort', abortHandler); + + processingChain.then(() => { + const finalBuffer = Buffer.concat(outputChunks); + + resolve({ + rawOutput: finalBuffer, + output, + exitCode, + signal: signal ?? null, + error, + aborted: abortSignal.aborted, + pid: ptyProcess.pid, + executionMethod: ptyInfo?.name ?? 'node-pty', + }); + }); + }, + ); + + const abortHandler = async () => { + if (ptyProcess.pid && !exited) { + ptyProcess.kill('SIGHUP'); + } + }; + + abortSignal.addEventListener('abort', abortHandler, { once: true }); + }); + + return { pid: ptyProcess.pid, result }; + } catch (e) { + const error = e as Error; + return { + pid: undefined, + result: Promise.resolve({ + error, + rawOutput: Buffer.from(''), + output: '', + exitCode: 1, + signal: null, + aborted: false, + pid: undefined, + executionMethod: 'none', + }), + }; + } } } diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index c0b409fa..79354cf8 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -56,6 +56,7 @@ describe('ShellTool', () => { getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined), getWorkspaceContext: () => createMockWorkspaceContext('.'), getGeminiClient: vi.fn(), + getShouldUseNodePtyShell: vi.fn().mockReturnValue(false), } as unknown as Config; shellTool = new ShellTool(mockConfig); @@ -123,13 +124,12 @@ describe('ShellTool', () => { const fullResult: ShellExecutionResult = { rawOutput: Buffer.from(result.output || ''), output: 'Success', - stdout: 'Success', - stderr: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, + executionMethod: 'child_process', ...result, }; resolveExecutionPromise(fullResult); @@ -152,6 +152,9 @@ describe('ShellTool', () => { expect.any(String), expect.any(Function), mockAbortSignal, + false, + undefined, + undefined, ); expect(result.llmContent).toContain('Background PIDs: 54322'); expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); @@ -164,13 +167,12 @@ describe('ShellTool', () => { resolveShellExecution({ rawOutput: Buffer.from(''), output: '', - stdout: '', - stderr: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, + executionMethod: 'child_process', }); await promise; expect(mockShellExecutionService).toHaveBeenCalledWith( @@ -178,6 +180,9 @@ describe('ShellTool', () => { expect.any(String), expect.any(Function), mockAbortSignal, + false, + undefined, + undefined, ); }); @@ -189,16 +194,14 @@ describe('ShellTool', () => { error, exitCode: 1, output: 'err', - stderr: 'err', rawOutput: Buffer.from('err'), - stdout: '', signal: null, aborted: false, pid: 12345, + executionMethod: 'child_process', }); const result = await promise; - // The final llmContent should contain the user's command, not the wrapper expect(result.llmContent).toContain('Error: wrapped command failed'); expect(result.llmContent).not.toContain('pgrep'); }); @@ -231,13 +234,12 @@ describe('ShellTool', () => { resolveExecutionPromise({ output: 'long output', rawOutput: Buffer.from('long output'), - stdout: 'long output', - stderr: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, + executionMethod: 'child_process', }); const result = await promise; @@ -283,7 +285,6 @@ describe('ShellTool', () => { // First chunk, should be throttled. mockShellOutputCallback({ type: 'data', - stream: 'stdout', chunk: 'hello ', }); expect(updateOutputMock).not.toHaveBeenCalled(); @@ -294,24 +295,22 @@ describe('ShellTool', () => { // Send a second chunk. THIS event triggers the update with the CUMULATIVE content. mockShellOutputCallback({ type: 'data', - stream: 'stderr', - chunk: 'world', + chunk: 'hello world', }); // It should have been called once now with the combined output. expect(updateOutputMock).toHaveBeenCalledOnce(); - expect(updateOutputMock).toHaveBeenCalledWith('hello \nworld'); + expect(updateOutputMock).toHaveBeenCalledWith('hello world'); resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', - stdout: '', - stderr: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, + executionMethod: 'child_process', }); await promise; }); @@ -350,13 +349,12 @@ describe('ShellTool', () => { resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', - stdout: '', - stderr: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, + executionMethod: 'child_process', }); await promise; }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 8d5c624c..3fce7c2d 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -96,6 +96,8 @@ class ShellToolInvocation extends BaseToolInvocation< async execute( signal: AbortSignal, updateOutput?: (output: string) => void, + terminalColumns?: number, + terminalRows?: number, ): Promise { const strippedCommand = stripShellWrapper(this.params.command); @@ -128,13 +130,11 @@ class ShellToolInvocation extends BaseToolInvocation< this.params.directory || '', ); - let cumulativeStdout = ''; - let cumulativeStderr = ''; - + let cumulativeOutput = ''; let lastUpdateTime = Date.now(); let isBinaryStream = false; - const { result: resultPromise } = ShellExecutionService.execute( + const { result: resultPromise } = await ShellExecutionService.execute( commandToExecute, cwd, (event: ShellOutputEvent) => { @@ -147,15 +147,9 @@ class ShellToolInvocation extends BaseToolInvocation< switch (event.type) { case 'data': - if (isBinaryStream) break; // Don't process text if we are in binary mode - if (event.stream === 'stdout') { - cumulativeStdout += event.chunk; - } else { - cumulativeStderr += event.chunk; - } - currentDisplayOutput = - cumulativeStdout + - (cumulativeStderr ? `\n${cumulativeStderr}` : ''); + if (isBinaryStream) break; + cumulativeOutput = event.chunk; + currentDisplayOutput = cumulativeOutput; if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) { shouldUpdate = true; } @@ -186,6 +180,9 @@ class ShellToolInvocation extends BaseToolInvocation< } }, signal, + this.config.getShouldUseNodePtyShell(), + terminalColumns, + terminalRows, ); const result = await resultPromise; @@ -217,7 +214,7 @@ class ShellToolInvocation extends BaseToolInvocation< if (result.aborted) { llmContent = 'Command was cancelled by user before it could complete.'; if (result.output.trim()) { - llmContent += ` Below is the output (on stdout and stderr) before it was cancelled:\n${result.output}`; + llmContent += ` Below is the output before it was cancelled:\n${result.output}`; } else { llmContent += ' There was no output before it was cancelled.'; } @@ -231,8 +228,7 @@ class ShellToolInvocation extends BaseToolInvocation< llmContent = [ `Command: ${this.params.command}`, `Directory: ${this.params.directory || '(root)'}`, - `Stdout: ${result.stdout || '(empty)'}`, - `Stderr: ${result.stderr || '(empty)'}`, + `Output: ${result.output || '(empty)'}`, `Error: ${finalError}`, // Use the cleaned error string. `Exit Code: ${result.exitCode ?? '(none)'}`, `Signal: ${result.signal ?? '(none)'}`, diff --git a/packages/core/src/utils/getPty.ts b/packages/core/src/utils/getPty.ts new file mode 100644 index 00000000..2d7cb16f --- /dev/null +++ b/packages/core/src/utils/getPty.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export type PtyImplementation = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + module: any; + name: 'lydell-node-pty' | 'node-pty'; +} | null; + +export interface PtyProcess { + readonly pid: number; + onData(callback: (data: string) => void): void; + onExit(callback: (e: { exitCode: number; signal?: number }) => void): void; + kill(signal?: string): void; +} + +export const getPty = async (): Promise => { + try { + const lydell = '@lydell/node-pty'; + const module = await import(lydell); + return { module, name: 'lydell-node-pty' }; + } catch (_e) { + try { + const nodePty = 'node-pty'; + const module = await import(nodePty); + return { module, name: 'node-pty' }; + } catch (_e2) { + return null; + } + } +}; diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 060be7c6..bcb32e81 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -47,6 +47,7 @@ async function main() { /* add to the end of plugins array */ esbuildProblemMatcherPlugin, ], + loader: { '.node': 'file' }, }); if (watch) { await ctx.watch();