fix(cli): allow ask question dialog to take full window height (#23693)

This commit is contained in:
Jacob Richman
2026-03-25 16:26:34 -07:00
committed by GitHub
parent b91758bf6b
commit a86935b6de
12 changed files with 494 additions and 53 deletions
+2
View File
@@ -524,6 +524,8 @@ const baseMockUiState = {
nightly: false, nightly: false,
updateInfo: null, updateInfo: null,
pendingHistoryItems: [], pendingHistoryItems: [],
mainControlsRef: () => {},
rootUiRef: { current: null },
}; };
export const mockAppState: AppState = { export const mockAppState: AppState = {
+1 -3
View File
@@ -70,9 +70,7 @@ describe('App', () => {
cleanUiDetailsVisible: true, cleanUiDetailsVisible: true,
quittingMessages: null, quittingMessages: null,
dialogsVisible: false, dialogsVisible: false,
mainControlsRef: { mainControlsRef: vi.fn(),
current: null,
} as unknown as React.MutableRefObject<DOMElement | null>,
rootUiRef: { rootUiRef: {
current: null, current: null,
} as unknown as React.MutableRefObject<DOMElement | null>, } as unknown as React.MutableRefObject<DOMElement | null>,
+21 -10
View File
@@ -14,7 +14,7 @@ import {
} from 'react'; } from 'react';
import { import {
type DOMElement, type DOMElement,
measureElement, ResizeObserver,
useApp, useApp,
useStdout, useStdout,
useStdin, useStdin,
@@ -397,7 +397,6 @@ export const AppContainer = (props: AppContainerProps) => {
const branchName = useGitBranchName(config.getTargetDir()); const branchName = useGitBranchName(config.getTargetDir());
// Layout measurements // Layout measurements
const mainControlsRef = useRef<DOMElement>(null);
// For performance profiling only // For performance profiling only
const rootUiRef = useRef<DOMElement>(null); const rootUiRef = useRef<DOMElement>(null);
const lastTitleRef = useRef<string | null>(null); const lastTitleRef = useRef<string | null>(null);
@@ -1396,6 +1395,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
!proQuotaRequest && !proQuotaRequest &&
!copyModeEnabled; !copyModeEnabled;
const observerRef = useRef<ResizeObserver | null>(null);
const [controlsHeight, setControlsHeight] = useState(0); const [controlsHeight, setControlsHeight] = useState(0);
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0); const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
@@ -1410,15 +1410,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
? lastNonCopyControlsHeight ? lastNonCopyControlsHeight
: controlsHeight; : controlsHeight;
useLayoutEffect(() => { const mainControlsRef = useCallback((node: DOMElement | null) => {
if (mainControlsRef.current) { if (observerRef.current) {
const fullFooterMeasurement = measureElement(mainControlsRef.current); observerRef.current.disconnect();
const roundedHeight = Math.round(fullFooterMeasurement.height); observerRef.current = null;
if (roundedHeight > 0 && roundedHeight !== controlsHeight) {
setControlsHeight(roundedHeight);
}
} }
}, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]);
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const roundedHeight = Math.round(entry.contentRect.height);
setControlsHeight((prev) =>
roundedHeight !== prev ? roundedHeight : prev,
);
}
});
observer.observe(node);
observerRef.current = observer;
}
}, []);
// Compute available terminal height based on stable controls measurement // Compute available terminal height based on stable controls measurement
const availableTerminalHeight = Math.max( const availableTerminalHeight = Math.max(
@@ -1491,4 +1491,47 @@ describe('AskUserDialog', () => {
expect(frame).toContain('3. Option 3'); 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 listHeight && !isAlternateBuffer
? question.unconstrainedHeight ? question.unconstrainedHeight
? Math.max(1, listHeight - selectionItems.length * 2) ? Math.max(1, listHeight - selectionItems.length * 2)
: Math.min( : Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight))
15,
Math.max(
1,
listHeight - Math.max(DIALOG_PADDING, reservedListHeight),
),
)
: undefined; : undefined;
const maxItemsToShow = const maxItemsToShow =
@@ -21,6 +21,10 @@ import {
type UIState, type UIState,
} from '../contexts/UIStateContext.js'; } from '../contexts/UIStateContext.js';
import { type IndividualToolCallDisplay } from '../types.js'; import { type IndividualToolCallDisplay } from '../types.js';
import {
type ConfirmingToolState,
useConfirmingTool,
} from '../hooks/useConfirmingTool.js';
// Mock dependencies // Mock dependencies
const mockUseSettings = vi.fn().mockReturnValue({ const mockUseSettings = vi.fn().mockReturnValue({
@@ -53,6 +57,10 @@ vi.mock('../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: vi.fn(), useAlternateBuffer: vi.fn(),
})); }));
vi.mock('../hooks/useConfirmingTool.js', () => ({
useConfirmingTool: vi.fn(),
}));
vi.mock('./AppHeader.js', () => ({ vi.mock('./AppHeader.js', () => ({
AppHeader: ({ showDetails = true }: { showDetails?: boolean }) => ( AppHeader: ({ showDetails = true }: { showDetails?: boolean }) => (
<Text>{showDetails ? 'AppHeader(full)' : 'AppHeader(minimal)'}</Text> <Text>{showDetails ? 'AppHeader(full)' : 'AppHeader(minimal)'}</Text>
@@ -503,6 +511,54 @@ describe('MainContent', () => {
unmount(); 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 () => { it('renders a split tool group without a gap between static and pending areas', async () => {
const toolCalls = [ const toolCalls = [
{ {
@@ -547,13 +603,124 @@ describe('MainContent', () => {
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, { const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>, uiState: uiState as Partial<UIState>,
}); });
const output = lastFrame();
// Verify Part 1 and Part 2 are rendered. await waitFor(() => {
expect(output).toContain('Part 1'); const output = lastFrame();
expect(output).toContain('Part 2'); // 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. // 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(); unmount();
}); });
@@ -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`] = ` exports[`MainContent > renders a split tool group without a gap between static and pending areas 1`] = `
"AppHeader(full) "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`] = ` exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = `
"ScrollableList "ScrollableList
AppHeader(full) AppHeader(full)
@@ -172,12 +172,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity // 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 // 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 // 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; const isExplicitClosingSlice = allToolCalls.length === 0;
if ( if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) {
visibleToolCalls.length === 0 &&
(!isExplicitClosingSlice || borderBottomOverride !== true)
) {
return null; 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 We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it. drawn over by the sticky header directly inside it.
*/ */
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
<Box borderBottomOverride !== false && (
height={0} <Box
width={contentWidth} height={0}
borderLeft={true} width={contentWidth}
borderRight={true} borderLeft={true}
borderTop={false} borderRight={true}
borderBottom={borderBottomOverride ?? true} borderTop={false}
borderColor={borderColor} borderBottom={borderBottomOverride ?? true}
borderDimColor={borderDimColor} borderColor={borderColor}
borderStyle="round" borderDimColor={borderDimColor}
/> borderStyle="round"
) />
)
} }
</Box> </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();
});
});
@@ -191,7 +191,7 @@ export interface UIState {
sessionStats: SessionStatsState; sessionStats: SessionStatsState;
terminalWidth: number; terminalWidth: number;
terminalHeight: number; terminalHeight: number;
mainControlsRef: React.MutableRefObject<DOMElement | null>; mainControlsRef: React.RefCallback<DOMElement | null>;
// NOTE: This is for performance profiling only. // NOTE: This is for performance profiling only.
rootUiRef: React.MutableRefObject<DOMElement | null>; rootUiRef: React.MutableRefObject<DOMElement | null>;
currentIDE: IdeInfo | null; currentIDE: IdeInfo | null;
+38 -8
View File
@@ -26,7 +26,6 @@ import {
debugLogger, debugLogger,
runInDevTraceSpan, runInDevTraceSpan,
EDIT_TOOL_NAMES, EDIT_TOOL_NAMES,
ASK_USER_TOOL_NAME,
processRestorableToolCalls, processRestorableToolCalls,
recordToolCallInteractions, recordToolCallInteractions,
ToolErrorType, ToolErrorType,
@@ -40,6 +39,7 @@ import {
isBackgroundExecutionData, isBackgroundExecutionData,
Kind, Kind,
ACTIVATE_SKILL_TOOL_NAME, ACTIVATE_SKILL_TOOL_NAME,
shouldHideToolCall,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { import type {
Config, Config,
@@ -66,7 +66,12 @@ import type {
SlashCommandProcessorResult, SlashCommandProcessorResult,
HistoryItemModel, HistoryItemModel,
} from '../types.js'; } from '../types.js';
import { StreamingState, MessageType } from '../types.js'; import {
StreamingState,
MessageType,
mapCoreStatusToDisplayStatus,
ToolCallStatus,
} from '../types.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { handleAtCommand } from './atCommandProcessor.js'; import { handleAtCommand } from './atCommandProcessor.js';
@@ -541,14 +546,39 @@ export const useGeminiStream = (
const anyVisibleInHistory = pushedToolCallIds.size > 0; const anyVisibleInHistory = pushedToolCallIds.size > 0;
const anyVisibleInPending = remainingTools.some((tc) => { const anyVisibleInPending = remainingTools.some((tc) => {
// AskUser tools are rendered by AskUserDialog, not ToolGroupMessage const displayName = tc.tool?.displayName ?? tc.request.name;
const isInProgress =
tc.status !== 'success' && let hasResultDisplay = false;
tc.status !== 'error' && if (
tc.status !== 'cancelled'; tc.status === CoreToolCallStatus.Success ||
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) { tc.status === CoreToolCallStatus.Error ||
tc.status === CoreToolCallStatus.Cancelled
) {
hasResultDisplay = !!tc.response?.resultDisplay;
} else if (tc.status === CoreToolCallStatus.Executing) {
hasResultDisplay = !!tc.liveOutput;
}
// AskUser tools and Plan Mode write/edit are handled by this logic
if (
shouldHideToolCall({
displayName,
status: tc.status,
approvalMode: tc.approvalMode,
hasResultDisplay,
parentCallId: tc.request.parentCallId,
})
) {
return false; return false;
} }
// ToolGroupMessage explicitly hides Confirming tools because they are
// rendered in the interactive ToolConfirmationQueue instead.
const displayStatus = mapCoreStatusToDisplayStatus(tc.status);
if (displayStatus === ToolCallStatus.Confirming) {
return false;
}
// ToolGroupMessage now shows all non-canceled tools, so they are visible // ToolGroupMessage now shows all non-canceled tools, so they are visible
// in pending and we need to draw the closing border for them. // in pending and we need to draw the closing border for them.
return true; return true;
@@ -25,7 +25,7 @@ const mockUIState = {
dialogsVisible: false, dialogsVisible: false,
streamingState: StreamingState.Idle, streamingState: StreamingState.Idle,
isBackgroundShellListOpen: false, isBackgroundShellListOpen: false,
mainControlsRef: { current: null }, mainControlsRef: vi.fn(),
customDialog: null, customDialog: null,
historyManager: { addItem: vi.fn() }, historyManager: { addItem: vi.fn() },
history: [], history: [],