mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 06:31:01 -07:00
Fix bug where users are unable to re-enter disconnected terminals. (#8765)
This commit is contained in:
@@ -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<typeof render> =>
|
||||
render(
|
||||
<KeypressProvider kittyProtocolEnabled={true}>
|
||||
{component}
|
||||
</KeypressProvider>,
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<KeypressProvider kittyProtocolEnabled={true}>
|
||||
{component}
|
||||
</KeypressProvider>
|
||||
</ShellFocusContext.Provider>,
|
||||
);
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
const [shellFocused, setShellFocused] = useState(false);
|
||||
const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
|
||||
|
||||
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(
|
||||
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 || [],
|
||||
}}
|
||||
>
|
||||
<FocusContext.Provider value={isFocused}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</FocusContext.Provider>
|
||||
</ShellFocusContext.Provider>
|
||||
</AppContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</UIActionsContext.Provider>
|
||||
|
||||
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
{!uiState.shellFocused && (
|
||||
{!uiState.embeddedShellFocused && (
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
||||
@@ -167,9 +165,9 @@ export const Composer = () => {
|
||||
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."
|
||||
|
||||
@@ -33,7 +33,7 @@ interface HistoryItemDisplayProps {
|
||||
isFocused?: boolean;
|
||||
commands?: readonly SlashCommand[];
|
||||
activeShellPtyId?: number | null;
|
||||
shellFocused?: boolean;
|
||||
embeddedShellFocused?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -44,7 +44,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
commands,
|
||||
isFocused = true,
|
||||
activeShellPtyId,
|
||||
shellFocused,
|
||||
embeddedShellFocused,
|
||||
}) => (
|
||||
<Box flexDirection="column" key={item.id}>
|
||||
{/* Render standard message types */}
|
||||
@@ -93,7 +93,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
isFocused={isFocused}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
shellFocused={shellFocused}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'compression' && (
|
||||
|
||||
@@ -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(
|
||||
<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', () => {
|
||||
@@ -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(
|
||||
<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 {
|
||||
|
||||
@@ -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<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
|
||||
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<InputPromptProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, {
|
||||
isActive: !isShellFocused,
|
||||
});
|
||||
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
@@ -842,7 +847,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
statusColor ?? (focus ? theme.border.focused : theme.border.default)
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default
|
||||
}
|
||||
paddingX={1}
|
||||
>
|
||||
@@ -871,7 +878,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
focus ? (
|
||||
showCursor ? (
|
||||
<Text>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
|
||||
@@ -926,7 +933,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
if (!currentLineGhost) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{chalk.inverse(' ')}
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
@@ -978,7 +987,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost && chalk.inverse(' ')}
|
||||
{showCursorBeforeGhost &&
|
||||
(showCursor ? chalk.inverse(' ') : ' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const MainContent = () => {
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
shellFocused={uiState.shellFocused}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
<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"
|
||||
`;
|
||||
|
||||
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 │
|
||||
|
||||
@@ -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<ToolGroupMessageProps> = ({
|
||||
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<ToolGroupMessageProps> = ({
|
||||
(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<ToolGroupMessageProps> = ({
|
||||
*/
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
borderDimColor={hasPending}
|
||||
borderDimColor={
|
||||
hasPending && (!isShellCommand || !isEmbeddedShellFocused)
|
||||
}
|
||||
borderColor={borderColor}
|
||||
gap={1}
|
||||
>
|
||||
@@ -119,7 +121,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
: 'medium'
|
||||
}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
shellFocused={shellFocused}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
config={config}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -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<ToolMessageProps> = ({
|
||||
emphasis = 'medium',
|
||||
renderOutputAsMarkdown = true,
|
||||
activeShellPtyId,
|
||||
shellFocused,
|
||||
embeddedShellFocused,
|
||||
ptyId,
|
||||
config,
|
||||
}) => {
|
||||
@@ -60,7 +60,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
(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<ToolMessageProps> = ({
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||
<ShellInputPrompt
|
||||
activeShellPtyId={activeShellPtyId ?? null}
|
||||
focus={shellFocused}
|
||||
focus={embeddedShellFocused}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
|
||||
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;
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
||||
activePtyId: number | undefined;
|
||||
shellFocused: boolean;
|
||||
embeddedShellFocused: boolean;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
@@ -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<typeof useStdin>);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user