fix: keep footer mounted across slash suggestions (#1943)

This commit is contained in:
Xiang Han
2026-07-11 23:22:23 +08:00
committed by GitHub
parent 4f971a1316
commit 4faf666cba
4 changed files with 116 additions and 14 deletions
@@ -0,0 +1,61 @@
import { PassThrough } from 'node:stream'
import { stripVTControlCharacters as stripAnsi } from 'node:util'
import { expect, test } from 'bun:test'
import { useEffect } from 'react'
import { createRoot, Text } from '../../ink.js'
import { KeepMounted } from './KeepMounted.js'
function createTestStdout(): NodeJS.WriteStream {
const stdout = new PassThrough()
;(stdout as unknown as { columns: number }).columns = 80
return stdout as unknown as NodeJS.WriteStream
}
test('keeps children mounted while visibility changes', async () => {
let mounts = 0
let unmounts = 0
const stdout = createTestStdout() as unknown as PassThrough
let output = ''
stdout.on('data', chunk => {
output += chunk.toString()
})
function Probe() {
useEffect(() => {
mounts++
return () => {
unmounts++
}
}, [])
return <Text>persistent child</Text>
}
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
patchConsole: false,
})
const render = (hidden: boolean) =>
root.render(
<KeepMounted hidden={hidden}>
<Probe />
</KeepMounted>,
)
render(false)
await Bun.sleep(10)
expect(stripAnsi(output)).toContain('persistent child')
output = ''
render(true)
await Bun.sleep(10)
const hiddenFrame = stripAnsi(output).replaceAll('\r', '').replaceAll('\n', '')
expect(hiddenFrame).toBe('')
render(false)
expect(mounts).toBe(1)
expect(unmounts).toBe(0)
root.unmount()
expect(unmounts).toBe(1)
})
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { Box } from '../../ink.js'
export function KeepMounted({
hidden,
children,
}: {
hidden: boolean
children: ReactNode
}): ReactNode {
return (
<Box height={hidden ? 0 : undefined} overflow="hidden">
{children}
</Box>
)
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test'
import { DEFAULT_GLOBAL_CONFIG } from '../../utils/config.js'
import {
resolveConfiguredFooterStatusLine,
resolveFooterStatusLine,
SHORTCUTS_HINT_STARTUP_GRACE,
shouldSuppressShortcutsHint,
@@ -60,6 +61,18 @@ describe('resolveFooterStatusLine', () => {
}
})
describe('resolveConfiguredFooterStatusLine', () => {
it('keeps a custom statusline configured while transient UI hides its row', () => {
expect(resolveConfiguredFooterStatusLine(customSettings)).toBe('custom')
expect(
resolveFooterStatusLine(customSettings, {
...guardsPass,
exitMessageShown: true,
}),
).toBeNull()
})
})
describe('shouldSuppressShortcutsHint', () => {
const base = {
suppressedByCaller: false,
@@ -21,6 +21,7 @@ import { BuiltinStatusLine, builtinStatusLineShouldDisplay } from '../BuiltinSta
import { useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js';
import { getLastAssistantMessageId, StatusLine, statusLineShouldDisplay } from '../StatusLine.js';
import { Notifications } from './Notifications.js';
import { KeepMounted } from './KeepMounted.js';
import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js';
import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js';
import { PromptInputHelpMenu } from './PromptInputHelpMenu.js';
@@ -44,6 +45,15 @@ export function resolveFooterStatusLine(settings: ReadonlySettings, guards: {
return null;
}
export function resolveConfiguredFooterStatusLine(settings: ReadonlySettings): 'custom' | 'builtin' | null {
return resolveFooterStatusLine(settings, {
isPromptMode: true,
isShort: false,
exitMessageShown: false,
isPasting: false
});
}
/**
* The builtin status line ships enabled, so a status line renders for nearly
* everyone — treating that as a reason to hide `? for shortcuts` would kill
@@ -165,6 +175,7 @@ function PromptInputFooter({
exitMessageShown: exitMessage.show,
isPasting
});
const configuredFooterStatusLine = resolveConfiguredFooterStatusLine(settings);
// Hide `? for shortcuts` during ctrl-r search, or — for established users
// only — when a status line actually renders below (display setting AND
// render guards). See shouldSuppressShortcutsHint.
@@ -181,25 +192,26 @@ function PromptInputFooter({
maxColumnWidth
} : null, [isFullscreen, suggestions, selectedSuggestion, maxColumnWidth]);
useSetPromptOverlay(overlayData);
if (suggestions.length && !isFullscreen) {
return <Box paddingX={2} paddingY={0}>
<PromptInputFooterSuggestions suggestions={suggestions} selectedSuggestion={selectedSuggestion} maxColumnWidth={maxColumnWidth} />
</Box>;
}
if (helpOpen) {
return <PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />;
}
const showInlineSuggestions = suggestions.length > 0 && !isFullscreen;
const hideRegularFooter = showInlineSuggestions || helpOpen;
return <>
<Box flexDirection={isNarrow ? 'column' : 'row'} justifyContent={isNarrow ? 'flex-start' : 'space-between'} paddingX={2} gap={isNarrow ? 0 : 1}>
<Box flexDirection="column" flexShrink={isNarrow ? 0 : 1}>
{footerStatusLine === 'custom' ? <StatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} vimMode={vimMode} /> : footerStatusLine === 'builtin' ? <BuiltinStatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} /> : null}
<KeepMounted hidden={hideRegularFooter}>
<Box flexDirection={isNarrow ? 'column' : 'row'} justifyContent={isNarrow ? 'flex-start' : 'space-between'} paddingX={2} gap={isNarrow ? 0 : 1}>
<Box flexDirection="column" flexShrink={isNarrow ? 0 : 1}>
<KeepMounted hidden={footerStatusLine === null}>
{configuredFooterStatusLine === 'custom' ? <StatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} vimMode={vimMode} /> : configuredFooterStatusLine === 'builtin' ? <BuiltinStatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} /> : null}
</KeepMounted>
<PromptInputFooterLeftSide exitMessage={exitMessage} vimMode={vimMode} mode={mode} toolPermissionContext={toolPermissionContext} suppressHint={suppressHint} isLoading={isLoading} tasksSelected={pillSelected} teamsSelected={teamsSelected} teammateFooterIndex={teammateFooterIndex} tmuxSelected={tmuxSelected} isPasting={isPasting} isSearching={isSearching} historyQuery={historyQuery} setHistoryQuery={setHistoryQuery} historyFailedMatch={historyFailedMatch} onOpenTasksDialog={onOpenTasksDialog} />
</Box>
<Box flexShrink={1} gap={1}>
</Box>
<Box flexShrink={1} gap={1}>
{isFullscreen ? null : <Notifications apiKeyStatus={apiKeyStatus} autoUpdaterResult={autoUpdaterResult} debug={debug} isAutoUpdating={isAutoUpdating} verbose={verbose} messages={messages} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={onChangeIsUpdating} ideSelection={ideSelection} mcpClients={mcpClients} isInputWrapped={isInputWrapped} isNarrow={isNarrow} />}
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
</Box>
</Box>
</Box>
</KeepMounted>
{showInlineSuggestions ? <Box paddingX={2} paddingY={0}>
<PromptInputFooterSuggestions suggestions={suggestions} selectedSuggestion={selectedSuggestion} maxColumnWidth={maxColumnWidth} />
</Box> : helpOpen ? <PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} /> : null}
</>;
}
export default memo(PromptInputFooter);