Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/components/PromptInput/KeepMounted.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
})
Comment thread
Hanxcellent marked this conversation as resolved.
16 changes: 16 additions & 0 deletions src/components/PromptInput/KeepMounted.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
13 changes: 13 additions & 0 deletions src/components/PromptInput/PromptInputFooter.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 26 additions & 14 deletions src/components/PromptInput/PromptInputFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down