chore: Merge branch 'main' into channels

This commit is contained in:
Jack Wotherspoon
2026-03-26 20:36:06 -04:00
191 changed files with 8499 additions and 3412 deletions
@@ -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 =
+19 -442
View File
@@ -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();
+11 -2
View File
@@ -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>
);