mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-10 21:30:40 -07:00
feat(cli): implement compact tool output (#20974)
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { act, useState, useCallback } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js';
|
||||
@@ -71,16 +71,61 @@ describe('ToolActionsContext', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to a pending promise to avoid unwanted async state updates in tests
|
||||
// that don't specifically test the IdeClient initialization.
|
||||
vi.mocked(IdeClient.getInstance).mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ToolActionsProvider config={mockConfig} toolCalls={mockToolCalls}>
|
||||
{children}
|
||||
</ToolActionsProvider>
|
||||
);
|
||||
const WrapperReactComp = ({ children }: { children: React.ReactNode }) => {
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(callId: string) => expandedTools.has(callId),
|
||||
[expandedTools],
|
||||
);
|
||||
|
||||
const toggleExpansion = useCallback((callId: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(callId)) {
|
||||
next.delete(callId);
|
||||
} else {
|
||||
next.add(callId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAllExpansion = useCallback((callIds: string[]) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
const anyCollapsed = callIds.some((id) => !next.has(id));
|
||||
|
||||
if (anyCollapsed) {
|
||||
callIds.forEach((id) => next.add(id));
|
||||
} else {
|
||||
callIds.forEach((id) => next.delete(id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<ToolActionsProvider
|
||||
config={mockConfig}
|
||||
toolCalls={mockToolCalls}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAllExpansion={toggleAllExpansion}
|
||||
>
|
||||
{children}
|
||||
</ToolActionsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('publishes to MessageBus for tools with correlationId', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
await result.current.confirm(
|
||||
'modern-call',
|
||||
@@ -98,7 +143,9 @@ describe('ToolActionsContext', () => {
|
||||
});
|
||||
|
||||
it('handles cancel by calling confirm with Cancel outcome', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
await result.current.cancel('modern-call');
|
||||
|
||||
@@ -127,7 +174,9 @@ describe('ToolActionsContext', () => {
|
||||
);
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
deferredIdeClient.resolve(mockIdeClient);
|
||||
@@ -169,7 +218,9 @@ describe('ToolActionsContext', () => {
|
||||
);
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
|
||||
const { result } = await renderHook(() => useToolActions(), { wrapper });
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
deferredIdeClient.resolve(mockIdeClient);
|
||||
@@ -214,7 +265,13 @@ describe('ToolActionsContext', () => {
|
||||
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: ({ children }) => (
|
||||
<ToolActionsProvider config={mockConfig} toolCalls={[legacyTool]}>
|
||||
<ToolActionsProvider
|
||||
config={mockConfig}
|
||||
toolCalls={[legacyTool]}
|
||||
isExpanded={vi.fn().mockReturnValue(false)}
|
||||
toggleExpansion={vi.fn()}
|
||||
toggleAllExpansion={vi.fn()}
|
||||
>
|
||||
{children}
|
||||
</ToolActionsProvider>
|
||||
),
|
||||
@@ -233,4 +290,58 @@ describe('ToolActionsContext', () => {
|
||||
);
|
||||
expect(mockMessageBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('toggleAllExpansion', () => {
|
||||
it('expands all when none are expanded', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(true);
|
||||
});
|
||||
|
||||
it('expands all when some are expanded', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpansion('modern-call');
|
||||
});
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses all when all are expanded', async () => {
|
||||
const { result } = await renderHook(() => useToolActions(), {
|
||||
wrapper: WrapperReactComp,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpansion('modern-call');
|
||||
result.current.toggleExpansion('edit-call');
|
||||
});
|
||||
expect(result.current.isExpanded('modern-call')).toBe(true);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded('modern-call')).toBe(false);
|
||||
expect(result.current.isExpanded('edit-call')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,11 +48,14 @@ interface ToolActionsContextValue {
|
||||
) => Promise<void>;
|
||||
cancel: (callId: string) => Promise<void>;
|
||||
isDiffingEnabled: boolean;
|
||||
isExpanded: (callId: string) => boolean;
|
||||
toggleExpansion: (callId: string) => void;
|
||||
toggleAllExpansion: (callIds: string[]) => void;
|
||||
}
|
||||
|
||||
const ToolActionsContext = createContext<ToolActionsContextValue | null>(null);
|
||||
|
||||
export const useToolActions = () => {
|
||||
export const useToolActions = (): ToolActionsContextValue => {
|
||||
const context = useContext(ToolActionsContext);
|
||||
if (!context) {
|
||||
throw new Error('useToolActions must be used within a ToolActionsProvider');
|
||||
@@ -64,12 +67,22 @@ interface ToolActionsProviderProps {
|
||||
children: React.ReactNode;
|
||||
config: Config;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
isExpanded: (callId: string) => boolean;
|
||||
toggleExpansion: (callId: string) => void;
|
||||
toggleAllExpansion: (callIds: string[]) => void;
|
||||
}
|
||||
|
||||
export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
props: ToolActionsProviderProps,
|
||||
) => {
|
||||
const { children, config, toolCalls } = props;
|
||||
const {
|
||||
children,
|
||||
config,
|
||||
toolCalls,
|
||||
isExpanded,
|
||||
toggleExpansion,
|
||||
toggleAllExpansion,
|
||||
} = props;
|
||||
|
||||
// Hoist IdeClient logic here to keep UI pure
|
||||
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
|
||||
@@ -77,24 +90,23 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
let activeClient: IdeClient | null = null;
|
||||
|
||||
const handleStatusChange = () => {
|
||||
if (isMounted && activeClient) {
|
||||
setIsDiffingEnabled(activeClient.isDiffingEnabled());
|
||||
}
|
||||
};
|
||||
|
||||
if (config.getIdeMode()) {
|
||||
IdeClient.getInstance()
|
||||
.then((client) => {
|
||||
if (!isMounted) return;
|
||||
activeClient = client;
|
||||
setIdeClient(client);
|
||||
setIsDiffingEnabled(client.isDiffingEnabled());
|
||||
|
||||
const handleStatusChange = () => {
|
||||
if (isMounted) {
|
||||
setIsDiffingEnabled(client.isDiffingEnabled());
|
||||
}
|
||||
};
|
||||
|
||||
client.addStatusChangeListener(handleStatusChange);
|
||||
// Return a cleanup function for the listener
|
||||
return () => {
|
||||
client.removeStatusChangeListener(handleStatusChange);
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
debugLogger.error('Failed to get IdeClient instance:', error);
|
||||
@@ -102,6 +114,9 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
}
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (activeClient) {
|
||||
activeClient.removeStatusChangeListener(handleStatusChange);
|
||||
}
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
@@ -164,7 +179,16 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolActionsContext.Provider value={{ confirm, cancel, isDiffingEnabled }}>
|
||||
<ToolActionsContext.Provider
|
||||
value={{
|
||||
confirm,
|
||||
cancel,
|
||||
isDiffingEnabled,
|
||||
isExpanded,
|
||||
toggleExpansion,
|
||||
toggleAllExpansion,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ToolActionsContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user