diff --git a/Composer.tsx.conflicted b/Composer.tsx.conflicted
new file mode 100644
index 0000000000..ddcb389344
--- /dev/null
+++ b/Composer.tsx.conflicted
@@ -0,0 +1,457 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+<<<<<<< HEAD
+import { Box, useIsScreenReaderEnabled } from 'ink';
+import { useState, useEffect } from 'react';
+import { useConfig } from '../contexts/ConfigContext.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useUIActions } from '../contexts/UIActionsContext.js';
+import { useVimMode } from '../contexts/VimModeContext.js';
+import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
+import { useTerminalSize } from '../hooks/useTerminalSize.js';
+import { isNarrowWidth } from '../utils/isNarrowWidth.js';
+=======
+import { useState, useEffect, useMemo } from 'react';
+import { Box, useIsScreenReaderEnabled } from 'ink';
+import { CoreToolCallStatus } from '@google/gemini-cli-core';
+import { LoadingIndicator } from './LoadingIndicator.js';
+import { StatusDisplay } from './StatusDisplay.js';
+>>>>>>> 96b0876e6 (feat(cli): unify session modes in footer and reorganize composer layout)
+import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
+import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
+import { ShortcutsHelp } from './ShortcutsHelp.js';
+import { ShortcutsHint } from './ShortcutsHint.js';
+import { InputPrompt } from './InputPrompt.js';
+import { Footer } from './Footer.js';
+import { StatusRow } from './StatusRow.js';
+import { ShowMoreLines } from './ShowMoreLines.js';
+import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
+import { ContextUsageDisplay } from './ContextUsageDisplay.js';
+import { HorizontalLine } from './shared/HorizontalLine.js';
+import { OverflowProvider } from '../contexts/OverflowContext.js';
+import { isNarrowWidth } from '../utils/isNarrowWidth.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useUIActions } from '../contexts/UIActionsContext.js';
+import { useVimMode } from '../contexts/VimModeContext.js';
+import { useConfig } from '../contexts/ConfigContext.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
+import { StreamingState, type HistoryItemToolGroup } from '../types.js';
+import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
+import { TodoTray } from './messages/Todo.js';
+<<<<<<< HEAD
+import { useComposerStatus } from '../hooks/useComposerStatus.js';
+=======
+import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
+import { isContextUsageHigh } from '../utils/contextUsage.js';
+>>>>>>> 96b0876e6 (feat(cli): unify session modes in footer and reorganize composer layout)
+
+export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
+ const config = useConfig();
+ const settings = useSettings();
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
+ const uiState = useUIState();
+ const uiActions = useUIActions();
+ const { vimEnabled, vimMode } = useVimMode();
+ const inlineThinkingMode = getInlineThinkingMode(settings);
+ const terminalWidth = uiState.terminalWidth;
+ const isNarrow = isNarrowWidth(terminalWidth);
+ const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
+ const [suggestionsVisible, setSuggestionsVisible] = useState(false);
+
+ const isAlternateBuffer = useAlternateBuffer();
+<<<<<<< HEAD
+=======
+ const { showApprovalModeIndicator } = uiState;
+>>>>>>> 96b0876e6 (feat(cli): unify session modes in footer and reorganize composer layout)
+ const showUiDetails = uiState.cleanUiDetailsVisible;
+ const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
+ const hideContextSummary =
+ suggestionsVisible && suggestionsPosition === 'above';
+
+ const { hasPendingActionRequired, shouldCollapseDuringApproval } =
+ useComposerStatus();
+
+ const isPassiveShortcutsHelpState =
+ uiState.isInputActive &&
+ uiState.streamingState === 'idle' &&
+ !hasPendingActionRequired;
+
+ const { setShortcutsHelpVisible } = uiActions;
+
+ useEffect(() => {
+ if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) {
+ setShortcutsHelpVisible(false);
+ }
+ }, [
+ uiState.shortcutsHelpVisible,
+ isPassiveShortcutsHelpState,
+ setShortcutsHelpVisible,
+ ]);
+
+<<<<<<< HEAD
+ const showShortcutsHelp =
+ uiState.shortcutsHelpVisible &&
+ uiState.streamingState === 'idle' &&
+ !hasPendingActionRequired;
+
+=======
+ const hideUiDetailsForSuggestions =
+ suggestionsVisible && suggestionsPosition === 'above';
+ const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
+ const isModelIdle = uiState.streamingState === StreamingState.Idle;
+ const isModelResponding =
+ uiState.streamingState === StreamingState.Responding;
+ const isBufferEmpty = uiState.buffer.text.length === 0;
+ const canShowShortcutsHint =
+ (isModelIdle || isModelResponding) &&
+ isBufferEmpty &&
+ !hasPendingActionRequired;
+
+ const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] =
+ useState(canShowShortcutsHint);
+
+ useEffect(() => {
+ if (!canShowShortcutsHint) {
+ setShowShortcutsHintDebounced(false);
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ setShowShortcutsHintDebounced(true);
+ }, 200);
+
+ return () => clearTimeout(timeout);
+ }, [canShowShortcutsHint]);
+
+ /**
+ * Use the setting if provided, otherwise default to true for the new UX.
+ * This allows tests to override the collapse behavior.
+ */
+ const shouldCollapseDuringApproval =
+ settings.merged.ui.collapseDrawerDuringApproval !== false;
+
+>>>>>>> 96b0876e6 (feat(cli): unify session modes in footer and reorganize composer layout)
+ if (hasPendingActionRequired && shouldCollapseDuringApproval) {
+ return null;
+ }
+
+ const showShortcutsHelp =
+ uiState.shortcutsHelpVisible &&
+ uiState.streamingState === StreamingState.Idle &&
+ !hasPendingActionRequired;
+ const hasToast = shouldShowToast(uiState);
+<<<<<<< HEAD
+ const hideUiDetailsForSuggestions =
+ suggestionsVisible && suggestionsPosition === 'above';
+
+ // Mini Mode VIP Flags (Pure Content Triggers)
+ const showMinimalToast = hasToast;
+=======
+ const showLoadingIndicator =
+ (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
+ uiState.streamingState === StreamingState.Responding &&
+ !hasPendingActionRequired;
+
+ const hasMinimalStatusBleedThrough = shouldShowToast(uiState);
+
+ const showMinimalContextBleedThrough =
+ !settings.merged.ui.footer.hideContextPercentage &&
+ isContextUsageHigh(
+ uiState.sessionStats.lastPromptTokenCount,
+ typeof uiState.currentModel === 'string'
+ ? uiState.currentModel
+ : undefined,
+ );
+
+ const shouldReserveSpaceForShortcutsHint =
+ settings.merged.ui.showShortcutsHint &&
+ !hideShortcutsHintForSuggestions &&
+ !hasPendingActionRequired;
+ const showShortcutsHint =
+ shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced;
+ const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
+ const showMinimalBleedThroughRow =
+ !showUiDetails &&
+ (hasMinimalStatusBleedThrough || showMinimalContextBleedThrough);
+ const showMinimalMetaRow =
+ !showUiDetails &&
+ (showMinimalInlineLoading ||
+ showMinimalBleedThroughRow ||
+ shouldReserveSpaceForShortcutsHint);
+
+ const loadingPhrases = settings.merged.ui.loadingPhrases;
+ const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
+ const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
+>>>>>>> 96b0876e6 (feat(cli): unify session modes in footer and reorganize composer layout)
+
+ return (
+
+ {(!uiState.slashCommands ||
+ !uiState.isConfigInitialized ||
+ uiState.isResuming) && (
+
+ )}
+
+ {showUiDetails && (
+
+ )}
+
+ {showUiDetails && }
+
+<<<<<<< HEAD
+ {showShortcutsHelp && }
+
+ {(showUiDetails || showMinimalToast) && (
+
+
+
+ )}
+
+
+
+=======
+
+
+
+ {showUiDetails && (hasToast ? : null)}
+
+
+ {showUiDetails && showShortcutsHint && }
+
+
+ {showMinimalMetaRow && (
+
+
+ {showMinimalInlineLoading && (
+
+ )}
+ {hasMinimalStatusBleedThrough && (
+
+
+
+ )}
+
+ {(showMinimalContextBleedThrough ||
+ shouldReserveSpaceForShortcutsHint) && (
+
+ {showMinimalContextBleedThrough && (
+
+ )}
+
+ {showShortcutsHint && }
+
+
+ )}
+
+ )}
+ {showShortcutsHelp && }
+ {showUiDetails && }
+ {showUiDetails && (
+
+
+ {showLoadingIndicator && (
+
+ )}
+
+
+
+ {!showLoadingIndicator && (
+
+ )}
+
+
+ )}
+>>>>>>> 96b0876e6 (feat(cli): unify session modes in footer and reorganize composer layout)
+
+
+ {showUiDetails && uiState.showErrorDetails && (
+
+
+
+
+
+
+ )}
+
+ {uiState.isInputActive && (
+
+ )}
+
+ {showUiDetails &&
+ !settings.merged.ui.hideFooter &&
+ !isScreenReaderEnabled && }
+
+ );
+};
diff --git a/composer-layout-spec.md b/composer-layout-spec.md
new file mode 100644
index 0000000000..958bfc5664
--- /dev/null
+++ b/composer-layout-spec.md
@@ -0,0 +1,79 @@
+# Layout Refinement: Unified Session State & Composer Organization
+
+## Goal
+
+The objective is to consolidate all persistent "Session Modes" into a single,
+unified status area in the footer and reorganize the remaining transient
+elements in the Composer for a cleaner information hierarchy.
+
+## Phase 1: Unified Footer Modes
+
+The footer's first column is expanded to be the **Unified Mode Indicator**. It
+natively incorporates the three primary state toggles of the application.
+
+- **Approval Mode:** (manual, auto-accept, plan, YOLO) - Always visible.
+- **Shell Mode:** Visible only when active.
+- **Raw Markdown Mode:** Visible only when active.
+
+### Footer Layout
+
+- **Header:** `mode (Shift+Tab)`
+- **Data Row:** Multiple modes are displayed in their respective semantic
+ colors, separated by a middle dot (`·`).
+- **Example:** `plan · shell · raw`
+
+## Phase 2: Composer Cleanup & Swap
+
+With all modes moved to the footer, the Composer is simplified to handle only
+transient notifications and active processing states. These two areas are
+swapped across the horizontal divider.
+
+### 1. The "Above Divider" Zone (Environment Alerts)
+
+Reserved for transient notifications that alert the user to environment-level
+changes.
+
+- **Toast Messages:** (e.g., "Press Ctrl+C again to exit")
+- **Shortcuts Hint:** (e.g., "? for shortcuts") - Remains flush right.
+
+### 2. The "Below Divider" Zone (Active processing)
+
+Reserved exclusively for the application's current activity. It sits directly
+above the input prompt for maximum visibility during streaming.
+
+- **Loading Indicator:** (e.g., "Thinking...", "Executing Hooks")
+- **Status Display:** (Context usage summary)
+
+## Target Layout Mockup
+
+### Composer Area
+
+```text
+[ConfigInitDisplay]
+[QueuedMessageDisplay]
+[TodoTray]
+
+[ToastDisplay] [ShortcutsHint]
+----------------------------------------------------------------------
+[LoadingIndicator (e.g., Thinking...)]
+ [StatusDisplay]
+
+[InputPrompt]
+```
+
+### Footer Area (Status Line)
+
+```text
+ mode (Shift+Tab) workspace /model
+ manual · shell · raw ~/src/gemini-cli gemini-pro
+```
+
+## Key Principles
+
+- **Single Source of Truth:** All "modes" now live in the footer. If a user
+ wants to know what state the CLI is in, they only need to look at the far-left
+ footer item.
+- **Reduced Jitter:** Moving the Shell and Markdown indicators out of the
+ Composer reduces vertical jumping in the main interaction area.
+- **Immediate Feedback:** The Loading Indicator remains closest to the Input
+ Prompt, providing the most direct feedback during generation.
diff --git a/packages/cli/src/config/footerItems.ts b/packages/cli/src/config/footerItems.ts
index 8410d0b5ec..90cd694798 100644
--- a/packages/cli/src/config/footerItems.ts
+++ b/packages/cli/src/config/footerItems.ts
@@ -6,7 +6,28 @@
import type { MergedSettings } from './settings.js';
-export const ALL_ITEMS = [
+export const ALL_ITEMS: ReadonlyArray<{
+ id:
+ | 'mode'
+ | 'workspace'
+ | 'git-branch'
+ | 'sandbox'
+ | 'model-name'
+ | 'context-used'
+ | 'quota'
+ | 'memory-usage'
+ | 'session-id'
+ | 'code-changes'
+ | 'token-count';
+ header: string;
+ description: string;
+}> = [
+ {
+ id: 'mode',
+ header: 'mode (Shift+Tab)',
+ description:
+ 'Current session state: approval mode, shell mode, markdown mode',
+ },
{
id: 'workspace',
header: 'workspace (/directory)',
@@ -57,11 +78,12 @@ export const ALL_ITEMS = [
header: 'tokens',
description: 'Total tokens used in the session (not shown when zero)',
},
-] as const;
+];
export type FooterItemId = (typeof ALL_ITEMS)[number]['id'];
-export const DEFAULT_ORDER = [
+export const DEFAULT_ORDER: FooterItemId[] = [
+ 'mode',
'workspace',
'git-branch',
'sandbox',
@@ -77,7 +99,8 @@ export const DEFAULT_ORDER = [
export function deriveItemsFromLegacySettings(
settings: MergedSettings,
): string[] {
- const defaults = [
+ const defaults: string[] = [
+ 'mode',
'workspace',
'git-branch',
'sandbox',
diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx
deleted file mode 100644
index 1b2decbe16..0000000000
--- a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { render } from '../../test-utils/render.js';
-import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
-import { describe, it, expect } from 'vitest';
-import { ApprovalMode } from '@google/gemini-cli-core';
-
-describe('ApprovalModeIndicator', () => {
- it('renders correctly for AUTO_EDIT mode', async () => {
- const { lastFrame } = await render(
- ,
- );
- expect(lastFrame()).toMatchSnapshot();
- });
-
- it('renders correctly for AUTO_EDIT mode with plan enabled', async () => {
- const { lastFrame } = await render(
- ,
- );
- expect(lastFrame()).toMatchSnapshot();
- });
-
- it('renders correctly for PLAN mode', async () => {
- const { lastFrame } = await render(
- ,
- );
- expect(lastFrame()).toMatchSnapshot();
- });
-
- it('renders correctly for YOLO mode', async () => {
- const { lastFrame } = await render(
- ,
- );
- expect(lastFrame()).toMatchSnapshot();
- });
-
- it('renders correctly for DEFAULT mode', async () => {
- const { lastFrame } = await render(
- ,
- );
- expect(lastFrame()).toMatchSnapshot();
- });
-
- it('renders correctly for DEFAULT mode with plan enabled', async () => {
- const { lastFrame } = await render(
- ,
- );
- expect(lastFrame()).toMatchSnapshot();
- });
-});
diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx
deleted file mode 100644
index 7e8f388c82..0000000000
--- a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import type React from 'react';
-import { Box, Text } from 'ink';
-import { theme } from '../semantic-colors.js';
-import { ApprovalMode } from '@google/gemini-cli-core';
-import { formatCommand } from '../key/keybindingUtils.js';
-import { Command } from '../key/keyBindings.js';
-
-interface ApprovalModeIndicatorProps {
- approvalMode: ApprovalMode;
- allowPlanMode?: boolean;
-}
-
-export const ApprovalModeIndicator: React.FC = ({
- approvalMode,
- allowPlanMode,
-}) => {
- let textColor = '';
- let textContent = '';
- let subText = '';
-
- const cycleHint = formatCommand(Command.CYCLE_APPROVAL_MODE);
- const yoloHint = formatCommand(Command.TOGGLE_YOLO);
-
- switch (approvalMode) {
- case ApprovalMode.AUTO_EDIT:
- textColor = theme.status.warning;
- textContent = 'auto-accept edits';
- subText = allowPlanMode
- ? `${cycleHint} to plan`
- : `${cycleHint} to manual`;
- break;
- case ApprovalMode.PLAN:
- textColor = theme.status.success;
- textContent = 'plan';
- subText = `${cycleHint} to manual`;
- break;
- case ApprovalMode.YOLO:
- textColor = theme.status.error;
- textContent = 'YOLO';
- subText = yoloHint;
- break;
- case ApprovalMode.DEFAULT:
- default:
- textColor = theme.text.accent;
- textContent = '';
- subText = `${cycleHint} to accept edits`;
- break;
- }
-
- return (
-
-
- {textContent ? textContent : null}
- {subText ? (
-
- {textContent ? ' ' : ''}
- {subText}
-
- ) : null}
-
-
- );
-};
diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx
index 1cbb29a06c..7b36ec0816 100644
--- a/packages/cli/src/ui/components/Composer.test.tsx
+++ b/packages/cli/src/ui/components/Composer.test.tsx
@@ -17,16 +17,11 @@ import {
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
-import {
- ApprovalMode,
- tokenLimit,
- CoreToolCallStatus,
-} from '@google/gemini-cli-core';
+import { ApprovalMode, CoreToolCallStatus } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { TransientMessageType } from '../../utils/events.js';
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
@@ -54,14 +49,25 @@ vi.mock('./LoadingIndicator.js', () => ({
LoadingIndicator: ({
thought,
thoughtLabel,
+ wittyPhrase,
}: {
thought?: { subject?: string } | string;
thoughtLabel?: string;
+ wittyPhrase?: string;
}) => {
const fallbackText =
typeof thought === 'string' ? thought : thought?.subject;
const text = thoughtLabel ?? fallbackText;
- return LoadingIndicator{text ? `: ${text}` : ''};
+ return (
+
+ LoadingIndicator{text ? `: ${text}` : ''}
+ {wittyPhrase && (
+
+ {wittyPhrase}
+
+ )}
+
+ );
},
}));
@@ -75,24 +81,14 @@ vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: () => ContextSummaryDisplay,
}));
-vi.mock('./HookStatusDisplay.js', () => ({
- HookStatusDisplay: () => HookStatusDisplay,
-}));
-
-vi.mock('./ApprovalModeIndicator.js', () => ({
- ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => (
- ApprovalModeIndicator: {approvalMode}
- ),
-}));
-
-vi.mock('./ShellModeIndicator.js', () => ({
- ShellModeIndicator: () => ShellModeIndicator,
-}));
-
vi.mock('./ShortcutsHelp.js', () => ({
ShortcutsHelp: () => ShortcutsHelp,
}));
+vi.mock('./ShortcutsHint.js', () => ({
+ ShortcutsHint: () => ShortcutsHint,
+}));
+
vi.mock('./DetailedMessagesDisplay.js', () => ({
DetailedMessagesDisplay: () => DetailedMessagesDisplay,
}));
@@ -145,6 +141,12 @@ vi.mock('./QueuedMessageDisplay.js', () => ({
},
}));
+vi.mock('./ContextUsageDisplay.js', () => ({
+ ContextUsageDisplay: ({ promptTokenCount }: { promptTokenCount: number }) => (
+ ContextUsageDisplay: {promptTokenCount}
+ ),
+}));
+
// Mock contexts
vi.mock('../contexts/OverflowContext.js', () => ({
OverflowProvider: ({ children }: { children: React.ReactNode }) => children,
@@ -153,6 +155,7 @@ vi.mock('../contexts/OverflowContext.js', () => ({
// Create mock context providers
const createMockUIState = (overrides: Partial = {}): UIState =>
({
+ terminalWidth: 100,
streamingState: StreamingState.Idle,
isConfigInitialized: true,
contextFileNames: [],
@@ -186,8 +189,10 @@ const createMockUIState = (overrides: Partial = {}): UIState =>
sessionStats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- metrics: {} as any,
+ metrics: {
+ files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
+ models: {},
+ },
lastPromptTokenCount: 0,
promptCount: 0,
},
@@ -266,7 +271,11 @@ const renderComposer = async (
// Wait for shortcuts hint debounce if using fake timers
if (vi.isFakeTimers()) {
await act(async () => {
- await vi.advanceTimersByTimeAsync(250);
+ vi.advanceTimersByTime(250);
+ });
+ // Extra tick for state updates
+ await act(async () => {
+ await Promise.resolve();
});
}
@@ -301,52 +310,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
- // Check for content that only appears IN the Footer component itself
- expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
- expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses
- });
-
- it('passes correct props to Footer including vim mode when enabled', async () => {
- const uiState = createMockUIState({
- branchName: 'feature-branch',
- corgiMode: true,
- errorCount: 2,
- sessionStats: {
- sessionId: 'test-session',
- sessionStartTime: new Date(),
- metrics: {
- models: {},
- tools: {},
- files: {},
- } as SessionMetrics,
- lastPromptTokenCount: 150,
- promptCount: 5,
- },
- });
- const config = createMockConfig({
- getModel: vi.fn(() => 'gemini-1.5-flash'),
- getTargetDir: vi.fn(() => '/project/path'),
- getDebugMode: vi.fn(() => true),
- });
- const settings = createMockSettings({
- ui: {
- hideFooter: false,
- showMemoryUsage: true,
- },
- });
- // Mock vim mode for this test
- const { useVimMode } = await import('../contexts/VimModeContext.js');
- vi.mocked(useVimMode).mockReturnValueOnce({
- vimEnabled: true,
- vimMode: 'INSERT',
- toggleVimEnabled: vi.fn(),
- setVimMode: vi.fn(),
- } as unknown as ReturnType);
-
- const { lastFrame } = await renderComposer(uiState, settings, config);
-
- expect(lastFrame()).toContain('Footer');
- // Footer should be rendered with all the state passed through
+ expect(lastFrame()).not.toContain('Footer');
});
});
@@ -383,12 +347,10 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
- // In Refreshed UX, we don't force 'Thinking...' label in renderStatusNode
- // It uses the subject directly
expect(output).toContain('LoadingIndicator: Thinking about code');
});
- it('shows shortcuts hint while loading', async () => {
+ it('shows shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
elapsedTime: 1,
@@ -400,7 +362,6 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).toContain('press tab twice for more');
- expect(output).not.toContain('? for shortcuts');
});
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
@@ -416,7 +377,6 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
- expect(output).toContain('LoadingIndicator: Hidden');
});
it('does not render LoadingIndicator when waiting for confirmation', async () => {
@@ -473,23 +433,6 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator');
});
- it('renders both LoadingIndicator and ApprovalModeIndicator when streaming in full UI mode', async () => {
- const uiState = createMockUIState({
- streamingState: StreamingState.Responding,
- thought: {
- subject: 'Thinking',
- description: '',
- },
- showApprovalModeIndicator: ApprovalMode.PLAN,
- });
-
- const { lastFrame } = await renderComposer(uiState);
-
- const output = lastFrame();
- expect(output).toContain('LoadingIndicator: Thinking');
- expect(output).toContain('ApprovalModeIndicator');
- });
-
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
@@ -502,6 +445,23 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
});
+
+ it('renders both Thinking and witty phrase', async () => {
+ const uiState = createMockUIState({
+ streamingState: StreamingState.Responding,
+ thought: { subject: 'Processing', description: '' },
+ currentWittyPhrase: 'Reticulating splines...',
+ });
+ const settings = createMockSettings({
+ ui: { loadingPhrases: 'witty' },
+ });
+
+ const { lastFrame } = await renderComposer(uiState, settings);
+
+ const output = lastFrame();
+ expect(output).toContain('LoadingIndicator: Processing');
+ expect(output).toContain('Reticulating splines...');
+ });
});
describe('Message Queue Display', () => {
@@ -521,23 +481,10 @@ describe('Composer', () => {
expect(output).toContain('Second queued message');
expect(output).toContain('Third queued message');
});
-
- it('renders QueuedMessageDisplay with empty message queue', async () => {
- const uiState = createMockUIState({
- messageQueue: [],
- });
-
- const { lastFrame } = await renderComposer(uiState);
-
- // The component should render but return null for empty queue
- // This test verifies that the component receives the correct prop
- const output = lastFrame();
- expect(output).toContain('InputPrompt'); // Verify basic Composer rendering
- });
});
describe('Context and Status Display', () => {
- it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => {
+ it('shows StatusDisplay in normal state', async () => {
const uiState = createMockUIState({
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
@@ -548,11 +495,9 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('StatusDisplay');
- expect(output).toContain('ApprovalModeIndicator');
- expect(output).not.toContain('ToastDisplay');
});
- it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => {
+ it('shows ToastDisplay when a toast is present', async () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
});
@@ -561,10 +506,6 @@ describe('Composer', () => {
const output = lastFrame();
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');
});
it('shows ToastDisplay for other toast types', async () => {
@@ -579,7 +520,6 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('Warning');
- expect(output).toContain('ApprovalModeIndicator');
});
});
@@ -595,8 +535,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
- expect(output).not.toContain('press tab twice for more');
- expect(output).not.toContain('? for shortcuts');
+ expect(output).not.toContain('ShortcutsHint');
expect(output).toContain('InputPrompt');
expect(output).not.toContain('Footer');
});
@@ -621,148 +560,41 @@ describe('Composer', () => {
expect(lastFrame()).not.toContain('InputPrompt');
});
- it.each([
- [ApprovalMode.DEFAULT],
- [ApprovalMode.AUTO_EDIT],
- [ApprovalMode.PLAN],
- [ApprovalMode.YOLO],
- ])(
- 'shows ApprovalModeIndicator when approval mode is %s and shell mode is inactive',
- async (mode) => {
- const uiState = createMockUIState({
- showApprovalModeIndicator: mode,
- shellModeActive: false,
- });
-
- const { lastFrame } = await renderComposer(uiState);
-
- expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/);
- },
- );
-
- it('shows ShellModeIndicator when shell mode is active', async () => {
- const uiState = createMockUIState({
- shellModeActive: true,
- });
-
- const { lastFrame } = await renderComposer(uiState);
-
- expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
- });
-
- it('shows RawMarkdownIndicator when renderMarkdown is false', async () => {
- const uiState = createMockUIState({
- renderMarkdown: false,
- });
-
- const { lastFrame } = await renderComposer(uiState);
-
- expect(lastFrame()).toContain('raw markdown mode');
- });
-
- it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => {
- const uiState = createMockUIState({
- renderMarkdown: true,
- });
-
- const { lastFrame } = await renderComposer(uiState);
-
- expect(lastFrame()).not.toContain('raw markdown mode');
- });
-
- it.each([
- { mode: ApprovalMode.YOLO, label: '● YOLO' },
- { mode: ApprovalMode.PLAN, label: '● plan' },
- {
- mode: ApprovalMode.AUTO_EDIT,
- label: '● auto edit',
- },
- ])(
- 'shows minimal mode badge "$mode" when clean UI details are hidden',
- async ({ mode, label }) => {
- const uiState = createMockUIState({
- cleanUiDetailsVisible: false,
- showApprovalModeIndicator: mode,
- });
-
- const { lastFrame } = await renderComposer(uiState);
- expect(lastFrame()).toContain(label);
- },
- );
-
- it('hides minimal mode badge while loading in clean mode', async () => {
- const uiState = createMockUIState({
- cleanUiDetailsVisible: false,
- streamingState: StreamingState.Responding,
- elapsedTime: 1,
- showApprovalModeIndicator: ApprovalMode.PLAN,
- });
-
- const { lastFrame } = await renderComposer(uiState);
- const output = lastFrame();
- expect(output).toContain('LoadingIndicator');
- expect(output).not.toContain('plan');
- 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 () => {
- const uiState = createMockUIState({
- cleanUiDetailsVisible: false,
- showApprovalModeIndicator: ApprovalMode.PLAN,
- customDialog: (
-
- Prompt
-
- ),
- });
-
- const { lastFrame } = await renderComposer(uiState);
- expect(lastFrame({ allowEmpty: true })).toBe('');
- });
-
- it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
- const uiState = createMockUIState({
- cleanUiDetailsVisible: false,
- showEscapePrompt: true,
- history: [{ id: 1, type: 'user', text: 'msg' }],
- });
-
- const { lastFrame } = await renderComposer(uiState);
- const output = lastFrame();
- expect(output).toContain('Press Esc again to rewind.');
- expect(output).not.toContain('ContextSummaryDisplay');
- });
-
it('shows context usage bleed-through when over 60%', async () => {
- const model = 'gemini-2.5-pro';
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
- currentModel: model,
sessionStats: {
- sessionId: 'test-session',
+ lastPromptTokenCount: 700000,
+ metrics: {
+ files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: {
+ accept: 0,
+ reject: 0,
+ modify: 0,
+ auto_accept: 0,
+ },
+ byName: {},
+ },
+ },
+ sessionId: 'test',
sessionStartTime: new Date(),
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- metrics: {} as any,
- lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7),
promptCount: 0,
},
+ currentModel: 'gemini-1.5-pro',
});
const settings = createMockSettings({
- ui: {
- footer: { hideContextPercentage: false },
- },
+ ui: { footer: { hideContextPercentage: false } },
});
const { lastFrame } = await renderComposer(uiState, settings);
- 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');
+ expect(lastFrame()).toContain('ContextUsageDisplay: 700000');
});
});
@@ -834,10 +666,6 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
- await act(async () => {
- await vi.advanceTimersByTimeAsync(250);
- });
-
expect(lastFrame({ allowEmpty: true })).toContain(
'press tab twice for more',
);
@@ -851,8 +679,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
- expect(lastFrame()).not.toContain('press tab twice for more');
- expect(lastFrame()).not.toContain('? for shortcuts');
+ expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
@@ -865,7 +692,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
- expect(lastFrame()).not.toContain('? for shortcuts');
+ expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => {
@@ -891,10 +718,6 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
- await act(async () => {
- await vi.advanceTimersByTimeAsync(250);
- });
-
expect(lastFrame()).toContain('press tab twice for more');
});
@@ -905,51 +728,9 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
- 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('shows shortcuts hint while loading when full UI details are visible', async () => {
- const uiState = createMockUIState({
- cleanUiDetailsVisible: true,
- streamingState: StreamingState.Responding,
- });
-
- const { lastFrame } = await renderComposer(uiState);
-
- 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('shows shortcuts hint while loading in minimal mode', async () => {
- const uiState = createMockUIState({
- cleanUiDetailsVisible: false,
- streamingState: StreamingState.Responding,
- elapsedTime: 1,
- });
-
- const { lastFrame } = await renderComposer(uiState);
-
- 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 () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
@@ -967,46 +748,11 @@ describe('Composer', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
- showApprovalModeIndicator: ApprovalMode.PLAN,
});
const { lastFrame } = await renderComposer(uiState);
- expect(lastFrame()).not.toContain('press tab twice for more');
- expect(lastFrame()).not.toContain('? for shortcuts');
- expect(lastFrame()).not.toContain('plan');
- });
-
- it('hides approval mode indicator when suggestions are visible above input in alternate buffer', async () => {
- composerTestControls.isAlternateBuffer = true;
- composerTestControls.suggestionsVisible = true;
-
- const uiState = createMockUIState({
- cleanUiDetailsVisible: true,
- showApprovalModeIndicator: ApprovalMode.YOLO,
- });
-
- const { lastFrame } = await renderComposer(uiState);
-
- expect(lastFrame()).not.toContain('ApprovalModeIndicator');
- });
-
- it('keeps shortcuts hint when suggestions are visible below input in regular buffer', async () => {
- composerTestControls.isAlternateBuffer = false;
- composerTestControls.suggestionsVisible = true;
-
- const uiState = createMockUIState({
- cleanUiDetailsVisible: false,
- });
-
- const { lastFrame } = await renderComposer(uiState);
-
- 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');
+ expect(lastFrame()).not.toContain('ShortcutsHint');
});
});
@@ -1034,22 +780,8 @@ describe('Composer', () => {
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
- it('hides shortcuts help when action is required', async () => {
- const uiState = createMockUIState({
- shortcutsHelpVisible: true,
- customDialog: (
-
- Test Dialog
-
- ),
- });
-
- const { lastFrame, unmount } = await renderComposer(uiState);
-
- expect(lastFrame({ allowEmpty: true })).toBe('');
- unmount();
- });
});
+
describe('Snapshots', () => {
it('matches snapshot in idle state', async () => {
const uiState = createMockUIState();
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 5c9850bf92..6c79e86dda 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { Box, useIsScreenReaderEnabled } from 'ink';
+import { Box, useIsScreenReaderEnabled, Text } from 'ink';
import { useState, useEffect } from 'react';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
@@ -26,6 +26,8 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
+import { HorizontalLine } from './shared/HorizontalLine.js';
+import { theme } from '../semantic-colors.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiState = useUIState();
@@ -40,6 +42,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
const isAlternateBuffer = useAlternateBuffer();
+ const { showApprovalModeIndicator } = uiState;
const showUiDetails = uiState.cleanUiDetailsVisible;
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
@@ -65,20 +68,69 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
setShortcutsHelpVisible,
]);
- const showShortcutsHelp =
- uiState.shortcutsHelpVisible &&
- uiState.streamingState === 'idle' &&
+ const hideUiDetailsForSuggestions =
+ suggestionsVisible && suggestionsPosition === 'above';
+ const isModelIdle = uiState.streamingState === 'idle';
+ const isModelResponding = uiState.streamingState === 'responding';
+ const isBufferEmpty = uiState.buffer.text.length === 0;
+ const canShowShortcutsHint =
+ (isModelIdle || isModelResponding) &&
+ isBufferEmpty &&
!hasPendingActionRequired;
+ const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] =
+ useState(canShowShortcutsHint);
+
+ useEffect(() => {
+ if (!canShowShortcutsHint) {
+ setShowShortcutsHintDebounced(false);
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ setShowShortcutsHintDebounced(true);
+ }, 200);
+
+ return () => clearTimeout(timeout);
+ }, [canShowShortcutsHint]);
+
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
return null;
}
- const hasToast = shouldShowToast(uiState);
- const hideUiDetailsForSuggestions =
- suggestionsVisible && suggestionsPosition === 'above';
+ const showShortcutsHelp =
+ uiState.shortcutsHelpVisible &&
+ uiState.streamingState === 'idle' &&
+ !hasPendingActionRequired;
+
+ const hasToast = shouldShowToast(uiState);
+
+ const shouldReserveSpaceForShortcutsHint =
+ settings.merged.ui.showShortcutsHint &&
+ !hideUiDetailsForSuggestions &&
+ !hasPendingActionRequired;
+ const showShortcutsHint =
+ shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced;
+
+ const loadingPhrases = settings.merged.ui.loadingPhrases;
+ const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
+
+ /**
+ * Determine the ambient text (tip or shortcut hint) to display.
+ */
+ const ambientContent = (() => {
+ if (showTips && uiState.currentTip) {
+ return { text: `Tip: ${uiState.currentTip}`, isTip: true };
+ }
+ if (showShortcutsHint) {
+ const text = showUiDetails
+ ? '? for shortcuts'
+ : 'press tab twice for more';
+ return { text, isTip: false };
+ }
+ return null;
+ })();
- // Mini Mode VIP Flags (Pure Content Triggers)
const showMinimalToast = hasToast;
return (
@@ -98,23 +150,60 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{showUiDetails && }
- {showShortcutsHelp && }
-
- {(showUiDetails || showMinimalToast) && (
-
-
-
- )}
-
+ {/* Above Divider Zone: Alerts, Tips, and Hints */}
+
+
+ {showUiDetails && hasToast && }
+
+
+ {showUiDetails && ambientContent && (
+
+
+ {ambientContent.text}
+
+
+ )}
+
+
+
+
+ {showShortcutsHelp && }
+ {showUiDetails && }
{showUiDetails && uiState.showErrorDetails && (
@@ -146,7 +235,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
commandContext={uiState.commandContext}
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
- approvalMode={uiState.showApprovalModeIndicator}
+ approvalMode={showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
@@ -165,15 +254,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
- copyModeEnabled={uiState.copyModeEnabled}
/>
)}
{showUiDetails &&
!settings.merged.ui.hideFooter &&
- !isScreenReaderEnabled && (
-
- )}
+ !isScreenReaderEnabled && }
);
};
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index 696cc5e417..05a329a96a 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -18,7 +18,10 @@ import process from 'node:process';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js';
-import { DebugProfiler } from './DebugProfiler.js';
+import {
+ UnifiedModeIndicator,
+ getModeHeaderLabel,
+} from './UnifiedModeIndicator.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
@@ -168,7 +171,7 @@ function isFooterItemId(id: string): id is FooterItemId {
}
interface FooterColumn {
- id: string;
+ id: FooterItemId;
header: string;
element: (maxWidth: number) => React.ReactNode;
width: number;
@@ -230,7 +233,7 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
const potentialColumns: FooterColumn[] = [];
const addCol = (
- id: string,
+ id: FooterItemId,
header: string,
element: (maxWidth: number) => React.ReactNode,
dataWidth: number,
@@ -246,13 +249,12 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
};
// 1. System Indicators (Far Left, high priority)
- if (uiState.showDebugProfiler) {
- addCol('debug', '', () => , 45, true);
- }
+ // Note: These don't have IDs in ALL_ITEMS yet, but we handle them as specials
if (displayVimMode) {
const vimStr = `[${displayVimMode}]`;
+ // We'll use a hacky cast for now or ideally update ALL_ITEMS
addCol(
- 'vim',
+ 'mode', // Using 'mode' as a placeholder for system indicators
'',
() => {vimStr},
vimStr.length,
@@ -264,9 +266,39 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
for (const id of items) {
if (!isFooterItemId(id)) continue;
const itemConfig = ALL_ITEMS.find((i) => i.id === id);
- const header = itemConfig?.header ?? id;
+ let header = itemConfig?.header ?? id;
switch (id) {
+ case 'mode': {
+ header = getModeHeaderLabel(
+ uiState.showApprovalModeIndicator,
+ uiState.shellModeActive,
+ );
+
+ // Calculate dynamic width based on which modes are active
+ let contentWidth = 6; // 'manual' or 'plan' or 'YOLO' (max 11 for auto-accept)
+ if (uiState.showApprovalModeIndicator === 'autoEdit') contentWidth = 11;
+ if (uiState.shellModeActive)
+ contentWidth = 5; // 'shell' (obscures others)
+ else if (uiState.showApprovalModeIndicator === 'yolo') contentWidth = 4; // 'YOLO' (obscures others)
+
+ if (!uiState.renderMarkdown) contentWidth += 6; // ' · raw'
+
+ addCol(
+ id,
+ header,
+ () => (
+
+ ),
+ Math.max(contentWidth, showLabels ? header.length : 0),
+ true, // high priority, always shown
+ );
+ break;
+ }
case 'workspace': {
const fullPath = tildeifyPath(targetDir);
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
@@ -430,10 +462,10 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
}
// 3. Transients
- if (corgiMode) addCol('corgi', '', () => , 5);
+ if (corgiMode) addCol('mode', '', () => , 5); // Hacky ID for now
if (showErrorSummary) {
addCol(
- 'error-count',
+ 'mode', // Hacky ID
'',
() => ,
12,
@@ -482,7 +514,7 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
return {
- key: col.id,
+ key: col.id + index,
header: col.header,
element: col.element(estimatedWidth),
flexGrow: 0,
diff --git a/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx b/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx
deleted file mode 100644
index 2c17ec1357..0000000000
--- a/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { render } from '../../test-utils/render.js';
-import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
-import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
-
-describe('RawMarkdownIndicator', () => {
- const originalPlatform = process.platform;
-
- beforeEach(() => vi.stubEnv('FORCE_GENERIC_KEYBINDING_HINTS', ''));
-
- afterEach(() => {
- Object.defineProperty(process, 'platform', {
- value: originalPlatform,
- });
- vi.unstubAllEnvs();
- });
-
- it('renders correct key binding for darwin', async () => {
- Object.defineProperty(process, 'platform', {
- value: 'darwin',
- });
- const { lastFrame, unmount } = await render();
- expect(lastFrame()).toContain('raw markdown mode');
- expect(lastFrame()).toContain('Option+M to toggle');
- unmount();
- });
-
- it('renders correct key binding for other platforms', async () => {
- Object.defineProperty(process, 'platform', {
- value: 'linux',
- });
- const { lastFrame, unmount } = await render();
- expect(lastFrame()).toContain('raw markdown mode');
- expect(lastFrame()).toContain('Alt+M to toggle');
- unmount();
- });
-});
diff --git a/packages/cli/src/ui/components/RawMarkdownIndicator.tsx b/packages/cli/src/ui/components/RawMarkdownIndicator.tsx
deleted file mode 100644
index 3a88c7ff34..0000000000
--- a/packages/cli/src/ui/components/RawMarkdownIndicator.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import type React from 'react';
-import { Box, Text } from 'ink';
-import { theme } from '../semantic-colors.js';
-import { formatCommand } from '../key/keybindingUtils.js';
-import { Command } from '../key/keyBindings.js';
-
-export const RawMarkdownIndicator: React.FC = () => {
- const modKey = formatCommand(Command.TOGGLE_MARKDOWN);
- return (
-
-
- raw markdown mode
- ({modKey} to toggle)
-
-
- );
-};
diff --git a/packages/cli/src/ui/components/ShellModeIndicator.test.tsx b/packages/cli/src/ui/components/ShellModeIndicator.test.tsx
deleted file mode 100644
index 0ab5d42116..0000000000
--- a/packages/cli/src/ui/components/ShellModeIndicator.test.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { render } from '../../test-utils/render.js';
-import { ShellModeIndicator } from './ShellModeIndicator.js';
-import { describe, it, expect } from 'vitest';
-
-describe('ShellModeIndicator', () => {
- it('renders correctly', async () => {
- const { lastFrame, unmount } = await render();
- expect(lastFrame()).toContain('shell mode enabled');
- expect(lastFrame()).toContain('esc to disable');
- unmount();
- });
-});
diff --git a/packages/cli/src/ui/components/ShellModeIndicator.tsx b/packages/cli/src/ui/components/ShellModeIndicator.tsx
deleted file mode 100644
index 10370d2e55..0000000000
--- a/packages/cli/src/ui/components/ShellModeIndicator.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import type React from 'react';
-import { Box, Text } from 'ink';
-import { theme } from '../semantic-colors.js';
-
-export const ShellModeIndicator: React.FC = () => (
-
-
- shell mode enabled
- (esc to disable)
-
-
-);
diff --git a/packages/cli/src/ui/components/ShortcutsHint.tsx b/packages/cli/src/ui/components/ShortcutsHint.tsx
new file mode 100644
index 0000000000..e6351c7116
--- /dev/null
+++ b/packages/cli/src/ui/components/ShortcutsHint.tsx
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+
+/**
+ * A concise, ambient hint for shortcuts shown in the multipurpose status row.
+ */
+export const ShortcutsHint: React.FC = () => {
+ const { cleanUiDetailsVisible, shortcutsHelpVisible } = useUIState();
+
+ const text = cleanUiDetailsVisible
+ ? '? for shortcuts'
+ : 'press tab twice for more';
+
+ const color = shortcutsHelpVisible ? theme.text.accent : theme.text.secondary;
+
+ return (
+
+ {text}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx
index 4585438bee..da1fc0e80f 100644
--- a/packages/cli/src/ui/components/StatusRow.tsx
+++ b/packages/cli/src/ui/components/StatusRow.tsx
@@ -5,420 +5,209 @@
*/
import type React from 'react';
-import { useCallback, useRef, useState } from 'react';
-import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
-import {
- isUserVisibleHook,
- type ThoughtSummary,
-} from '@google/gemini-cli-core';
-import stripAnsi from 'strip-ansi';
-import { type ActiveHook } from '../types.js';
-import { useUIState } from '../contexts/UIStateContext.js';
-import { useSettings } from '../contexts/SettingsContext.js';
-import { theme } from '../semantic-colors.js';
+import { Box, Text } from 'ink';
+import { isUserVisibleHook } from '@google/gemini-cli-core';
+import type { useSettings } from '../contexts/SettingsContext.js';
+import type { useUIState } from '../contexts/UIStateContext.js';
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
-import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
-import { HorizontalLine } from './shared/HorizontalLine.js';
-import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
-import { ShellModeIndicator } from './ShellModeIndicator.js';
-import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
-import { useComposerStatus } from '../hooks/useComposerStatus.js';
+import { isContextUsageHigh } from '../utils/contextUsage.js';
+import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
+import { ToastDisplay } from './ToastDisplay.js';
+import { theme } from '../semantic-colors.js';
+import { StreamingState } from '../types.js';
-/**
- * Layout constants to prevent magic numbers.
- */
-const LAYOUT = {
- STATUS_MIN_HEIGHT: 1,
- TIP_LEFT_MARGIN: 2,
- TIP_RIGHT_MARGIN_NARROW: 0,
- TIP_RIGHT_MARGIN_WIDE: 1,
- INDICATOR_LEFT_MARGIN: 1,
- CONTEXT_DISPLAY_TOP_MARGIN_NARROW: 1,
- CONTEXT_DISPLAY_LEFT_MARGIN_NARROW: 1,
- CONTEXT_DISPLAY_LEFT_MARGIN_WIDE: 0,
- COLLISION_GAP: 10,
-};
+interface AmbientContent {
+ text: string;
+ isTip: boolean;
+}
-interface StatusRowProps {
- showUiDetails: boolean;
- isNarrow: boolean;
- terminalWidth: number;
+export interface StatusRowProps {
+ uiState: ReturnType;
+ settings: ReturnType;
hideContextSummary: boolean;
+ isNarrow: boolean;
+ ambientContent: AmbientContent | null;
+ showUiDetails: boolean;
+ showMinimalToast: boolean;
hideUiDetailsForSuggestions: boolean;
hasPendingActionRequired: boolean;
}
-/**
- * Renders the loading or hook execution status.
- */
-export const StatusNode: React.FC<{
- showTips: boolean;
- showWit: boolean;
- thought: ThoughtSummary | null;
- elapsedTime: number;
- currentWittyPhrase: string | undefined;
- activeHooks: ActiveHook[];
- showLoadingIndicator: boolean;
- errorVerbosity: 'low' | 'full' | undefined;
- onResize?: (width: number) => void;
-}> = ({
- showTips,
- showWit,
- thought,
- elapsedTime,
- currentWittyPhrase,
- activeHooks,
- showLoadingIndicator,
- errorVerbosity,
- onResize,
+export const StatusRow: React.FC = ({
+ uiState,
+ settings,
+ hideContextSummary,
+ isNarrow,
+ ambientContent,
+ showUiDetails,
+ showMinimalToast,
+ hideUiDetailsForSuggestions,
+ hasPendingActionRequired,
}) => {
- const observerRef = useRef(null);
+ const inlineThinkingMode = getInlineThinkingMode(settings);
+ const loadingPhrases = settings.merged.ui.loadingPhrases;
+ const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
+ const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
- const onRefChange = useCallback(
- (node: DOMElement | null) => {
- if (observerRef.current) {
- observerRef.current.disconnect();
- observerRef.current = null;
- }
+ const showLoadingIndicator =
+ (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
+ uiState.streamingState === StreamingState.Responding &&
+ !hasPendingActionRequired;
- if (node && onResize) {
- const observer = new ResizeObserver((entries) => {
- const entry = entries[0];
- if (entry) {
- onResize(Math.round(entry.contentRect.width));
- }
- });
- observer.observe(node);
- observerRef.current = observer;
- }
- },
- [onResize],
- );
-
- if (activeHooks.length === 0 && !showLoadingIndicator) return null;
-
- let currentLoadingPhrase: string | undefined = undefined;
- let currentThought: ThoughtSummary | null = null;
-
- if (activeHooks.length > 0) {
- const userVisibleHooks = activeHooks.filter((h) =>
- isUserVisibleHook(h.source),
+ const showMinimalContextBleedThrough =
+ !settings.merged.ui.footer.hideContextPercentage &&
+ isContextUsageHigh(
+ uiState.sessionStats.lastPromptTokenCount,
+ typeof uiState.currentModel === 'string'
+ ? uiState.currentModel
+ : undefined,
);
+ const shouldReserveSpaceForShortcutsHint =
+ settings.merged.ui.showShortcutsHint &&
+ !hideUiDetailsForSuggestions &&
+ !hasPendingActionRequired;
+
+ // Hook Status Logic
+ const allHooks = uiState.activeHooks;
+ const userVisibleHooks = allHooks.filter((h) => isUserVisibleHook(h.source));
+ let hookText: string | undefined = undefined;
+ if (allHooks.length > 0) {
+ hookText = GENERIC_WORKING_LABEL;
if (userVisibleHooks.length > 0) {
const label =
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = userVisibleHooks.map((h) => {
- let name = stripAnsi(h.name);
+ let name = h.name;
if (h.index && h.total && h.total > 1) {
name += ` (${h.index}/${h.total})`;
}
return name;
});
- currentLoadingPhrase = `${label}: ${displayNames.join(', ')}`;
- } else {
- currentLoadingPhrase = GENERIC_WORKING_LABEL;
+ hookText = `${label}: ${displayNames.join(', ')}`;
}
- } else {
- // Sanitize thought subject to prevent terminal injection
- currentThought = thought
- ? { ...thought, subject: stripAnsi(thought.subject) }
- : null;
}
- return (
-
-
-
- );
-};
+ const showMinimalMetaRow =
+ !showUiDetails &&
+ (showLoadingIndicator ||
+ showMinimalToast ||
+ showMinimalContextBleedThrough ||
+ shouldReserveSpaceForShortcutsHint);
-export const StatusRow: React.FC = ({
- showUiDetails,
- isNarrow,
- terminalWidth,
- hideContextSummary,
- hideUiDetailsForSuggestions,
- hasPendingActionRequired,
-}) => {
- const uiState = useUIState();
- const settings = useSettings();
- const {
- isInteractiveShellWaiting,
- showLoadingIndicator,
- showTips,
- showWit,
- modeContentObj,
- showMinimalContext,
- } = useComposerStatus();
-
- const [statusWidth, setStatusWidth] = useState(0);
- const [tipWidth, setTipWidth] = useState(0);
- const tipObserverRef = useRef(null);
-
- const onTipRefChange = useCallback((node: DOMElement | null) => {
- if (tipObserverRef.current) {
- tipObserverRef.current.disconnect();
- tipObserverRef.current = null;
- }
-
- if (node) {
- const observer = new ResizeObserver((entries) => {
- const entry = entries[0];
- if (entry) {
- setTipWidth(Math.round(entry.contentRect.width));
- }
- });
- observer.observe(node);
- tipObserverRef.current = observer;
- }
- }, []);
-
- const tipContentStr = (() => {
- // 1. Proactive Tip (Priority)
- if (
- showTips &&
- uiState.currentTip &&
- !(
- isInteractiveShellWaiting &&
- uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
- )
- ) {
- return uiState.currentTip;
- }
-
- // 2. Shortcut Hint (Fallback)
- if (
- settings.merged.ui.showShortcutsHint &&
- !hideUiDetailsForSuggestions &&
- !hasPendingActionRequired &&
- uiState.buffer.text.length === 0
- ) {
- return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
- }
-
- return undefined;
- })();
-
- // Collision detection using measured widths
- const willCollideTip =
- statusWidth + tipWidth + LAYOUT.COLLISION_GAP > terminalWidth;
-
- const showTipLine = Boolean(
- !hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow,
- );
-
- const showRow1Minimal =
- showLoadingIndicator || uiState.activeHooks.length > 0 || showTipLine;
- const showRow2Minimal =
- (Boolean(modeContentObj) && !hideUiDetailsForSuggestions) ||
- showMinimalContext;
-
- const showRow1 = showUiDetails || showRow1Minimal;
- const showRow2 = showUiDetails || showRow2Minimal;
-
- const statusNode = (
- (
+
);
- const renderTipNode = () => {
- if (!tipContentStr) return null;
-
- const isShortcutHint =
- tipContentStr === '? for shortcuts' ||
- tipContentStr === 'press tab twice for more';
- const color =
- isShortcutHint && uiState.shortcutsHelpVisible
- ? theme.text.accent
- : theme.text.secondary;
-
- return (
-
-
- {tipContentStr === uiState.currentTip
- ? `Tip: ${tipContentStr}`
- : tipContentStr}
-
-
- );
- };
-
- if (!showUiDetails && !showRow1Minimal && !showRow2Minimal) {
- return ;
- }
-
return (
-
- {/* Row 1: Status & Tips */}
- {showRow1 && (
+ <>
+ {/* Minimal UI Mode Meta Row */}
+ {showMinimalMetaRow && (
-
- {!showUiDetails && showRow1Minimal ? (
-
- {statusNode}
- {!showUiDetails && showRow2Minimal && modeContentObj && (
-
-
- ● {modeContentObj.text}
-
-
- )}
-
- ) : isInteractiveShellWaiting ? (
-
-
- ! Shell awaiting input (Tab to focus)
-
-
- ) : (
-
- {statusNode}
-
- )}
-
-
-
- {/*
- We always render the tip node so it can be measured by ResizeObserver,
- but we control its visibility based on the collision detection.
- */}
-
- {!isNarrow && tipContentStr && renderTipNode()}
-
-
-
- )}
-
- {/* Internal Separator */}
- {showRow1 &&
- showRow2 &&
- (showUiDetails || (showRow1Minimal && showRow2Minimal)) && (
-
-
-
- )}
-
- {/* Row 2: Modes & Context */}
- {showRow2 && (
-
- {showUiDetails ? (
- <>
- {!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
-
- )}
- {uiState.shellModeActive && (
-
-
-
- )}
- {!uiState.renderMarkdown && (
-
-
-
- )}
- >
- ) : (
- showRow2Minimal &&
- modeContentObj && (
-
- ● {modeContentObj.text}
-
- )
+ {!showUiDetails && showLoadingIndicator && renderLoadingIndicator()}
+ {showMinimalToast && (
+
+
+
)}
-
- {(showUiDetails || showMinimalContext) && (
-
- )}
- {showMinimalContext && !showUiDetails && (
-
+ {(showMinimalContextBleedThrough ||
+ (ambientContent && !showUiDetails)) && (
+
+ {showMinimalContextBleedThrough && (
-
+ )}
+ {ambientContent && !showUiDetails && (
+
+
+ {ambientContent.text}
+
+
+ )}
+
+ )}
+
+ )}
+
+ {/* Below Divider Zone: Active Processing and Status */}
+ {showUiDetails && (
+
+
+ {showLoadingIndicator && renderLoadingIndicator()}
+
+
+
+ {!showLoadingIndicator && (
+
)}
)}
-
+ >
);
};
diff --git a/packages/cli/src/ui/components/UnifiedModeIndicator.test.tsx b/packages/cli/src/ui/components/UnifiedModeIndicator.test.tsx
new file mode 100644
index 0000000000..f526fd8ee1
--- /dev/null
+++ b/packages/cli/src/ui/components/UnifiedModeIndicator.test.tsx
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { render } from '../../test-utils/render.js';
+import {
+ UnifiedModeIndicator,
+ getModeHeaderLabel,
+} from './UnifiedModeIndicator.js';
+import { ApprovalMode } from '@google/gemini-cli-core';
+
+describe('UnifiedModeIndicator', () => {
+ describe('getModeHeaderLabel', () => {
+ it('returns shell exit label when shell is active', () => {
+ expect(getModeHeaderLabel(ApprovalMode.DEFAULT, true)).toBe(
+ 'exit shell (!)',
+ );
+ });
+
+ it('returns yolo toggle label when YOLO is active and shell is NOT active', () => {
+ expect(getModeHeaderLabel(ApprovalMode.YOLO, false)).toBe(
+ 'toggle yolo (Ctrl+Y)',
+ );
+ });
+
+ it('returns default mode label for other modes', () => {
+ expect(getModeHeaderLabel(ApprovalMode.DEFAULT, false)).toBe(
+ 'mode (Shift+Tab)',
+ );
+ expect(getModeHeaderLabel(ApprovalMode.PLAN, false)).toBe(
+ 'mode (Shift+Tab)',
+ );
+ expect(getModeHeaderLabel(ApprovalMode.AUTO_EDIT, false)).toBe(
+ 'mode (Shift+Tab)',
+ );
+ });
+ });
+
+ describe('Rendering', () => {
+ it('renders shell mode with precedence over YOLO', async () => {
+ const { lastFrame } = await render(
+ ,
+ );
+ expect(lastFrame()).toContain('shell');
+ expect(lastFrame()).not.toContain('YOLO');
+ });
+
+ it('renders YOLO mode with precedence over background mode', async () => {
+ const { lastFrame } = await render(
+ ,
+ );
+ expect(lastFrame()).toContain('YOLO');
+ expect(lastFrame()).not.toContain('manual');
+ });
+
+ it('renders background mode (manual)', async () => {
+ const { lastFrame } = await render(
+ ,
+ );
+ expect(lastFrame()).toContain('manual');
+ });
+
+ it('renders background mode (plan)', async () => {
+ const { lastFrame } = await render(
+ ,
+ );
+ expect(lastFrame()).toContain('plan');
+ });
+
+ it('renders background mode (auto-accept)', async () => {
+ const { lastFrame } = await render(
+ ,
+ );
+ expect(lastFrame()).toContain('auto-accept');
+ });
+
+ it('renders raw markdown modifier', async () => {
+ const { lastFrame } = await render(
+ ,
+ );
+ expect(lastFrame()).toContain('manual');
+ expect(lastFrame()).toContain('·');
+ expect(lastFrame()).toContain('raw');
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/UnifiedModeIndicator.tsx b/packages/cli/src/ui/components/UnifiedModeIndicator.tsx
new file mode 100644
index 0000000000..65f750cb63
--- /dev/null
+++ b/packages/cli/src/ui/components/UnifiedModeIndicator.tsx
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { ApprovalMode } from '@google/gemini-cli-core';
+import { theme } from '../semantic-colors.js';
+
+export interface UnifiedModeIndicatorProps {
+ approvalMode: ApprovalMode;
+ shellModeActive: boolean;
+ renderMarkdown: boolean;
+}
+
+/**
+ * Returns the dynamic header label for the mode section.
+ */
+export function getModeHeaderLabel(
+ approvalMode: ApprovalMode,
+ shellModeActive: boolean,
+): string {
+ if (shellModeActive) {
+ return 'exit shell (!)';
+ }
+ if (approvalMode === ApprovalMode.YOLO) {
+ return 'toggle yolo (Ctrl+Y)';
+ }
+ return 'mode (Shift+Tab)';
+}
+
+/**
+ * A unified indicator that handles ApprovalMode, ShellMode, and RawMarkdownMode.
+ * It enforces a visual hierarchy where special modes like Shell and YOLO
+ * obscure the background mode.
+ */
+export const UnifiedModeIndicator: React.FC = ({
+ approvalMode,
+ shellModeActive,
+ renderMarkdown,
+}) => {
+ const parts: React.ReactNode[] = [];
+
+ // 1. Primary Mode (Shell > YOLO > Others)
+ let modeTextColor = theme.text.accent;
+ let modeText = 'manual';
+
+ if (shellModeActive) {
+ modeTextColor = theme.ui.symbol;
+ modeText = 'shell';
+ } else if (approvalMode === ApprovalMode.YOLO) {
+ modeTextColor = theme.status.error;
+ modeText = 'YOLO';
+ } else {
+ switch (approvalMode) {
+ case ApprovalMode.AUTO_EDIT:
+ modeTextColor = theme.status.warning;
+ modeText = 'auto-accept';
+ break;
+ case ApprovalMode.PLAN:
+ modeTextColor = theme.status.success;
+ modeText = 'plan';
+ break;
+ case ApprovalMode.DEFAULT:
+ default:
+ modeTextColor = theme.text.accent;
+ modeText = 'manual';
+ break;
+ }
+ }
+
+ parts.push(
+
+ {modeText}
+ ,
+ );
+
+ // 2. Secondary Modifier: Raw Markdown Mode
+ if (!renderMarkdown) {
+ parts.push(
+
+ raw
+ ,
+ );
+ }
+
+ // Join parts with middle dot separator
+ const renderedParts: React.ReactNode[] = [];
+ parts.forEach((part, index) => {
+ if (index > 0) {
+ renderedParts.push(
+
+ {' · '}
+ ,
+ );
+ }
+ renderedParts.push(part);
+ });
+
+ return {renderedParts};
+};
diff --git a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap
index 745347bc95..f1426de641 100644
--- a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap
@@ -1,33 +1,30 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Composer > Snapshots > matches snapshot in idle state 1`] = `
-"
- ? for shortcuts
+" ? for shortcuts
+ StatusDisplay
────────────────────────────────────────────────────────────────────────────────────────────────────
- ApprovalModeIndicator: default StatusDisplay
InputPrompt: Type your message or @path/to/file
Footer
"
`;
exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = `
-" press tab twice for more
+" press tab twice for more
InputPrompt: Type your message or @path/to/file
"
`;
exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = `
-"LoadingIndicator press tab twice for more
+" LoadingIndicator press tab twice for more
InputPrompt: Type your message or @path/to/file
"
`;
exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = `
-"
- ? for shortcuts
+" ? for shortcuts
+ StatusDisplay
────────────────────────────────────────
- ApprovalModeIndicator: StatusDispl
- default ay
InputPrompt: Type your message or
@path/to/file
Footer
@@ -35,10 +32,9 @@ Footer
`;
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
-"
- LoadingIndicator: Thinking ? for shortcuts
+" ? for shortcuts
+ LoadingIndicator: Thinking
────────────────────────────────────────────────────────────────────────────────────────────────────
- ApprovalModeIndicator: default StatusDisplay
InputPrompt: Type your message or @path/to/file
Footer
"