feat(cli): unify session modes in footer and stabilize Composer layout

This commit is contained in:
Keith Guerin
2026-03-26 17:18:40 -07:00
parent 8868b34c75
commit a44ea49cf7
17 changed files with 1201 additions and 992 deletions
+457
View File
@@ -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 (
<Box
flexDirection="column"
width={uiState.terminalWidth}
flexGrow={0}
flexShrink={0}
>
{(!uiState.slashCommands ||
!uiState.isConfigInitialized ||
uiState.isResuming) && (
<ConfigInitDisplay
message={uiState.isResuming ? 'Resuming session...' : undefined}
/>
)}
{showUiDetails && (
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
)}
{showUiDetails && <TodoTray />}
<<<<<<< HEAD
{showShortcutsHelp && <ShortcutsHelp />}
{(showUiDetails || showMinimalToast) && (
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
<ToastDisplay />
</Box>
)}
<Box width="100%" flexDirection="column">
<StatusRow
showUiDetails={showUiDetails}
isNarrow={isNarrow}
terminalWidth={terminalWidth}
hideContextSummary={hideContextSummary}
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
hasPendingActionRequired={hasPendingActionRequired}
/>
=======
<Box width="100%" flexDirection="column">
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
>
{showUiDetails && (hasToast ? <ToastDisplay /> : null)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={
showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0
}
>
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
</Box>
</Box>
{showMinimalMetaRow && (
<Box
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
>
{showMinimalInlineLoading && (
<LoadingIndicator
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation ||
inlineThinkingMode === 'full'
? undefined
: uiState.thought
}
currentLoadingPhrase={
loadingPhrases === 'off'
? undefined
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full'
? typeof uiState.thought === 'string'
? uiState.thought
: uiState.thought?.subject || 'Thinking...'
: undefined
}
elapsedTime={uiState.elapsedTime}
showTips={showTips}
showWit={showWit}
wittyPhrase={uiState.currentWittyPhrase}
errorVerbosity={settings.merged.ui.errorVerbosity}
/>
)}
{hasMinimalStatusBleedThrough && (
<Box marginLeft={showMinimalInlineLoading ? 1 : 0}>
<ToastDisplay />
</Box>
)}
</Box>
{(showMinimalContextBleedThrough ||
shouldReserveSpaceForShortcutsHint) && (
<Box
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={1}
>
{showMinimalContextBleedThrough && (
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={uiState.currentModel}
terminalWidth={uiState.terminalWidth}
/>
)}
<Box
marginLeft={
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
}
marginTop={showMinimalContextBleedThrough && isNarrow ? 1 : 0}
>
{showShortcutsHint && <ShortcutsHint />}
</Box>
</Box>
)}
</Box>
)}
{showShortcutsHelp && <ShortcutsHelp />}
{showUiDetails && <HorizontalLine />}
{showUiDetails && (
<Box
justifyContent={
settings.merged.ui.hideContextSummary
? 'flex-start'
: 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems="center"
flexGrow={1}
>
{showLoadingIndicator && (
<LoadingIndicator
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation ||
inlineThinkingMode === 'full'
? undefined
: uiState.thought
}
currentLoadingPhrase={
loadingPhrases === 'off'
? undefined
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full'
? typeof uiState.thought === 'string'
? uiState.thought
: uiState.thought?.subject || 'Thinking...'
: undefined
}
elapsedTime={uiState.elapsedTime}
showTips={showTips}
showWit={showWit}
wittyPhrase={uiState.currentWittyPhrase}
errorVerbosity={settings.merged.ui.errorVerbosity}
/>
)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{!showLoadingIndicator && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
</Box>
</Box>
)}
>>>>>>> 96b0876e6 (feat(cli): unify session modes in footer and reorganize composer layout)
</Box>
{showUiDetails && uiState.showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
<DetailedMessagesDisplay
maxHeight={
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
}
width={uiState.terminalWidth}
hasFocus={uiState.showErrorDetails}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
)}
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}
inputWidth={uiState.inputWidth}
suggestionsWidth={uiState.suggestionsWidth}
onSubmit={uiActions.handleFinalSubmit}
userMessages={uiState.userMessages}
setBannerVisible={uiActions.setBannerVisible}
onClearScreen={uiActions.handleClearScreen}
config={config}
slashCommands={uiState.slashCommands || []}
commandContext={uiState.commandContext}
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
approvalMode={uiState.showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
placeholder={
vimEnabled
? vimMode === 'INSERT'
? " Press 'Esc' for NORMAL mode."
: " Press 'i' for INSERT mode."
: uiState.shellModeActive
? ' Type your shell command'
: ' Type your message or @path/to/file'
}
setQueueErrorMessage={uiActions.setQueueErrorMessage}
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
/>
)}
{showUiDetails &&
!settings.merged.ui.hideFooter &&
!isScreenReaderEnabled && <Footer />}
</Box>
);
};
+79
View File
@@ -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.
+27 -4
View File
@@ -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',
@@ -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(
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for AUTO_EDIT mode with plan enabled', async () => {
const { lastFrame } = await render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.AUTO_EDIT}
allowPlanMode={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for PLAN mode', async () => {
const { lastFrame } = await render(
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for YOLO mode', async () => {
const { lastFrame } = await render(
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode', async () => {
const { lastFrame } = await render(
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode with plan enabled', async () => {
const { lastFrame } = await render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.DEFAULT}
allowPlanMode={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -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<ApprovalModeIndicatorProps> = ({
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 (
<Box>
<Text color={textColor}>
{textContent ? textContent : null}
{subText ? (
<Text color={theme.text.secondary}>
{textContent ? ' ' : ''}
{subText}
</Text>
) : null}
</Text>
</Box>
);
};
+81 -349
View File
@@ -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 <Text>LoadingIndicator{text ? `: ${text}` : ''}</Text>;
return (
<Box>
<Text>LoadingIndicator{text ? `: ${text}` : ''}</Text>
{wittyPhrase && (
<Box marginLeft={1}>
<Text>{wittyPhrase}</Text>
</Box>
)}
</Box>
);
},
}));
@@ -75,24 +81,14 @@ vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
}));
vi.mock('./HookStatusDisplay.js', () => ({
HookStatusDisplay: () => <Text>HookStatusDisplay</Text>,
}));
vi.mock('./ApprovalModeIndicator.js', () => ({
ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => (
<Text>ApprovalModeIndicator: {approvalMode}</Text>
),
}));
vi.mock('./ShellModeIndicator.js', () => ({
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
}));
vi.mock('./ShortcutsHelp.js', () => ({
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
}));
vi.mock('./ShortcutsHint.js', () => ({
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
}));
vi.mock('./DetailedMessagesDisplay.js', () => ({
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
}));
@@ -145,6 +141,12 @@ vi.mock('./QueuedMessageDisplay.js', () => ({
},
}));
vi.mock('./ContextUsageDisplay.js', () => ({
ContextUsageDisplay: ({ promptTokenCount }: { promptTokenCount: number }) => (
<Text>ContextUsageDisplay: {promptTokenCount}</Text>
),
}));
// 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> = {}): UIState =>
({
terminalWidth: 100,
streamingState: StreamingState.Idle,
isConfigInitialized: true,
contextFileNames: [],
@@ -186,8 +189,10 @@ const createMockUIState = (overrides: Partial<UIState> = {}): 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<typeof useVimMode>);
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: (
<Box>
<Text>Prompt</Text>
</Box>
),
});
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: (
<Box>
<Text>Test Dialog</Text>
</Box>
),
});
const { lastFrame, unmount } = await renderComposer(uiState);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
describe('Snapshots', () => {
it('matches snapshot in idle state', async () => {
const uiState = createMockUIState();
+110 -24
View File
@@ -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 && <TodoTray />}
{showShortcutsHelp && <ShortcutsHelp />}
{(showUiDetails || showMinimalToast) && (
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
<ToastDisplay />
</Box>
)}
<Box width="100%" flexDirection="column">
{/* Above Divider Zone: Alerts, Tips, and Hints */}
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
>
{showUiDetails && hasToast && <ToastDisplay />}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={showUiDetails && ambientContent ? 1 : 0}
>
{showUiDetails && ambientContent && (
<Box flexDirection="row" justifyContent="flex-end">
<Text
color={
!ambientContent.isTip && uiState.shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary
}
wrap="truncate-end"
>
{ambientContent.text}
</Text>
</Box>
)}
</Box>
</Box>
<StatusRow
showUiDetails={showUiDetails}
isNarrow={isNarrow}
terminalWidth={terminalWidth}
uiState={uiState}
settings={settings}
hideContextSummary={hideContextSummary}
isNarrow={isNarrow}
ambientContent={ambientContent}
showUiDetails={showUiDetails}
showMinimalToast={showMinimalToast}
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
hasPendingActionRequired={hasPendingActionRequired}
/>
{showShortcutsHelp && <ShortcutsHelp />}
{showUiDetails && <HorizontalLine />}
</Box>
{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 && (
<Footer copyModeEnabled={uiState.copyModeEnabled} />
)}
!isScreenReaderEnabled && <Footer />}
</Box>
);
};
+43 -11
View File
@@ -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', '', () => <DebugProfiler />, 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
'',
() => <Text color={theme.text.accent}>{vimStr}</Text>,
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,
() => (
<UnifiedModeIndicator
approvalMode={uiState.showApprovalModeIndicator}
shellModeActive={uiState.shellModeActive}
renderMarkdown={uiState.renderMarkdown}
/>
),
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', '', () => <CorgiIndicator />, 5);
if (corgiMode) addCol('mode', '', () => <CorgiIndicator />, 5); // Hacky ID for now
if (showErrorSummary) {
addCol(
'error-count',
'mode', // Hacky ID
'',
() => <ConsoleSummaryDisplay errorCount={errorCount} />,
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,
@@ -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(<RawMarkdownIndicator />);
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(<RawMarkdownIndicator />);
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('Alt+M to toggle');
unmount();
});
});
@@ -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 (
<Box>
<Text>
raw markdown mode
<Text color={theme.text.secondary}> ({modKey} to toggle) </Text>
</Text>
</Box>
);
};
@@ -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(<ShellModeIndicator />);
expect(lastFrame()).toContain('shell mode enabled');
expect(lastFrame()).toContain('esc to disable');
unmount();
});
});
@@ -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 = () => (
<Box>
<Text color={theme.ui.symbol}>
shell mode enabled
<Text color={theme.text.secondary}> (esc to disable)</Text>
</Text>
</Box>
);
@@ -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 (
<Box flexDirection="row">
<Text color={color}>{text}</Text>
</Box>
);
};
+151 -362
View File
@@ -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<typeof useUIState>;
settings: ReturnType<typeof useSettings>;
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<StatusRowProps> = ({
uiState,
settings,
hideContextSummary,
isNarrow,
ambientContent,
showUiDetails,
showMinimalToast,
hideUiDetailsForSuggestions,
hasPendingActionRequired,
}) => {
const observerRef = useRef<ResizeObserver | null>(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 (
<Box ref={onRefChange}>
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={errorVerbosity}
thought={currentThought}
currentLoadingPhrase={currentLoadingPhrase}
elapsedTime={elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={currentWittyPhrase}
/>
</Box>
);
};
const showMinimalMetaRow =
!showUiDetails &&
(showLoadingIndicator ||
showMinimalToast ||
showMinimalContextBleedThrough ||
shouldReserveSpaceForShortcutsHint);
export const StatusRow: React.FC<StatusRowProps> = ({
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<ResizeObserver | null>(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 = (
<StatusNode
const renderLoadingIndicator = () => (
<LoadingIndicator
inline
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
inlineThinkingMode === 'full'
? undefined
: uiState.thought
}
currentLoadingPhrase={loadingPhrases === 'off' ? undefined : hookText}
thoughtLabel={
inlineThinkingMode === 'full'
? typeof uiState.thought === 'string'
? uiState.thought
: uiState.thought?.subject || 'Thinking...'
: undefined
}
elapsedTime={uiState.elapsedTime}
showTips={showTips}
showWit={showWit}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
currentWittyPhrase={uiState.currentWittyPhrase}
activeHooks={uiState.activeHooks}
showLoadingIndicator={showLoadingIndicator}
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
onResize={setStatusWidth}
wittyPhrase={uiState.currentWittyPhrase}
errorVerbosity={settings.merged.ui.errorVerbosity}
/>
);
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 (
<Box flexDirection="row" justifyContent="flex-end" ref={onTipRefChange}>
<Text
color={color}
wrap="truncate-end"
italic={
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
}
>
{tipContentStr === uiState.currentTip
? `Tip: ${tipContentStr}`
: tipContentStr}
</Text>
</Box>
);
};
if (!showUiDetails && !showRow1Minimal && !showRow2Minimal) {
return <Box height={LAYOUT.STATUS_MIN_HEIGHT} />;
}
return (
<Box flexDirection="column" width="100%">
{/* Row 1: Status & Tips */}
{showRow1 && (
<>
{/* Minimal UI Mode Meta Row */}
{showMinimalMetaRow && (
<Box
width="100%"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
minHeight={LAYOUT.STATUS_MIN_HEIGHT}
>
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
{!showUiDetails && showRow1Minimal ? (
<Box flexDirection="row" columnGap={1}>
{statusNode}
{!showUiDetails && showRow2Minimal && modeContentObj && (
<Box>
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
</Box>
)}
</Box>
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
</Text>
</Box>
) : (
<Box
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
flexShrink={0}
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
>
{statusNode}
</Box>
)}
</Box>
<Box
flexShrink={0}
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
marginRight={
isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
}
>
{/*
We always render the tip node so it can be measured by ResizeObserver,
but we control its visibility based on the collision detection.
*/}
<Box display={showTipLine ? 'flex' : 'none'}>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
</Box>
</Box>
)}
{/* Internal Separator */}
{showRow1 &&
showRow2 &&
(showUiDetails || (showRow1Minimal && showRow2Minimal)) && (
<Box width="100%">
<HorizontalLine dim />
</Box>
)}
{/* Row 2: Modes & Context */}
{showRow2 && (
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent="space-between"
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems="center"
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
>
{showUiDetails ? (
<>
{!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
<ApprovalModeIndicator
approvalMode={uiState.showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{uiState.shellModeActive && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ShellModeIndicator />
</Box>
)}
{!uiState.renderMarkdown && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<RawMarkdownIndicator />
</Box>
)}
</>
) : (
showRow2Minimal &&
modeContentObj && (
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
)
{!showUiDetails && showLoadingIndicator && renderLoadingIndicator()}
{showMinimalToast && (
<Box marginLeft={!showUiDetails && showLoadingIndicator ? 1 : 0}>
<ToastDisplay />
</Box>
)}
</Box>
<Box
marginTop={isNarrow ? LAYOUT.CONTEXT_DISPLAY_TOP_MARGIN_NARROW : 0}
flexDirection="row"
alignItems="center"
marginLeft={
isNarrow
? LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_NARROW
: LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_WIDE
}
>
{(showUiDetails || showMinimalContext) && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
{showMinimalContext && !showUiDetails && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
{(showMinimalContextBleedThrough ||
(ambientContent && !showUiDetails)) && (
<Box
marginTop={isNarrow && showMinimalToast ? 1 : 0}
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={1}
>
{showMinimalContextBleedThrough && (
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined
}
terminalWidth={terminalWidth}
model={uiState.currentModel}
terminalWidth={uiState.terminalWidth}
/>
</Box>
)}
{ambientContent && !showUiDetails && (
<Box
marginLeft={
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
}
marginTop={showMinimalContextBleedThrough && isNarrow ? 1 : 0}
>
<Text color={theme.text.secondary} wrap="truncate-end">
{ambientContent.text}
</Text>
</Box>
)}
</Box>
)}
</Box>
)}
{/* Below Divider Zone: Active Processing and Status */}
{showUiDetails && (
<Box
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems="center"
flexGrow={1}
>
{showLoadingIndicator && renderLoadingIndicator()}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{!showLoadingIndicator && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
</Box>
</Box>
)}
</Box>
</>
);
};
@@ -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(
<UnifiedModeIndicator
approvalMode={ApprovalMode.YOLO}
shellModeActive={true}
renderMarkdown={true}
/>,
);
expect(lastFrame()).toContain('shell');
expect(lastFrame()).not.toContain('YOLO');
});
it('renders YOLO mode with precedence over background mode', async () => {
const { lastFrame } = await render(
<UnifiedModeIndicator
approvalMode={ApprovalMode.YOLO}
shellModeActive={false}
renderMarkdown={true}
/>,
);
expect(lastFrame()).toContain('YOLO');
expect(lastFrame()).not.toContain('manual');
});
it('renders background mode (manual)', async () => {
const { lastFrame } = await render(
<UnifiedModeIndicator
approvalMode={ApprovalMode.DEFAULT}
shellModeActive={false}
renderMarkdown={true}
/>,
);
expect(lastFrame()).toContain('manual');
});
it('renders background mode (plan)', async () => {
const { lastFrame } = await render(
<UnifiedModeIndicator
approvalMode={ApprovalMode.PLAN}
shellModeActive={false}
renderMarkdown={true}
/>,
);
expect(lastFrame()).toContain('plan');
});
it('renders background mode (auto-accept)', async () => {
const { lastFrame } = await render(
<UnifiedModeIndicator
approvalMode={ApprovalMode.AUTO_EDIT}
shellModeActive={false}
renderMarkdown={true}
/>,
);
expect(lastFrame()).toContain('auto-accept');
});
it('renders raw markdown modifier', async () => {
const { lastFrame } = await render(
<UnifiedModeIndicator
approvalMode={ApprovalMode.DEFAULT}
shellModeActive={false}
renderMarkdown={false}
/>,
);
expect(lastFrame()).toContain('manual');
expect(lastFrame()).toContain('·');
expect(lastFrame()).toContain('raw');
});
});
});
@@ -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<UnifiedModeIndicatorProps> = ({
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(
<Text key="mode" color={modeTextColor}>
{modeText}
</Text>,
);
// 2. Secondary Modifier: Raw Markdown Mode
if (!renderMarkdown) {
parts.push(
<Text key="raw" color={theme.text.secondary}>
raw
</Text>,
);
}
// Join parts with middle dot separator
const renderedParts: React.ReactNode[] = [];
parts.forEach((part, index) => {
if (index > 0) {
renderedParts.push(
<Text key={`sep-${index}`} color={theme.ui.comment}>
{' · '}
</Text>,
);
}
renderedParts.push(part);
});
return <Box>{renderedParts}</Box>;
};
@@ -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
"