Skip to content
Draft
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
79 changes: 79 additions & 0 deletions packages/cli/src/ui/components/Footer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';

vi.mock('../hooks/useTerminalSize.js');
vi.mock('./VoiceMicButton.js', () => ({ VoiceMicButton: () => null }));
Expand Down Expand Up @@ -99,4 +100,82 @@ describe('<Footer />', () => {
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
});
});

// The left slot shows exactly ONE thing, chosen by a priority chain. Each
// branch is pinned here so a refactor of that chain cannot silently reorder
// or drop a state.
describe('left slot priority chain (golden snapshots)', () => {
const leftStates: Array<[name: string, overrides: Partial<UIState>]> = [
['voice-recording', { voiceState: 'recording' as const }],
['voice-transcribing', { voiceState: 'transcribing' as const }],
['ctrl-c-pressed-once', { ctrlCPressedOnce: true }],
['ctrl-d-pressed-once', { ctrlDPressedOnce: true }],
['escape-prompt', { showEscapePrompt: true }],
['shell-mode', { shellModeActive: true }],
['auto-accept-mode', { showAutoAcceptIndicator: ApprovalMode.AUTO_EDIT }],
['yolo-mode', { showAutoAcceptIndicator: ApprovalMode.YOLO }],
['default-mode', { showAutoAcceptIndicator: ApprovalMode.DEFAULT }],
];

it.each(leftStates)('renders left slot: %s', (name, overrides) => {
const { lastFrame } = renderWithWidth(120, createMockUIState(overrides));
expect(lastFrame()).toMatchSnapshot(`left-${name}`);
});

// Priority is the contract, not an accident: recording must beat every
// lower-priority state when several are true at once.
it('prefers voice recording over ctrl-c and shell mode', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
voiceState: 'recording' as const,
ctrlCPressedOnce: true,
shellModeActive: true,
}),
);
expect(lastFrame()).toContain('Recording');
expect(lastFrame()).not.toContain('Ctrl+C');
});

it('prefers ctrl-c over the escape prompt', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState({ ctrlCPressedOnce: true, showEscapePrompt: true }),
);
expect(lastFrame()).toContain('Ctrl+C');
expect(lastFrame()).not.toContain('Esc again');
});
});

// The right slot is an ordered token array; order and separators are the
// contract that a registry refactor must preserve.
describe('right slot token order (golden snapshots)', () => {
it('renders sandbox + debug + context together', () => {
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 });
vi.stubEnv('SANDBOX', 'sandbox-exec');
const { lastFrame } = render(
<ConfigContext.Provider
value={createMockConfig({ getDebugMode: vi.fn(() => true) }) as never}
>
<VimModeProvider settings={createMockSettings()}>
<UIStateContext.Provider value={createMockUIState()}>
<Footer />
</UIStateContext.Provider>
</VimModeProvider>
</ConfigContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot('right-sandbox-debug-context');
vi.unstubAllEnvs();
});

it('omits the context token when no prompt tokens are counted', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
sessionStats: { lastPromptTokenCount: 0 },
} as Partial<UIState>),
);
expect(lastFrame()).not.toMatch(/% context used/);
});
});
});
172 changes: 100 additions & 72 deletions packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { t } from '../../i18n/index.js';
import { VoiceMicButton } from './VoiceMicButton.js';
import { GoalPill } from './GoalPill.js';
import { useGoalStatus } from '../hooks/useGoalStatus.js';
import { TokenBar } from './status/TokenBar.js';
import { firstToken, tokens } from './status/types.js';

export const Footer: React.FC = () => {
const uiState = useUIState();
Expand Down Expand Up @@ -54,80 +56,108 @@ export const Footer: React.FC = () => {

const goalStatus = useGoalStatus(config);

// Left section should show exactly ONE thing at any time, in priority order.
const leftContent =
uiState.voiceState === 'recording' ? (
<Text color={theme.status.error}>● Recording… (ctrl+space to stop)</Text>
) : uiState.voiceState === 'transcribing' ? (
<Text dimColor>◌ Transcribing…</Text>
) : uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>
{t('Press Ctrl+C again to exit.')}
</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>
{t('Press Ctrl+D again to exit.')}
</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>{t('Press Esc again to clear.')}</Text>
) : vimEnabled && vimMode === 'INSERT' ? (
<Text color={theme.text.secondary}>-- INSERT --</Text>
) : uiState.shellModeActive ? (
<ShellModeIndicator />
) : showAutoAcceptIndicator !== undefined &&
showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
) : (
// Default mode: the autonomy chip stays visible (so the current mode is
// always shown) next to the shortcut hints.
<Box>
<AutoAcceptIndicator approvalMode={ApprovalMode.DEFAULT} />
// Left slot shows exactly ONE thing: the first applicable token wins, so the
// order of this list IS the priority order.
const leftToken = firstToken(
uiState.voiceState === 'recording' && {
key: 'voice-recording',
node: (
<Text color={theme.status.error}>
● Recording… (ctrl+space to stop)
</Text>
),
},
uiState.voiceState === 'transcribing' && {
key: 'voice-transcribing',
node: <Text dimColor>◌ Transcribing…</Text>,
},
uiState.ctrlCPressedOnce && {
key: 'ctrl-c',
node: (
<Text color={theme.status.warning}>
{t('Press Ctrl+C again to exit.')}
</Text>
),
},
uiState.ctrlDPressedOnce && {
key: 'ctrl-d',
node: (
<Text color={theme.status.warning}>
{t('Press Ctrl+D again to exit.')}
</Text>
),
},
uiState.showEscapePrompt && {
key: 'escape-prompt',
node: (
<Text color={theme.text.secondary}>
{' · '}
{t('? for shortcuts')}
{' · '}
{t('Esc×2: rewind')}
{t('Press Esc again to clear.')}
</Text>
</Box>
);
),
},
vimEnabled &&
vimMode === 'INSERT' && {
key: 'vim-insert',
node: <Text color={theme.text.secondary}>-- INSERT --</Text>,
},
uiState.shellModeActive && {
key: 'shell-mode',
node: <ShellModeIndicator />,
},
showAutoAcceptIndicator !== undefined &&
showAutoAcceptIndicator !== ApprovalMode.DEFAULT && {
key: 'approval-mode',
node: <AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />,
},
{
// Default mode: the autonomy chip stays visible (so the current mode is
// always shown) next to the shortcut hints.
key: 'default-hint',
node: (
<Box>
<AutoAcceptIndicator approvalMode={ApprovalMode.DEFAULT} />
<Text color={theme.text.secondary}>
{' · '}
{t('? for shortcuts')}
{' · '}
{t('Esc×2: rewind')}
</Text>
</Box>
),
},
);

const rightItems: Array<{ key: string; node: React.ReactNode }> = [
const rightTokens = tokens(
{ key: 'voice', node: <VoiceMicButton /> },
];
if (sandboxInfo) {
rightItems.push({
sandboxInfo && {
key: 'sandbox',
node: <Text color={theme.status.success}>🔒 {sandboxInfo}</Text>,
});
}
if (debugMode) {
rightItems.push({
},
debugMode && {
key: 'debug',
node: <Text color={theme.status.warning}>Debug Mode</Text>,
});
}
if (promptTokenCount > 0 && contextWindowSize) {
rightItems.push({
key: 'context',
node: (
<Text color={theme.text.accent}>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
terminalWidth={terminalWidth}
contextWindowSize={contextWindowSize}
/>
</Text>
),
});
}
if (goalStatus) {
// Active /goal indicator -- pushed last so it lands rightmost (most
// visible) in the footer. Mirrors Codex CLI's status-line goal slot.
rightItems.push({
},
promptTokenCount > 0 &&
Boolean(contextWindowSize) && {
key: 'context',
node: (
<Text color={theme.text.accent}>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
terminalWidth={terminalWidth}
contextWindowSize={contextWindowSize as number}
/>
</Text>
),
},
goalStatus && {
// Active /goal indicator -- last so it lands rightmost (most visible) in
// the footer. Mirrors Codex CLI's status-line goal slot.
key: 'goal',
node: <GoalPill snapshot={goalStatus} />,
});
}
},
);

return (
<Box
justifyContent="space-between"
Expand All @@ -142,18 +172,16 @@ export const Footer: React.FC = () => {
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{leftContent}
{leftToken?.node}
<MCPHealthPill />
</Box>

{/* Right Section: Sandbox Info, Debug Mode, Context Usage, and Console Summary */}
{/* Right Section: an ordered token array — voice · sandbox · debug · context · goal */}
<Box alignItems="center" justifyContent="flex-end" marginRight={2}>
{rightItems.map(({ key, node }, index) => (
<Box key={key} alignItems="center">
{index > 0 && <Text color={theme.text.secondary}> | </Text>}
{node}
</Box>
))}
<TokenBar
tokens={rightTokens}
separator={<Text color={theme.text.secondary}> | </Text>}
/>
</Box>
</Box>
);
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/ui/components/StatusBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import os from 'node:os';
import { StatusBar } from './StatusBar.js';

// Pin the hostname so golden snapshots are machine-independent; homedir() is
// left real because tildify() depends on it.
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>();
return {
...actual,
default: { ...actual, hostname: () => 'test-host' },
hostname: () => 'test-host',
};
});

vi.mock('../hooks/useGitBranchName.js', () => ({
useGitBranchName: vi.fn(() => undefined),
}));
Expand Down Expand Up @@ -97,4 +108,34 @@ describe('<StatusBar />', () => {
const { lastFrame } = render$({ bgSessionActive: true });
expect(lastFrame()).toContain('⟳ bg session');
});

// Pins separator placement and badge order, which is what a token-array
// refactor is most likely to disturb.
describe('status bar rendering (golden snapshots)', () => {
it('renders the minimal bar (no branch, no diff)', () => {
const { lastFrame } = render$();
expect(lastFrame()).toMatchSnapshot('statusbar-minimal');
});

it('renders every token at once', () => {
mockBranch.mockReturnValue('main');
mockDiff.mockReturnValue({
filesChanged: 3,
linesAdded: 10,
linesRemoved: 2,
});
const { lastFrame } = render$({ bgSessionActive: true });
expect(lastFrame()).toMatchSnapshot('statusbar-all-tokens');
});

it('renders a diff with only additions', () => {
mockDiff.mockReturnValue({
filesChanged: 1,
linesAdded: 7,
linesRemoved: 0,
});
const { lastFrame } = render$();
expect(lastFrame()).toMatchSnapshot('statusbar-additions-only');
});
});
});
Loading
Loading