diff options
Diffstat (limited to 'packages/cli/src/ui/hooks')
| -rw-r--r-- | packages/cli/src/ui/hooks/shellCommandProcessor.test.ts | 2 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/shellCommandProcessor.ts | 78 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useLoadingIndicator.test.ts | 58 |
3 files changed, 85 insertions, 53 deletions
diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts index 0ec2bb60..129a5401 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts @@ -44,8 +44,10 @@ vi.mock('path', () => ({ vi.mock('os', () => ({ default: { tmpdir: vi.fn(() => '/tmp'), + platform: vi.fn(() => 'linux'), }, tmpdir: vi.fn(() => '/tmp'), + platform: vi.fn(() => 'linux'), })); // Configure the fs mock to use new vi.fn() instances created within the factory diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index 9d6ff03a..f7502f3f 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -43,13 +43,23 @@ export const useShellCommandProcessor = ( return false; } - // wrap command to write pwd to temporary file - let commandToExecute = rawQuery.trim(); - const pwdFileName = `shell_pwd_${crypto.randomBytes(6).toString('hex')}.tmp`; - const pwdFilePath = path.join(os.tmpdir(), pwdFileName); - if (!commandToExecute.endsWith('&')) commandToExecute += ';'; - // note here we could also restore a previous pwd with `cd {cwd}; { ... }` - commandToExecute = `{ ${commandToExecute} }; __code=$?; pwd >${pwdFilePath}; exit $__code`; + const isWindows = os.platform() === 'win32'; + let commandToExecute: string; + let pwdFilePath: string | undefined; + + if (isWindows) { + commandToExecute = rawQuery; + } else { + // wrap command to write pwd to temporary file + let command = rawQuery.trim(); + const pwdFileName = `shell_pwd_${crypto + .randomBytes(6) + .toString('hex')}.tmp`; + pwdFilePath = path.join(os.tmpdir(), pwdFileName); + if (!command.endsWith('&')) command += ';'; + // note here we could also restore a previous pwd with `cd {cwd}; { ... }` + commandToExecute = `{ ${command} }; __code=$?; pwd >${pwdFilePath}; exit $__code`; + } const userMessageTimestamp = Date.now(); addItemToHistory( @@ -101,7 +111,7 @@ export const useShellCommandProcessor = ( userMessageTimestamp, ); } - if (fs.existsSync(pwdFilePath)) { + if (pwdFilePath && fs.existsSync(pwdFilePath)) { const pwd = fs.readFileSync(pwdFilePath, 'utf8').trim(); if (pwd !== targetDir) { addItemToHistory( @@ -118,11 +128,16 @@ export const useShellCommandProcessor = ( }, ); } else { - const child = spawn('bash', ['-c', commandToExecute], { - cwd: targetDir, - stdio: ['ignore', 'pipe', 'pipe'], - detached: true, // Important for process group killing - }); + const child = isWindows + ? spawn('cmd.exe', ['/c', commandToExecute], { + cwd: targetDir, + stdio: ['ignore', 'pipe', 'pipe'], + }) + : spawn('bash', ['-c', commandToExecute], { + cwd: targetDir, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, // Important for process group killing + }); let exited = false; let output = ''; @@ -155,24 +170,29 @@ export const useShellCommandProcessor = ( onDebugMessage( `Aborting shell command (PID: ${child.pid}) due to signal.`, ); - try { - // attempt to SIGTERM process group (negative PID) - // fall back to SIGKILL (to group) after 200ms - process.kill(-child.pid, 'SIGTERM'); - await new Promise((resolve) => setTimeout(resolve, 200)); - if (child.pid && !exited) { - process.kill(-child.pid, 'SIGKILL'); - } - } catch (_e) { - // if group kill fails, fall back to killing just the main process + if (os.platform() === 'win32') { + // For Windows, use taskkill to kill the process tree + spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']); + } else { try { - if (child.pid) { - child.kill('SIGKILL'); + // attempt to SIGTERM process group (negative PID) + // fall back to SIGKILL (to group) after 200ms + process.kill(-child.pid, 'SIGTERM'); + await new Promise((resolve) => setTimeout(resolve, 200)); + if (child.pid && !exited) { + process.kill(-child.pid, 'SIGKILL'); } } catch (_e) { - console.error( - `failed to kill shell process ${child.pid}: ${_e}`, - ); + // if group kill fails, fall back to killing just the main process + try { + if (child.pid) { + child.kill('SIGKILL'); + } + } catch (_e) { + console.error( + `failed to kill shell process ${child.pid}: ${_e}`, + ); + } } } } @@ -208,7 +228,7 @@ export const useShellCommandProcessor = ( userMessageTimestamp, ); } - if (fs.existsSync(pwdFilePath)) { + if (pwdFilePath && fs.existsSync(pwdFilePath)) { const pwd = fs.readFileSync(pwdFilePath, 'utf8').trim(); if (pwd !== targetDir) { addItemToHistory( diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts index 92ae81a2..ec6732c7 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { act, renderHook } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react'; import { useLoadingIndicator } from './useLoadingIndicator.js'; import { StreamingState } from '../types.js'; import { @@ -32,7 +32,7 @@ describe('useLoadingIndicator', () => { expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]); }); - it('should reflect values when Responding', () => { + it('should reflect values when Responding', async () => { const { result } = renderHook(() => useLoadingIndicator(StreamingState.Responding), ); @@ -42,29 +42,33 @@ describe('useLoadingIndicator', () => { expect(WITTY_LOADING_PHRASES).toContain( result.current.currentLoadingPhrase, ); - const _initialPhrase = result.current.currentLoadingPhrase; + const initialPhrase = result.current.currentLoadingPhrase; - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + await act(async () => { + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); }); + // Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed + expect(result.current.currentLoadingPhrase).not.toBe(initialPhrase); expect(WITTY_LOADING_PHRASES).toContain( result.current.currentLoadingPhrase, ); }); - it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', () => { + it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', async () => { const { result, rerender } = renderHook( ({ streamingState }) => useLoadingIndicator(streamingState), { initialProps: { streamingState: StreamingState.Responding } }, ); - act(() => { - vi.advanceTimersByTime(60000); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); }); expect(result.current.elapsedTime).toBe(60); - rerender({ streamingState: StreamingState.WaitingForConfirmation }); + act(() => { + rerender({ streamingState: StreamingState.WaitingForConfirmation }); + }); expect(result.current.currentLoadingPhrase).toBe( 'Waiting for user confirmation...', @@ -72,60 +76,66 @@ describe('useLoadingIndicator', () => { expect(result.current.elapsedTime).toBe(60); // Elapsed time should be retained // Timer should not advance further - act(() => { - vi.advanceTimersByTime(2000); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); }); expect(result.current.elapsedTime).toBe(60); }); - it('should reset elapsedTime and use a witty phrase when transitioning from WaitingForConfirmation to Responding', () => { + it('should reset elapsedTime and use a witty phrase when transitioning from WaitingForConfirmation to Responding', async () => { const { result, rerender } = renderHook( ({ streamingState }) => useLoadingIndicator(streamingState), { initialProps: { streamingState: StreamingState.Responding } }, ); - act(() => { - vi.advanceTimersByTime(5000); // 5s + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); // 5s }); expect(result.current.elapsedTime).toBe(5); - rerender({ streamingState: StreamingState.WaitingForConfirmation }); + act(() => { + rerender({ streamingState: StreamingState.WaitingForConfirmation }); + }); expect(result.current.elapsedTime).toBe(5); expect(result.current.currentLoadingPhrase).toBe( 'Waiting for user confirmation...', ); - rerender({ streamingState: StreamingState.Responding }); + act(() => { + rerender({ streamingState: StreamingState.Responding }); + }); expect(result.current.elapsedTime).toBe(0); // Should reset expect(WITTY_LOADING_PHRASES).toContain( result.current.currentLoadingPhrase, ); - act(() => { - vi.advanceTimersByTime(1000); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); }); expect(result.current.elapsedTime).toBe(1); }); - it('should reset timer and phrase when streamingState changes from Responding to Idle', () => { + it('should reset timer and phrase when streamingState changes from Responding to Idle', async () => { const { result, rerender } = renderHook( ({ streamingState }) => useLoadingIndicator(streamingState), { initialProps: { streamingState: StreamingState.Responding } }, ); - act(() => { - vi.advanceTimersByTime(10000); // 10s + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); // 10s }); expect(result.current.elapsedTime).toBe(10); - rerender({ streamingState: StreamingState.Idle }); + act(() => { + rerender({ streamingState: StreamingState.Idle }); + }); expect(result.current.elapsedTime).toBe(0); expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]); // Timer should not advance - act(() => { - vi.advanceTimersByTime(2000); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); }); expect(result.current.elapsedTime).toBe(0); }); |
