mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-14 03:50:49 -07:00
feat(cli): unify session modes in footer and stabilize Composer layout
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
@@ -6,7 +6,28 @@
|
|||||||
|
|
||||||
import type { MergedSettings } from './settings.js';
|
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',
|
id: 'workspace',
|
||||||
header: 'workspace (/directory)',
|
header: 'workspace (/directory)',
|
||||||
@@ -57,11 +78,12 @@ export const ALL_ITEMS = [
|
|||||||
header: 'tokens',
|
header: 'tokens',
|
||||||
description: 'Total tokens used in the session (not shown when zero)',
|
description: 'Total tokens used in the session (not shown when zero)',
|
||||||
},
|
},
|
||||||
] as const;
|
];
|
||||||
|
|
||||||
export type FooterItemId = (typeof ALL_ITEMS)[number]['id'];
|
export type FooterItemId = (typeof ALL_ITEMS)[number]['id'];
|
||||||
|
|
||||||
export const DEFAULT_ORDER = [
|
export const DEFAULT_ORDER: FooterItemId[] = [
|
||||||
|
'mode',
|
||||||
'workspace',
|
'workspace',
|
||||||
'git-branch',
|
'git-branch',
|
||||||
'sandbox',
|
'sandbox',
|
||||||
@@ -77,7 +99,8 @@ export const DEFAULT_ORDER = [
|
|||||||
export function deriveItemsFromLegacySettings(
|
export function deriveItemsFromLegacySettings(
|
||||||
settings: MergedSettings,
|
settings: MergedSettings,
|
||||||
): string[] {
|
): string[] {
|
||||||
const defaults = [
|
const defaults: string[] = [
|
||||||
|
'mode',
|
||||||
'workspace',
|
'workspace',
|
||||||
'git-branch',
|
'git-branch',
|
||||||
'sandbox',
|
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -17,16 +17,11 @@ import {
|
|||||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||||
import { createMockSettings } from '../../test-utils/settings.js';
|
import { createMockSettings } from '../../test-utils/settings.js';
|
||||||
import {
|
import { ApprovalMode, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||||
ApprovalMode,
|
|
||||||
tokenLimit,
|
|
||||||
CoreToolCallStatus,
|
|
||||||
} from '@google/gemini-cli-core';
|
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { TransientMessageType } from '../../utils/events.js';
|
import { TransientMessageType } from '../../utils/events.js';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
import type { SessionMetrics } from '../contexts/SessionContext.js';
|
|
||||||
import type { TextBuffer } from './shared/text-buffer.js';
|
import type { TextBuffer } from './shared/text-buffer.js';
|
||||||
|
|
||||||
// Mock VimModeContext hook
|
// Mock VimModeContext hook
|
||||||
@@ -54,14 +49,25 @@ vi.mock('./LoadingIndicator.js', () => ({
|
|||||||
LoadingIndicator: ({
|
LoadingIndicator: ({
|
||||||
thought,
|
thought,
|
||||||
thoughtLabel,
|
thoughtLabel,
|
||||||
|
wittyPhrase,
|
||||||
}: {
|
}: {
|
||||||
thought?: { subject?: string } | string;
|
thought?: { subject?: string } | string;
|
||||||
thoughtLabel?: string;
|
thoughtLabel?: string;
|
||||||
|
wittyPhrase?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const fallbackText =
|
const fallbackText =
|
||||||
typeof thought === 'string' ? thought : thought?.subject;
|
typeof thought === 'string' ? thought : thought?.subject;
|
||||||
const text = thoughtLabel ?? fallbackText;
|
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>,
|
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', () => ({
|
vi.mock('./ShortcutsHelp.js', () => ({
|
||||||
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
|
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./ShortcutsHint.js', () => ({
|
||||||
|
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./DetailedMessagesDisplay.js', () => ({
|
vi.mock('./DetailedMessagesDisplay.js', () => ({
|
||||||
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
|
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
|
// Mock contexts
|
||||||
vi.mock('../contexts/OverflowContext.js', () => ({
|
vi.mock('../contexts/OverflowContext.js', () => ({
|
||||||
OverflowProvider: ({ children }: { children: React.ReactNode }) => children,
|
OverflowProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
@@ -153,6 +155,7 @@ vi.mock('../contexts/OverflowContext.js', () => ({
|
|||||||
// Create mock context providers
|
// Create mock context providers
|
||||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||||
({
|
({
|
||||||
|
terminalWidth: 100,
|
||||||
streamingState: StreamingState.Idle,
|
streamingState: StreamingState.Idle,
|
||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
contextFileNames: [],
|
contextFileNames: [],
|
||||||
@@ -186,8 +189,10 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|||||||
sessionStats: {
|
sessionStats: {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
sessionStartTime: new Date(),
|
sessionStartTime: new Date(),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
metrics: {
|
||||||
metrics: {} as any,
|
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
|
||||||
|
models: {},
|
||||||
|
},
|
||||||
lastPromptTokenCount: 0,
|
lastPromptTokenCount: 0,
|
||||||
promptCount: 0,
|
promptCount: 0,
|
||||||
},
|
},
|
||||||
@@ -266,7 +271,11 @@ const renderComposer = async (
|
|||||||
// Wait for shortcuts hint debounce if using fake timers
|
// Wait for shortcuts hint debounce if using fake timers
|
||||||
if (vi.isFakeTimers()) {
|
if (vi.isFakeTimers()) {
|
||||||
await act(async () => {
|
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);
|
const { lastFrame } = await renderComposer(uiState, settings);
|
||||||
|
|
||||||
// Check for content that only appears IN the Footer component itself
|
expect(lastFrame()).not.toContain('Footer');
|
||||||
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
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -383,12 +347,10 @@ describe('Composer', () => {
|
|||||||
const { lastFrame } = await renderComposer(uiState, settings);
|
const { lastFrame } = await renderComposer(uiState, settings);
|
||||||
|
|
||||||
const output = lastFrame();
|
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');
|
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({
|
const uiState = createMockUIState({
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
elapsedTime: 1,
|
elapsedTime: 1,
|
||||||
@@ -400,7 +362,6 @@ describe('Composer', () => {
|
|||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('LoadingIndicator');
|
expect(output).toContain('LoadingIndicator');
|
||||||
expect(output).toContain('press tab twice for more');
|
expect(output).toContain('press tab twice for more');
|
||||||
expect(output).not.toContain('? for shortcuts');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
|
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
|
||||||
@@ -416,7 +377,6 @@ describe('Composer', () => {
|
|||||||
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('LoadingIndicator');
|
expect(output).toContain('LoadingIndicator');
|
||||||
expect(output).toContain('LoadingIndicator: Hidden');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render LoadingIndicator when waiting for confirmation', async () => {
|
it('does not render LoadingIndicator when waiting for confirmation', async () => {
|
||||||
@@ -473,23 +433,6 @@ describe('Composer', () => {
|
|||||||
expect(output).toContain('LoadingIndicator');
|
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 () => {
|
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', async () => {
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
@@ -502,6 +445,23 @@ describe('Composer', () => {
|
|||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).not.toContain('LoadingIndicator');
|
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', () => {
|
describe('Message Queue Display', () => {
|
||||||
@@ -521,23 +481,10 @@ describe('Composer', () => {
|
|||||||
expect(output).toContain('Second queued message');
|
expect(output).toContain('Second queued message');
|
||||||
expect(output).toContain('Third 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', () => {
|
describe('Context and Status Display', () => {
|
||||||
it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => {
|
it('shows StatusDisplay in normal state', async () => {
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
ctrlCPressedOnce: false,
|
ctrlCPressedOnce: false,
|
||||||
ctrlDPressedOnce: false,
|
ctrlDPressedOnce: false,
|
||||||
@@ -548,11 +495,9 @@ describe('Composer', () => {
|
|||||||
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('StatusDisplay');
|
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({
|
const uiState = createMockUIState({
|
||||||
ctrlCPressedOnce: true,
|
ctrlCPressedOnce: true,
|
||||||
});
|
});
|
||||||
@@ -561,10 +506,6 @@ describe('Composer', () => {
|
|||||||
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('Press Ctrl+C again to exit.');
|
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 () => {
|
it('shows ToastDisplay for other toast types', async () => {
|
||||||
@@ -579,7 +520,6 @@ describe('Composer', () => {
|
|||||||
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('Warning');
|
expect(output).toContain('Warning');
|
||||||
expect(output).toContain('ApprovalModeIndicator');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -595,8 +535,7 @@ describe('Composer', () => {
|
|||||||
const { lastFrame } = await renderComposer(uiState, settings);
|
const { lastFrame } = await renderComposer(uiState, settings);
|
||||||
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).not.toContain('press tab twice for more');
|
expect(output).not.toContain('ShortcutsHint');
|
||||||
expect(output).not.toContain('? for shortcuts');
|
|
||||||
expect(output).toContain('InputPrompt');
|
expect(output).toContain('InputPrompt');
|
||||||
expect(output).not.toContain('Footer');
|
expect(output).not.toContain('Footer');
|
||||||
});
|
});
|
||||||
@@ -621,148 +560,41 @@ describe('Composer', () => {
|
|||||||
expect(lastFrame()).not.toContain('InputPrompt');
|
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 () => {
|
it('shows context usage bleed-through when over 60%', async () => {
|
||||||
const model = 'gemini-2.5-pro';
|
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
cleanUiDetailsVisible: false,
|
cleanUiDetailsVisible: false,
|
||||||
currentModel: model,
|
|
||||||
sessionStats: {
|
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(),
|
sessionStartTime: new Date(),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
metrics: {} as any,
|
|
||||||
lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7),
|
|
||||||
promptCount: 0,
|
promptCount: 0,
|
||||||
},
|
},
|
||||||
|
currentModel: 'gemini-1.5-pro',
|
||||||
});
|
});
|
||||||
const settings = createMockSettings({
|
const settings = createMockSettings({
|
||||||
ui: {
|
ui: { footer: { hideContextPercentage: false } },
|
||||||
footer: { hideContextPercentage: false },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastFrame } = await renderComposer(uiState, settings);
|
const { lastFrame } = await renderComposer(uiState, settings);
|
||||||
|
|
||||||
await act(async () => {
|
expect(lastFrame()).toContain('ContextUsageDisplay: 700000');
|
||||||
await vi.advanceTimersByTimeAsync(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
// StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode
|
|
||||||
expect(lastFrame()).toContain('StatusDisplay');
|
|
||||||
expect(lastFrame()).toContain('70% used');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -834,10 +666,6 @@ describe('Composer', () => {
|
|||||||
|
|
||||||
const { lastFrame } = await renderComposer(uiState);
|
const { lastFrame } = await renderComposer(uiState);
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await vi.advanceTimersByTimeAsync(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toContain(
|
expect(lastFrame({ allowEmpty: true })).toContain(
|
||||||
'press tab twice for more',
|
'press tab twice for more',
|
||||||
);
|
);
|
||||||
@@ -851,8 +679,7 @@ describe('Composer', () => {
|
|||||||
|
|
||||||
const { lastFrame } = await renderComposer(uiState);
|
const { lastFrame } = await renderComposer(uiState);
|
||||||
|
|
||||||
expect(lastFrame()).not.toContain('press tab twice for more');
|
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||||
expect(lastFrame()).not.toContain('? for shortcuts');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
|
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
|
||||||
@@ -865,7 +692,7 @@ describe('Composer', () => {
|
|||||||
|
|
||||||
const { lastFrame } = await renderComposer(uiState, settings);
|
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 () => {
|
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);
|
const { lastFrame } = await renderComposer(uiState);
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await vi.advanceTimersByTimeAsync(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('press tab twice for more');
|
expect(lastFrame()).toContain('press tab twice for more');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -905,51 +728,9 @@ describe('Composer', () => {
|
|||||||
|
|
||||||
const { lastFrame } = await renderComposer(uiState);
|
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');
|
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 () => {
|
it('shows shortcuts help in minimal mode when toggled on', async () => {
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
cleanUiDetailsVisible: false,
|
cleanUiDetailsVisible: false,
|
||||||
@@ -967,46 +748,11 @@ describe('Composer', () => {
|
|||||||
|
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
cleanUiDetailsVisible: false,
|
cleanUiDetailsVisible: false,
|
||||||
showApprovalModeIndicator: ApprovalMode.PLAN,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastFrame } = await renderComposer(uiState);
|
const { lastFrame } = await renderComposer(uiState);
|
||||||
|
|
||||||
expect(lastFrame()).not.toContain('press tab twice for more');
|
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1034,22 +780,8 @@ describe('Composer', () => {
|
|||||||
expect(lastFrame()).not.toContain('ShortcutsHelp');
|
expect(lastFrame()).not.toContain('ShortcutsHelp');
|
||||||
unmount();
|
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', () => {
|
describe('Snapshots', () => {
|
||||||
it('matches snapshot in idle state', async () => {
|
it('matches snapshot in idle state', async () => {
|
||||||
const uiState = createMockUIState();
|
const uiState = createMockUIState();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box, useIsScreenReaderEnabled } from 'ink';
|
import { Box, useIsScreenReaderEnabled, Text } from 'ink';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useConfig } from '../contexts/ConfigContext.js';
|
import { useConfig } from '../contexts/ConfigContext.js';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
@@ -26,6 +26,8 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
|
|||||||
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
|
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
|
||||||
import { TodoTray } from './messages/Todo.js';
|
import { TodoTray } from './messages/Todo.js';
|
||||||
import { useComposerStatus } from '../hooks/useComposerStatus.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 }) => {
|
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
@@ -40,6 +42,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||||
|
|
||||||
const isAlternateBuffer = useAlternateBuffer();
|
const isAlternateBuffer = useAlternateBuffer();
|
||||||
|
const { showApprovalModeIndicator } = uiState;
|
||||||
const showUiDetails = uiState.cleanUiDetailsVisible;
|
const showUiDetails = uiState.cleanUiDetailsVisible;
|
||||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||||
const hideContextSummary =
|
const hideContextSummary =
|
||||||
@@ -65,20 +68,69 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
setShortcutsHelpVisible,
|
setShortcutsHelpVisible,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const showShortcutsHelp =
|
const hideUiDetailsForSuggestions =
|
||||||
uiState.shortcutsHelpVisible &&
|
suggestionsVisible && suggestionsPosition === 'above';
|
||||||
uiState.streamingState === 'idle' &&
|
const isModelIdle = uiState.streamingState === 'idle';
|
||||||
|
const isModelResponding = uiState.streamingState === 'responding';
|
||||||
|
const isBufferEmpty = uiState.buffer.text.length === 0;
|
||||||
|
const canShowShortcutsHint =
|
||||||
|
(isModelIdle || isModelResponding) &&
|
||||||
|
isBufferEmpty &&
|
||||||
!hasPendingActionRequired;
|
!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) {
|
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasToast = shouldShowToast(uiState);
|
const showShortcutsHelp =
|
||||||
const hideUiDetailsForSuggestions =
|
uiState.shortcutsHelpVisible &&
|
||||||
suggestionsVisible && suggestionsPosition === 'above';
|
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;
|
const showMinimalToast = hasToast;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,23 +150,60 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
|
|
||||||
{showUiDetails && <TodoTray />}
|
{showUiDetails && <TodoTray />}
|
||||||
|
|
||||||
{showShortcutsHelp && <ShortcutsHelp />}
|
|
||||||
|
|
||||||
{(showUiDetails || showMinimalToast) && (
|
|
||||||
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
|
|
||||||
<ToastDisplay />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box width="100%" flexDirection="column">
|
<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
|
<StatusRow
|
||||||
showUiDetails={showUiDetails}
|
uiState={uiState}
|
||||||
isNarrow={isNarrow}
|
settings={settings}
|
||||||
terminalWidth={terminalWidth}
|
|
||||||
hideContextSummary={hideContextSummary}
|
hideContextSummary={hideContextSummary}
|
||||||
|
isNarrow={isNarrow}
|
||||||
|
ambientContent={ambientContent}
|
||||||
|
showUiDetails={showUiDetails}
|
||||||
|
showMinimalToast={showMinimalToast}
|
||||||
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
|
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
|
||||||
hasPendingActionRequired={hasPendingActionRequired}
|
hasPendingActionRequired={hasPendingActionRequired}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showShortcutsHelp && <ShortcutsHelp />}
|
||||||
|
{showUiDetails && <HorizontalLine />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{showUiDetails && uiState.showErrorDetails && (
|
{showUiDetails && uiState.showErrorDetails && (
|
||||||
@@ -146,7 +235,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
commandContext={uiState.commandContext}
|
commandContext={uiState.commandContext}
|
||||||
shellModeActive={uiState.shellModeActive}
|
shellModeActive={uiState.shellModeActive}
|
||||||
setShellModeActive={uiActions.setShellModeActive}
|
setShellModeActive={uiActions.setShellModeActive}
|
||||||
approvalMode={uiState.showApprovalModeIndicator}
|
approvalMode={showApprovalModeIndicator}
|
||||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||||
focus={isFocused}
|
focus={isFocused}
|
||||||
vimHandleInput={uiActions.vimHandleInput}
|
vimHandleInput={uiActions.vimHandleInput}
|
||||||
@@ -165,15 +254,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
streamingState={uiState.streamingState}
|
streamingState={uiState.streamingState}
|
||||||
suggestionsPosition={suggestionsPosition}
|
suggestionsPosition={suggestionsPosition}
|
||||||
onSuggestionsVisibilityChange={setSuggestionsVisible}
|
onSuggestionsVisibilityChange={setSuggestionsVisible}
|
||||||
copyModeEnabled={uiState.copyModeEnabled}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showUiDetails &&
|
{showUiDetails &&
|
||||||
!settings.merged.ui.hideFooter &&
|
!settings.merged.ui.hideFooter &&
|
||||||
!isScreenReaderEnabled && (
|
!isScreenReaderEnabled && <Footer />}
|
||||||
<Footer copyModeEnabled={uiState.copyModeEnabled} />
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import process from 'node:process';
|
|||||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||||
import { QuotaDisplay } from './QuotaDisplay.js';
|
import { QuotaDisplay } from './QuotaDisplay.js';
|
||||||
import { DebugProfiler } from './DebugProfiler.js';
|
import {
|
||||||
|
UnifiedModeIndicator,
|
||||||
|
getModeHeaderLabel,
|
||||||
|
} from './UnifiedModeIndicator.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import { useConfig } from '../contexts/ConfigContext.js';
|
import { useConfig } from '../contexts/ConfigContext.js';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
@@ -168,7 +171,7 @@ function isFooterItemId(id: string): id is FooterItemId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FooterColumn {
|
interface FooterColumn {
|
||||||
id: string;
|
id: FooterItemId;
|
||||||
header: string;
|
header: string;
|
||||||
element: (maxWidth: number) => React.ReactNode;
|
element: (maxWidth: number) => React.ReactNode;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -230,7 +233,7 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
|
|||||||
const potentialColumns: FooterColumn[] = [];
|
const potentialColumns: FooterColumn[] = [];
|
||||||
|
|
||||||
const addCol = (
|
const addCol = (
|
||||||
id: string,
|
id: FooterItemId,
|
||||||
header: string,
|
header: string,
|
||||||
element: (maxWidth: number) => React.ReactNode,
|
element: (maxWidth: number) => React.ReactNode,
|
||||||
dataWidth: number,
|
dataWidth: number,
|
||||||
@@ -246,13 +249,12 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 1. System Indicators (Far Left, high priority)
|
// 1. System Indicators (Far Left, high priority)
|
||||||
if (uiState.showDebugProfiler) {
|
// Note: These don't have IDs in ALL_ITEMS yet, but we handle them as specials
|
||||||
addCol('debug', '', () => <DebugProfiler />, 45, true);
|
|
||||||
}
|
|
||||||
if (displayVimMode) {
|
if (displayVimMode) {
|
||||||
const vimStr = `[${displayVimMode}]`;
|
const vimStr = `[${displayVimMode}]`;
|
||||||
|
// We'll use a hacky cast for now or ideally update ALL_ITEMS
|
||||||
addCol(
|
addCol(
|
||||||
'vim',
|
'mode', // Using 'mode' as a placeholder for system indicators
|
||||||
'',
|
'',
|
||||||
() => <Text color={theme.text.accent}>{vimStr}</Text>,
|
() => <Text color={theme.text.accent}>{vimStr}</Text>,
|
||||||
vimStr.length,
|
vimStr.length,
|
||||||
@@ -264,9 +266,39 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
|
|||||||
for (const id of items) {
|
for (const id of items) {
|
||||||
if (!isFooterItemId(id)) continue;
|
if (!isFooterItemId(id)) continue;
|
||||||
const itemConfig = ALL_ITEMS.find((i) => i.id === id);
|
const itemConfig = ALL_ITEMS.find((i) => i.id === id);
|
||||||
const header = itemConfig?.header ?? id;
|
let header = itemConfig?.header ?? id;
|
||||||
|
|
||||||
switch (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': {
|
case 'workspace': {
|
||||||
const fullPath = tildeifyPath(targetDir);
|
const fullPath = tildeifyPath(targetDir);
|
||||||
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
|
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
|
||||||
@@ -430,10 +462,10 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Transients
|
// 3. Transients
|
||||||
if (corgiMode) addCol('corgi', '', () => <CorgiIndicator />, 5);
|
if (corgiMode) addCol('mode', '', () => <CorgiIndicator />, 5); // Hacky ID for now
|
||||||
if (showErrorSummary) {
|
if (showErrorSummary) {
|
||||||
addCol(
|
addCol(
|
||||||
'error-count',
|
'mode', // Hacky ID
|
||||||
'',
|
'',
|
||||||
() => <ConsoleSummaryDisplay errorCount={errorCount} />,
|
() => <ConsoleSummaryDisplay errorCount={errorCount} />,
|
||||||
12,
|
12,
|
||||||
@@ -482,7 +514,7 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
|
|||||||
const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
|
const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: col.id,
|
key: col.id + index,
|
||||||
header: col.header,
|
header: col.header,
|
||||||
element: col.element(estimatedWidth),
|
element: col.element(estimatedWidth),
|
||||||
flexGrow: 0,
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,420 +5,209 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { Box, Text } from 'ink';
|
||||||
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
|
import { isUserVisibleHook } from '@google/gemini-cli-core';
|
||||||
import {
|
import type { useSettings } from '../contexts/SettingsContext.js';
|
||||||
isUserVisibleHook,
|
import type { useUIState } from '../contexts/UIStateContext.js';
|
||||||
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 { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
|
||||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||||
import { StatusDisplay } from './StatusDisplay.js';
|
import { StatusDisplay } from './StatusDisplay.js';
|
||||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||||
import { HorizontalLine } from './shared/HorizontalLine.js';
|
import { isContextUsageHigh } from '../utils/contextUsage.js';
|
||||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
import { ToastDisplay } from './ToastDisplay.js';
|
||||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
import { StreamingState } from '../types.js';
|
||||||
|
|
||||||
/**
|
interface AmbientContent {
|
||||||
* Layout constants to prevent magic numbers.
|
text: string;
|
||||||
*/
|
isTip: boolean;
|
||||||
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 StatusRowProps {
|
export interface StatusRowProps {
|
||||||
showUiDetails: boolean;
|
uiState: ReturnType<typeof useUIState>;
|
||||||
isNarrow: boolean;
|
settings: ReturnType<typeof useSettings>;
|
||||||
terminalWidth: number;
|
|
||||||
hideContextSummary: boolean;
|
hideContextSummary: boolean;
|
||||||
|
isNarrow: boolean;
|
||||||
|
ambientContent: AmbientContent | null;
|
||||||
|
showUiDetails: boolean;
|
||||||
|
showMinimalToast: boolean;
|
||||||
hideUiDetailsForSuggestions: boolean;
|
hideUiDetailsForSuggestions: boolean;
|
||||||
hasPendingActionRequired: boolean;
|
hasPendingActionRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const StatusRow: React.FC<StatusRowProps> = ({
|
||||||
* Renders the loading or hook execution status.
|
uiState,
|
||||||
*/
|
settings,
|
||||||
export const StatusNode: React.FC<{
|
hideContextSummary,
|
||||||
showTips: boolean;
|
isNarrow,
|
||||||
showWit: boolean;
|
ambientContent,
|
||||||
thought: ThoughtSummary | null;
|
showUiDetails,
|
||||||
elapsedTime: number;
|
showMinimalToast,
|
||||||
currentWittyPhrase: string | undefined;
|
hideUiDetailsForSuggestions,
|
||||||
activeHooks: ActiveHook[];
|
hasPendingActionRequired,
|
||||||
showLoadingIndicator: boolean;
|
|
||||||
errorVerbosity: 'low' | 'full' | undefined;
|
|
||||||
onResize?: (width: number) => void;
|
|
||||||
}> = ({
|
|
||||||
showTips,
|
|
||||||
showWit,
|
|
||||||
thought,
|
|
||||||
elapsedTime,
|
|
||||||
currentWittyPhrase,
|
|
||||||
activeHooks,
|
|
||||||
showLoadingIndicator,
|
|
||||||
errorVerbosity,
|
|
||||||
onResize,
|
|
||||||
}) => {
|
}) => {
|
||||||
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(
|
const showLoadingIndicator =
|
||||||
(node: DOMElement | null) => {
|
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||||
if (observerRef.current) {
|
uiState.streamingState === StreamingState.Responding &&
|
||||||
observerRef.current.disconnect();
|
!hasPendingActionRequired;
|
||||||
observerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node && onResize) {
|
const showMinimalContextBleedThrough =
|
||||||
const observer = new ResizeObserver((entries) => {
|
!settings.merged.ui.footer.hideContextPercentage &&
|
||||||
const entry = entries[0];
|
isContextUsageHigh(
|
||||||
if (entry) {
|
uiState.sessionStats.lastPromptTokenCount,
|
||||||
onResize(Math.round(entry.contentRect.width));
|
typeof uiState.currentModel === 'string'
|
||||||
}
|
? uiState.currentModel
|
||||||
});
|
: undefined,
|
||||||
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 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) {
|
if (userVisibleHooks.length > 0) {
|
||||||
const label =
|
const label =
|
||||||
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||||
const displayNames = userVisibleHooks.map((h) => {
|
const displayNames = userVisibleHooks.map((h) => {
|
||||||
let name = stripAnsi(h.name);
|
let name = h.name;
|
||||||
if (h.index && h.total && h.total > 1) {
|
if (h.index && h.total && h.total > 1) {
|
||||||
name += ` (${h.index}/${h.total})`;
|
name += ` (${h.index}/${h.total})`;
|
||||||
}
|
}
|
||||||
return name;
|
return name;
|
||||||
});
|
});
|
||||||
currentLoadingPhrase = `${label}: ${displayNames.join(', ')}`;
|
hookText = `${label}: ${displayNames.join(', ')}`;
|
||||||
} else {
|
|
||||||
currentLoadingPhrase = GENERIC_WORKING_LABEL;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Sanitize thought subject to prevent terminal injection
|
|
||||||
currentThought = thought
|
|
||||||
? { ...thought, subject: stripAnsi(thought.subject) }
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const showMinimalMetaRow =
|
||||||
<Box ref={onRefChange}>
|
!showUiDetails &&
|
||||||
<LoadingIndicator
|
(showLoadingIndicator ||
|
||||||
inline
|
showMinimalToast ||
|
||||||
showTips={showTips}
|
showMinimalContextBleedThrough ||
|
||||||
showWit={showWit}
|
shouldReserveSpaceForShortcutsHint);
|
||||||
errorVerbosity={errorVerbosity}
|
|
||||||
thought={currentThought}
|
|
||||||
currentLoadingPhrase={currentLoadingPhrase}
|
|
||||||
elapsedTime={elapsedTime}
|
|
||||||
forceRealStatusOnly={false}
|
|
||||||
wittyPhrase={currentWittyPhrase}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const StatusRow: React.FC<StatusRowProps> = ({
|
const renderLoadingIndicator = () => (
|
||||||
showUiDetails,
|
<LoadingIndicator
|
||||||
isNarrow,
|
inline
|
||||||
terminalWidth,
|
thought={
|
||||||
hideContextSummary,
|
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
||||||
hideUiDetailsForSuggestions,
|
inlineThinkingMode === 'full'
|
||||||
hasPendingActionRequired,
|
? undefined
|
||||||
}) => {
|
: uiState.thought
|
||||||
const uiState = useUIState();
|
}
|
||||||
const settings = useSettings();
|
currentLoadingPhrase={loadingPhrases === 'off' ? undefined : hookText}
|
||||||
const {
|
thoughtLabel={
|
||||||
isInteractiveShellWaiting,
|
inlineThinkingMode === 'full'
|
||||||
showLoadingIndicator,
|
? typeof uiState.thought === 'string'
|
||||||
showTips,
|
? uiState.thought
|
||||||
showWit,
|
: uiState.thought?.subject || 'Thinking...'
|
||||||
modeContentObj,
|
: undefined
|
||||||
showMinimalContext,
|
}
|
||||||
} = useComposerStatus();
|
elapsedTime={uiState.elapsedTime}
|
||||||
|
|
||||||
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
|
|
||||||
showTips={showTips}
|
showTips={showTips}
|
||||||
showWit={showWit}
|
showWit={showWit}
|
||||||
thought={uiState.thought}
|
wittyPhrase={uiState.currentWittyPhrase}
|
||||||
elapsedTime={uiState.elapsedTime}
|
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||||
currentWittyPhrase={uiState.currentWittyPhrase}
|
|
||||||
activeHooks={uiState.activeHooks}
|
|
||||||
showLoadingIndicator={showLoadingIndicator}
|
|
||||||
errorVerbosity={
|
|
||||||
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
|
|
||||||
}
|
|
||||||
onResize={setStatusWidth}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Box flexDirection="column" width="100%">
|
<>
|
||||||
{/* Row 1: Status & Tips */}
|
{/* Minimal UI Mode Meta Row */}
|
||||||
{showRow1 && (
|
{showMinimalMetaRow && (
|
||||||
<Box
|
<Box
|
||||||
width="100%"
|
|
||||||
flexDirection="row"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="space-between"
|
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%"
|
width="100%"
|
||||||
flexDirection={isNarrow ? 'column' : 'row'}
|
flexDirection={isNarrow ? 'column' : 'row'}
|
||||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||||
justifyContent="space-between"
|
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
marginLeft={1}
|
||||||
|
marginRight={isNarrow ? 0 : 1}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
alignItems="center"
|
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||||
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
{showUiDetails ? (
|
{!showUiDetails && showLoadingIndicator && renderLoadingIndicator()}
|
||||||
<>
|
{showMinimalToast && (
|
||||||
{!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
|
<Box marginLeft={!showUiDetails && showLoadingIndicator ? 1 : 0}>
|
||||||
<ApprovalModeIndicator
|
<ToastDisplay />
|
||||||
approvalMode={uiState.showApprovalModeIndicator}
|
</Box>
|
||||||
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>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
{(showMinimalContextBleedThrough ||
|
||||||
marginTop={isNarrow ? LAYOUT.CONTEXT_DISPLAY_TOP_MARGIN_NARROW : 0}
|
(ambientContent && !showUiDetails)) && (
|
||||||
flexDirection="row"
|
<Box
|
||||||
alignItems="center"
|
marginTop={isNarrow && showMinimalToast ? 1 : 0}
|
||||||
marginLeft={
|
flexDirection={isNarrow ? 'column' : 'row'}
|
||||||
isNarrow
|
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||||
? LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_NARROW
|
minHeight={1}
|
||||||
: LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_WIDE
|
>
|
||||||
}
|
{showMinimalContextBleedThrough && (
|
||||||
>
|
|
||||||
{(showUiDetails || showMinimalContext) && (
|
|
||||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
|
||||||
)}
|
|
||||||
{showMinimalContext && !showUiDetails && (
|
|
||||||
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
|
|
||||||
<ContextUsageDisplay
|
<ContextUsageDisplay
|
||||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
||||||
model={
|
model={uiState.currentModel}
|
||||||
typeof uiState.currentModel === 'string'
|
terminalWidth={uiState.terminalWidth}
|
||||||
? uiState.currentModel
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
terminalWidth={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>
|
</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
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`Composer > Snapshots > matches snapshot in idle state 1`] = `
|
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
|
InputPrompt: Type your message or @path/to/file
|
||||||
Footer
|
Footer
|
||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = `
|
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
|
InputPrompt: Type your message or @path/to/file
|
||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = `
|
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
|
InputPrompt: Type your message or @path/to/file
|
||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = `
|
exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = `
|
||||||
"
|
" ? for shortcuts
|
||||||
? for shortcuts
|
StatusDisplay
|
||||||
────────────────────────────────────────
|
────────────────────────────────────────
|
||||||
ApprovalModeIndicator: StatusDispl
|
|
||||||
default ay
|
|
||||||
InputPrompt: Type your message or
|
InputPrompt: Type your message or
|
||||||
@path/to/file
|
@path/to/file
|
||||||
Footer
|
Footer
|
||||||
@@ -35,10 +32,9 @@ Footer
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
|
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
|
||||||
"
|
" ? for shortcuts
|
||||||
LoadingIndicator: Thinking ? for shortcuts
|
LoadingIndicator: Thinking
|
||||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
ApprovalModeIndicator: default StatusDisplay
|
|
||||||
InputPrompt: Type your message or @path/to/file
|
InputPrompt: Type your message or @path/to/file
|
||||||
Footer
|
Footer
|
||||||
"
|
"
|
||||||
|
|||||||
Reference in New Issue
Block a user