From 375b8522fcce7c17bde42e16aaaf4543667fc4aa Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Sat, 20 Sep 2025 10:59:37 -0700 Subject: [PATCH] Fix bug where users are unable to re-enter disconnected terminals. (#8765) --- packages/cli/src/test-utils/render.tsx | 10 +- packages/cli/src/ui/AppContainer.tsx | 22 +-- packages/cli/src/ui/components/Composer.tsx | 8 +- .../src/ui/components/HistoryItemDisplay.tsx | 6 +- .../src/ui/components/InputPrompt.test.tsx | 149 ++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 30 ++-- .../cli/src/ui/components/MainContent.tsx | 2 +- .../__snapshots__/InputPrompt.test.tsx.snap | 6 + .../components/messages/ToolGroupMessage.tsx | 16 +- .../ui/components/messages/ToolMessage.tsx | 8 +- .../cli/src/ui/contexts/KeypressContext.tsx | 3 + ...FocusContext.tsx => ShellFocusContext.tsx} | 4 +- .../cli/src/ui/contexts/UIStateContext.tsx | 2 +- packages/cli/src/ui/hooks/useFocus.test.ts | 42 ++++- packages/cli/src/ui/hooks/useFocus.ts | 14 ++ 15 files changed, 267 insertions(+), 55 deletions(-) rename packages/cli/src/ui/contexts/{FocusContext.tsx => ShellFocusContext.tsx} (51%) diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 0aff7c7449..90372a7d91 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -7,12 +7,16 @@ import { render } from 'ink-testing-library'; import type React from 'react'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; +import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; export const renderWithProviders = ( component: React.ReactElement, + { shellFocus = true } = {}, ): ReturnType => render( - - {component} - , + + + {component} + + , ); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1c80c9c59b..d9dec05d11 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -86,7 +86,7 @@ import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useExtensionUpdates } from './hooks/useExtensionUpdates.js'; -import { FocusContext } from './contexts/FocusContext.js'; +import { ShellFocusContext } from './contexts/ShellFocusContext.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -135,7 +135,7 @@ export const AppContainer = (props: AppContainerProps) => { initializationResult.themeError, ); const [isProcessing, setIsProcessing] = useState(false); - const [shellFocused, setShellFocused] = useState(false); + const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); const [geminiMdFileCount, setGeminiMdFileCount] = useState( initializationResult.geminiMdFileCount, @@ -557,10 +557,10 @@ Logging in with Google... Please restart Gemini CLI to continue. setModelSwitchedFromQuotaError, refreshStatic, () => cancelHandlerRef.current(), - setShellFocused, + setEmbeddedShellFocused, terminalWidth, terminalHeight, - shellFocused, + embeddedShellFocused, ); // Auto-accept indicator @@ -917,8 +917,8 @@ Logging in with Google... Please restart Gemini CLI to continue. ) { setConstrainHeight(false); } else if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) { - if (activePtyId || shellFocused) { - setShellFocused((prev) => !prev); + if (activePtyId || embeddedShellFocused) { + setEmbeddedShellFocused((prev) => !prev); } } }, @@ -941,7 +941,7 @@ Logging in with Google... Please restart Gemini CLI to continue. handleSlashCommand, cancelOngoingRequest, activePtyId, - shellFocused, + embeddedShellFocused, settings.merged.general?.debugKeystrokeLogging, ], ); @@ -1069,7 +1069,7 @@ Logging in with Google... Please restart Gemini CLI to continue. isRestarting, extensionsUpdateState, activePtyId, - shellFocused, + embeddedShellFocused, }), [ historyManager.history, @@ -1145,7 +1145,7 @@ Logging in with Google... Please restart Gemini CLI to continue. currentModel, extensionsUpdateState, activePtyId, - shellFocused, + embeddedShellFocused, ], ); @@ -1207,9 +1207,9 @@ Logging in with Google... Please restart Gemini CLI to continue. startupWarnings: props.startupWarnings || [], }} > - + - + diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 37c095c6d1..9acc49a44d 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -19,7 +19,6 @@ import { OverflowProvider } from '../contexts/OverflowContext.js'; import { theme } from '../semantic-colors.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { useUIState } from '../contexts/UIStateContext.js'; -import { useFocusState } from '../contexts/FocusContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; @@ -32,7 +31,6 @@ export const Composer = () => { const config = useConfig(); const settings = useSettings(); const uiState = useUIState(); - const isFocused = useFocusState(); const uiActions = useUIActions(); const { vimEnabled, vimMode } = useVimMode(); const terminalWidth = process.stdout.columns; @@ -69,7 +67,7 @@ export const Composer = () => { return ( - {!uiState.shellFocused && ( + {!uiState.embeddedShellFocused && ( { setShellModeActive={uiActions.setShellModeActive} approvalMode={showAutoAcceptIndicator} onEscapePromptChange={uiActions.onEscapePromptChange} - focus={isFocused} + focus={true} vimHandleInput={uiActions.vimHandleInput} - isShellFocused={uiState.shellFocused} + isEmbeddedShellFocused={uiState.embeddedShellFocused} placeholder={ vimEnabled ? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode." diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 8748301968..cee2895576 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -33,7 +33,7 @@ interface HistoryItemDisplayProps { isFocused?: boolean; commands?: readonly SlashCommand[]; activeShellPtyId?: number | null; - shellFocused?: boolean; + embeddedShellFocused?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -44,7 +44,7 @@ export const HistoryItemDisplay: React.FC = ({ commands, isFocused = true, activeShellPtyId, - shellFocused, + embeddedShellFocused, }) => ( {/* Render standard message types */} @@ -93,7 +93,7 @@ export const HistoryItemDisplay: React.FC = ({ terminalWidth={terminalWidth} isFocused={isFocused} activeShellPtyId={activeShellPtyId} - shellFocused={shellFocused} + embeddedShellFocused={embeddedShellFocused} /> )} {item.type === 'compression' && ( diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 52bd3b8fd6..9ae0921861 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1336,6 +1336,128 @@ describe('InputPrompt', () => { expect(frame).toContain(`hello 👍${chalk.inverse(' ')}`); unmount(); }); + + it('should display cursor on an empty line', async () => { + mockBuffer.text = ''; + mockBuffer.lines = ['']; + mockBuffer.viewportVisualLines = ['']; + mockBuffer.visualCursor = [0, 0]; + + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); + + const frame = stdout.lastFrame(); + expect(frame).toContain(chalk.inverse(' ')); + unmount(); + }); + + it('should display cursor on a space between words', async () => { + mockBuffer.text = 'hello world'; + mockBuffer.lines = ['hello world']; + mockBuffer.viewportVisualLines = ['hello world']; + mockBuffer.visualCursor = [0, 5]; // cursor on the space + + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); + + const frame = stdout.lastFrame(); + expect(frame).toContain(`hello${chalk.inverse(' ')}world`); + unmount(); + }); + + it('should display cursor in the middle of a line in a multiline block', async () => { + const text = 'first line\nsecond line\nthird line'; + mockBuffer.text = text; + mockBuffer.lines = text.split('\n'); + mockBuffer.viewportVisualLines = text.split('\n'); + mockBuffer.visualCursor = [1, 3]; // cursor on 'o' in 'second' + mockBuffer.visualToLogicalMap = [ + [0, 0], + [1, 0], + [2, 0], + ]; + + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); + + const frame = stdout.lastFrame(); + expect(frame).toContain(`sec${chalk.inverse('o')}nd line`); + unmount(); + }); + + it('should display cursor at the beginning of a line in a multiline block', async () => { + const text = 'first line\nsecond line'; + mockBuffer.text = text; + mockBuffer.lines = text.split('\n'); + mockBuffer.viewportVisualLines = text.split('\n'); + mockBuffer.visualCursor = [1, 0]; // cursor on 's' in 'second' + mockBuffer.visualToLogicalMap = [ + [0, 0], + [1, 0], + ]; + + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); + + const frame = stdout.lastFrame(); + expect(frame).toContain(`${chalk.inverse('s')}econd line`); + unmount(); + }); + + it('should display cursor at the end of a line in a multiline block', async () => { + const text = 'first line\nsecond line'; + mockBuffer.text = text; + mockBuffer.lines = text.split('\n'); + mockBuffer.viewportVisualLines = text.split('\n'); + mockBuffer.visualCursor = [0, 10]; // cursor after 'first line' + mockBuffer.visualToLogicalMap = [ + [0, 0], + [1, 0], + ]; + + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); + + const frame = stdout.lastFrame(); + expect(frame).toContain(`first line${chalk.inverse(' ')}`); + unmount(); + }); + + it('should display cursor on a blank line in a multiline block', async () => { + const text = 'first line\n\nthird line'; + mockBuffer.text = text; + mockBuffer.lines = text.split('\n'); + mockBuffer.viewportVisualLines = text.split('\n'); + mockBuffer.visualCursor = [1, 0]; // cursor on the blank line + mockBuffer.visualToLogicalMap = [ + [0, 0], + [1, 0], + [2, 0], + ]; + + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); + + const frame = stdout.lastFrame(); + const lines = frame!.split('\n'); + // The line with the cursor should just be an inverted space inside the box border + expect( + lines.find((l) => l.includes(chalk.inverse(' '))), + ).not.toBeUndefined(); + unmount(); + }); }); describe('multiline rendering', () => { @@ -1966,6 +2088,33 @@ describe('InputPrompt', () => { expect(stdout.lastFrame()).toMatchSnapshot(); unmount(); }); + + it('should not show inverted cursor when shell is focused', async () => { + props.isEmbeddedShellFocused = true; + props.focus = false; + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); + expect(stdout.lastFrame()).not.toContain(`{chalk.inverse(' ')}`); + // This snapshot is good to make sure there was an input prompt but does + // not show the inverted cursor because snapshots do not show colors. + expect(stdout.lastFrame()).toMatchSnapshot(); + unmount(); + }); + }); + + it('should still allow input when shell is not focused', async () => { + const { stdin, unmount } = renderWithProviders(, { + shellFocus: false, + }); + await wait(); + + stdin.write('a'); + await wait(); + + expect(mockBuffer.handleInput).toHaveBeenCalled(); + unmount(); }); }); function clean(str: string | undefined): string { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index b56d7212c6..1ab76f8385 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -35,6 +35,7 @@ import { } from '../utils/clipboardUtils.js'; import * as path from 'node:path'; import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; +import { useShellFocusState } from '../contexts/ShellFocusContext.js'; export interface InputPromptProps { buffer: TextBuffer; onSubmit: (value: string) => void; @@ -52,7 +53,7 @@ export interface InputPromptProps { approvalMode: ApprovalMode; onEscapePromptChange?: (showPrompt: boolean) => void; vimHandleInput?: (key: Key) => boolean; - isShellFocused?: boolean; + isEmbeddedShellFocused?: boolean; } // The input content, input container, and input suggestions list may have different widths @@ -97,8 +98,9 @@ export const InputPrompt: React.FC = ({ approvalMode, onEscapePromptChange, vimHandleInput, - isShellFocused, + isEmbeddedShellFocused, }) => { + const isShellFocused = useShellFocusState(); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [escPressCount, setEscPressCount] = useState(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); @@ -154,6 +156,8 @@ export const InputPrompt: React.FC = ({ const resetCommandSearchCompletionState = commandSearchCompletion.resetCompletionState; + const showCursor = focus && isShellFocused && !isEmbeddedShellFocused; + const resetEscapeState = useCallback(() => { if (escapeTimerRef.current) { clearTimeout(escapeTimerRef.current); @@ -291,6 +295,9 @@ export const InputPrompt: React.FC = ({ const handleInput = useCallback( (key: Key) => { + // TODO(jacobr): this special case is likely not needed anymore. + // We should probably stop supporting paste if the InputPrompt is not + // focused. /// We want to handle paste even when not focused to support drag and drop. if (!focus && !key.paste) { return; @@ -689,9 +696,7 @@ export const InputPrompt: React.FC = ({ ], ); - useKeypress(handleInput, { - isActive: !isShellFocused, - }); + useKeypress(handleInput, { isActive: !isEmbeddedShellFocused }); const linesToRender = buffer.viewportVisualLines; const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = @@ -842,7 +847,9 @@ export const InputPrompt: React.FC = ({ @@ -871,7 +878,7 @@ export const InputPrompt: React.FC = ({ {buffer.text.length === 0 && placeholder ? ( - focus ? ( + showCursor ? ( {chalk.inverse(placeholder.slice(0, 1))} {placeholder.slice(1)} @@ -926,7 +933,9 @@ export const InputPrompt: React.FC = ({ relativeVisualColForHighlight - segStart, relativeVisualColForHighlight - segStart + 1, ); - const highlighted = chalk.inverse(charToHighlight); + const highlighted = showCursor + ? chalk.inverse(charToHighlight) + : charToHighlight; display = cpSlice( seg.text, @@ -962,7 +971,7 @@ export const InputPrompt: React.FC = ({ if (!currentLineGhost) { renderedLine.push( - {chalk.inverse(' ')} + {showCursor ? chalk.inverse(' ') : ' '} , ); } @@ -978,7 +987,8 @@ export const InputPrompt: React.FC = ({ {renderedLine} - {showCursorBeforeGhost && chalk.inverse(' ')} + {showCursorBeforeGhost && + (showCursor ? chalk.inverse(' ') : ' ')} {currentLineGhost && ( {currentLineGhost} diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index ea6ccda612..dfb0ba6e64 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -55,7 +55,7 @@ export const MainContent = () => { isPending={true} isFocused={!uiState.isEditorDialogOpen} activeShellPtyId={uiState.activePtyId} - shellFocused={uiState.shellFocused} + embeddedShellFocused={uiState.embeddedShellFocused} /> ))} diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index de5fc6c90a..9ae6eab1c9 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -32,6 +32,12 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match git commit -m "feat: add search" in src/app" `; +exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ > Type your message or @path/to/file │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ! Type your message or @path/to/file │ diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index cc8fe82716..70d58fbc32 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -22,7 +22,7 @@ interface ToolGroupMessageProps { terminalWidth: number; isFocused?: boolean; activeShellPtyId?: number | null; - shellFocused?: boolean; + embeddedShellFocused?: boolean; onShellInputSubmit?: (input: string) => void; } @@ -33,10 +33,10 @@ export const ToolGroupMessage: React.FC = ({ terminalWidth, isFocused = true, activeShellPtyId, - shellFocused, + embeddedShellFocused, }) => { - const isShellFocused = - shellFocused && + const isEmbeddedShellFocused = + embeddedShellFocused && toolCalls.some( (t) => t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing, @@ -51,7 +51,7 @@ export const ToolGroupMessage: React.FC = ({ (t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME, ); const borderColor = - isShellCommand || isShellFocused + isShellCommand || isEmbeddedShellFocused ? theme.ui.symbol : hasPending ? theme.status.warning @@ -98,7 +98,9 @@ export const ToolGroupMessage: React.FC = ({ */ width="100%" marginLeft={1} - borderDimColor={hasPending} + borderDimColor={ + hasPending && (!isShellCommand || !isEmbeddedShellFocused) + } borderColor={borderColor} gap={1} > @@ -119,7 +121,7 @@ export const ToolGroupMessage: React.FC = ({ : 'medium' } activeShellPtyId={activeShellPtyId} - shellFocused={shellFocused} + embeddedShellFocused={embeddedShellFocused} config={config} /> diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 0099bbc02f..817a4dd70c 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -38,7 +38,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { emphasis?: TextEmphasis; renderOutputAsMarkdown?: boolean; activeShellPtyId?: number | null; - shellFocused?: boolean; + embeddedShellFocused?: boolean; config?: Config; } @@ -52,7 +52,7 @@ export const ToolMessage: React.FC = ({ emphasis = 'medium', renderOutputAsMarkdown = true, activeShellPtyId, - shellFocused, + embeddedShellFocused, ptyId, config, }) => { @@ -60,7 +60,7 @@ export const ToolMessage: React.FC = ({ (name === SHELL_COMMAND_NAME || name === 'Shell') && status === ToolCallStatus.Executing && ptyId === activeShellPtyId && - shellFocused; + embeddedShellFocused; const isThisShellFocusable = (name === SHELL_COMMAND_NAME || name === 'Shell') && @@ -149,7 +149,7 @@ export const ToolMessage: React.FC = ({ )} diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 4df48d8d6b..4930f7101d 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -388,6 +388,9 @@ export function KeypressProvider({ }; const handleKeypress = (_: unknown, key: Key) => { + if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { + return; + } if (key.name === 'paste-start') { isPaste = true; return; diff --git a/packages/cli/src/ui/contexts/FocusContext.tsx b/packages/cli/src/ui/contexts/ShellFocusContext.tsx similarity index 51% rename from packages/cli/src/ui/contexts/FocusContext.tsx rename to packages/cli/src/ui/contexts/ShellFocusContext.tsx index 791f2aac22..aaed98c9d5 100644 --- a/packages/cli/src/ui/contexts/FocusContext.tsx +++ b/packages/cli/src/ui/contexts/ShellFocusContext.tsx @@ -6,6 +6,6 @@ import { createContext, useContext } from 'react'; -export const FocusContext = createContext(true); +export const ShellFocusContext = createContext(true); -export const useFocusState = () => useContext(FocusContext); +export const useShellFocusState = () => useContext(ShellFocusContext); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 71ad7c5b28..693652bc96 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -110,7 +110,7 @@ export interface UIState { isRestarting: boolean; extensionsUpdateState: Map; activePtyId: number | undefined; - shellFocused: boolean; + embeddedShellFocused: boolean; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useFocus.test.ts b/packages/cli/src/ui/hooks/useFocus.test.ts index e9a48ca5c7..cf0dad0dcc 100644 --- a/packages/cli/src/ui/hooks/useFocus.test.ts +++ b/packages/cli/src/ui/hooks/useFocus.test.ts @@ -9,6 +9,8 @@ import { EventEmitter } from 'node:events'; import { useFocus } from './useFocus.js'; import { vi } from 'vitest'; import { useStdin, useStdout } from 'ink'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import React from 'react'; // Mock the ink hooks vi.mock('ink', async (importOriginal) => { @@ -23,12 +25,17 @@ vi.mock('ink', async (importOriginal) => { const mockedUseStdin = vi.mocked(useStdin); const mockedUseStdout = vi.mocked(useStdout); +const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(KeypressProvider, null, children); + describe('useFocus', () => { let stdin: EventEmitter; let stdout: { write: vi.Func }; beforeEach(() => { stdin = new EventEmitter(); + stdin.resume = vi.fn(); + stdin.pause = vi.fn(); stdout = { write: vi.fn() }; mockedUseStdin.mockReturnValue({ stdin } as ReturnType); mockedUseStdout.mockReturnValue({ stdout } as unknown as ReturnType< @@ -38,17 +45,18 @@ describe('useFocus', () => { afterEach(() => { vi.clearAllMocks(); + stdin.removeAllListeners(); }); it('should initialize with focus and enable focus reporting', () => { - const { result } = renderHook(() => useFocus()); + const { result } = renderHook(() => useFocus(), { wrapper }); 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()); + const { result } = renderHook(() => useFocus(), { wrapper }); // Initial state is focused expect(result.current).toBe(true); @@ -63,7 +71,7 @@ describe('useFocus', () => { }); it('should set isFocused to true when a focus-in event is received', () => { - const { result } = renderHook(() => useFocus()); + const { result } = renderHook(() => useFocus(), { wrapper }); // Simulate focus-out to set initial state to false act(() => { @@ -81,20 +89,22 @@ describe('useFocus', () => { }); it('should clean up and disable focus reporting on unmount', () => { - const { unmount } = renderHook(() => useFocus()); + const { unmount } = renderHook(() => useFocus(), { wrapper }); - // Ensure listener was attached - expect(stdin.listenerCount('data')).toBe(1); + // At this point we should have listeners from both KeypressProvider and useFocus + const listenerCountAfterMount = stdin.listenerCount('data'); + expect(listenerCountAfterMount).toBeGreaterThanOrEqual(1); unmount(); // Assert that the cleanup function was called expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004l'); - expect(stdin.listenerCount('data')).toBe(0); + // Ensure useFocus listener was removed (but KeypressProvider listeners may remain) + expect(stdin.listenerCount('data')).toBeLessThan(listenerCountAfterMount); }); it('should handle multiple focus events correctly', () => { - const { result } = renderHook(() => useFocus()); + const { result } = renderHook(() => useFocus(), { wrapper }); act(() => { stdin.emit('data', Buffer.from('\x1b[O')); @@ -116,4 +126,20 @@ describe('useFocus', () => { }); expect(result.current).toBe(true); }); + + it('restores focus on keypress after focus is lost', () => { + const { result } = renderHook(() => useFocus(), { wrapper }); + + // Simulate focus-out event + act(() => { + stdin.emit('data', Buffer.from('\x1b[O')); + }); + expect(result.current).toBe(false); + + // Simulate a keypress + act(() => { + stdin.emit('data', Buffer.from('a')); + }); + expect(result.current).toBe(true); + }); }); diff --git a/packages/cli/src/ui/hooks/useFocus.ts b/packages/cli/src/ui/hooks/useFocus.ts index 8a7f9f6c8d..65288cb0da 100644 --- a/packages/cli/src/ui/hooks/useFocus.ts +++ b/packages/cli/src/ui/hooks/useFocus.ts @@ -6,6 +6,7 @@ import { useStdin, useStdout } from 'ink'; import { useEffect, useState } from 'react'; +import { useKeypress } from './useKeypress.js'; // ANSI escape codes to enable/disable terminal focus reporting export const ENABLE_FOCUS_REPORTING = '\x1b[?1004h'; @@ -44,5 +45,18 @@ export const useFocus = () => { }; }, [stdin, stdout]); + useKeypress( + (_) => { + if (!isFocused) { + // If the user has typed a key, and we cannot possibly be focused out. + // This is a workaround for some tmux use cases. It is still useful to + // listen for the true FOCUS_IN event as well as that will update the + // focus state earlier than waiting for a keypress. + setIsFocused(true); + } + }, + { isActive: true }, + ); + return isFocused; };