mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -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 { 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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+2
-2
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user