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

View File

@@ -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."

View File

@@ -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' && (

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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} />

View File

@@ -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 │

View 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>

View File

@@ -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>
)}