fix(ui): handle large content in AskUserDialog with MaxSizedBox

Constrain content height using MaxSizedBox to prevent flicker when
displaying large plan files. Content is truncated based on available
terminal height with a "lines hidden" indicator.
This commit is contained in:
Jerop Kipruto
2026-01-28 19:27:35 -05:00
parent 22f5203f10
commit 6b650c263b
3 changed files with 127 additions and 4 deletions

View File

@@ -962,5 +962,70 @@ describe('AskUserDialog', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('truncates long content when availableTerminalHeight is small', async () => {
const longContent = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const questionWithLongContent: Question[] = [
{
question: 'Approve this plan?',
header: 'Plan',
options: [{ label: 'Yes', description: '' }],
content: longContent,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questionWithLongContent}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{
width: 120,
uiState: {
availableTerminalHeight: 15,
},
},
);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
it('does not truncate content when availableTerminalHeight is undefined', () => {
const content = Array.from(
{ length: 10 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const questionWithContent: Question[] = [
{
question: 'Approve this plan?',
header: 'Plan',
options: [{ label: 'Yes', description: '' }],
content,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questionWithContent}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
{
width: 120,
uiState: {
availableTerminalHeight: undefined,
},
},
);
expect(lastFrame()).not.toContain('lines hidden');
expect(lastFrame()).toMatchSnapshot();
});
});
});

View File

@@ -31,6 +31,17 @@ import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
// Width reduction for content inside the dialog border/padding
const CONTENT_WIDTH_REDUCTION = 4;
// Height consumed by dialog chrome surrounding the content area:
// - Border top/bottom: 2
// - Question text + marginBottom: 2
// - Footer (keyboard hints): 2
// - Options/input minimum: 4
// - Buffer for tab header when present: 2
const DIALOG_CHROME_HEIGHT = 12;
interface AskUserDialogState {
answers: { [key: string]: string };
isEditingCustomOption: boolean;
@@ -283,11 +294,16 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
{progressHeader}
{question.content && (
<Box marginBottom={1} flexDirection="column">
<MaxSizedBox maxHeight={uiState?.availableTerminalHeight}>
<MaxSizedBox
maxHeight={
uiState?.availableTerminalHeight &&
uiState.availableTerminalHeight - DIALOG_CHROME_HEIGHT
}
>
<MarkdownDisplay
text={question.content}
isPending={false}
terminalWidth={availableWidth - 4} // Adjust for parent border/padding
terminalWidth={availableWidth - CONTENT_WIDTH_REDUCTION}
/>
</MaxSizedBox>
</Box>
@@ -722,11 +738,16 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
{progressHeader}
{question.content && (
<Box marginBottom={1} flexDirection="column">
<MaxSizedBox maxHeight={uiState?.availableTerminalHeight}>
<MaxSizedBox
maxHeight={
uiState?.availableTerminalHeight &&
uiState.availableTerminalHeight - DIALOG_CHROME_HEIGHT
}
>
<MarkdownDisplay
text={question.content}
isPending={false}
terminalWidth={availableWidth - 4} // Adjust for parent border/padding
terminalWidth={availableWidth - CONTENT_WIDTH_REDUCTION}
/>
</MaxSizedBox>
</Box>

View File

@@ -11,6 +11,28 @@ exports[`AskUserDialog > Question content field > does not render content when c
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > Question content field > does not truncate content when availableTerminalHeight is undefined 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ Line 8 │
│ Line 9 │
│ Line 10 │
│ │
│ Approve this plan? │
│ │
│ ● 1. Yes │
│ 2. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > Question content field > renders content in a choice question 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Plan Details │
@@ -44,6 +66,21 @@ exports[`AskUserDialog > Question content field > renders content in a text ques
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > Question content field > truncates long content when availableTerminalHeight is small 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ... first 28 lines hidden ... │
│ Line 29 │
│ Line 30 │
│ │
│ Approve this plan? │
│ │
│ ● 1. Yes │
│ 2. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > Question content field > uses customOptionPlaceholder for the Other option 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Approve this? │