Skip to content

Commit 375b852

Browse files
authored
Fix bug where users are unable to re-enter disconnected terminals. (#8765)
1 parent 2216856 commit 375b852

15 files changed

+267
-55
lines changed

packages/cli/src/test-utils/render.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
import { render } from 'ink-testing-library';
88
import type React from 'react';
99
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
10+
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
1011

1112
export const renderWithProviders = (
1213
component: React.ReactElement,
14+
{ shellFocus = true } = {},
1315
): ReturnType<typeof render> =>
1416
render(
15-
<KeypressProvider kittyProtocolEnabled={true}>
16-
{component}
17-
</KeypressProvider>,
17+
<ShellFocusContext.Provider value={shellFocus}>
18+
<KeypressProvider kittyProtocolEnabled={true}>
19+
{component}
20+
</KeypressProvider>
21+
</ShellFocusContext.Provider>,
1822
);

packages/cli/src/ui/AppContainer.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
8686
import { useSessionStats } from './contexts/SessionContext.js';
8787
import { useGitBranchName } from './hooks/useGitBranchName.js';
8888
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
89-
import { FocusContext } from './contexts/FocusContext.js';
89+
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
9090

9191
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
9292

@@ -135,7 +135,7 @@ export const AppContainer = (props: AppContainerProps) => {
135135
initializationResult.themeError,
136136
);
137137
const [isProcessing, setIsProcessing] = useState<boolean>(false);
138-
const [shellFocused, setShellFocused] = useState(false);
138+
const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
139139

140140
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(
141141
initializationResult.geminiMdFileCount,
@@ -557,10 +557,10 @@ Logging in with Google... Please restart Gemini CLI to continue.
557557
setModelSwitchedFromQuotaError,
558558
refreshStatic,
559559
() => cancelHandlerRef.current(),
560-
setShellFocused,
560+
setEmbeddedShellFocused,
561561
terminalWidth,
562562
terminalHeight,
563-
shellFocused,
563+
embeddedShellFocused,
564564
);
565565

566566
// Auto-accept indicator
@@ -917,8 +917,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
917917
) {
918918
setConstrainHeight(false);
919919
} else if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) {
920-
if (activePtyId || shellFocused) {
921-
setShellFocused((prev) => !prev);
920+
if (activePtyId || embeddedShellFocused) {
921+
setEmbeddedShellFocused((prev) => !prev);
922922
}
923923
}
924924
},
@@ -941,7 +941,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
941941
handleSlashCommand,
942942
cancelOngoingRequest,
943943
activePtyId,
944-
shellFocused,
944+
embeddedShellFocused,
945945
settings.merged.general?.debugKeystrokeLogging,
946946
],
947947
);
@@ -1069,7 +1069,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
10691069
isRestarting,
10701070
extensionsUpdateState,
10711071
activePtyId,
1072-
shellFocused,
1072+
embeddedShellFocused,
10731073
}),
10741074
[
10751075
historyManager.history,
@@ -1145,7 +1145,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
11451145
currentModel,
11461146
extensionsUpdateState,
11471147
activePtyId,
1148-
shellFocused,
1148+
embeddedShellFocused,
11491149
],
11501150
);
11511151

@@ -1207,9 +1207,9 @@ Logging in with Google... Please restart Gemini CLI to continue.
12071207
startupWarnings: props.startupWarnings || [],
12081208
}}
12091209
>
1210-
<FocusContext.Provider value={isFocused}>
1210+
<ShellFocusContext.Provider value={isFocused}>
12111211
<App />
1212-
</FocusContext.Provider>
1212+
</ShellFocusContext.Provider>
12131213
</AppContext.Provider>
12141214
</ConfigContext.Provider>
12151215
</UIActionsContext.Provider>

packages/cli/src/ui/components/Composer.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
1919
import { theme } from '../semantic-colors.js';
2020
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
2121
import { useUIState } from '../contexts/UIStateContext.js';
22-
import { useFocusState } from '../contexts/FocusContext.js';
2322
import { useUIActions } from '../contexts/UIActionsContext.js';
2423
import { useVimMode } from '../contexts/VimModeContext.js';
2524
import { useConfig } from '../contexts/ConfigContext.js';
@@ -32,7 +31,6 @@ export const Composer = () => {
3231
const config = useConfig();
3332
const settings = useSettings();
3433
const uiState = useUIState();
35-
const isFocused = useFocusState();
3634
const uiActions = useUIActions();
3735
const { vimEnabled, vimMode } = useVimMode();
3836
const terminalWidth = process.stdout.columns;
@@ -69,7 +67,7 @@ export const Composer = () => {
6967

7068
return (
7169
<Box flexDirection="column">
72-
{!uiState.shellFocused && (
70+
{!uiState.embeddedShellFocused && (
7371
<LoadingIndicator
7472
thought={
7573
uiState.streamingState === StreamingState.WaitingForConfirmation ||
@@ -167,9 +165,9 @@ export const Composer = () => {
167165
setShellModeActive={uiActions.setShellModeActive}
168166
approvalMode={showAutoAcceptIndicator}
169167
onEscapePromptChange={uiActions.onEscapePromptChange}
170-
focus={isFocused}
168+
focus={true}
171169
vimHandleInput={uiActions.vimHandleInput}
172-
isShellFocused={uiState.shellFocused}
170+
isEmbeddedShellFocused={uiState.embeddedShellFocused}
173171
placeholder={
174172
vimEnabled
175173
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."

packages/cli/src/ui/components/HistoryItemDisplay.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ interface HistoryItemDisplayProps {
3333
isFocused?: boolean;
3434
commands?: readonly SlashCommand[];
3535
activeShellPtyId?: number | null;
36-
shellFocused?: boolean;
36+
embeddedShellFocused?: boolean;
3737
}
3838

3939
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -44,7 +44,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
4444
commands,
4545
isFocused = true,
4646
activeShellPtyId,
47-
shellFocused,
47+
embeddedShellFocused,
4848
}) => (
4949
<Box flexDirection="column" key={item.id}>
5050
{/* Render standard message types */}
@@ -93,7 +93,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
9393
terminalWidth={terminalWidth}
9494
isFocused={isFocused}
9595
activeShellPtyId={activeShellPtyId}
96-
shellFocused={shellFocused}
96+
embeddedShellFocused={embeddedShellFocused}
9797
/>
9898
)}
9999
{item.type === 'compression' && (

packages/cli/src/ui/components/InputPrompt.test.tsx

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,6 +1336,128 @@ describe('InputPrompt', () => {
13361336
expect(frame).toContain(`hello 👍${chalk.inverse(' ')}`);
13371337
unmount();
13381338
});
1339+
1340+
it('should display cursor on an empty line', async () => {
1341+
mockBuffer.text = '';
1342+
mockBuffer.lines = [''];
1343+
mockBuffer.viewportVisualLines = [''];
1344+
mockBuffer.visualCursor = [0, 0];
1345+
1346+
const { stdout, unmount } = renderWithProviders(
1347+
<InputPrompt {...props} />,
1348+
);
1349+
await wait();
1350+
1351+
const frame = stdout.lastFrame();
1352+
expect(frame).toContain(chalk.inverse(' '));
1353+
unmount();
1354+
});
1355+
1356+
it('should display cursor on a space between words', async () => {
1357+
mockBuffer.text = 'hello world';
1358+
mockBuffer.lines = ['hello world'];
1359+
mockBuffer.viewportVisualLines = ['hello world'];
1360+
mockBuffer.visualCursor = [0, 5]; // cursor on the space
1361+
1362+
const { stdout, unmount } = renderWithProviders(
1363+
<InputPrompt {...props} />,
1364+
);
1365+
await wait();
1366+
1367+
const frame = stdout.lastFrame();
1368+
expect(frame).toContain(`hello${chalk.inverse(' ')}world`);
1369+
unmount();
1370+
});
1371+
1372+
it('should display cursor in the middle of a line in a multiline block', async () => {
1373+
const text = 'first line\nsecond line\nthird line';
1374+
mockBuffer.text = text;
1375+
mockBuffer.lines = text.split('\n');
1376+
mockBuffer.viewportVisualLines = text.split('\n');
1377+
mockBuffer.visualCursor = [1, 3]; // cursor on 'o' in 'second'
1378+
mockBuffer.visualToLogicalMap = [
1379+
[0, 0],
1380+
[1, 0],
1381+
[2, 0],
1382+
];
1383+
1384+
const { stdout, unmount } = renderWithProviders(
1385+
<InputPrompt {...props} />,
1386+
);
1387+
await wait();
1388+
1389+
const frame = stdout.lastFrame();
1390+
expect(frame).toContain(`sec${chalk.inverse('o')}nd line`);
1391+
unmount();
1392+
});
1393+
1394+
it('should display cursor at the beginning of a line in a multiline block', async () => {
1395+
const text = 'first line\nsecond line';
1396+
mockBuffer.text = text;
1397+
mockBuffer.lines = text.split('\n');
1398+
mockBuffer.viewportVisualLines = text.split('\n');
1399+
mockBuffer.visualCursor = [1, 0]; // cursor on 's' in 'second'
1400+
mockBuffer.visualToLogicalMap = [
1401+
[0, 0],
1402+
[1, 0],
1403+
];
1404+
1405+
const { stdout, unmount } = renderWithProviders(
1406+
<InputPrompt {...props} />,
1407+
);
1408+
await wait();
1409+
1410+
const frame = stdout.lastFrame();
1411+
expect(frame).toContain(`${chalk.inverse('s')}econd line`);
1412+
unmount();
1413+
});
1414+
1415+
it('should display cursor at the end of a line in a multiline block', async () => {
1416+
const text = 'first line\nsecond line';
1417+
mockBuffer.text = text;
1418+
mockBuffer.lines = text.split('\n');
1419+
mockBuffer.viewportVisualLines = text.split('\n');
1420+
mockBuffer.visualCursor = [0, 10]; // cursor after 'first line'
1421+
mockBuffer.visualToLogicalMap = [
1422+
[0, 0],
1423+
[1, 0],
1424+
];
1425+
1426+
const { stdout, unmount } = renderWithProviders(
1427+
<InputPrompt {...props} />,
1428+
);
1429+
await wait();
1430+
1431+
const frame = stdout.lastFrame();
1432+
expect(frame).toContain(`first line${chalk.inverse(' ')}`);
1433+
unmount();
1434+
});
1435+
1436+
it('should display cursor on a blank line in a multiline block', async () => {
1437+
const text = 'first line\n\nthird line';
1438+
mockBuffer.text = text;
1439+
mockBuffer.lines = text.split('\n');
1440+
mockBuffer.viewportVisualLines = text.split('\n');
1441+
mockBuffer.visualCursor = [1, 0]; // cursor on the blank line
1442+
mockBuffer.visualToLogicalMap = [
1443+
[0, 0],
1444+
[1, 0],
1445+
[2, 0],
1446+
];
1447+
1448+
const { stdout, unmount } = renderWithProviders(
1449+
<InputPrompt {...props} />,
1450+
);
1451+
await wait();
1452+
1453+
const frame = stdout.lastFrame();
1454+
const lines = frame!.split('\n');
1455+
// The line with the cursor should just be an inverted space inside the box border
1456+
expect(
1457+
lines.find((l) => l.includes(chalk.inverse(' '))),
1458+
).not.toBeUndefined();
1459+
unmount();
1460+
});
13391461
});
13401462

13411463
describe('multiline rendering', () => {
@@ -1966,6 +2088,33 @@ describe('InputPrompt', () => {
19662088
expect(stdout.lastFrame()).toMatchSnapshot();
19672089
unmount();
19682090
});
2091+
2092+
it('should not show inverted cursor when shell is focused', async () => {
2093+
props.isEmbeddedShellFocused = true;
2094+
props.focus = false;
2095+
const { stdout, unmount } = renderWithProviders(
2096+
<InputPrompt {...props} />,
2097+
);
2098+
await wait();
2099+
expect(stdout.lastFrame()).not.toContain(`{chalk.inverse(' ')}`);
2100+
// This snapshot is good to make sure there was an input prompt but does
2101+
// not show the inverted cursor because snapshots do not show colors.
2102+
expect(stdout.lastFrame()).toMatchSnapshot();
2103+
unmount();
2104+
});
2105+
});
2106+
2107+
it('should still allow input when shell is not focused', async () => {
2108+
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
2109+
shellFocus: false,
2110+
});
2111+
await wait();
2112+
2113+
stdin.write('a');
2114+
await wait();
2115+
2116+
expect(mockBuffer.handleInput).toHaveBeenCalled();
2117+
unmount();
19692118
});
19702119
});
19712120
function clean(str: string | undefined): string {

0 commit comments

Comments
 (0)