Fix bug where users are unable to re-enter disconnected terminals. (#8765)

This commit is contained in:
Jacob Richman
2025-09-20 10:59:37 -07:00
committed by GitHub
parent 2216856e3c
commit 375b8522fc
15 changed files with 267 additions and 55 deletions
+5 -1
View File
@@ -7,12 +7,16 @@
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import type React from 'react'; import type React from 'react';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
export const renderWithProviders = ( export const renderWithProviders = (
component: React.ReactElement, component: React.ReactElement,
{ shellFocus = true } = {},
): ReturnType<typeof render> => ): ReturnType<typeof render> =>
render( render(
<ShellFocusContext.Provider value={shellFocus}>
<KeypressProvider kittyProtocolEnabled={true}> <KeypressProvider kittyProtocolEnabled={true}>
{component} {component}
</KeypressProvider>, </KeypressProvider>
</ShellFocusContext.Provider>,
); );
+11 -11
View File
@@ -86,7 +86,7 @@ import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
import { useSessionStats } from './contexts/SessionContext.js'; import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.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; const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -135,7 +135,7 @@ export const AppContainer = (props: AppContainerProps) => {
initializationResult.themeError, initializationResult.themeError,
); );
const [isProcessing, setIsProcessing] = useState<boolean>(false); const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [shellFocused, setShellFocused] = useState(false); const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>( const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(
initializationResult.geminiMdFileCount, initializationResult.geminiMdFileCount,
@@ -557,10 +557,10 @@ Logging in with Google... Please restart Gemini CLI to continue.
setModelSwitchedFromQuotaError, setModelSwitchedFromQuotaError,
refreshStatic, refreshStatic,
() => cancelHandlerRef.current(), () => cancelHandlerRef.current(),
setShellFocused, setEmbeddedShellFocused,
terminalWidth, terminalWidth,
terminalHeight, terminalHeight,
shellFocused, embeddedShellFocused,
); );
// Auto-accept indicator // Auto-accept indicator
@@ -917,8 +917,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
) { ) {
setConstrainHeight(false); setConstrainHeight(false);
} else if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) { } else if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) {
if (activePtyId || shellFocused) { if (activePtyId || embeddedShellFocused) {
setShellFocused((prev) => !prev); setEmbeddedShellFocused((prev) => !prev);
} }
} }
}, },
@@ -941,7 +941,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
handleSlashCommand, handleSlashCommand,
cancelOngoingRequest, cancelOngoingRequest,
activePtyId, activePtyId,
shellFocused, embeddedShellFocused,
settings.merged.general?.debugKeystrokeLogging, settings.merged.general?.debugKeystrokeLogging,
], ],
); );
@@ -1069,7 +1069,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
isRestarting, isRestarting,
extensionsUpdateState, extensionsUpdateState,
activePtyId, activePtyId,
shellFocused, embeddedShellFocused,
}), }),
[ [
historyManager.history, historyManager.history,
@@ -1145,7 +1145,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
currentModel, currentModel,
extensionsUpdateState, extensionsUpdateState,
activePtyId, activePtyId,
shellFocused, embeddedShellFocused,
], ],
); );
@@ -1207,9 +1207,9 @@ Logging in with Google... Please restart Gemini CLI to continue.
startupWarnings: props.startupWarnings || [], startupWarnings: props.startupWarnings || [],
}} }}
> >
<FocusContext.Provider value={isFocused}> <ShellFocusContext.Provider value={isFocused}>
<App /> <App />
</FocusContext.Provider> </ShellFocusContext.Provider>
</AppContext.Provider> </AppContext.Provider>
</ConfigContext.Provider> </ConfigContext.Provider>
</UIActionsContext.Provider> </UIActionsContext.Provider>
+3 -5
View File
@@ -19,7 +19,6 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useFocusState } from '../contexts/FocusContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js'; import { useVimMode } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
@@ -32,7 +31,6 @@ export const Composer = () => {
const config = useConfig(); const config = useConfig();
const settings = useSettings(); const settings = useSettings();
const uiState = useUIState(); const uiState = useUIState();
const isFocused = useFocusState();
const uiActions = useUIActions(); const uiActions = useUIActions();
const { vimEnabled, vimMode } = useVimMode(); const { vimEnabled, vimMode } = useVimMode();
const terminalWidth = process.stdout.columns; const terminalWidth = process.stdout.columns;
@@ -69,7 +67,7 @@ export const Composer = () => {
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{!uiState.shellFocused && ( {!uiState.embeddedShellFocused && (
<LoadingIndicator <LoadingIndicator
thought={ thought={
uiState.streamingState === StreamingState.WaitingForConfirmation || uiState.streamingState === StreamingState.WaitingForConfirmation ||
@@ -167,9 +165,9 @@ export const Composer = () => {
setShellModeActive={uiActions.setShellModeActive} setShellModeActive={uiActions.setShellModeActive}
approvalMode={showAutoAcceptIndicator} approvalMode={showAutoAcceptIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange} onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused} focus={true}
vimHandleInput={uiActions.vimHandleInput} vimHandleInput={uiActions.vimHandleInput}
isShellFocused={uiState.shellFocused} isEmbeddedShellFocused={uiState.embeddedShellFocused}
placeholder={ placeholder={
vimEnabled vimEnabled
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode." ? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
@@ -33,7 +33,7 @@ interface HistoryItemDisplayProps {
isFocused?: boolean; isFocused?: boolean;
commands?: readonly SlashCommand[]; commands?: readonly SlashCommand[];
activeShellPtyId?: number | null; activeShellPtyId?: number | null;
shellFocused?: boolean; embeddedShellFocused?: boolean;
} }
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -44,7 +44,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
commands, commands,
isFocused = true, isFocused = true,
activeShellPtyId, activeShellPtyId,
shellFocused, embeddedShellFocused,
}) => ( }) => (
<Box flexDirection="column" key={item.id}> <Box flexDirection="column" key={item.id}>
{/* Render standard message types */} {/* Render standard message types */}
@@ -93,7 +93,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
terminalWidth={terminalWidth} terminalWidth={terminalWidth}
isFocused={isFocused} isFocused={isFocused}
activeShellPtyId={activeShellPtyId} activeShellPtyId={activeShellPtyId}
shellFocused={shellFocused} embeddedShellFocused={embeddedShellFocused}
/> />
)} )}
{item.type === 'compression' && ( {item.type === 'compression' && (
@@ -1336,6 +1336,128 @@ describe('InputPrompt', () => {
expect(frame).toContain(`hello 👍${chalk.inverse(' ')}`); expect(frame).toContain(`hello 👍${chalk.inverse(' ')}`);
unmount(); unmount();
}); });
it('should display cursor on an empty line', async () => {
mockBuffer.text = '';
mockBuffer.lines = [''];
mockBuffer.viewportVisualLines = [''];
mockBuffer.visualCursor = [0, 0];
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
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(
<InputPrompt {...props} />,
);
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(
<InputPrompt {...props} />,
);
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(
<InputPrompt {...props} />,
);
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(
<InputPrompt {...props} />,
);
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(
<InputPrompt {...props} />,
);
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', () => { describe('multiline rendering', () => {
@@ -1966,6 +2088,33 @@ describe('InputPrompt', () => {
expect(stdout.lastFrame()).toMatchSnapshot(); expect(stdout.lastFrame()).toMatchSnapshot();
unmount(); unmount();
}); });
it('should not show inverted cursor when shell is focused', async () => {
props.isEmbeddedShellFocused = true;
props.focus = false;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
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(<InputPrompt {...props} />, {
shellFocus: false,
});
await wait();
stdin.write('a');
await wait();
expect(mockBuffer.handleInput).toHaveBeenCalled();
unmount();
}); });
}); });
function clean(str: string | undefined): string { function clean(str: string | undefined): string {
+20 -10
View File
@@ -35,6 +35,7 @@ import {
} from '../utils/clipboardUtils.js'; } from '../utils/clipboardUtils.js';
import * as path from 'node:path'; import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
export interface InputPromptProps { export interface InputPromptProps {
buffer: TextBuffer; buffer: TextBuffer;
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
@@ -52,7 +53,7 @@ export interface InputPromptProps {
approvalMode: ApprovalMode; approvalMode: ApprovalMode;
onEscapePromptChange?: (showPrompt: boolean) => void; onEscapePromptChange?: (showPrompt: boolean) => void;
vimHandleInput?: (key: Key) => boolean; vimHandleInput?: (key: Key) => boolean;
isShellFocused?: boolean; isEmbeddedShellFocused?: boolean;
} }
// The input content, input container, and input suggestions list may have different widths // The input content, input container, and input suggestions list may have different widths
@@ -97,8 +98,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
approvalMode, approvalMode,
onEscapePromptChange, onEscapePromptChange,
vimHandleInput, vimHandleInput,
isShellFocused, isEmbeddedShellFocused,
}) => { }) => {
const isShellFocused = useShellFocusState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0); const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -154,6 +156,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetCommandSearchCompletionState = const resetCommandSearchCompletionState =
commandSearchCompletion.resetCompletionState; commandSearchCompletion.resetCompletionState;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const resetEscapeState = useCallback(() => { const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) { if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current); clearTimeout(escapeTimerRef.current);
@@ -291,6 +295,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleInput = useCallback( const handleInput = useCallback(
(key: Key) => { (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. /// We want to handle paste even when not focused to support drag and drop.
if (!focus && !key.paste) { if (!focus && !key.paste) {
return; return;
@@ -689,9 +696,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
], ],
); );
useKeypress(handleInput, { useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
isActive: !isShellFocused,
});
const linesToRender = buffer.viewportVisualLines; const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
@@ -842,7 +847,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Box <Box
borderStyle="round" borderStyle="round"
borderColor={ borderColor={
statusColor ?? (focus ? theme.border.focused : theme.border.default) isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default
} }
paddingX={1} paddingX={1}
> >
@@ -871,7 +878,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Text> </Text>
<Box flexGrow={1} flexDirection="column"> <Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? ( {buffer.text.length === 0 && placeholder ? (
focus ? ( showCursor ? (
<Text> <Text>
{chalk.inverse(placeholder.slice(0, 1))} {chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text> <Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
@@ -926,7 +933,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
relativeVisualColForHighlight - segStart, relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1, relativeVisualColForHighlight - segStart + 1,
); );
const highlighted = chalk.inverse(charToHighlight); const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display = display =
cpSlice( cpSlice(
seg.text, seg.text,
@@ -962,7 +971,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (!currentLineGhost) { if (!currentLineGhost) {
renderedLine.push( renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}> <Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{chalk.inverse(' ')} {showCursor ? chalk.inverse(' ') : ' '}
</Text>, </Text>,
); );
} }
@@ -978,7 +987,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Box key={`line-${visualIdxInRenderedSet}`} height={1}> <Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text> <Text>
{renderedLine} {renderedLine}
{showCursorBeforeGhost && chalk.inverse(' ')} {showCursorBeforeGhost &&
(showCursor ? chalk.inverse(' ') : ' ')}
{currentLineGhost && ( {currentLineGhost && (
<Text color={theme.text.secondary}> <Text color={theme.text.secondary}>
{currentLineGhost} {currentLineGhost}
@@ -55,7 +55,7 @@ export const MainContent = () => {
isPending={true} isPending={true}
isFocused={!uiState.isEditorDialogOpen} isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId} activeShellPtyId={uiState.activePtyId}
shellFocused={uiState.shellFocused} embeddedShellFocused={uiState.embeddedShellFocused}
/> />
))} ))}
<ShowMoreLines constrainHeight={uiState.constrainHeight} /> <ShowMoreLines constrainHeight={uiState.constrainHeight} />
@@ -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" 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`] = ` exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ! Type your message or @path/to/file │ │ ! Type your message or @path/to/file │
@@ -22,7 +22,7 @@ interface ToolGroupMessageProps {
terminalWidth: number; terminalWidth: number;
isFocused?: boolean; isFocused?: boolean;
activeShellPtyId?: number | null; activeShellPtyId?: number | null;
shellFocused?: boolean; embeddedShellFocused?: boolean;
onShellInputSubmit?: (input: string) => void; onShellInputSubmit?: (input: string) => void;
} }
@@ -33,10 +33,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
terminalWidth, terminalWidth,
isFocused = true, isFocused = true,
activeShellPtyId, activeShellPtyId,
shellFocused, embeddedShellFocused,
}) => { }) => {
const isShellFocused = const isEmbeddedShellFocused =
shellFocused && embeddedShellFocused &&
toolCalls.some( toolCalls.some(
(t) => (t) =>
t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing, t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing,
@@ -51,7 +51,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME, (t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
); );
const borderColor = const borderColor =
isShellCommand || isShellFocused isShellCommand || isEmbeddedShellFocused
? theme.ui.symbol ? theme.ui.symbol
: hasPending : hasPending
? theme.status.warning ? theme.status.warning
@@ -98,7 +98,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/ */
width="100%" width="100%"
marginLeft={1} marginLeft={1}
borderDimColor={hasPending} borderDimColor={
hasPending && (!isShellCommand || !isEmbeddedShellFocused)
}
borderColor={borderColor} borderColor={borderColor}
gap={1} gap={1}
> >
@@ -119,7 +121,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
: 'medium' : 'medium'
} }
activeShellPtyId={activeShellPtyId} activeShellPtyId={activeShellPtyId}
shellFocused={shellFocused} embeddedShellFocused={embeddedShellFocused}
config={config} config={config}
/> />
</Box> </Box>
@@ -38,7 +38,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
emphasis?: TextEmphasis; emphasis?: TextEmphasis;
renderOutputAsMarkdown?: boolean; renderOutputAsMarkdown?: boolean;
activeShellPtyId?: number | null; activeShellPtyId?: number | null;
shellFocused?: boolean; embeddedShellFocused?: boolean;
config?: Config; config?: Config;
} }
@@ -52,7 +52,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
emphasis = 'medium', emphasis = 'medium',
renderOutputAsMarkdown = true, renderOutputAsMarkdown = true,
activeShellPtyId, activeShellPtyId,
shellFocused, embeddedShellFocused,
ptyId, ptyId,
config, config,
}) => { }) => {
@@ -60,7 +60,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
(name === SHELL_COMMAND_NAME || name === 'Shell') && (name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing && status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId && ptyId === activeShellPtyId &&
shellFocused; embeddedShellFocused;
const isThisShellFocusable = const isThisShellFocusable =
(name === SHELL_COMMAND_NAME || name === 'Shell') && (name === SHELL_COMMAND_NAME || name === 'Shell') &&
@@ -149,7 +149,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}> <Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt <ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null} activeShellPtyId={activeShellPtyId ?? null}
focus={shellFocused} focus={embeddedShellFocused}
/> />
</Box> </Box>
)} )}
@@ -388,6 +388,9 @@ export function KeypressProvider({
}; };
const handleKeypress = (_: unknown, key: Key) => { const handleKeypress = (_: unknown, key: Key) => {
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
return;
}
if (key.name === 'paste-start') { if (key.name === 'paste-start') {
isPaste = true; isPaste = true;
return; return;
@@ -6,6 +6,6 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
export const FocusContext = createContext<boolean>(true); export const ShellFocusContext = createContext<boolean>(true);
export const useFocusState = () => useContext(FocusContext); export const useShellFocusState = () => useContext(ShellFocusContext);
@@ -110,7 +110,7 @@ export interface UIState {
isRestarting: boolean; isRestarting: boolean;
extensionsUpdateState: Map<string, ExtensionUpdateState>; extensionsUpdateState: Map<string, ExtensionUpdateState>;
activePtyId: number | undefined; activePtyId: number | undefined;
shellFocused: boolean; embeddedShellFocused: boolean;
} }
export const UIStateContext = createContext<UIState | null>(null); export const UIStateContext = createContext<UIState | null>(null);
+34 -8
View File
@@ -9,6 +9,8 @@ import { EventEmitter } from 'node:events';
import { useFocus } from './useFocus.js'; import { useFocus } from './useFocus.js';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { useStdin, useStdout } from 'ink'; import { useStdin, useStdout } from 'ink';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import React from 'react';
// Mock the ink hooks // Mock the ink hooks
vi.mock('ink', async (importOriginal) => { vi.mock('ink', async (importOriginal) => {
@@ -23,12 +25,17 @@ vi.mock('ink', async (importOriginal) => {
const mockedUseStdin = vi.mocked(useStdin); const mockedUseStdin = vi.mocked(useStdin);
const mockedUseStdout = vi.mocked(useStdout); const mockedUseStdout = vi.mocked(useStdout);
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(KeypressProvider, null, children);
describe('useFocus', () => { describe('useFocus', () => {
let stdin: EventEmitter; let stdin: EventEmitter;
let stdout: { write: vi.Func }; let stdout: { write: vi.Func };
beforeEach(() => { beforeEach(() => {
stdin = new EventEmitter(); stdin = new EventEmitter();
stdin.resume = vi.fn();
stdin.pause = vi.fn();
stdout = { write: vi.fn() }; stdout = { write: vi.fn() };
mockedUseStdin.mockReturnValue({ stdin } as ReturnType<typeof useStdin>); mockedUseStdin.mockReturnValue({ stdin } as ReturnType<typeof useStdin>);
mockedUseStdout.mockReturnValue({ stdout } as unknown as ReturnType< mockedUseStdout.mockReturnValue({ stdout } as unknown as ReturnType<
@@ -38,17 +45,18 @@ describe('useFocus', () => {
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
stdin.removeAllListeners();
}); });
it('should initialize with focus and enable focus reporting', () => { it('should initialize with focus and enable focus reporting', () => {
const { result } = renderHook(() => useFocus()); const { result } = renderHook(() => useFocus(), { wrapper });
expect(result.current).toBe(true); expect(result.current).toBe(true);
expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004h'); expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004h');
}); });
it('should set isFocused to false when a focus-out event is received', () => { 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 // Initial state is focused
expect(result.current).toBe(true); expect(result.current).toBe(true);
@@ -63,7 +71,7 @@ describe('useFocus', () => {
}); });
it('should set isFocused to true when a focus-in event is received', () => { 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 // Simulate focus-out to set initial state to false
act(() => { act(() => {
@@ -81,20 +89,22 @@ describe('useFocus', () => {
}); });
it('should clean up and disable focus reporting on unmount', () => { it('should clean up and disable focus reporting on unmount', () => {
const { unmount } = renderHook(() => useFocus()); const { unmount } = renderHook(() => useFocus(), { wrapper });
// Ensure listener was attached // At this point we should have listeners from both KeypressProvider and useFocus
expect(stdin.listenerCount('data')).toBe(1); const listenerCountAfterMount = stdin.listenerCount('data');
expect(listenerCountAfterMount).toBeGreaterThanOrEqual(1);
unmount(); unmount();
// Assert that the cleanup function was called // Assert that the cleanup function was called
expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004l'); 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', () => { it('should handle multiple focus events correctly', () => {
const { result } = renderHook(() => useFocus()); const { result } = renderHook(() => useFocus(), { wrapper });
act(() => { act(() => {
stdin.emit('data', Buffer.from('\x1b[O')); stdin.emit('data', Buffer.from('\x1b[O'));
@@ -116,4 +126,20 @@ describe('useFocus', () => {
}); });
expect(result.current).toBe(true); 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);
});
}); });
+14
View File
@@ -6,6 +6,7 @@
import { useStdin, useStdout } from 'ink'; import { useStdin, useStdout } from 'ink';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useKeypress } from './useKeypress.js';
// ANSI escape codes to enable/disable terminal focus reporting // ANSI escape codes to enable/disable terminal focus reporting
export const ENABLE_FOCUS_REPORTING = '\x1b[?1004h'; export const ENABLE_FOCUS_REPORTING = '\x1b[?1004h';
@@ -44,5 +45,18 @@ export const useFocus = () => {
}; };
}, [stdin, stdout]); }, [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; return isFocused;
}; };