feat(cli): implement compact tool output (#20974)

This commit is contained in:
Jarrod Whelan
2026-03-30 16:43:29 -07:00
committed by GitHub
parent 3e95b8ec59
commit 1df5c98b33
45 changed files with 2670 additions and 386 deletions

View File

@@ -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);
});
});
});

View File

@@ -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>
);