chore: Merge branch 'main' into channels
@@ -1491,4 +1491,47 @@ describe('AskUserDialog', () => {
|
||||
expect(frame).toContain('3. Option 3');
|
||||
});
|
||||
});
|
||||
|
||||
it('allows the question to exceed 15 lines in a tall terminal', async () => {
|
||||
const longQuestion = Array.from(
|
||||
{ length: 25 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: longQuestion,
|
||||
header: 'Tall Test',
|
||||
type: QuestionType.CHOICE,
|
||||
options: [
|
||||
{ label: 'Option 1', description: 'D1' },
|
||||
{ label: 'Option 2', description: 'D2' },
|
||||
{ label: 'Option 3', description: 'D3' },
|
||||
],
|
||||
multiSelect: false,
|
||||
unconstrainedHeight: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
availableHeight={40} // Tall terminal
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
await waitFor(async () => {
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should show more than 15 lines of the question
|
||||
// (The limit was previously 15, so showing Line 20 proves it's working)
|
||||
expect(frame).toContain('Line 20');
|
||||
expect(frame).toContain('Line 25');
|
||||
// Should still show the options
|
||||
expect(frame).toContain('1. Option 1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -855,13 +855,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
listHeight && !isAlternateBuffer
|
||||
? question.unconstrainedHeight
|
||||
? Math.max(1, listHeight - selectionItems.length * 2)
|
||||
: Math.min(
|
||||
15,
|
||||
Math.max(
|
||||
1,
|
||||
listHeight - Math.max(DIALOG_PADDING, reservedListHeight),
|
||||
),
|
||||
)
|
||||
: Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight))
|
||||
: undefined;
|
||||
|
||||
const maxItemsToShow =
|
||||
|
||||
@@ -4,14 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
ApprovalMode,
|
||||
checkExhaustive,
|
||||
CoreToolCallStatus,
|
||||
isUserVisibleHook,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
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';
|
||||
@@ -20,28 +14,18 @@ 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 { isContextUsageHigh } from '../utils/contextUsage.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
import { ShortcutsHelp } from './ShortcutsHelp.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 { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
|
||||
import { TodoTray } from './messages/Todo.js';
|
||||
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||
|
||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const uiState = useUIState();
|
||||
@@ -56,43 +40,17 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const showApprovalModeIndicator = uiState.showApprovalModeIndicator;
|
||||
const loadingPhrases = settings.merged.ui.loadingPhrases;
|
||||
const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
|
||||
const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
|
||||
|
||||
const showUiDetails = uiState.cleanUiDetailsVisible;
|
||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||
const hideContextSummary =
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
|
||||
const hasPendingToolConfirmation = useMemo(
|
||||
() =>
|
||||
(uiState.pendingHistoryItems ?? [])
|
||||
.filter(
|
||||
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
|
||||
)
|
||||
.some((item) =>
|
||||
item.tools.some(
|
||||
(tool) => tool.status === CoreToolCallStatus.AwaitingApproval,
|
||||
),
|
||||
),
|
||||
[uiState.pendingHistoryItems],
|
||||
);
|
||||
|
||||
const hasPendingActionRequired =
|
||||
hasPendingToolConfirmation ||
|
||||
Boolean(uiState.commandConfirmationRequest) ||
|
||||
Boolean(uiState.authConsentRequest) ||
|
||||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
|
||||
Boolean(uiState.loopDetectionConfirmationRequest) ||
|
||||
Boolean(uiState.quota.proQuotaRequest) ||
|
||||
Boolean(uiState.quota.validationRequest) ||
|
||||
Boolean(uiState.customDialog);
|
||||
const { hasPendingActionRequired, shouldCollapseDuringApproval } =
|
||||
useComposerStatus();
|
||||
|
||||
const isPassiveShortcutsHelpState =
|
||||
uiState.isInputActive &&
|
||||
uiState.streamingState === StreamingState.Idle &&
|
||||
uiState.streamingState === 'idle' &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
const { setShortcutsHelpVisible } = uiActions;
|
||||
@@ -109,407 +67,19 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
const showShortcutsHelp =
|
||||
uiState.shortcutsHelpVisible &&
|
||||
uiState.streamingState === StreamingState.Idle &&
|
||||
uiState.streamingState === 'idle' &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasToast = shouldShowToast(uiState);
|
||||
const showLoadingIndicator =
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||
uiState.streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
const hideUiDetailsForSuggestions =
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
const showApprovalIndicator =
|
||||
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
|
||||
const showRawMarkdownIndicator = !uiState.renderMarkdown;
|
||||
|
||||
let modeBleedThrough: { text: string; color: string } | null = null;
|
||||
switch (showApprovalModeIndicator) {
|
||||
case ApprovalMode.YOLO:
|
||||
modeBleedThrough = { text: 'YOLO', color: theme.status.error };
|
||||
break;
|
||||
case ApprovalMode.PLAN:
|
||||
modeBleedThrough = { text: 'plan', color: theme.status.success };
|
||||
break;
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
modeBleedThrough = { text: 'auto edit', color: theme.status.warning };
|
||||
break;
|
||||
case ApprovalMode.DEFAULT:
|
||||
modeBleedThrough = null;
|
||||
break;
|
||||
default:
|
||||
checkExhaustive(showApprovalModeIndicator);
|
||||
modeBleedThrough = null;
|
||||
break;
|
||||
}
|
||||
|
||||
const hideMinimalModeHintWhileBusy =
|
||||
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
|
||||
|
||||
// Universal Content Objects
|
||||
const modeContentObj = hideMinimalModeHintWhileBusy ? null : modeBleedThrough;
|
||||
|
||||
const allHooks = uiState.activeHooks;
|
||||
const hasAnyHooks = allHooks.length > 0;
|
||||
const userVisibleHooks = allHooks.filter((h) => isUserVisibleHook(h.source));
|
||||
const hasUserVisibleHooks = userVisibleHooks.length > 0;
|
||||
|
||||
const shouldReserveSpaceForShortcutsHint =
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideUiDetailsForSuggestions &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes(
|
||||
INTERACTIVE_SHELL_WAITING_PHRASE,
|
||||
);
|
||||
|
||||
/**
|
||||
* Calculate the estimated length of the status message to avoid collisions
|
||||
* with the tips area.
|
||||
*/
|
||||
let estimatedStatusLength = 0;
|
||||
if (hasAnyHooks) {
|
||||
if (hasUserVisibleHooks) {
|
||||
const hookLabel =
|
||||
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const hookNames = userVisibleHooks
|
||||
.map(
|
||||
(h) =>
|
||||
h.name +
|
||||
(h.index && h.total && h.total > 1
|
||||
? ` (${h.index}/${h.total})`
|
||||
: ''),
|
||||
)
|
||||
.join(', ');
|
||||
estimatedStatusLength = hookLabel.length + hookNames.length + 10;
|
||||
} else {
|
||||
estimatedStatusLength = GENERIC_WORKING_LABEL.length + 10;
|
||||
}
|
||||
} else if (showLoadingIndicator) {
|
||||
const thoughtText = uiState.thought?.subject || GENERIC_WORKING_LABEL;
|
||||
const inlineWittyLength =
|
||||
showWit && uiState.currentWittyPhrase
|
||||
? uiState.currentWittyPhrase.length + 1
|
||||
: 0;
|
||||
estimatedStatusLength = thoughtText.length + 25 + inlineWittyLength;
|
||||
} else if (hasPendingActionRequired) {
|
||||
estimatedStatusLength = 20;
|
||||
} else if (hasToast) {
|
||||
estimatedStatusLength = 40;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the ambient text (tip) to display.
|
||||
*/
|
||||
const tipContentStr = (() => {
|
||||
// 1. Proactive Tip (Priority)
|
||||
if (
|
||||
showTips &&
|
||||
uiState.currentTip &&
|
||||
!(
|
||||
isInteractiveShellWaiting &&
|
||||
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
|
||||
)
|
||||
) {
|
||||
if (
|
||||
estimatedStatusLength + uiState.currentTip.length + 10 <=
|
||||
terminalWidth
|
||||
) {
|
||||
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;
|
||||
})();
|
||||
|
||||
const tipLength = tipContentStr?.length || 0;
|
||||
const willCollideTip = estimatedStatusLength + tipLength + 5 > terminalWidth;
|
||||
|
||||
const showTipLine =
|
||||
!hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow;
|
||||
|
||||
// Mini Mode VIP Flags (Pure Content Triggers)
|
||||
const miniMode_ShowApprovalMode =
|
||||
Boolean(modeContentObj) && !hideUiDetailsForSuggestions;
|
||||
const miniMode_ShowToast = hasToast;
|
||||
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
|
||||
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
|
||||
const miniMode_ShowTip = showTipLine;
|
||||
const miniMode_ShowContext = isContextUsageHigh(
|
||||
uiState.sessionStats.lastPromptTokenCount,
|
||||
uiState.currentModel,
|
||||
settings.merged.model?.compressionThreshold,
|
||||
);
|
||||
|
||||
// Composite Mini Mode Triggers
|
||||
const showRow1_MiniMode =
|
||||
miniMode_ShowToast ||
|
||||
miniMode_ShowStatus ||
|
||||
miniMode_ShowShortcuts ||
|
||||
miniMode_ShowTip;
|
||||
|
||||
const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext;
|
||||
|
||||
// Final Display Rules (Stable Footer Architecture)
|
||||
const showRow1 = showUiDetails || showRow1_MiniMode;
|
||||
const showRow2 = showUiDetails || showRow2_MiniMode;
|
||||
|
||||
const showMinimalBleedThroughRow = !showUiDetails && showRow2_MiniMode;
|
||||
|
||||
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">
|
||||
<Text
|
||||
color={color}
|
||||
wrap="truncate-end"
|
||||
italic={
|
||||
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
|
||||
}
|
||||
>
|
||||
{tipContentStr === uiState.currentTip
|
||||
? `Tip: ${tipContentStr}`
|
||||
: tipContentStr}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusNode = () => {
|
||||
const allHooks = uiState.activeHooks;
|
||||
if (allHooks.length === 0 && !showLoadingIndicator) return null;
|
||||
|
||||
if (allHooks.length > 0) {
|
||||
const userVisibleHooks = allHooks.filter((h) =>
|
||||
isUserVisibleHook(h.source),
|
||||
);
|
||||
|
||||
let hookText = GENERIC_WORKING_LABEL;
|
||||
if (userVisibleHooks.length > 0) {
|
||||
const label =
|
||||
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = userVisibleHooks.map((h) => {
|
||||
let name = h.name;
|
||||
if (h.index && h.total && h.total > 1) {
|
||||
name += ` (${h.index}/${h.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
hookText = `${label}: ${displayNames.join(', ')}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
showTips={showTips}
|
||||
showWit={showWit}
|
||||
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||
currentLoadingPhrase={hookText}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
forceRealStatusOnly={false}
|
||||
wittyPhrase={uiState.currentWittyPhrase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
showTips={showTips}
|
||||
showWit={showWit}
|
||||
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||
thought={uiState.thought}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
forceRealStatusOnly={false}
|
||||
wittyPhrase={uiState.currentWittyPhrase}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const statusNode = renderStatusNode();
|
||||
|
||||
/**
|
||||
* Renders the minimal metadata row content shown when UI details are hidden.
|
||||
*/
|
||||
const renderMinimalMetaRowContent = () => (
|
||||
<Box flexDirection="row" columnGap={1}>
|
||||
{renderStatusNode()}
|
||||
{showMinimalBleedThroughRow && (
|
||||
<Box>
|
||||
{miniMode_ShowApprovalMode && modeContentObj && (
|
||||
<Text color={modeContentObj.color}>● {modeContentObj.text}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderStatusRow = () => {
|
||||
// Mini Mode Height Reservation (The "Anti-Jitter" line)
|
||||
if (!showUiDetails && !showRow1_MiniMode && !showRow2_MiniMode) {
|
||||
return <Box height={1} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Row 1: multipurpose status (thinking, hooks, wit, tips) */}
|
||||
{showRow1 && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
minHeight={1}
|
||||
>
|
||||
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
|
||||
{!showUiDetails && showRow1_MiniMode ? (
|
||||
renderMinimalMetaRowContent()
|
||||
) : isInteractiveShellWaiting ? (
|
||||
<Box width="100%" marginLeft={1}>
|
||||
<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={1}
|
||||
>
|
||||
{statusNode}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexShrink={0} marginLeft={2} marginRight={isNarrow ? 0 : 1}>
|
||||
{!isNarrow && showTipLine && renderTipNode()}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Internal Separator Line */}
|
||||
{showRow1 &&
|
||||
showRow2 &&
|
||||
(showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && (
|
||||
<Box width="100%">
|
||||
<HorizontalLine dim />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Row 2: Mode and Context Summary */}
|
||||
{showRow2 && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box flexDirection="row" alignItems="center" marginLeft={1}>
|
||||
{showUiDetails ? (
|
||||
<>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
allowPlanMode={uiState.allowPlanMode}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
miniMode_ShowApprovalMode &&
|
||||
modeContentObj && (
|
||||
<Text color={modeContentObj.color}>
|
||||
● {modeContentObj.text}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginLeft={isNarrow ? 1 : 0}
|
||||
>
|
||||
{(showUiDetails || miniMode_ShowContext) && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
{miniMode_ShowContext && !showUiDetails && (
|
||||
<Box marginLeft={1}>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
||||
model={
|
||||
typeof uiState.currentModel === 'string'
|
||||
? uiState.currentModel
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
const showMinimalToast = hasToast;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -530,14 +100,21 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
{showShortcutsHelp && <ShortcutsHelp />}
|
||||
|
||||
{(showUiDetails || miniMode_ShowToast) && (
|
||||
{(showUiDetails || showMinimalToast) && (
|
||||
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
|
||||
<ToastDisplay />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box width="100%" flexDirection="column">
|
||||
{renderStatusRow()}
|
||||
<StatusRow
|
||||
showUiDetails={showUiDetails}
|
||||
isNarrow={isNarrow}
|
||||
terminalWidth={terminalWidth}
|
||||
hideContextSummary={hideContextSummary}
|
||||
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
|
||||
hasPendingActionRequired={hasPendingActionRequired}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{showUiDetails && uiState.showErrorDetails && (
|
||||
@@ -569,7 +146,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
commandContext={uiState.commandContext}
|
||||
shellModeActive={uiState.shellModeActive}
|
||||
setShellModeActive={uiActions.setShellModeActive}
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
approvalMode={uiState.showApprovalModeIndicator}
|
||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||
focus={isFocused}
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
|
||||
@@ -61,7 +61,7 @@ import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { cpLen } from '../utils/textUtils.js';
|
||||
import { defaultKeyMatchers, Command } from '../key/keyMatchers.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
import {
|
||||
appEvents,
|
||||
AppEvent,
|
||||
@@ -163,6 +163,18 @@ describe('InputPrompt', () => {
|
||||
let mockBuffer: TextBuffer;
|
||||
let mockCommandContext: CommandContext;
|
||||
|
||||
const GlobalEscapeHandler = ({ onEscape }: { onEscape: () => void }) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name !== 'escape') return false;
|
||||
onEscape();
|
||||
return true;
|
||||
},
|
||||
{ isActive: true, priority: false },
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
const mockedUseShellHistory = vi.mocked(useShellHistory);
|
||||
const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
|
||||
const mockedUseInputHistory = vi.mocked(useInputHistory);
|
||||
@@ -2770,6 +2782,54 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not propagate ESC to global cancellation handler when shell mode is active (responding)', async () => {
|
||||
props.shellModeActive = true;
|
||||
props.streamingState = StreamingState.Responding;
|
||||
const onGlobalEscape = vi.fn();
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<>
|
||||
<GlobalEscapeHandler onEscape={onGlobalEscape} />
|
||||
<InputPrompt {...props} />
|
||||
</>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.setShellModeActive).toHaveBeenCalledWith(false);
|
||||
});
|
||||
expect(onGlobalEscape).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow ESC to reach global cancellation handler when responding and no overlay is active', async () => {
|
||||
props.shellModeActive = false;
|
||||
props.streamingState = StreamingState.Responding;
|
||||
const onGlobalEscape = vi.fn();
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<>
|
||||
<GlobalEscapeHandler onEscape={onGlobalEscape} />
|
||||
<InputPrompt {...props} />
|
||||
</>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onGlobalEscape).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(props.setShellModeActive).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle ESC when completion suggestions are showing', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
|
||||
@@ -686,13 +686,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
key.name === 'escape' &&
|
||||
(streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const isGenerating =
|
||||
streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation;
|
||||
|
||||
const isPlainTab =
|
||||
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
||||
@@ -877,6 +873,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we're generating and no local overlay consumed Escape, let it
|
||||
// propagate to the global cancellation handler.
|
||||
if (isGenerating) {
|
||||
return false;
|
||||
}
|
||||
|
||||
handleEscPress();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
type UIState,
|
||||
} from '../contexts/UIStateContext.js';
|
||||
import { type IndividualToolCallDisplay } from '../types.js';
|
||||
import {
|
||||
type ConfirmingToolState,
|
||||
useConfirmingTool,
|
||||
} from '../hooks/useConfirmingTool.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseSettings = vi.fn().mockReturnValue({
|
||||
@@ -53,6 +57,10 @@ vi.mock('../hooks/useAlternateBuffer.js', () => ({
|
||||
useAlternateBuffer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useConfirmingTool.js', () => ({
|
||||
useConfirmingTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./AppHeader.js', () => ({
|
||||
AppHeader: ({ showDetails = true }: { showDetails?: boolean }) => (
|
||||
<Text>{showDetails ? 'AppHeader(full)' : 'AppHeader(minimal)'}</Text>
|
||||
@@ -503,6 +511,54 @@ describe('MainContent', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders a subagent with a complete box including bottom border', async () => {
|
||||
const subagentCall = {
|
||||
callId: 'subagent-1',
|
||||
name: 'codebase_investigator',
|
||||
description: 'Investigating codebase',
|
||||
status: CoreToolCallStatus.Executing,
|
||||
kind: 'agent',
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'codebase_investigator',
|
||||
recentActivity: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: '{"command": "echo hello"}',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
state: 'running',
|
||||
},
|
||||
} as Partial<IndividualToolCallDisplay> as IndividualToolCallDisplay;
|
||||
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [{ id: 1, type: 'user', text: 'Investigate' }],
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
type: 'tool_group' as const,
|
||||
tools: [subagentCall],
|
||||
borderBottom: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
|
||||
uiState: uiState as Partial<UIState>,
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('codebase_investigator');
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders a split tool group without a gap between static and pending areas', async () => {
|
||||
const toolCalls = [
|
||||
{
|
||||
@@ -547,13 +603,124 @@ describe('MainContent', () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
|
||||
uiState: uiState as Partial<UIState>,
|
||||
});
|
||||
const output = lastFrame();
|
||||
// Verify Part 1 and Part 2 are rendered.
|
||||
expect(output).toContain('Part 1');
|
||||
expect(output).toContain('Part 2');
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
// Verify Part 1 and Part 2 are rendered.
|
||||
expect(output).toContain('Part 1');
|
||||
expect(output).toContain('Part 2');
|
||||
});
|
||||
|
||||
// The snapshot will be the best way to verify there is no gap (empty line) between them.
|
||||
expect(output).toMatchSnapshot();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders a ToolConfirmationQueue without an extra line when preceded by hidden tools', async () => {
|
||||
const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const hiddenToolCalls = [
|
||||
{
|
||||
callId: 'tool-hidden',
|
||||
name: WRITE_FILE_DISPLAY_NAME,
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'Hidden content',
|
||||
} as Partial<IndividualToolCallDisplay> as IndividualToolCallDisplay,
|
||||
];
|
||||
|
||||
const confirmingTool = {
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'exit_plan_mode',
|
||||
status: CoreToolCallStatus.AwaitingApproval,
|
||||
confirmationDetails: {
|
||||
type: 'exit_plan_mode' as const,
|
||||
planPath: '/path/to/plan',
|
||||
},
|
||||
},
|
||||
index: 1,
|
||||
total: 1,
|
||||
};
|
||||
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [{ id: 1, type: 'user', text: 'Apply plan' }],
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
type: 'tool_group' as const,
|
||||
tools: hiddenToolCalls,
|
||||
borderBottom: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// We need to mock useConfirmingTool to return our confirmingTool
|
||||
vi.mocked(useConfirmingTool).mockReturnValue(
|
||||
confirmingTool as unknown as ConfirmingToolState,
|
||||
);
|
||||
|
||||
mockUseSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
security: { enablePermanentToolApproval: true },
|
||||
ui: { errorVerbosity: 'full' },
|
||||
}),
|
||||
);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
|
||||
uiState: uiState as Partial<UIState>,
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
// The output should NOT contain 'Hidden content'
|
||||
expect(output).not.toContain('Hidden content');
|
||||
// The output should contain the confirmation header
|
||||
expect(output).toContain('Ready to start implementation?');
|
||||
});
|
||||
|
||||
// Snapshot will reveal if there are extra blank lines
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders a spurious line when a tool group has only hidden tools and borderBottom true', async () => {
|
||||
const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [{ id: 1, type: 'user', text: 'Apply plan' }],
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
type: 'tool_group' as const,
|
||||
tools: [
|
||||
{
|
||||
callId: 'tool-1',
|
||||
name: WRITE_FILE_DISPLAY_NAME,
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'hidden',
|
||||
} as Partial<IndividualToolCallDisplay> as IndividualToolCallDisplay,
|
||||
],
|
||||
borderBottom: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
|
||||
uiState: uiState as Partial<UIState>,
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Apply plan');
|
||||
});
|
||||
|
||||
// This snapshot will show no spurious line because the group is now correctly suppressed.
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('<ModelDialog />', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockGetHasAccessToPreviewModel = vi.fn();
|
||||
const mockGetGemini31LaunchedSync = vi.fn();
|
||||
const mockGetGemini31FlashLiteLaunchedSync = vi.fn();
|
||||
const mockGetProModelNoAccess = vi.fn();
|
||||
const mockGetProModelNoAccessSync = vi.fn();
|
||||
const mockGetUserTier = vi.fn();
|
||||
@@ -63,6 +64,7 @@ describe('<ModelDialog />', () => {
|
||||
getHasAccessToPreviewModel: () => boolean;
|
||||
getIdeMode: () => boolean;
|
||||
getGemini31LaunchedSync: () => boolean;
|
||||
getGemini31FlashLiteLaunchedSync: () => boolean;
|
||||
getProModelNoAccess: () => Promise<boolean>;
|
||||
getProModelNoAccessSync: () => boolean;
|
||||
getUserTier: () => UserTierId | undefined;
|
||||
@@ -74,6 +76,7 @@ describe('<ModelDialog />', () => {
|
||||
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
|
||||
getIdeMode: () => false,
|
||||
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
|
||||
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
|
||||
getProModelNoAccess: mockGetProModelNoAccess,
|
||||
getProModelNoAccessSync: mockGetProModelNoAccessSync,
|
||||
getUserTier: mockGetUserTier,
|
||||
@@ -84,6 +87,7 @@ describe('<ModelDialog />', () => {
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(false);
|
||||
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
|
||||
@@ -131,6 +135,7 @@ describe('<ModelDialog />', () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(true);
|
||||
mockGetProModelNoAccess.mockResolvedValue(true);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.FREE);
|
||||
mockGetDisplayString.mockImplementation((val: string) => val);
|
||||
|
||||
@@ -463,6 +468,7 @@ describe('<ModelDialog />', () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.FREE);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
@@ -63,6 +63,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
|
||||
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config?.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
@@ -86,6 +88,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
];
|
||||
if (manualModels.includes(preferredModel)) {
|
||||
@@ -210,7 +213,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
|
||||
// Flag Guard: Versioned models only show if their flag is active.
|
||||
if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false;
|
||||
if (id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && !useGemini31)
|
||||
if (
|
||||
id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL &&
|
||||
!useGemini31FlashLite
|
||||
)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
@@ -218,11 +224,13 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
.map(([id, m]) => {
|
||||
const resolvedId = config.modelConfigService.resolveModelId(id, {
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
});
|
||||
// Title ID is the resolved ID without custom tools flag
|
||||
const titleId = config.modelConfigService.resolveModelId(id, {
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
});
|
||||
return {
|
||||
value: resolvedId,
|
||||
@@ -284,7 +292,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
if (isFreeTier) {
|
||||
if (isFreeTier && useGemini31FlashLite) {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
@@ -304,6 +312,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
}, [
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
config,
|
||||
|
||||
@@ -92,6 +92,7 @@ const buildModelRows = (
|
||||
config: Config,
|
||||
quotas?: RetrieveUserQuotaResponse,
|
||||
useGemini3_1 = false,
|
||||
useGemini3_1FlashLite = false,
|
||||
useCustomToolModel = false,
|
||||
) => {
|
||||
const getBaseModelName = (name: string) => name.replace('-001', '');
|
||||
@@ -124,7 +125,12 @@ const buildModelRows = (
|
||||
?.filter(
|
||||
(b) =>
|
||||
b.modelId &&
|
||||
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
|
||||
isActiveModel(
|
||||
b.modelId,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
) &&
|
||||
!usedModelNames.has(getDisplayString(b.modelId, config)),
|
||||
)
|
||||
.map((bucket) => ({
|
||||
@@ -152,6 +158,7 @@ const ModelUsageTable: React.FC<{
|
||||
pooledLimit?: number;
|
||||
pooledResetTime?: string;
|
||||
useGemini3_1?: boolean;
|
||||
useGemini3_1FlashLite?: boolean;
|
||||
useCustomToolModel?: boolean;
|
||||
}> = ({
|
||||
models,
|
||||
@@ -164,6 +171,7 @@ const ModelUsageTable: React.FC<{
|
||||
pooledLimit,
|
||||
pooledResetTime,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
@@ -173,6 +181,7 @@ const ModelUsageTable: React.FC<{
|
||||
config,
|
||||
quotas,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
);
|
||||
|
||||
@@ -541,6 +550,8 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini3_1FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const useCustomToolModel =
|
||||
useGemini3_1 &&
|
||||
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
|
||||
@@ -697,6 +708,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
pooledLimit={pooledLimit}
|
||||
pooledResetTime={pooledResetTime}
|
||||
useGemini3_1={useGemini3_1}
|
||||
useGemini3_1FlashLite={useGemini3_1FlashLite}
|
||||
useCustomToolModel={useCustomToolModel}
|
||||
/>
|
||||
{renderFooter()}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
|
||||
import {
|
||||
isUserVisibleHook,
|
||||
type ThoughtSummary,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { type ActiveHook } from '../types.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||
|
||||
/**
|
||||
* Layout constants to prevent magic numbers.
|
||||
*/
|
||||
const LAYOUT = {
|
||||
STATUS_MIN_HEIGHT: 1,
|
||||
TIP_LEFT_MARGIN: 2,
|
||||
TIP_RIGHT_MARGIN_NARROW: 0,
|
||||
TIP_RIGHT_MARGIN_WIDE: 1,
|
||||
INDICATOR_LEFT_MARGIN: 1,
|
||||
CONTEXT_DISPLAY_TOP_MARGIN_NARROW: 1,
|
||||
CONTEXT_DISPLAY_LEFT_MARGIN_NARROW: 1,
|
||||
CONTEXT_DISPLAY_LEFT_MARGIN_WIDE: 0,
|
||||
COLLISION_GAP: 10,
|
||||
};
|
||||
|
||||
interface StatusRowProps {
|
||||
showUiDetails: boolean;
|
||||
isNarrow: boolean;
|
||||
terminalWidth: number;
|
||||
hideContextSummary: boolean;
|
||||
hideUiDetailsForSuggestions: boolean;
|
||||
hasPendingActionRequired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the loading or hook execution status.
|
||||
*/
|
||||
export const StatusNode: React.FC<{
|
||||
showTips: boolean;
|
||||
showWit: boolean;
|
||||
thought: ThoughtSummary | null;
|
||||
elapsedTime: number;
|
||||
currentWittyPhrase: string | undefined;
|
||||
activeHooks: ActiveHook[];
|
||||
showLoadingIndicator: boolean;
|
||||
errorVerbosity: 'low' | 'full' | undefined;
|
||||
onResize?: (width: number) => void;
|
||||
}> = ({
|
||||
showTips,
|
||||
showWit,
|
||||
thought,
|
||||
elapsedTime,
|
||||
currentWittyPhrase,
|
||||
activeHooks,
|
||||
showLoadingIndicator,
|
||||
errorVerbosity,
|
||||
onResize,
|
||||
}) => {
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const onRefChange = useCallback(
|
||||
(node: DOMElement | null) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
if (node && onResize) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
onResize(Math.round(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
}
|
||||
},
|
||||
[onResize],
|
||||
);
|
||||
|
||||
if (activeHooks.length === 0 && !showLoadingIndicator) return null;
|
||||
|
||||
let currentLoadingPhrase: string | undefined = undefined;
|
||||
let currentThought: ThoughtSummary | null = null;
|
||||
|
||||
if (activeHooks.length > 0) {
|
||||
const userVisibleHooks = activeHooks.filter((h) =>
|
||||
isUserVisibleHook(h.source),
|
||||
);
|
||||
|
||||
if (userVisibleHooks.length > 0) {
|
||||
const label =
|
||||
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = userVisibleHooks.map((h) => {
|
||||
let name = stripAnsi(h.name);
|
||||
if (h.index && h.total && h.total > 1) {
|
||||
name += ` (${h.index}/${h.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
currentLoadingPhrase = `${label}: ${displayNames.join(', ')}`;
|
||||
} else {
|
||||
currentLoadingPhrase = GENERIC_WORKING_LABEL;
|
||||
}
|
||||
} else {
|
||||
// Sanitize thought subject to prevent terminal injection
|
||||
currentThought = thought
|
||||
? { ...thought, subject: stripAnsi(thought.subject) }
|
||||
: null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={onRefChange}>
|
||||
<LoadingIndicator
|
||||
inline
|
||||
showTips={showTips}
|
||||
showWit={showWit}
|
||||
errorVerbosity={errorVerbosity}
|
||||
thought={currentThought}
|
||||
currentLoadingPhrase={currentLoadingPhrase}
|
||||
elapsedTime={elapsedTime}
|
||||
forceRealStatusOnly={false}
|
||||
wittyPhrase={currentWittyPhrase}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
showUiDetails,
|
||||
isNarrow,
|
||||
terminalWidth,
|
||||
hideContextSummary,
|
||||
hideUiDetailsForSuggestions,
|
||||
hasPendingActionRequired,
|
||||
}) => {
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const {
|
||||
isInteractiveShellWaiting,
|
||||
showLoadingIndicator,
|
||||
showTips,
|
||||
showWit,
|
||||
modeContentObj,
|
||||
showMinimalContext,
|
||||
} = useComposerStatus();
|
||||
|
||||
const [statusWidth, setStatusWidth] = useState(0);
|
||||
const [tipWidth, setTipWidth] = useState(0);
|
||||
const tipObserverRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const onTipRefChange = useCallback((node: DOMElement | null) => {
|
||||
if (tipObserverRef.current) {
|
||||
tipObserverRef.current.disconnect();
|
||||
tipObserverRef.current = null;
|
||||
}
|
||||
|
||||
if (node) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setTipWidth(Math.round(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
tipObserverRef.current = observer;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tipContentStr = (() => {
|
||||
// 1. Proactive Tip (Priority)
|
||||
if (
|
||||
showTips &&
|
||||
uiState.currentTip &&
|
||||
!(
|
||||
isInteractiveShellWaiting &&
|
||||
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
|
||||
)
|
||||
) {
|
||||
return uiState.currentTip;
|
||||
}
|
||||
|
||||
// 2. Shortcut Hint (Fallback)
|
||||
if (
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideUiDetailsForSuggestions &&
|
||||
!hasPendingActionRequired &&
|
||||
uiState.buffer.text.length === 0
|
||||
) {
|
||||
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// Collision detection using measured widths
|
||||
const willCollideTip =
|
||||
statusWidth + tipWidth + LAYOUT.COLLISION_GAP > terminalWidth;
|
||||
|
||||
const showTipLine = Boolean(
|
||||
!hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow,
|
||||
);
|
||||
|
||||
const showRow1Minimal =
|
||||
showLoadingIndicator || uiState.activeHooks.length > 0 || showTipLine;
|
||||
const showRow2Minimal =
|
||||
(Boolean(modeContentObj) && !hideUiDetailsForSuggestions) ||
|
||||
showMinimalContext;
|
||||
|
||||
const showRow1 = showUiDetails || showRow1Minimal;
|
||||
const showRow2 = showUiDetails || showRow2Minimal;
|
||||
|
||||
const statusNode = (
|
||||
<StatusNode
|
||||
showTips={showTips}
|
||||
showWit={showWit}
|
||||
thought={uiState.thought}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
currentWittyPhrase={uiState.currentWittyPhrase}
|
||||
activeHooks={uiState.activeHooks}
|
||||
showLoadingIndicator={showLoadingIndicator}
|
||||
errorVerbosity={
|
||||
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
|
||||
}
|
||||
onResize={setStatusWidth}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderTipNode = () => {
|
||||
if (!tipContentStr) return null;
|
||||
|
||||
const isShortcutHint =
|
||||
tipContentStr === '? for shortcuts' ||
|
||||
tipContentStr === 'press tab twice for more';
|
||||
const color =
|
||||
isShortcutHint && uiState.shortcutsHelpVisible
|
||||
? theme.text.accent
|
||||
: theme.text.secondary;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="flex-end" ref={onTipRefChange}>
|
||||
<Text
|
||||
color={color}
|
||||
wrap="truncate-end"
|
||||
italic={
|
||||
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
|
||||
}
|
||||
>
|
||||
{tipContentStr === uiState.currentTip
|
||||
? `Tip: ${tipContentStr}`
|
||||
: tipContentStr}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (!showUiDetails && !showRow1Minimal && !showRow2Minimal) {
|
||||
return <Box height={LAYOUT.STATUS_MIN_HEIGHT} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Row 1: Status & Tips */}
|
||||
{showRow1 && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
minHeight={LAYOUT.STATUS_MIN_HEIGHT}
|
||||
>
|
||||
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
|
||||
{!showUiDetails && showRow1Minimal ? (
|
||||
<Box flexDirection="row" columnGap={1}>
|
||||
{statusNode}
|
||||
{!showUiDetails && showRow2Minimal && modeContentObj && (
|
||||
<Box>
|
||||
<Text color={modeContentObj.color}>
|
||||
● {modeContentObj.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : isInteractiveShellWaiting ? (
|
||||
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
|
||||
<Text color={theme.status.warning}>
|
||||
! Shell awaiting input (Tab to focus)
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
flexShrink={0}
|
||||
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
|
||||
>
|
||||
{statusNode}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
flexShrink={0}
|
||||
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
|
||||
marginRight={
|
||||
isNarrow
|
||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||
}
|
||||
>
|
||||
{/*
|
||||
We always render the tip node so it can be measured by ResizeObserver,
|
||||
but we control its visibility based on the collision detection.
|
||||
*/}
|
||||
<Box display={showTipLine ? 'flex' : 'none'}>
|
||||
{!isNarrow && tipContentStr && renderTipNode()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Internal Separator */}
|
||||
{showRow1 &&
|
||||
showRow2 &&
|
||||
(showUiDetails || (showRow1Minimal && showRow2Minimal)) && (
|
||||
<Box width="100%">
|
||||
<HorizontalLine dim />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Row 2: Modes & Context */}
|
||||
{showRow2 && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
|
||||
>
|
||||
{showUiDetails ? (
|
||||
<>
|
||||
{!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={uiState.showApprovalModeIndicator}
|
||||
allowPlanMode={uiState.allowPlanMode}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{!uiState.renderMarkdown && (
|
||||
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
showRow2Minimal &&
|
||||
modeContentObj && (
|
||||
<Text color={modeContentObj.color}>
|
||||
● {modeContentObj.text}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? LAYOUT.CONTEXT_DISPLAY_TOP_MARGIN_NARROW : 0}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginLeft={
|
||||
isNarrow
|
||||
? LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_NARROW
|
||||
: LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_WIDE
|
||||
}
|
||||
>
|
||||
{(showUiDetails || showMinimalContext) && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
{showMinimalContext && !showUiDetails && (
|
||||
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
||||
model={
|
||||
typeof uiState.currentModel === 'string'
|
||||
? uiState.currentModel
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -91,6 +91,19 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent > renders a ToolConfirmationQueue without an extra line when preceded by hidden tools 1`] = `
|
||||
"AppHeader(full)
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Apply plan
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Ready to start implementation? │
|
||||
│ │
|
||||
│ Error reading plan: Storage must be initialized before use │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent > renders a split tool group without a gap between static and pending areas 1`] = `
|
||||
"AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -105,6 +118,30 @@ exports[`MainContent > renders a split tool group without a gap between static a
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent > renders a spurious line when a tool group has only hidden tools and borderBottom true 1`] = `
|
||||
"AppHeader(full)
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Apply plan
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent > renders a subagent with a complete box including bottom border 1`] = `
|
||||
"AppHeader(full)
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Investigate
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ≡ Running Agent... (ctrl+o to collapse) │
|
||||
│ │
|
||||
│ Running subagent codebase_investigator... │
|
||||
│ │
|
||||
│ ⠋ run_shell_command echo hello │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader(full)
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -60,7 +60,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -71,7 +71,7 @@
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion. …</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -20,7 +20,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -66,7 +66,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -112,7 +112,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -158,7 +158,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -204,7 +204,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -250,7 +250,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -296,7 +296,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -342,7 +342,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
@@ -388,7 +388,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
|
||||
@@ -172,12 +172,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
// If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity
|
||||
// internal errors, plan-mode hidden write/edit), we should not emit standalone
|
||||
// border fragments. The only case where an empty group should render is the
|
||||
// explicit "closing slice" (tools: []) used to bridge static/pending sections.
|
||||
// explicit "closing slice" (tools: []) used to bridge static/pending sections,
|
||||
// and only if it's actually continuing an open box from above.
|
||||
const isExplicitClosingSlice = allToolCalls.length === 0;
|
||||
if (
|
||||
visibleToolCalls.length === 0 &&
|
||||
(!isExplicitClosingSlice || borderBottomOverride !== true)
|
||||
) {
|
||||
if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -269,19 +267,20 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
|
||||
<Box
|
||||
height={0}
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={borderBottomOverride ?? true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
|
||||
borderBottomOverride !== false && (
|
||||
<Box
|
||||
height={0}
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={borderBottomOverride ?? true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import {
|
||||
makeFakeConfig,
|
||||
CoreToolCallStatus,
|
||||
ApprovalMode,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
Kind,
|
||||
} from '@google/gemini-cli-core';
|
||||
import os from 'node:os';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
|
||||
describe('ToolGroupMessage Regression Tests', () => {
|
||||
const baseMockConfig = makeFakeConfig({
|
||||
model: 'gemini-pro',
|
||||
targetDir: os.tmpdir(),
|
||||
});
|
||||
const fullVerbositySettings = createMockSettings({
|
||||
ui: { errorVerbosity: 'full' },
|
||||
});
|
||||
|
||||
const createToolCall = (
|
||||
overrides: Partial<IndividualToolCallDisplay> = {},
|
||||
): IndividualToolCallDisplay =>
|
||||
({
|
||||
callId: 'tool-123',
|
||||
name: 'test-tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
...overrides,
|
||||
}) as IndividualToolCallDisplay;
|
||||
|
||||
const createItem = (tools: IndividualToolCallDisplay[]) => ({
|
||||
id: 1,
|
||||
type: 'tool_group' as const,
|
||||
tools,
|
||||
});
|
||||
|
||||
it('Plan Mode: suppresses phantom tool group (hidden tools)', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
name: WRITE_FILE_DISPLAY_NAME,
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
status: CoreToolCallStatus.Success,
|
||||
}),
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
terminalWidth={80}
|
||||
item={item}
|
||||
toolCalls={toolCalls}
|
||||
borderBottom={true}
|
||||
/>,
|
||||
{ config: baseMockConfig, settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('Agent Case: suppresses the bottom border box for ongoing agents (no vertical ticks)', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
name: 'agent',
|
||||
kind: Kind.Agent,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
state: 'running',
|
||||
recentActivity: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
terminalWidth={80}
|
||||
item={item}
|
||||
toolCalls={toolCalls}
|
||||
borderBottom={false} // Ongoing
|
||||
/>,
|
||||
{ config: baseMockConfig, settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Running Agent...');
|
||||
// It should render side borders from the content
|
||||
expect(output).toContain('│');
|
||||
// It should NOT render the bottom border box (no corners ╰ ╯)
|
||||
expect(output).not.toContain('╰');
|
||||
expect(output).not.toContain('╯');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('Agent Case: renders a bottom border horizontal line for completed agents', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
name: 'agent',
|
||||
kind: Kind.Agent,
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
state: 'completed',
|
||||
recentActivity: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
terminalWidth={80}
|
||||
item={item}
|
||||
toolCalls={toolCalls}
|
||||
borderBottom={true} // Completed
|
||||
/>,
|
||||
{ config: baseMockConfig, settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
// Verify it rendered subagent content
|
||||
expect(output).toContain('Agent');
|
||||
// It should render the bottom horizontal line
|
||||
expect(output).toContain(
|
||||
'╰──────────────────────────────────────────────────────────────────────────╯',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('Bridges: still renders a bridge if it has a top border', async () => {
|
||||
const toolCalls: IndividualToolCallDisplay[] = [];
|
||||
const item = createItem(toolCalls);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
terminalWidth={80}
|
||||
item={item}
|
||||
toolCalls={toolCalls}
|
||||
borderTop={true}
|
||||
borderBottom={true}
|
||||
/>,
|
||||
{ config: baseMockConfig, settings: fullVerbositySettings },
|
||||
);
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).not.toBe('');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { render } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SkillsList } from './SkillsList.js';
|
||||
import { type SkillDefinition } from '@google/gemini-cli-core';
|
||||
import { SKILLS_DOCS_URL } from '../../constants.js';
|
||||
|
||||
describe('SkillsList Component', () => {
|
||||
const mockSkills: SkillDefinition[] = [
|
||||
@@ -74,9 +75,8 @@ describe('SkillsList Component', () => {
|
||||
<SkillsList skills={[]} showDescriptions={true} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('No skills available');
|
||||
|
||||
expect(output).toContain('No skills available.');
|
||||
expect(output).toContain(`Learn how to add skills: ${SKILLS_DOCS_URL}`);
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { type SkillDefinition } from '../../types.js';
|
||||
import { SKILLS_DOCS_URL } from '../../constants.js';
|
||||
|
||||
interface SkillsListProps {
|
||||
skills: readonly SkillDefinition[];
|
||||
@@ -86,7 +87,13 @@ export const SkillsList: React.FC<SkillsListProps> = ({
|
||||
)}
|
||||
|
||||
{skills.length === 0 && (
|
||||
<Text color={theme.text.primary}> No skills available</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary}>No skills available.</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.primary}>Learn how to add skills: </Text>
|
||||
<Text color={theme.text.link}>{SKILLS_DOCS_URL}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||