diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index 4a9b17c49..42029bf1a 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -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 }));
@@ -99,4 +100,82 @@ describe('', () => {
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]> = [
+ ['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(
+ true) }) as never}
+ >
+
+
+
+
+
+ ,
+ );
+ 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),
+ );
+ expect(lastFrame()).not.toMatch(/% context used/);
+ });
+ });
});
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index bafaa7db6..4d7089c97 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -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();
@@ -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' ? (
- ● Recording… (ctrl+space to stop)
- ) : uiState.voiceState === 'transcribing' ? (
- ◌ Transcribing…
- ) : uiState.ctrlCPressedOnce ? (
-
- {t('Press Ctrl+C again to exit.')}
-
- ) : uiState.ctrlDPressedOnce ? (
-
- {t('Press Ctrl+D again to exit.')}
-
- ) : uiState.showEscapePrompt ? (
- {t('Press Esc again to clear.')}
- ) : vimEnabled && vimMode === 'INSERT' ? (
- -- INSERT --
- ) : uiState.shellModeActive ? (
-
- ) : showAutoAcceptIndicator !== undefined &&
- showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
-
- ) : (
- // Default mode: the autonomy chip stays visible (so the current mode is
- // always shown) next to the shortcut hints.
-
-
+ // 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: (
+
+ ● Recording… (ctrl+space to stop)
+
+ ),
+ },
+ uiState.voiceState === 'transcribing' && {
+ key: 'voice-transcribing',
+ node: ◌ Transcribing…,
+ },
+ uiState.ctrlCPressedOnce && {
+ key: 'ctrl-c',
+ node: (
+
+ {t('Press Ctrl+C again to exit.')}
+
+ ),
+ },
+ uiState.ctrlDPressedOnce && {
+ key: 'ctrl-d',
+ node: (
+
+ {t('Press Ctrl+D again to exit.')}
+
+ ),
+ },
+ uiState.showEscapePrompt && {
+ key: 'escape-prompt',
+ node: (
- {' · '}
- {t('? for shortcuts')}
- {' · '}
- {t('Esc×2: rewind')}
+ {t('Press Esc again to clear.')}
-
- );
+ ),
+ },
+ vimEnabled &&
+ vimMode === 'INSERT' && {
+ key: 'vim-insert',
+ node: -- INSERT --,
+ },
+ uiState.shellModeActive && {
+ key: 'shell-mode',
+ node: ,
+ },
+ showAutoAcceptIndicator !== undefined &&
+ showAutoAcceptIndicator !== ApprovalMode.DEFAULT && {
+ key: 'approval-mode',
+ node: ,
+ },
+ {
+ // Default mode: the autonomy chip stays visible (so the current mode is
+ // always shown) next to the shortcut hints.
+ key: 'default-hint',
+ node: (
+
+
+
+ {' · '}
+ {t('? for shortcuts')}
+ {' · '}
+ {t('Esc×2: rewind')}
+
+
+ ),
+ },
+ );
- const rightItems: Array<{ key: string; node: React.ReactNode }> = [
+ const rightTokens = tokens(
{ key: 'voice', node: },
- ];
- if (sandboxInfo) {
- rightItems.push({
+ sandboxInfo && {
key: 'sandbox',
node: 🔒 {sandboxInfo},
- });
- }
- if (debugMode) {
- rightItems.push({
+ },
+ debugMode && {
key: 'debug',
node: Debug Mode,
- });
- }
- if (promptTokenCount > 0 && contextWindowSize) {
- rightItems.push({
- key: 'context',
- node: (
-
-
-
- ),
- });
- }
- 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: (
+
+
+
+ ),
+ },
+ 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: ,
- });
- }
+ },
+ );
+
return (
{
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
- {leftContent}
+ {leftToken?.node}
- {/* Right Section: Sandbox Info, Debug Mode, Context Usage, and Console Summary */}
+ {/* Right Section: an ordered token array — voice · sandbox · debug · context · goal */}
- {rightItems.map(({ key, node }, index) => (
-
- {index > 0 && | }
- {node}
-
- ))}
+ | }
+ />
);
diff --git a/packages/cli/src/ui/components/StatusBar.test.tsx b/packages/cli/src/ui/components/StatusBar.test.tsx
index bcc3dfd5d..7ed30c3bb 100644
--- a/packages/cli/src/ui/components/StatusBar.test.tsx
+++ b/packages/cli/src/ui/components/StatusBar.test.tsx
@@ -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();
+ return {
+ ...actual,
+ default: { ...actual, hostname: () => 'test-host' },
+ hostname: () => 'test-host',
+ };
+});
+
vi.mock('../hooks/useGitBranchName.js', () => ({
useGitBranchName: vi.fn(() => undefined),
}));
@@ -97,4 +108,34 @@ describe('', () => {
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');
+ });
+ });
});
diff --git a/packages/cli/src/ui/components/StatusBar.tsx b/packages/cli/src/ui/components/StatusBar.tsx
index 24b63cbd1..157c08b80 100644
--- a/packages/cli/src/ui/components/StatusBar.tsx
+++ b/packages/cli/src/ui/components/StatusBar.tsx
@@ -11,6 +11,8 @@ import os from 'node:os';
import { useGitBranchName } from '../hooks/useGitBranchName.js';
import { useGitDiffStat } from '../hooks/useGitDiffStat.js';
import { theme } from '../semantic-colors.js';
+import { TokenBar } from './status/TokenBar.js';
+import { tokens } from './status/types.js';
// ─── Badge ────────────────────────────────────────────────────────────────────
@@ -74,6 +76,71 @@ export const StatusBar = ({
// Only render the diff badge when there are actual changes.
const hasDiff = diff !== null && diff.filesChanged > 0;
+ // Ordered token array — owns the ⟡-to-branch separator chain.
+ // Active background agents render as cards in BackgroundAgentsPanel (above
+ // the status bar), not as badges crammed in here.
+ const barTokens = tokens(
+ {
+ key: 'logo',
+ node: (
+
+ ⟡
+
+ ),
+ },
+ {
+ key: 'hostname',
+ node: (
+
+ ⬡
+ {os.hostname()}
+
+ ),
+ },
+ bgSessionActive && {
+ key: 'bg-session',
+ node: (
+
+ ⟳ bg session
+
+ ),
+ },
+ hasDiff && {
+ key: 'diff',
+ node: (
+
+
+ {diff.filesChanged} file{diff.filesChanged !== 1 ? 's' : ''}
+
+ {diff.linesAdded > 0 && (
+ +{diff.linesAdded}
+ )}
+ {diff.linesRemoved > 0 && (
+ −{diff.linesRemoved}
+ )}
+
+ ),
+ },
+ {
+ key: 'cwd',
+ node: (
+
+ ⌂
+ {cwdDisplay}
+
+ ),
+ },
+ branch && {
+ key: 'branch',
+ node: (
+
+ ⎇
+ {branch}
+
+ ),
+ },
+ );
+
return (
- {/* Logo mark */}
-
- ⟡
-
-
-
-
- {/* Hostname */}
-
- ⬡
- {os.hostname()}
-
-
- {/* Active background agents render as cards in BackgroundAgentsPanel
- (above the status bar), not as badges crammed in here. */}
-
- {bgSessionActive && (
- <>
-
-
- ⟳ bg session
-
- >
- )}
-
- {hasDiff && (
- <>
-
-
-
- {diff.filesChanged} file{diff.filesChanged !== 1 ? 's' : ''}
-
- {diff.linesAdded > 0 && (
- +{diff.linesAdded}
- )}
- {diff.linesRemoved > 0 && (
- −{diff.linesRemoved}
- )}
-
- >
- )}
-
-
-
- {/* CWD */}
-
- ⌂
- {cwdDisplay}
-
-
- {/* Git branch */}
- {branch && (
- <>
-
-
- ⎇
- {branch}
-
- >
- )}
+ } />
);
};
diff --git a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
index 32afe1f46..e618a1393 100644
--- a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
@@ -3,3 +3,23 @@
exports[` > footer rendering (golden snapshots) > renders complete footer on narrow terminal > complete-footer-narrow 1`] = `" default · ? for shortcuts · Esc×2: rewind | 0.1% used"`;
exports[` > footer rendering (golden snapshots) > renders complete footer on wide terminal > complete-footer-wide 1`] = `" default · ? for shortcuts · Esc×2: rewind | 0.1% context used"`;
+
+exports[` > left slot priority chain (golden snapshots) > renders left slot: auto-accept-mode > left-auto-accept-mode 1`] = `" auto-accept edits (shift + tab to cycle) | 0.1% context used"`;
+
+exports[` > left slot priority chain (golden snapshots) > renders left slot: ctrl-c-pressed-once > left-ctrl-c-pressed-once 1`] = `" Press Ctrl+C again to exit. | 0.1% context used"`;
+
+exports[` > left slot priority chain (golden snapshots) > renders left slot: ctrl-d-pressed-once > left-ctrl-d-pressed-once 1`] = `" Press Ctrl+D again to exit. | 0.1% context used"`;
+
+exports[` > left slot priority chain (golden snapshots) > renders left slot: default-mode > left-default-mode 1`] = `" default · ? for shortcuts · Esc×2: rewind | 0.1% context used"`;
+
+exports[` > left slot priority chain (golden snapshots) > renders left slot: escape-prompt > left-escape-prompt 1`] = `" Press Esc again to clear. | 0.1% context used"`;
+
+exports[` > left slot priority chain (golden snapshots) > renders left slot: shell-mode > left-shell-mode 1`] = `" shell mode enabled (esc to disable) | 0.1% context used"`;
+
+exports[` > left slot priority chain (golden snapshots) > renders left slot: voice-recording > left-voice-recording 1`] = `" ● Recording… (ctrl+space to stop) | 0.1% context used"`;
+
+exports[` > left slot priority chain (golden snapshots) > renders left slot: voice-transcribing > left-voice-transcribing 1`] = `" ◌ Transcribing… | 0.1% context used"`;
+
+exports[` > left slot priority chain (golden snapshots) > renders left slot: yolo-mode > left-yolo-mode 1`] = `" YOLO mode (shift + tab to cycle) | 0.1% context used"`;
+
+exports[` > right slot token order (golden snapshots) > renders sandbox + debug + context together > right-sandbox-debug-context 1`] = `" default · ? for shortcuts · Esc×2: rewind | 🔒 seatbelt | Debug Mode | 0.1% context used"`;
diff --git a/packages/cli/src/ui/components/__snapshots__/StatusBar.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusBar.test.tsx.snap
new file mode 100644
index 000000000..2ebfaafed
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/StatusBar.test.tsx.snap
@@ -0,0 +1,16 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > status bar rendering (golden snapshots) > renders a diff with only additions > statusbar-additions-only 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+ ⟡ │ ⬡ test-host │ 1 file +7 │ ⌂ /home/user/project"
+`;
+
+exports[` > status bar rendering (golden snapshots) > renders every token at once > statusbar-all-tokens 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+ ⟡ │ ⬡ test-host │ ⟳ bg session │ 3 files +10 −2 │ ⌂ /home/user/project │ ⎇ main"
+`;
+
+exports[` > status bar rendering (golden snapshots) > renders the minimal bar (no branch, no diff) > statusbar-minimal 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+ ⟡ │ ⬡ test-host │ ⌂ /home/user/project"
+`;
diff --git a/packages/cli/src/ui/components/status/TokenBar.test.tsx b/packages/cli/src/ui/components/status/TokenBar.test.tsx
new file mode 100644
index 000000000..c9ebfc48d
--- /dev/null
+++ b/packages/cli/src/ui/components/status/TokenBar.test.tsx
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright 2025 protoLabs.studio
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from 'ink-testing-library';
+import { describe, it, expect } from 'vitest';
+import { Box, Text } from 'ink';
+import { TokenBar } from './TokenBar.js';
+import { firstToken, tokens, type StatusToken } from './types.js';
+
+const token = (key: string, label = key): StatusToken => ({
+ key,
+ node: {label},
+});
+
+/**
+ * Both real surfaces host the bar inside a row-flex Box; rendering it at the
+ * Ink root instead would stack the tokens in a column.
+ */
+const renderBar = (items: StatusToken[], separator: React.ReactNode) =>
+ render(
+
+
+ ,
+ );
+
+describe('tokens()', () => {
+ it('keeps applicable tokens in declared order', () => {
+ expect(
+ tokens(token('a'), token('b'), token('c')).map((t) => t.key),
+ ).toEqual(['a', 'b', 'c']);
+ });
+
+ it('drops every falsy form a builder can produce', () => {
+ const result = tokens(token('a'), false, null, undefined, token('b'));
+ expect(result.map((t) => t.key)).toEqual(['a', 'b']);
+ });
+
+ it('returns an empty array when nothing applies', () => {
+ expect(tokens(false, null, undefined)).toEqual([]);
+ });
+});
+
+describe('firstToken()', () => {
+ it('returns the first applicable token, honouring declared priority', () => {
+ expect(firstToken(false, token('b'), token('c'))?.key).toBe('b');
+ });
+
+ it('returns null when nothing applies', () => {
+ expect(firstToken(false, null, undefined)).toBeNull();
+ });
+
+ it('does not let a later token outrank an earlier one', () => {
+ expect(firstToken(token('high'), token('low'))?.key).toBe('high');
+ });
+});
+
+describe('', () => {
+ const sep = {' | '};
+
+ it('interleaves separators between tokens', () => {
+ const { lastFrame } = renderBar(
+ tokens(token('a'), token('b'), token('c')),
+ sep,
+ );
+ expect(lastFrame()).toBe('a | b | c');
+ });
+
+ it('draws no separator for a single token', () => {
+ const { lastFrame } = renderBar(tokens(token('solo')), sep);
+ expect(lastFrame()).toBe('solo');
+ });
+
+ it('renders nothing for an empty token array', () => {
+ const { lastFrame } = renderBar([], sep);
+ expect(lastFrame()).toBe('');
+ });
+
+ // The reason this component exists: separators are placed by position in the
+ // rendered array, so an omitted token cannot strand a separator with nothing
+ // on its left. Placing them by source-order index is what breaks here.
+ it('leaves no dangling separator when a leading token is omitted', () => {
+ const { lastFrame } = renderBar(tokens(false, token('b'), token('c')), sep);
+ expect(lastFrame()).toBe('b | c');
+ expect(lastFrame()).not.toMatch(/^\s*\|/);
+ });
+
+ it('leaves no dangling separator when a trailing token is omitted', () => {
+ const { lastFrame } = renderBar(tokens(token('a'), token('b'), false), sep);
+ expect(lastFrame()).toBe('a | b');
+ expect(lastFrame()).not.toMatch(/\|\s*$/);
+ });
+
+ it('collapses separators around an omitted middle token', () => {
+ const { lastFrame } = renderBar(tokens(token('a'), false, token('c')), sep);
+ expect(lastFrame()).toBe('a | c');
+ });
+});
diff --git a/packages/cli/src/ui/components/status/TokenBar.tsx b/packages/cli/src/ui/components/status/TokenBar.tsx
new file mode 100644
index 000000000..96bc62a72
--- /dev/null
+++ b/packages/cli/src/ui/components/status/TokenBar.tsx
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright 2025 protoLabs.studio
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box } from 'ink';
+import type { StatusToken } from './types.js';
+
+interface TokenBarProps {
+ /** Ordered, already-filtered tokens. Build with `tokens(...)`. */
+ tokens: StatusToken[];
+ /** Drawn between adjacent tokens — never before the first or after the last. */
+ separator: React.ReactNode;
+}
+
+/**
+ * Renders an ordered token array, interleaving `separator` between cells.
+ *
+ * The separator is placed by position within the *rendered* array, so a token
+ * that was filtered out upstream cannot leave a dangling separator behind.
+ */
+export const TokenBar: React.FC = ({
+ tokens: items,
+ separator,
+}) => (
+ <>
+ {items.map((token, index) => (
+
+ {index > 0 && separator}
+ {token.node}
+
+ ))}
+ >
+);
diff --git a/packages/cli/src/ui/components/status/types.ts b/packages/cli/src/ui/components/status/types.ts
new file mode 100644
index 000000000..c080d1d22
--- /dev/null
+++ b/packages/cli/src/ui/components/status/types.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright 2025 protoLabs.studio
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+
+/**
+ * One addressable cell in a status surface (the Footer or the StatusBar).
+ *
+ * Tokens are data, not JSX: a surface declares an ordered array of them and
+ * hands it to , which owns separator placement. Adding a pill is
+ * therefore a push into an array rather than another hand-placed .
+ */
+export interface StatusToken {
+ /** Stable identity — used as the React key and to address a token in tests. */
+ key: string;
+ /** The rendered cell. Must render something; use `null` to omit a token. */
+ node: React.ReactNode;
+}
+
+/**
+ * A token, or a falsy value meaning "not applicable right now".
+ *
+ * Letting builders return falsy is what keeps separator placement correct:
+ * omitted tokens never reach , so it can never draw a separator
+ * against a cell that rendered nothing.
+ *
+ * The union covers every falsy scalar a truthy-guard can leave behind, so a
+ * string- or number-guarded builder — `sandboxInfo && {…}`, `branch && {…}` —
+ * type-checks: the empty string and `0` are as valid a "not applicable" as
+ * `false`.
+ */
+export type MaybeToken = StatusToken | false | null | undefined | '' | 0;
+
+/** Drop inapplicable tokens, preserving declared order. */
+export function tokens(...items: MaybeToken[]): StatusToken[] {
+ return items.filter((item): item is StatusToken => Boolean(item));
+}
+
+/**
+ * First applicable token wins.
+ *
+ * Models a slot that shows exactly one thing chosen by priority — the Footer's
+ * left slot, where "recording" outranks "press ctrl+c again" outranks the
+ * default hint. Order in the argument list *is* the priority order.
+ */
+export function firstToken(...items: MaybeToken[]): StatusToken | null {
+ return items.find((item): item is StatusToken => Boolean(item)) ?? null;
+}