feat(ui): implement refreshed UX for Composer layout (#21212)

Co-authored-by: Keith Guerin <keithguerin@gmail.com>
This commit is contained in:
Jarrod Whelan
2026-03-23 19:30:48 -07:00
committed by GitHub
parent 1560131f94
commit 271908dc94
50 changed files with 1578 additions and 1362 deletions
+124 -74
View File
@@ -17,13 +17,6 @@ import {
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
vimEnabled: false,
vimMode: 'INSERT',
})),
}));
import {
ApprovalMode,
tokenLimit,
@@ -36,6 +29,21 @@ import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import type { TextBuffer } from './shared/text-buffer.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
vimEnabled: false,
vimMode: 'INSERT',
})),
}));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({
columns: 100,
rows: 24,
})),
}));
const composerTestControls = vi.hoisted(() => ({
suggestionsVisible: false,
isAlternateBuffer: false,
@@ -58,18 +66,9 @@ vi.mock('./LoadingIndicator.js', () => ({
}));
vi.mock('./StatusDisplay.js', () => ({
StatusDisplay: () => <Text>StatusDisplay</Text>,
}));
vi.mock('./ToastDisplay.js', () => ({
ToastDisplay: () => <Text>ToastDisplay</Text>,
shouldShowToast: (uiState: UIState) =>
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage),
StatusDisplay: ({ hideContextSummary }: { hideContextSummary: boolean }) => (
<Text>StatusDisplay{hideContextSummary ? ' (hidden summary)' : ''}</Text>
),
}));
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -81,17 +80,15 @@ vi.mock('./HookStatusDisplay.js', () => ({
}));
vi.mock('./ApprovalModeIndicator.js', () => ({
ApprovalModeIndicator: () => <Text>ApprovalModeIndicator</Text>,
ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => (
<Text>ApprovalModeIndicator: {approvalMode}</Text>
),
}));
vi.mock('./ShellModeIndicator.js', () => ({
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
}));
vi.mock('./ShortcutsHint.js', () => ({
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
}));
vi.mock('./ShortcutsHelp.js', () => ({
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
}));
@@ -174,6 +171,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
isFocused: true,
thought: '',
currentLoadingPhrase: '',
currentTip: '',
currentWittyPhrase: '',
elapsedTime: 0,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
@@ -201,6 +200,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
activeHooks: [],
isBackgroundShellVisible: false,
embeddedShellFocused: false,
showIsExpandableHint: false,
quota: {
userTier: undefined,
stats: undefined,
@@ -247,7 +247,7 @@ const createMockConfig = (overrides = {}): Config =>
const renderComposer = async (
uiState: UIState,
settings = createMockSettings(),
settings = createMockSettings({ ui: {} }),
config = createMockConfig(),
uiActions = createMockUIActions(),
) => {
@@ -256,7 +256,7 @@ const renderComposer = async (
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer />
<Composer isFocused={true} />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</SettingsContext.Provider>
@@ -383,10 +383,12 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Thinking...');
// In Refreshed UX, we don't force 'Thinking...' label in renderStatusNode
// It uses the subject directly
expect(output).toContain('LoadingIndicator: Thinking about code');
});
it('hides shortcuts hint while loading', async () => {
it('shows shortcuts hint while loading', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
elapsedTime: 1,
@@ -397,7 +399,8 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('ShortcutsHint');
expect(output).toContain('press tab twice for more');
expect(output).not.toContain('? for shortcuts');
});
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
@@ -453,9 +456,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
expect(output).not.toContain('esc to cancel');
const output = lastFrame({ allowEmpty: true });
expect(output).toBe('');
});
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => {
@@ -558,8 +560,10 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ApprovalModeIndicator');
expect(output).toContain('Press Ctrl+C again to exit.');
// In Refreshed UX, Row 1 shows toast, and Row 2 shows ApprovalModeIndicator/StatusDisplay
// They are no longer mutually exclusive.
expect(output).toContain('ApprovalModeIndicator');
expect(output).toContain('StatusDisplay');
});
@@ -574,8 +578,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ApprovalModeIndicator');
expect(output).toContain('Warning');
expect(output).toContain('ApprovalModeIndicator');
});
});
@@ -584,15 +588,17 @@ describe('Composer', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const settings = createMockSettings({
ui: { showShortcutsHint: false },
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
expect(output).toContain('ShortcutsHint');
expect(output).not.toContain('press tab twice for more');
expect(output).not.toContain('? for shortcuts');
expect(output).toContain('InputPrompt');
expect(output).not.toContain('Footer');
expect(output).not.toContain('ApprovalModeIndicator');
expect(output).not.toContain('ContextSummaryDisplay');
});
it('renders InputPrompt when input is active', async () => {
@@ -665,12 +671,15 @@ describe('Composer', () => {
});
it.each([
[ApprovalMode.YOLO, 'YOLO'],
[ApprovalMode.PLAN, 'plan'],
[ApprovalMode.AUTO_EDIT, 'auto edit'],
{ mode: ApprovalMode.YOLO, label: '● YOLO' },
{ mode: ApprovalMode.PLAN, label: '● plan' },
{
mode: ApprovalMode.AUTO_EDIT,
label: '● auto edit',
},
])(
'shows minimal mode badge "%s" when clean UI details are hidden',
async (mode, label) => {
'shows minimal mode badge "$mode" when clean UI details are hidden',
async ({ mode, label }) => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showApprovalModeIndicator: mode,
@@ -693,7 +702,8 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
expect(output).toContain('press tab twice for more');
expect(output).not.toContain('? for shortcuts');
});
it('hides minimal mode badge while action-required state is active', async () => {
@@ -708,9 +718,7 @@ describe('Composer', () => {
});
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
@@ -722,7 +730,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).toContain('Press Esc again to rewind.');
expect(output).not.toContain('ContextSummaryDisplay');
});
@@ -747,7 +755,14 @@ describe('Composer', () => {
});
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).toContain('%');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode
expect(lastFrame()).toContain('StatusDisplay');
expect(lastFrame()).toContain('70% used');
});
});
@@ -812,14 +827,20 @@ describe('Composer', () => {
describe('Shortcuts Hint', () => {
it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => {
const { lastFrame } = await renderComposer(
createMockUIState({
buffer: { text: '' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
}),
);
const uiState = createMockUIState({
buffer: { text: '' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
});
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
const { lastFrame } = await renderComposer(uiState);
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(lastFrame({ allowEmpty: true })).toContain(
'press tab twice for more',
);
});
it('hides shortcuts hint when text is typed in buffer', async () => {
@@ -830,7 +851,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
});
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
@@ -843,7 +865,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('? for shortcuts');
});
it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => {
@@ -856,9 +878,10 @@ describe('Composer', () => {
),
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame, unmount } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('keeps shortcuts hint visible when no action is required', async () => {
@@ -868,7 +891,11 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(lastFrame()).toContain('press tab twice for more');
});
it('shows shortcuts hint when full UI details are visible', async () => {
@@ -878,10 +905,15 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// In Refreshed UX, shortcuts hint is in the top multipurpose status row
expect(lastFrame()).toContain('? for shortcuts');
});
it('hides shortcuts hint while loading when full UI details are visible', async () => {
it('shows shortcuts hint while loading when full UI details are visible', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: true,
streamingState: StreamingState.Responding,
@@ -889,10 +921,17 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// In experimental layout, status row is visible during loading
expect(lastFrame()).toContain('LoadingIndicator');
expect(lastFrame()).toContain('? for shortcuts');
expect(lastFrame()).not.toContain('press tab twice for more');
});
it('hides shortcuts hint while loading in minimal mode', async () => {
it('shows shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
@@ -901,7 +940,14 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// In experimental layout, status row is visible in clean mode while busy
expect(lastFrame()).toContain('LoadingIndicator');
expect(lastFrame()).toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
});
it('shows shortcuts help in minimal mode when toggled on', async () => {
@@ -926,7 +972,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
expect(lastFrame()).not.toContain('plan');
});
@@ -954,7 +1001,12 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// In Refreshed UX, shortcuts hint is in the top status row and doesn't collide with suggestions below
expect(lastFrame()).toContain('press tab twice for more');
});
});
@@ -982,24 +1034,22 @@ describe('Composer', () => {
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
it('hides shortcuts help when action is required', async () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
customDialog: (
<Box>
<Text>Dialog content</Text>
<Text>Test Dialog</Text>
</Box>
),
});
const { lastFrame, unmount } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
describe('Snapshots', () => {
it('matches snapshot in idle state', async () => {
const uiState = createMockUIState();