mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
fix(cli): allow ask question dialog to take full window height (#23693)
This commit is contained in:
@@ -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 = {
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user