From 8ade3e7ee26c1dedd105a20fa769c68e4103b75d Mon Sep 17 00:00:00 2001 From: Keith Lyons Date: Thu, 17 Jul 2025 20:45:42 -0400 Subject: [PATCH] feat(ui): hide cursor when terminal is unfocused (#4012) --- packages/cli/src/ui/App.tsx | 3 + .../cli/src/ui/components/InputPrompt.tsx | 2 +- packages/cli/src/ui/hooks/useFocus.test.ts | 119 ++++++++++++++++++ packages/cli/src/ui/hooks/useFocus.ts | 48 +++++++ 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/hooks/useFocus.test.ts create mode 100644 packages/cli/src/ui/hooks/useFocus.ts diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 713ada01..5d8ab39d 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -67,6 +67,7 @@ import { useSessionStats, } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; +import { useFocus } from './hooks/useFocus.js'; import { useBracketedPaste } from './hooks/useBracketedPaste.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import * as fs from 'fs'; @@ -98,6 +99,7 @@ export const AppWrapper = (props: AppProps) => ( ); const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { + const isFocused = useFocus(); useBracketedPaste(); const [updateMessage, setUpdateMessage] = useState(null); const { stdout } = useStdout(); @@ -927,6 +929,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { commandContext={commandContext} shellModeActive={shellModeActive} setShellModeActive={setShellModeActive} + focus={isFocused} /> )} diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 7cefdc08..01ed8db1 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -453,7 +453,7 @@ export const InputPrompt: React.FC = ({ display = display + ' '.repeat(inputWidth - currentVisualWidth); } - if (visualIdxInRenderedSet === cursorVisualRow) { + if (focus && visualIdxInRenderedSet === cursorVisualRow) { const relativeVisualColForHighlight = cursorVisualColAbsolute; if (relativeVisualColForHighlight >= 0) { diff --git a/packages/cli/src/ui/hooks/useFocus.test.ts b/packages/cli/src/ui/hooks/useFocus.test.ts new file mode 100644 index 00000000..5e17951e --- /dev/null +++ b/packages/cli/src/ui/hooks/useFocus.test.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook, act } from '@testing-library/react'; +import { EventEmitter } from 'events'; +import { useFocus } from './useFocus.js'; +import { vi } from 'vitest'; +import { useStdin, useStdout } from 'ink'; + +// Mock the ink hooks +vi.mock('ink', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useStdin: vi.fn(), + useStdout: vi.fn(), + }; +}); + +const mockedUseStdin = vi.mocked(useStdin); +const mockedUseStdout = vi.mocked(useStdout); + +describe('useFocus', () => { + let stdin: EventEmitter; + let stdout: { write: vi.Func }; + + beforeEach(() => { + stdin = new EventEmitter(); + stdout = { write: vi.fn() }; + mockedUseStdin.mockReturnValue({ stdin } as ReturnType); + mockedUseStdout.mockReturnValue({ stdout } as unknown as ReturnType< + typeof useStdout + >); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with focus and enable focus reporting', () => { + const { result } = renderHook(() => useFocus()); + + expect(result.current).toBe(true); + expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004h'); + }); + + it('should set isFocused to false when a focus-out event is received', () => { + const { result } = renderHook(() => useFocus()); + + // Initial state is focused + expect(result.current).toBe(true); + + // Simulate focus-out event + act(() => { + stdin.emit('data', Buffer.from('\x1b[O')); + }); + + // State should now be unfocused + expect(result.current).toBe(false); + }); + + it('should set isFocused to true when a focus-in event is received', () => { + const { result } = renderHook(() => useFocus()); + + // Simulate focus-out to set initial state to false + act(() => { + stdin.emit('data', Buffer.from('\x1b[O')); + }); + expect(result.current).toBe(false); + + // Simulate focus-in event + act(() => { + stdin.emit('data', Buffer.from('\x1b[I')); + }); + + // State should now be focused + expect(result.current).toBe(true); + }); + + it('should clean up and disable focus reporting on unmount', () => { + const { unmount } = renderHook(() => useFocus()); + + // Ensure listener was attached + expect(stdin.listenerCount('data')).toBe(1); + + unmount(); + + // Assert that the cleanup function was called + expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004l'); + expect(stdin.listenerCount('data')).toBe(0); + }); + + it('should handle multiple focus events correctly', () => { + const { result } = renderHook(() => useFocus()); + + act(() => { + stdin.emit('data', Buffer.from('\x1b[O')); + }); + expect(result.current).toBe(false); + + act(() => { + stdin.emit('data', Buffer.from('\x1b[O')); + }); + expect(result.current).toBe(false); + + act(() => { + stdin.emit('data', Buffer.from('\x1b[I')); + }); + expect(result.current).toBe(true); + + act(() => { + stdin.emit('data', Buffer.from('\x1b[I')); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/hooks/useFocus.ts b/packages/cli/src/ui/hooks/useFocus.ts new file mode 100644 index 00000000..6c9a6daa --- /dev/null +++ b/packages/cli/src/ui/hooks/useFocus.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useStdin, useStdout } from 'ink'; +import { useEffect, useState } from 'react'; + +// ANSI escape codes to enable/disable terminal focus reporting +const ENABLE_FOCUS_REPORTING = '\x1b[?1004h'; +const DISABLE_FOCUS_REPORTING = '\x1b[?1004l'; + +// ANSI escape codes for focus events +const FOCUS_IN = '\x1b[I'; +const FOCUS_OUT = '\x1b[O'; + +export const useFocus = () => { + const { stdin } = useStdin(); + const { stdout } = useStdout(); + const [isFocused, setIsFocused] = useState(true); + + useEffect(() => { + const handleData = (data: Buffer) => { + const sequence = data.toString(); + const lastFocusIn = sequence.lastIndexOf(FOCUS_IN); + const lastFocusOut = sequence.lastIndexOf(FOCUS_OUT); + + if (lastFocusIn > lastFocusOut) { + setIsFocused(true); + } else if (lastFocusOut > lastFocusIn) { + setIsFocused(false); + } + }; + + // Enable focus reporting + stdout?.write(ENABLE_FOCUS_REPORTING); + stdin?.on('data', handleData); + + return () => { + // Disable focus reporting on cleanup + stdout?.write(DISABLE_FOCUS_REPORTING); + stdin?.removeListener('data', handleData); + }; + }, [stdin, stdout]); + + return isFocused; +};