chore: refactor based on feedback

This commit is contained in:
Jack Wotherspoon
2026-02-16 12:54:39 -05:00
committed by Keith Guerin
parent 39be41d6ae
commit 686998c1c8
11 changed files with 918 additions and 1013 deletions
+11
View File
@@ -9,60 +9,70 @@ import type { MergedSettings } from './settings.js';
export const ALL_ITEMS = [ export const ALL_ITEMS = [
{ {
id: 'cwd', id: 'cwd',
header: 'Path',
label: 'cwd', label: 'cwd',
description: 'Current directory path', description: 'Current directory path',
defaultEnabled: true, defaultEnabled: true,
}, },
{ {
id: 'git-branch', id: 'git-branch',
header: 'Branch',
label: 'git-branch', label: 'git-branch',
description: 'Current git branch name', description: 'Current git branch name',
defaultEnabled: true, defaultEnabled: true,
}, },
{ {
id: 'sandbox-status', id: 'sandbox-status',
header: '/docs',
label: 'sandbox-status', label: 'sandbox-status',
description: 'Sandbox type and trust indicator', description: 'Sandbox type and trust indicator',
defaultEnabled: true, defaultEnabled: true,
}, },
{ {
id: 'model-name', id: 'model-name',
header: '/model',
label: 'model-name', label: 'model-name',
description: 'Current model identifier', description: 'Current model identifier',
defaultEnabled: true, defaultEnabled: true,
}, },
{ {
id: 'context-remaining', id: 'context-remaining',
header: 'Context',
label: 'context-remaining', label: 'context-remaining',
description: 'Percentage of context window remaining', description: 'Percentage of context window remaining',
defaultEnabled: false, defaultEnabled: false,
}, },
{ {
id: 'quota', id: 'quota',
header: '/stats',
label: 'quota', label: 'quota',
description: 'Remaining usage on daily limit', description: 'Remaining usage on daily limit',
defaultEnabled: true, defaultEnabled: true,
}, },
{ {
id: 'memory-usage', id: 'memory-usage',
header: 'Memory',
label: 'memory-usage', label: 'memory-usage',
description: 'Node.js heap memory usage', description: 'Node.js heap memory usage',
defaultEnabled: false, defaultEnabled: false,
}, },
{ {
id: 'session-id', id: 'session-id',
header: 'Session',
label: 'session-id', label: 'session-id',
description: 'Unique identifier for the current session', description: 'Unique identifier for the current session',
defaultEnabled: false, defaultEnabled: false,
}, },
{ {
id: 'code-changes', id: 'code-changes',
header: 'Diff',
label: 'code-changes', label: 'code-changes',
description: 'Lines added/removed in the session', description: 'Lines added/removed in the session',
defaultEnabled: true, defaultEnabled: true,
}, },
{ {
id: 'token-count', id: 'token-count',
header: 'Tokens',
label: 'token-count', label: 'token-count',
description: 'Total tokens used in the session', description: 'Total tokens used in the session',
defaultEnabled: false, defaultEnabled: false,
@@ -73,6 +83,7 @@ export type FooterItemId = (typeof ALL_ITEMS)[number]['id'];
export interface FooterItem { export interface FooterItem {
id: string; id: string;
header: string;
label: string; label: string;
description: string; description: string;
defaultEnabled: boolean; defaultEnabled: boolean;
+10
View File
@@ -582,6 +582,16 @@ const SETTINGS_SCHEMA = {
showInDialog: false, showInDialog: false,
items: { type: 'string' }, items: { type: 'string' },
}, },
showLabels: {
type: 'boolean',
label: 'Show Footer Labels',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Display a second line above the footer items with descriptive headers (e.g., /model).',
showInDialog: false,
},
hideCWD: { hideCWD: {
type: 'boolean', type: 'boolean',
label: 'Hide CWD', label: 'Hide CWD',
@@ -27,46 +27,40 @@ vi.mock('../../config/settings.js', () => ({
})); }));
describe('ContextUsageDisplay', () => { describe('ContextUsageDisplay', () => {
it('renders correct percentage left', async () => { it('renders correct percentage left', () => {
const { lastFrame, waitUntilReady, unmount } = render( const { lastFrame } = render(
<ContextUsageDisplay <ContextUsageDisplay
promptTokenCount={5000} promptTokenCount={5000}
model="gemini-pro" model="gemini-pro"
terminalWidth={120} terminalWidth={120}
/>, />,
); );
await waitUntilReady();
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('50% context left'); expect(output).toContain('50% left');
unmount();
}); });
it('renders short label when terminal width is small', async () => { it('renders short label when terminal width is small', () => {
const { lastFrame, waitUntilReady, unmount } = render( const { lastFrame } = render(
<ContextUsageDisplay <ContextUsageDisplay
promptTokenCount={2000} promptTokenCount={2000}
model="gemini-pro" model="gemini-pro"
terminalWidth={80} terminalWidth={80}
/>, />,
); );
await waitUntilReady();
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('80%'); expect(output).toContain('80%');
expect(output).not.toContain('context left'); expect(output).not.toContain('context left');
unmount();
}); });
it('renders 0% when full', async () => { it('renders 0% when full', () => {
const { lastFrame, waitUntilReady, unmount } = render( const { lastFrame } = render(
<ContextUsageDisplay <ContextUsageDisplay
promptTokenCount={10000} promptTokenCount={10000}
model="gemini-pro" model="gemini-pro"
terminalWidth={120} terminalWidth={120}
/>, />,
); );
await waitUntilReady();
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('0% context left'); expect(output).toContain('0% left');
unmount();
}); });
}); });
@@ -12,18 +12,20 @@ export const ContextUsageDisplay = ({
promptTokenCount, promptTokenCount,
model, model,
terminalWidth, terminalWidth,
color = theme.text.primary,
}: { }: {
promptTokenCount: number; promptTokenCount: number;
model: string; model: string;
terminalWidth: number; terminalWidth: number;
color?: string;
}) => { }) => {
const percentage = getContextUsagePercentage(promptTokenCount, model); const percentage = getContextUsagePercentage(promptTokenCount, model);
const percentageLeft = ((1 - percentage) * 100).toFixed(0); const percentageLeft = ((1 - percentage) * 100).toFixed(0);
const label = terminalWidth < 100 ? '%' : '% context left'; const label = terminalWidth < 100 ? '%' : '% left';
return ( return (
<Text color={theme.text.secondary}> <Text color={color}>
{percentageLeft} {percentageLeft}
{label} {label}
</Text> </Text>
File diff suppressed because it is too large Load Diff
+252 -220
View File
@@ -17,23 +17,24 @@ import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process'; import process from 'node:process';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js'; import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js';
import { import {
getStatusColor,
QUOTA_THRESHOLD_HIGH, QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM, QUOTA_THRESHOLD_MEDIUM,
} from '../utils/displayUtils.js'; } from '../utils/displayUtils.js';
import { DebugProfiler } from './DebugProfiler.js'; import { DebugProfiler } from './DebugProfiler.js';
import { isDevelopment } from '../../utils/installationInfo.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js'; import { useVimMode } from '../contexts/VimModeContext.js';
import { ALL_ITEMS, type FooterItemId } from '../../config/footerItems.js'; import {
ALL_ITEMS,
type FooterItemId,
deriveItemsFromLegacySettings,
} from '../../config/footerItems.js';
interface CwdIndicatorProps { interface CwdIndicatorProps {
targetDir: string; targetDir: string;
terminalWidth: number; maxWidth: number;
debugMode?: boolean; debugMode?: boolean;
debugMessage?: string; debugMessage?: string;
color?: string; color?: string;
@@ -41,21 +42,19 @@ interface CwdIndicatorProps {
const CwdIndicator: React.FC<CwdIndicatorProps> = ({ const CwdIndicator: React.FC<CwdIndicatorProps> = ({
targetDir, targetDir,
terminalWidth, maxWidth,
debugMode, debugMode,
debugMessage, debugMessage,
color = theme.text.primary, color = theme.text.primary,
}) => { }) => {
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25)); const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength); const availableForPath = Math.max(10, maxWidth - debugSuffix.length);
const displayPath = shortenPath(tildeifyPath(targetDir), availableForPath);
return ( return (
<Text color={color}> <Text color={color}>
{displayPath} {displayPath}
{debugMode && ( {debugMode && <Text color={theme.status.error}>{debugSuffix}</Text>}
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
</Text>
)}
</Text> </Text>
); );
}; };
@@ -63,13 +62,15 @@ const CwdIndicator: React.FC<CwdIndicatorProps> = ({
interface BranchIndicatorProps { interface BranchIndicatorProps {
branchName: string; branchName: string;
showParentheses?: boolean; showParentheses?: boolean;
color?: string;
} }
const BranchIndicator: React.FC<BranchIndicatorProps> = ({ const BranchIndicator: React.FC<BranchIndicatorProps> = ({
branchName, branchName,
showParentheses = true, showParentheses = true,
color = theme.text.primary,
}) => ( }) => (
<Text color={theme.text.secondary}> <Text color={color}>
{showParentheses ? `(${branchName}*)` : `${branchName}*`} {showParentheses ? `(${branchName}*)` : `${branchName}*`}
</Text> </Text>
); );
@@ -100,7 +101,7 @@ const SandboxIndicator: React.FC<SandboxIndicatorProps> = ({
return ( return (
<Text color={theme.status.warning}> <Text color={theme.status.warning}>
macOS Seatbelt{' '} macOS Seatbelt{' '}
<Text color={theme.text.secondary}> <Text color={theme.ui.comment}>
({process.env['SEATBELT_PROFILE']}) ({process.env['SEATBELT_PROFILE']})
</Text> </Text>
</Text> </Text>
@@ -141,6 +142,14 @@ function isFooterItemId(id: string): id is FooterItemId {
return ALL_ITEMS.some((i) => i.id === id); return ALL_ITEMS.some((i) => i.id === id);
} }
interface FooterColumn {
id: string;
header: string;
element: (maxWidth: number) => React.ReactNode;
width: number;
isHighPriority: boolean;
}
export const Footer: React.FC = () => { export const Footer: React.FC = () => {
const uiState = useUIState(); const uiState = useUIState();
const config = useConfig(); const config = useConfig();
@@ -176,264 +185,213 @@ export const Footer: React.FC = () => {
}; };
const displayVimMode = vimEnabled ? vimMode : undefined; const displayVimMode = vimEnabled ? vimMode : undefined;
const items =
settings.merged.ui.footer.items ??
deriveItemsFromLegacySettings(settings.merged);
const showLabels = settings.merged.ui.footer.showLabels !== false;
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
const hasCustomItems = settings.merged.ui.footer.items != null; const potentialColumns: FooterColumn[] = [];
if (!hasCustomItems) { const addCol = (
const showMemoryUsage = id: string,
config.getDebugMode() || settings.merged.ui.showMemoryUsage; header: string,
const hideCWD = settings.merged.ui.footer.hideCWD; element: (maxWidth: number) => React.ReactNode,
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus; dataWidth: number,
const hideModelInfo = settings.merged.ui.footer.hideModelInfo; isHighPriority = false,
const hideContextPercentage = ) => {
settings.merged.ui.footer.hideContextPercentage; potentialColumns.push({
id,
const justifyContent = header: showLabels ? header : '',
hideCWD && hideModelInfo ? 'center' : 'space-between'; element,
width: Math.max(dataWidth, showLabels ? header.length : 0),
const showDebugProfiler = debugMode || isDevelopment; isHighPriority,
});
return (
<Box
justifyContent={justifyContent}
width={terminalWidth}
flexDirection="row"
alignItems="center"
paddingX={1}
>
{(showDebugProfiler || displayVimMode || !hideCWD) && (
<Box>
{showDebugProfiler && <DebugProfiler />}
{displayVimMode && (
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
)}
{!hideCWD && (
<Box flexDirection="row">
<CwdIndicator
targetDir={targetDir}
terminalWidth={terminalWidth}
debugMode={debugMode}
debugMessage={debugMessage}
/>
{branchName && (
<>
<Text> </Text>
<BranchIndicator branchName={branchName} />
</>
)}
</Box>
)}
</Box>
)}
{/* Middle Section: Centered Trust/Sandbox Info */}
{!hideSandboxStatus && (
<Box
flexGrow={1}
alignItems="center"
justifyContent="center"
display="flex"
>
<SandboxIndicator
isTrustedFolder={isTrustedFolder}
terminalWidth={terminalWidth}
showDocsHint={true}
/>
</Box>
)}
{/* Right Section: Gemini Label and Console Summary */}
{!hideModelInfo && (
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.primary}>
<Text color={theme.text.secondary}>/model </Text>
{getDisplayString(model)}
{!hideContextPercentage && (
<>
{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={terminalWidth}
/>
</>
)}
{quotaStats && (
<>
{' '}
<QuotaDisplay
remaining={quotaStats.remaining}
limit={quotaStats.limit}
resetTime={quotaStats.resetTime}
terse={true}
/>
</>
)}
</Text>
{showMemoryUsage && (
<>
<Text color={theme.text.secondary}> | </Text>
<MemoryUsageDisplay />
</>
)}
</Box>
<Box alignItems="center">
{corgiMode && (
<Box paddingLeft={1} flexDirection="row">
<Text color={theme.ui.symbol}>| </Text>
<CorgiIndicator />
</Box>
)}
{!showErrorDetails && errorCount > 0 && (
<Box paddingLeft={1} flexDirection="row">
<Text color={theme.ui.comment}>| </Text>
<ErrorIndicator errorCount={errorCount} />
</Box>
)}
</Box>
</Box>
)}
</Box>
);
}
// Items-based rendering path
const items = settings.merged.ui.footer.items ?? [];
const elements: React.ReactNode[] = [];
const addElement = (id: string, element: React.ReactNode) => {
if (elements.length > 0) {
elements.push(
<Text key={`sep-${id}`} color={theme.text.secondary}>
{' | '}
</Text>,
);
}
elements.push(<Box key={id}>{element}</Box>);
}; };
// Prepend Vim mode if enabled // 1. System Indicators (Far Left, high priority)
if (uiState.showDebugProfiler) {
addCol('debug', '', () => <DebugProfiler />, 45, true);
}
if (displayVimMode) { if (displayVimMode) {
elements.push( const vimStr = `[${displayVimMode}]`;
<Box key="vim-mode-static"> addCol(
<Text color={theme.text.secondary}>[{displayVimMode}] </Text> 'vim',
</Box>, '',
() => <Text color={theme.text.accent}>{vimStr}</Text>,
vimStr.length,
true,
); );
} }
// 2. Main Configurable Items
for (const id of items) { for (const id of items) {
if (!isFooterItemId(id)) { if (!isFooterItemId(id)) continue;
continue; const itemConfig = ALL_ITEMS.find((i) => i.id === id);
} const header = itemConfig?.header ?? id;
switch (id) { switch (id) {
case 'cwd': { case 'cwd': {
addElement( const fullPath = tildeifyPath(targetDir);
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
addCol(
id, id,
<CwdIndicator header,
targetDir={targetDir} (maxWidth) => (
terminalWidth={terminalWidth} <CwdIndicator
debugMode={debugMode} targetDir={targetDir}
debugMessage={debugMessage} maxWidth={maxWidth}
color={theme.text.secondary} debugMode={debugMode}
/>, debugMessage={debugMessage}
color={itemColor}
/>
),
fullPath.length + debugSuffix.length,
); );
break; break;
} }
case 'git-branch': { case 'git-branch': {
if (branchName) { if (branchName) {
addElement( const str = `${branchName}*`;
addCol(
id, id,
<BranchIndicator branchName={branchName} showParentheses={false} />, header,
() => (
<BranchIndicator
branchName={branchName}
showParentheses={false}
color={itemColor}
/>
),
str.length,
); );
} }
break; break;
} }
case 'sandbox-status': { case 'sandbox-status': {
addElement( let str = 'no sandbox';
const sandbox = process.env['SANDBOX'];
if (isTrustedFolder === false) str = 'untrusted';
else if (sandbox === 'sandbox-exec')
str = `macOS Seatbelt (${process.env['SEATBELT_PROFILE']})`;
else if (sandbox) str = sandbox.replace(/^gemini-(?:cli-)?/, '');
addCol(
id, id,
<SandboxIndicator header,
isTrustedFolder={isTrustedFolder} () => (
terminalWidth={terminalWidth} <SandboxIndicator
/>, isTrustedFolder={isTrustedFolder}
terminalWidth={terminalWidth}
/>
),
str.length,
); );
break; break;
} }
case 'model-name': { case 'model-name': {
addElement( const str = getDisplayString(model);
addCol(
id, id,
<Text color={theme.text.secondary}>{getDisplayString(model)}</Text>, header,
() => <Text color={itemColor}>{str}</Text>,
str.length,
); );
break; break;
} }
case 'context-remaining': { case 'context-remaining': {
addElement( if (!settings.merged.ui.footer.hideContextPercentage) {
id, addCol(
<ContextUsageDisplay id,
promptTokenCount={promptTokenCount} header,
model={model} () => (
terminalWidth={terminalWidth} <ContextUsageDisplay
/>, promptTokenCount={promptTokenCount}
); model={model}
terminalWidth={terminalWidth}
color={itemColor}
/>
),
10, // "100% left" is 9 chars
);
}
break; break;
} }
case 'quota': { case 'quota': {
if ( if (quotaStats?.remaining !== undefined && quotaStats.limit) {
quotaStats &&
quotaStats.remaining !== undefined &&
quotaStats.limit
) {
const percentage = (quotaStats.remaining / quotaStats.limit) * 100; const percentage = (quotaStats.remaining / quotaStats.limit) * 100;
const color = getStatusColor(percentage, { let color = itemColor;
green: QUOTA_THRESHOLD_HIGH, if (percentage < QUOTA_THRESHOLD_MEDIUM) {
yellow: QUOTA_THRESHOLD_MEDIUM, color = theme.status.error;
}); } else if (percentage < QUOTA_THRESHOLD_HIGH) {
color = theme.status.warning;
}
const text = const text =
quotaStats.remaining === 0 quotaStats.remaining === 0
? 'limit reached' ? 'limit reached'
: `daily ${percentage.toFixed(0)}%`; : `daily ${percentage.toFixed(0)}%`;
addElement(id, <Text color={color}>{text}</Text>); addCol(
id,
header,
() => <Text color={color}>{text}</Text>,
text.length,
);
} }
break; break;
} }
case 'memory-usage': { case 'memory-usage': {
addElement(id, <MemoryUsageDisplay />); addCol(id, header, () => <MemoryUsageDisplay color={itemColor} />, 10);
break; break;
} }
case 'session-id': { case 'session-id': {
const idShort = uiState.sessionStats.sessionId.slice(0, 8); addCol(
addElement(id, <Text color={theme.text.secondary}>{idShort}</Text>); id,
header,
() => (
<Text color={itemColor}>
{uiState.sessionStats.sessionId.slice(0, 8)}
</Text>
),
8,
);
break; break;
} }
case 'code-changes': { case 'code-changes': {
const added = uiState.sessionStats.metrics.files.totalLinesAdded; const added = uiState.sessionStats.metrics.files.totalLinesAdded;
const removed = uiState.sessionStats.metrics.files.totalLinesRemoved; const removed = uiState.sessionStats.metrics.files.totalLinesRemoved;
if (added > 0 || removed > 0) { if (added > 0 || removed > 0) {
addElement( const str = `+${added} -${removed}`;
addCol(
id, id,
<Text> header,
<Text color={theme.status.success}>+{added}</Text>{' '} () => (
<Text color={theme.status.error}>-{removed}</Text> <Text>
</Text>, <Text color={theme.status.success}>+{added}</Text>{' '}
<Text color={theme.status.error}>-{removed}</Text>
</Text>
),
str.length,
); );
} }
break; break;
} }
case 'token-count': { case 'token-count': {
let totalTokens = 0; let total = 0;
for (const m of Object.values(uiState.sessionStats.metrics.models)) { for (const m of Object.values(uiState.sessionStats.metrics.models))
totalTokens += m.tokens.total; total += m.tokens.total;
} if (total > 0) {
if (totalTokens > 0) { const formatted =
const formatter = new Intl.NumberFormat('en-US', { new Intl.NumberFormat('en-US', {
notation: 'compact', notation: 'compact',
maximumFractionDigits: 1, maximumFractionDigits: 1,
}); })
const formatted = formatter.format(totalTokens).toLowerCase(); .format(total)
addElement( .toLowerCase() + ' tokens';
addCol(
id, id,
<Text color={theme.text.secondary}>{formatted} tokens</Text>, header,
() => <Text color={itemColor}>{formatted}</Text>,
formatted.length,
); );
} }
break; break;
@@ -444,14 +402,88 @@ export const Footer: React.FC = () => {
} }
} }
if (corgiMode) { // 3. Transients
addElement('corgi-transient', <CorgiIndicator />); if (corgiMode) addCol('corgi', '', () => <CorgiIndicator />, 5);
if (!showErrorDetails && errorCount > 0) {
addCol(
'error-count',
'',
() => <ErrorIndicator errorCount={errorCount} />,
12,
true,
);
} }
if (!showErrorDetails && errorCount > 0) { // --- Width Fitting Logic ---
addElement( const COLUMN_GAP = 3;
'error-count-transient', let currentWidth = 2; // Initial padding
<ErrorIndicator errorCount={errorCount} />, const columnsToRender: FooterColumn[] = [];
let droppedAny = false;
for (let i = 0; i < potentialColumns.length; i++) {
const col = potentialColumns[i];
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0; // Use 3 for dot separator width
const budgetWidth = col.id === 'cwd' ? 20 : col.width;
if (
col.isHighPriority ||
currentWidth + gap + budgetWidth <= terminalWidth - 2
) {
columnsToRender.push(col);
currentWidth += gap + budgetWidth;
} else {
droppedAny = true;
}
}
const totalBudgeted = columnsToRender.reduce(
(sum, c, idx) =>
sum +
(c.id === 'cwd' ? 20 : c.width) +
(idx > 0 ? (showLabels ? COLUMN_GAP : 3) : 0),
2,
);
const excessSpace = Math.max(0, terminalWidth - totalBudgeted);
const finalElements: React.ReactNode[] = [];
columnsToRender.forEach((col, idx) => {
if (idx > 0 && !showLabels) {
finalElements.push(
<Box key={`sep-${col.id}`} height={1}>
<Text color={theme.ui.comment}> · </Text>
</Box>,
);
}
const maxWidth = col.id === 'cwd' ? 20 + excessSpace : col.width;
finalElements.push(
<Box key={col.id} flexDirection="column">
{showLabels && (
<Box height={1}>
<Text color={theme.ui.comment}>{col.header}</Text>
</Box>
)}
<Box height={1}>{col.element(maxWidth)}</Box>
</Box>,
);
});
if (droppedAny) {
if (!showLabels) {
finalElements.push(
<Box key="sep-ellipsis" height={1}>
<Text color={theme.ui.comment}> · </Text>
</Box>,
);
}
finalElements.push(
<Box key="ellipsis" flexDirection="column">
{showLabels && <Box height={1} />}
<Box height={1}>
<Text color={theme.ui.comment}></Text>
</Box>
</Box>,
); );
} }
@@ -459,12 +491,12 @@ export const Footer: React.FC = () => {
<Box <Box
width={terminalWidth} width={terminalWidth}
flexDirection="row" flexDirection="row"
alignItems="center"
paddingX={1} paddingX={1}
flexWrap="nowrap"
overflow="hidden" overflow="hidden"
flexWrap="nowrap"
columnGap={showLabels ? COLUMN_GAP : 0}
> >
{elements} {finalElements}
</Box> </Box>
); );
}; };
@@ -83,8 +83,10 @@ describe('<FooterConfigDialog />', () => {
// Initial order: cwd, git-branch, ... // Initial order: cwd, git-branch, ...
const output = lastFrame(); const output = lastFrame();
const cwdIdx = output!.indexOf('cwd'); const cwdIdx = output!.indexOf('] cwd');
const branchIdx = output!.indexOf('git-branch'); const branchIdx = output!.indexOf('] git-branch');
expect(cwdIdx).toBeGreaterThan(-1);
expect(branchIdx).toBeGreaterThan(-1);
expect(cwdIdx).toBeLessThan(branchIdx); expect(cwdIdx).toBeLessThan(branchIdx);
// Move cwd down (right arrow) // Move cwd down (right arrow)
@@ -94,8 +96,10 @@ describe('<FooterConfigDialog />', () => {
await waitFor(() => { await waitFor(() => {
const outputAfter = lastFrame(); const outputAfter = lastFrame();
const cwdIdxAfter = outputAfter!.indexOf('cwd'); const cwdIdxAfter = outputAfter!.indexOf('] cwd');
const branchIdxAfter = outputAfter!.indexOf('git-branch'); const branchIdxAfter = outputAfter!.indexOf('] git-branch');
expect(cwdIdxAfter).toBeGreaterThan(-1);
expect(branchIdxAfter).toBeGreaterThan(-1);
expect(branchIdxAfter).toBeLessThan(cwdIdxAfter); expect(branchIdxAfter).toBeLessThan(cwdIdxAfter);
}); });
}); });
@@ -142,7 +146,7 @@ describe('<FooterConfigDialog />', () => {
{ settings }, { settings },
); );
for (let i = 0; i < 5; i++) { for (let i = 0; i < 10; i++) {
act(() => { act(() => {
stdin.write('\r'); // Toggle (deselect) stdin.write('\r'); // Toggle (deselect)
stdin.write('\u001b[B'); // Down arrow stdin.write('\u001b[B'); // Down arrow
@@ -13,6 +13,7 @@ import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js'; import { keyMatchers, Command } from '../keyMatchers.js';
import { TextInput } from './shared/TextInput.js'; import { TextInput } from './shared/TextInput.js';
import { useFuzzyList } from '../hooks/useFuzzyList.js'; import { useFuzzyList } from '../hooks/useFuzzyList.js';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { import {
ALL_ITEMS, ALL_ITEMS,
DEFAULT_ORDER, DEFAULT_ORDER,
@@ -55,7 +56,7 @@ function footerConfigReducer(
switch (action.type) { switch (action.type) {
case 'MOVE_UP': { case 'MOVE_UP': {
const { filteredCount, maxToShow } = action; const { filteredCount, maxToShow } = action;
const totalSlots = filteredCount + 1; const totalSlots = filteredCount + 2; // +1 for showLabels, +1 for reset
const newIndex = const newIndex =
state.activeIndex > 0 ? state.activeIndex - 1 : totalSlots - 1; state.activeIndex > 0 ? state.activeIndex - 1 : totalSlots - 1;
let newOffset = state.scrollOffset; let newOffset = state.scrollOffset;
@@ -71,7 +72,7 @@ function footerConfigReducer(
} }
case 'MOVE_DOWN': { case 'MOVE_DOWN': {
const { filteredCount, maxToShow } = action; const { filteredCount, maxToShow } = action;
const totalSlots = filteredCount + 1; const totalSlots = filteredCount + 2;
const newIndex = const newIndex =
state.activeIndex < totalSlots - 1 ? state.activeIndex + 1 : 0; state.activeIndex < totalSlots - 1 ? state.activeIndex + 1 : 0;
let newOffset = state.scrollOffset; let newOffset = state.scrollOffset;
@@ -108,8 +109,8 @@ function footerConfigReducer(
return { ...state, orderedIds: newOrderedIds, activeIndex: newIndex }; return { ...state, orderedIds: newOrderedIds, activeIndex: newIndex };
} }
case 'TOGGLE_ITEM': { case 'TOGGLE_ITEM': {
const isResetFocused = state.activeIndex === action.filteredItems.length; const isSystemFocused = state.activeIndex >= action.filteredItems.length;
if (isResetFocused) return state; // Handled by separate effect/callback if needed, or we can add a RESET_DEFAULTS action if (isSystemFocused) return state;
const item = action.filteredItems[state.activeIndex]; const item = action.filteredItems[state.activeIndex];
if (!item) return state; if (!item) return state;
@@ -209,7 +210,8 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
dispatch({ type: 'RESET_INDEX' }); dispatch({ type: 'RESET_INDEX' });
}, [searchQuery]); }, [searchQuery]);
const isResetFocused = activeIndex === filteredItems.length; const isResetFocused = activeIndex === filteredItems.length + 1;
const isShowLabelsFocused = activeIndex === filteredItems.length;
const handleResetToDefaults = useCallback(() => { const handleResetToDefaults = useCallback(() => {
setSetting(SettingScope.User, 'ui.footer.items', undefined); setSetting(SettingScope.User, 'ui.footer.items', undefined);
@@ -231,6 +233,11 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
}); });
}, [setSetting, settings.merged]); }, [setSetting, settings.merged]);
const handleToggleLabels = useCallback(() => {
const current = settings.merged.ui.footer.showLabels !== false;
setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
}, [setSetting, settings.merged.ui.footer.showLabels]);
useKeypress( useKeypress(
(key: Key) => { (key: Key) => {
if (keyMatchers[Command.ESCAPE](key)) { if (keyMatchers[Command.ESCAPE](key)) {
@@ -269,6 +276,8 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
if (keyMatchers[Command.RETURN](key)) { if (keyMatchers[Command.RETURN](key)) {
if (isResetFocused) { if (isResetFocused) {
handleResetToDefaults(); handleResetToDefaults();
} else if (isShowLabelsFocused) {
handleToggleLabels();
} else { } else {
dispatch({ type: 'TOGGLE_ITEM', filteredItems }); dispatch({ type: 'TOGGLE_ITEM', filteredItems });
} }
@@ -286,12 +295,13 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
); );
const activeId = filteredItems[activeIndex]?.key; const activeId = filteredItems[activeIndex]?.key;
const showLabels = settings.merged.ui.footer.showLabels !== false;
// Preview logic // Preview logic
const previewText = useMemo(() => { const previewContent = useMemo(() => {
if (isResetFocused) { if (isResetFocused) {
return ( return (
<Text color={theme.text.secondary} italic> <Text color={theme.ui.comment} italic>
Default footer (uses legacy settings) Default footer (uses legacy settings)
</Text> </Text>
); );
@@ -302,53 +312,103 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
); );
if (itemsToPreview.length === 0) return null; if (itemsToPreview.length === 0) return null;
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
const getColor = (id: string, defaultColor?: string) => const getColor = (id: string, defaultColor?: string) =>
id === activeId ? 'white' : defaultColor || theme.text.secondary; id === activeId ? 'white' : defaultColor || itemColor;
// Mock values for preview // Mock values for preview
const mockValues: Record<string, React.ReactNode> = { const mockValues: Record<
cwd: <Text color={getColor('cwd')}>~/project/path</Text>, string,
'git-branch': <Text color={getColor('git-branch')}>main*</Text>, { header: string; data: React.ReactNode }
'sandbox-status': ( > = {
<Text color={getColor('sandbox-status', 'green')}>docker</Text> cwd: {
), header: 'Path',
'model-name': ( data: <Text color={getColor('cwd', itemColor)}>~/project/path</Text>,
<Box flexDirection="row"> },
<Text color={getColor('model-name')}>gemini-2.5-pro</Text> 'git-branch': {
</Box> header: 'Branch',
), data: <Text color={getColor('git-branch', itemColor)}>main*</Text>,
'context-remaining': ( },
<Text color={getColor('context-remaining')}>85% context left</Text> 'sandbox-status': {
), header: '/docs',
quota: <Text color={getColor('quota')}>daily 97%</Text>, data: <Text color={getColor('sandbox-status', 'green')}>docker</Text>,
'memory-usage': <Text color={getColor('memory-usage')}>124MB</Text>, },
'session-id': <Text color={getColor('session-id')}>769992f9</Text>, 'model-name': {
'code-changes': ( header: '/model',
<Box flexDirection="row"> data: (
<Text color={getColor('code-changes', theme.status.success)}> <Text color={getColor('model-name', itemColor)}>gemini-2.5-pro</Text>
+12 ),
</Text> },
<Text color={getColor('code-changes')}> </Text> 'context-remaining': {
<Text color={getColor('code-changes', theme.status.error)}>-4</Text> header: 'Context',
</Box> data: (
), <Text color={getColor('context-remaining', itemColor)}>85% left</Text>
'token-count': <Text color={getColor('token-count')}>1.5k tokens</Text>, ),
},
quota: {
header: '/stats',
data: <Text color={getColor('quota', itemColor)}>daily 97%</Text>,
},
'memory-usage': {
header: 'Memory',
data: <MemoryUsageDisplay color={itemColor} />,
},
'session-id': {
header: 'Session',
data: <Text color={getColor('session-id', itemColor)}>769992f9</Text>,
},
'code-changes': {
header: 'Diff',
data: (
<Box flexDirection="row">
<Text color={getColor('code-changes', theme.status.success)}>
+12
</Text>
<Text color={getColor('code-changes')}> </Text>
<Text color={getColor('code-changes', theme.status.error)}>-4</Text>
</Box>
),
},
'token-count': {
header: 'Tokens',
data: (
<Text color={getColor('token-count', itemColor)}>1.5k tokens</Text>
),
},
}; };
const elements: React.ReactNode[] = []; const previewElements: React.ReactNode[] = [];
itemsToPreview.forEach((id: string, idx: number) => { itemsToPreview.forEach((id: string, idx: number) => {
if (idx > 0) { const mock = mockValues[id];
elements.push( if (!mock) return;
<Text key={`sep-${id}`} color={theme.text.secondary}>
{' | '} if (idx > 0 && !showLabels) {
</Text>, previewElements.push(
<Box key={`sep-${id}`} height={1}>
<Text color={theme.ui.comment}> · </Text>
</Box>,
); );
} }
elements.push(<Box key={id}>{mockValues[id] || id}</Box>);
previewElements.push(
<Box key={id} flexDirection="column">
{showLabels && (
<Box height={1}>
<Text color={theme.ui.comment}>{mock.header}</Text>
</Box>
)}
<Box height={1}>{mock.data}</Box>
</Box>,
);
}); });
return elements; return (
}, [orderedIds, selectedIds, activeId, isResetFocused]); <Box flexDirection="row" flexWrap="nowrap" columnGap={showLabels ? 3 : 0}>
{previewElements}
</Box>
);
}, [orderedIds, selectedIds, activeId, isResetFocused, showLabels]);
return ( return (
<Box <Box
@@ -403,13 +463,25 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
)} )}
</Box> </Box>
<Box marginTop={1}> <Box marginTop={1} flexDirection="column">
<Text <Box flexDirection="row">
color={isResetFocused ? theme.status.warning : theme.text.secondary} <Text color={isShowLabelsFocused ? theme.status.success : undefined}>
> {isShowLabelsFocused ? '> ' : ' '}
{isResetFocused ? '> ' : ' '} </Text>
Reset to default footer <Text color={isShowLabelsFocused ? theme.status.success : undefined}>
</Text> [{showLabels ? '✓' : ' '}] Show footer labels
</Text>
</Box>
<Box flexDirection="row">
<Text color={isResetFocused ? theme.status.warning : undefined}>
{isResetFocused ? '> ' : ' '}
</Text>
<Text
color={isResetFocused ? theme.status.warning : theme.text.secondary}
>
Reset to default footer
</Text>
</Box>
</Box> </Box>
<Box marginTop={1} flexDirection="column"> <Box marginTop={1} flexDirection="column">
@@ -431,7 +503,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
flexDirection="column" flexDirection="column"
> >
<Text bold>Preview:</Text> <Text bold>Preview:</Text>
<Box flexDirection="row">{previewText}</Box> <Box flexDirection="row">{previewContent}</Box>
</Box> </Box>
</Box> </Box>
); );
@@ -11,26 +11,24 @@ import { theme } from '../semantic-colors.js';
import process from 'node:process'; import process from 'node:process';
import { formatBytes } from '../utils/formatters.js'; import { formatBytes } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC = () => { export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
color = theme.text.primary,
}) => {
const [memoryUsage, setMemoryUsage] = useState<string>(''); const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>( const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
theme.text.secondary,
);
useEffect(() => { useEffect(() => {
const updateMemory = () => { const updateMemory = () => {
const usage = process.memoryUsage().rss; const usage = process.memoryUsage().rss;
setMemoryUsage(formatBytes(usage)); setMemoryUsage(formatBytes(usage));
setMemoryUsageColor( setMemoryUsageColor(
usage >= 2 * 1024 * 1024 * 1024 usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
? theme.status.error
: theme.text.secondary,
); );
}; };
const intervalId = setInterval(updateMemory, 2000); const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update updateMemory(); // Initial update
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, []); }, [color]);
return ( return (
<Box> <Box>
@@ -1,38 +1,38 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = ` exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro Limit reached " Path /docs /model /stats
" /Users/test/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached"
`; `;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = ` exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15% " Path /docs /model /stats
" /Users/test/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro daily 15%"
`; `;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = ` exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
" ...s/to/make/it/long no sandbox /model gemini-pro 100% " Path /docs /model Context
" /Users/.../directories/to/make/it/long no sandbox gemini-pro 100%"
`; `;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = ` exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left " Path /docs /model Context
" /Users/test/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 100% left"
`; `;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = ` exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `
" no sandbox (see /docs) " /docs
" no sandbox"
`; `;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`; exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = ` exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) " Path /docs
" /Users/test/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox"
`; `;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = ` exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro " Path /docs /model /stats
" /Users/test/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro daily 85%"
`; `;
@@ -22,13 +22,15 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
│ [ ] code-changes Lines added/removed in the session │ │ [ ] code-changes Lines added/removed in the session │
│ [ ] token-count Total tokens used in the session │ │ [ ] token-count Total tokens used in the session │
│ │ │ │
│ [✓] Show footer labels │
│ Reset to default footer │ │ Reset to default footer │
│ │ │ │
│ ↑/↓ navigate · ←/→ reorder · enter select · esc close │ │ ↑/↓ navigate · ←/→ reorder · enter select · esc close │
│ │ │ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ │ ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │ │ │ Preview: │ │
│ │ ~/project/path | main* | docker | gemini-2.5-pro | daily 97% │ │ │ │ Path Branch /docs /model /stats │ │
│ │ ~/project/path main* docker gemini-2.5-pro daily 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"