mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(core): improve subagent result display (#20378)
This commit is contained in:
@@ -191,26 +191,38 @@ describe('<ShellToolMessage />', () => {
|
|||||||
10,
|
10,
|
||||||
8,
|
8,
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
|
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
|
||||||
100,
|
100,
|
||||||
ACTIVE_SHELL_MAX_LINES - 3,
|
ACTIVE_SHELL_MAX_LINES - 3,
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'uses full availableTerminalHeight when focused in alternate buffer mode',
|
'uses full availableTerminalHeight when focused in alternate buffer mode',
|
||||||
100,
|
100,
|
||||||
98, // 100 - 2
|
98, // 100 - 2
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
|
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
|
||||||
undefined,
|
undefined,
|
||||||
ACTIVE_SHELL_MAX_LINES - 3,
|
ACTIVE_SHELL_MAX_LINES - 3,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
],
|
],
|
||||||
])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => {
|
])(
|
||||||
|
'%s',
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
availableTerminalHeight,
|
||||||
|
expectedMaxLines,
|
||||||
|
focused,
|
||||||
|
constrainHeight,
|
||||||
|
) => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = renderShell(
|
const { lastFrame, waitUntilReady, unmount } = renderShell(
|
||||||
{
|
{
|
||||||
resultDisplay: LONG_OUTPUT,
|
resultDisplay: LONG_OUTPUT,
|
||||||
@@ -224,6 +236,7 @@ describe('<ShellToolMessage />', () => {
|
|||||||
uiState: {
|
uiState: {
|
||||||
activePtyId: focused ? 1 : 2,
|
activePtyId: focused ? 1 : 2,
|
||||||
embeddedShellFocused: focused,
|
embeddedShellFocused: focused,
|
||||||
|
constrainHeight,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -233,7 +246,8 @@ describe('<ShellToolMessage />', () => {
|
|||||||
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
|
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
|
||||||
expect(frame).toMatchSnapshot();
|
expect(frame).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
|
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
|
||||||
const { lastFrame, unmount } = renderShell(
|
const { lastFrame, unmount } = renderShell(
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { ToolMessage, type ToolMessageProps } from './ToolMessage.js';
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { StreamingState } from '../../types.js';
|
import { StreamingState } from '../../types.js';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { type AnsiOutput, CoreToolCallStatus } from '@google/gemini-cli-core';
|
import {
|
||||||
|
type AnsiOutput,
|
||||||
|
CoreToolCallStatus,
|
||||||
|
Kind,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||||
|
|
||||||
@@ -435,4 +439,99 @@ describe('<ToolMessage />', () => {
|
|||||||
expect(output).toMatchSnapshot();
|
expect(output).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Truncation', () => {
|
||||||
|
it('applies truncation for Kind.Agent when availableTerminalHeight is provided', async () => {
|
||||||
|
const multilineString = Array.from(
|
||||||
|
{ length: 30 },
|
||||||
|
(_, i) => `Line ${i + 1}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<ToolMessage
|
||||||
|
{...baseProps}
|
||||||
|
kind={Kind.Agent}
|
||||||
|
resultDisplay={multilineString}
|
||||||
|
renderOutputAsMarkdown={false}
|
||||||
|
availableTerminalHeight={40}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
uiActions,
|
||||||
|
uiState: {
|
||||||
|
streamingState: StreamingState.Idle,
|
||||||
|
constrainHeight: true,
|
||||||
|
},
|
||||||
|
width: 80,
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const output = lastFrame();
|
||||||
|
|
||||||
|
// Since kind=Kind.Agent and availableTerminalHeight is provided, it should truncate to SUBAGENT_MAX_LINES (15)
|
||||||
|
// and show the FIRST lines (overflowDirection='bottom')
|
||||||
|
expect(output).toContain('Line 1');
|
||||||
|
expect(output).toContain('Line 14');
|
||||||
|
expect(output).not.toContain('Line 16');
|
||||||
|
expect(output).not.toContain('Line 30');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT apply truncation for Kind.Agent when availableTerminalHeight is undefined', async () => {
|
||||||
|
const multilineString = Array.from(
|
||||||
|
{ length: 30 },
|
||||||
|
(_, i) => `Line ${i + 1}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<ToolMessage
|
||||||
|
{...baseProps}
|
||||||
|
kind={Kind.Agent}
|
||||||
|
resultDisplay={multilineString}
|
||||||
|
renderOutputAsMarkdown={false}
|
||||||
|
availableTerminalHeight={undefined}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
uiActions,
|
||||||
|
uiState: { streamingState: StreamingState.Idle },
|
||||||
|
width: 80,
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const output = lastFrame();
|
||||||
|
|
||||||
|
expect(output).toContain('Line 1');
|
||||||
|
expect(output).toContain('Line 30');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT apply truncation for Kind.Read', async () => {
|
||||||
|
const multilineString = Array.from(
|
||||||
|
{ length: 30 },
|
||||||
|
(_, i) => `Line ${i + 1}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<ToolMessage
|
||||||
|
{...baseProps}
|
||||||
|
kind={Kind.Read}
|
||||||
|
resultDisplay={multilineString}
|
||||||
|
renderOutputAsMarkdown={false}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
uiActions,
|
||||||
|
uiState: { streamingState: StreamingState.Idle },
|
||||||
|
width: 80,
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const output = lastFrame();
|
||||||
|
|
||||||
|
expect(output).toContain('Line 1');
|
||||||
|
expect(output).toContain('Line 30');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ import {
|
|||||||
useFocusHint,
|
useFocusHint,
|
||||||
FocusHint,
|
FocusHint,
|
||||||
} from './ToolShared.js';
|
} from './ToolShared.js';
|
||||||
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
|
import { type Config, CoreToolCallStatus, Kind } from '@google/gemini-cli-core';
|
||||||
import { ShellInputPrompt } from '../ShellInputPrompt.js';
|
import { ShellInputPrompt } from '../ShellInputPrompt.js';
|
||||||
|
import { SUBAGENT_MAX_LINES } from '../../constants.js';
|
||||||
|
|
||||||
export type { TextEmphasis };
|
export type { TextEmphasis };
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||||||
description,
|
description,
|
||||||
resultDisplay,
|
resultDisplay,
|
||||||
status,
|
status,
|
||||||
|
kind,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
terminalWidth,
|
terminalWidth,
|
||||||
emphasis = 'medium',
|
emphasis = 'medium',
|
||||||
@@ -133,6 +135,12 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||||
hasFocus={isThisShellFocused}
|
hasFocus={isThisShellFocused}
|
||||||
|
maxLines={
|
||||||
|
kind === Kind.Agent && availableTerminalHeight !== undefined
|
||||||
|
? SUBAGENT_MAX_LINES
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
overflowDirection={kind === Kind.Agent ? 'bottom' : 'top'}
|
||||||
/>
|
/>
|
||||||
{isThisShellFocused && config && (
|
{isThisShellFocused && config && (
|
||||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||||
|
|||||||
@@ -6,35 +6,15 @@
|
|||||||
|
|
||||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||||
import { ToolResultDisplay } from './ToolResultDisplay.js';
|
import { ToolResultDisplay } from './ToolResultDisplay.js';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import type { AnsiOutput } from '@google/gemini-cli-core';
|
import type { AnsiOutput } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
// Mock UIStateContext partially
|
|
||||||
const mockUseUIState = vi.fn();
|
|
||||||
vi.mock('../../contexts/UIStateContext.js', async (importOriginal) => {
|
|
||||||
const actual =
|
|
||||||
await importOriginal<typeof import('../../contexts/UIStateContext.js')>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useUIState: () => mockUseUIState(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock useAlternateBuffer
|
|
||||||
const mockUseAlternateBuffer = vi.fn();
|
|
||||||
vi.mock('../../hooks/useAlternateBuffer.js', () => ({
|
|
||||||
useAlternateBuffer: () => mockUseAlternateBuffer(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('ToolResultDisplay', () => {
|
describe('ToolResultDisplay', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockUseUIState.mockReturnValue({ renderMarkdown: true });
|
|
||||||
mockUseAlternateBuffer.mockReturnValue(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses ScrollableList for ANSI output in alternate buffer mode', async () => {
|
it('uses ScrollableList for ANSI output in alternate buffer mode', async () => {
|
||||||
mockUseAlternateBuffer.mockReturnValue(true);
|
|
||||||
const content = 'ansi content';
|
const content = 'ansi content';
|
||||||
const ansiResult: AnsiOutput = [
|
const ansiResult: AnsiOutput = [
|
||||||
[
|
[
|
||||||
@@ -56,6 +36,7 @@ describe('ToolResultDisplay', () => {
|
|||||||
terminalWidth={80}
|
terminalWidth={80}
|
||||||
maxLines={10}
|
maxLines={10}
|
||||||
/>,
|
/>,
|
||||||
|
{ useAlternateBuffer: true },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
@@ -65,13 +46,13 @@ describe('ToolResultDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses Scrollable for non-ANSI output in alternate buffer mode', async () => {
|
it('uses Scrollable for non-ANSI output in alternate buffer mode', async () => {
|
||||||
mockUseAlternateBuffer.mockReturnValue(true);
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
<ToolResultDisplay
|
<ToolResultDisplay
|
||||||
resultDisplay="**Markdown content**"
|
resultDisplay="**Markdown content**"
|
||||||
terminalWidth={80}
|
terminalWidth={80}
|
||||||
maxLines={10}
|
maxLines={10}
|
||||||
/>,
|
/>,
|
||||||
|
{ useAlternateBuffer: true },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
@@ -82,13 +63,13 @@ describe('ToolResultDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('passes hasFocus prop to scrollable components', async () => {
|
it('passes hasFocus prop to scrollable components', async () => {
|
||||||
mockUseAlternateBuffer.mockReturnValue(true);
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
<ToolResultDisplay
|
<ToolResultDisplay
|
||||||
resultDisplay="Some result"
|
resultDisplay="Some result"
|
||||||
terminalWidth={80}
|
terminalWidth={80}
|
||||||
hasFocus={true}
|
hasFocus={true}
|
||||||
/>,
|
/>,
|
||||||
|
{ useAlternateBuffer: true },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
|
|
||||||
@@ -99,6 +80,7 @@ describe('ToolResultDisplay', () => {
|
|||||||
it('renders string result as markdown by default', async () => {
|
it('renders string result as markdown by default', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
<ToolResultDisplay resultDisplay="**Some result**" terminalWidth={80} />,
|
<ToolResultDisplay resultDisplay="**Some result**" terminalWidth={80} />,
|
||||||
|
{ useAlternateBuffer: false },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
@@ -115,6 +97,10 @@ describe('ToolResultDisplay', () => {
|
|||||||
availableTerminalHeight={20}
|
availableTerminalHeight={20}
|
||||||
renderOutputAsMarkdown={false}
|
renderOutputAsMarkdown={false}
|
||||||
/>,
|
/>,
|
||||||
|
{
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
uiState: { constrainHeight: true },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
@@ -131,6 +117,10 @@ describe('ToolResultDisplay', () => {
|
|||||||
terminalWidth={80}
|
terminalWidth={80}
|
||||||
availableTerminalHeight={20}
|
availableTerminalHeight={20}
|
||||||
/>,
|
/>,
|
||||||
|
{
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
uiState: { constrainHeight: true },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
@@ -150,6 +140,7 @@ describe('ToolResultDisplay', () => {
|
|||||||
terminalWidth={80}
|
terminalWidth={80}
|
||||||
availableTerminalHeight={20}
|
availableTerminalHeight={20}
|
||||||
/>,
|
/>,
|
||||||
|
{ useAlternateBuffer: false },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
@@ -179,6 +170,7 @@ describe('ToolResultDisplay', () => {
|
|||||||
terminalWidth={80}
|
terminalWidth={80}
|
||||||
availableTerminalHeight={20}
|
availableTerminalHeight={20}
|
||||||
/>,
|
/>,
|
||||||
|
{ useAlternateBuffer: false },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
@@ -197,6 +189,7 @@ describe('ToolResultDisplay', () => {
|
|||||||
terminalWidth={80}
|
terminalWidth={80}
|
||||||
availableTerminalHeight={20}
|
availableTerminalHeight={20}
|
||||||
/>,
|
/>,
|
||||||
|
{ useAlternateBuffer: false },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame({ allowEmpty: true });
|
const output = lastFrame({ allowEmpty: true });
|
||||||
@@ -206,7 +199,6 @@ describe('ToolResultDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not fall back to plain text if availableHeight is set and not in alternate buffer', async () => {
|
it('does not fall back to plain text if availableHeight is set and not in alternate buffer', async () => {
|
||||||
mockUseAlternateBuffer.mockReturnValue(false);
|
|
||||||
// availableHeight calculation: 20 - 1 - 5 = 14 > 3
|
// availableHeight calculation: 20 - 1 - 5 = 14 > 3
|
||||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
<ToolResultDisplay
|
<ToolResultDisplay
|
||||||
@@ -215,6 +207,10 @@ describe('ToolResultDisplay', () => {
|
|||||||
availableTerminalHeight={20}
|
availableTerminalHeight={20}
|
||||||
renderOutputAsMarkdown={true}
|
renderOutputAsMarkdown={true}
|
||||||
/>,
|
/>,
|
||||||
|
{
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
uiState: { constrainHeight: true },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
@@ -223,7 +219,6 @@ describe('ToolResultDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps markdown if in alternate buffer even with availableHeight', async () => {
|
it('keeps markdown if in alternate buffer even with availableHeight', async () => {
|
||||||
mockUseAlternateBuffer.mockReturnValue(true);
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
<ToolResultDisplay
|
<ToolResultDisplay
|
||||||
resultDisplay="**Some result**"
|
resultDisplay="**Some result**"
|
||||||
@@ -231,6 +226,7 @@ describe('ToolResultDisplay', () => {
|
|||||||
availableTerminalHeight={20}
|
availableTerminalHeight={20}
|
||||||
renderOutputAsMarkdown={true}
|
renderOutputAsMarkdown={true}
|
||||||
/>,
|
/>,
|
||||||
|
{ useAlternateBuffer: true },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
@@ -309,6 +305,10 @@ describe('ToolResultDisplay', () => {
|
|||||||
availableTerminalHeight={20}
|
availableTerminalHeight={20}
|
||||||
maxLines={3}
|
maxLines={3}
|
||||||
/>,
|
/>,
|
||||||
|
{
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
uiState: { constrainHeight: true },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
@@ -341,6 +341,10 @@ describe('ToolResultDisplay', () => {
|
|||||||
maxLines={25}
|
maxLines={25}
|
||||||
availableTerminalHeight={undefined}
|
availableTerminalHeight={undefined}
|
||||||
/>,
|
/>,
|
||||||
|
{
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
uiState: { constrainHeight: true },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Box, Text } from 'ink';
|
|||||||
import { DiffRenderer } from './DiffRenderer.js';
|
import { DiffRenderer } from './DiffRenderer.js';
|
||||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||||
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
|
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
|
||||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
import { SlicingMaxSizedBox } from '../shared/SlicingMaxSizedBox.js';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
import {
|
import {
|
||||||
type AnsiOutput,
|
type AnsiOutput,
|
||||||
@@ -26,10 +26,6 @@ import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
|||||||
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||||
|
|
||||||
// Large threshold to ensure we don't cause performance issues for very large
|
|
||||||
// outputs that will get truncated further MaxSizedBox anyway.
|
|
||||||
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000;
|
|
||||||
|
|
||||||
export interface ToolResultDisplayProps {
|
export interface ToolResultDisplayProps {
|
||||||
resultDisplay: string | object | undefined;
|
resultDisplay: string | object | undefined;
|
||||||
availableTerminalHeight?: number;
|
availableTerminalHeight?: number;
|
||||||
@@ -37,6 +33,7 @@ export interface ToolResultDisplayProps {
|
|||||||
renderOutputAsMarkdown?: boolean;
|
renderOutputAsMarkdown?: boolean;
|
||||||
maxLines?: number;
|
maxLines?: number;
|
||||||
hasFocus?: boolean;
|
hasFocus?: boolean;
|
||||||
|
overflowDirection?: 'top' | 'bottom';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileDiffResult {
|
interface FileDiffResult {
|
||||||
@@ -51,6 +48,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
|||||||
renderOutputAsMarkdown = true,
|
renderOutputAsMarkdown = true,
|
||||||
maxLines,
|
maxLines,
|
||||||
hasFocus = false,
|
hasFocus = false,
|
||||||
|
overflowDirection = 'top',
|
||||||
}) => {
|
}) => {
|
||||||
const { renderMarkdown } = useUIState();
|
const { renderMarkdown } = useUIState();
|
||||||
const isAlternateBuffer = useAlternateBuffer();
|
const isAlternateBuffer = useAlternateBuffer();
|
||||||
@@ -78,90 +76,21 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => {
|
if (!resultDisplay) return null;
|
||||||
let hiddenLines = 0;
|
|
||||||
// Only truncate string output if not in alternate buffer mode to ensure
|
|
||||||
// we can scroll through the full output.
|
|
||||||
if (typeof resultDisplay === 'string' && !isAlternateBuffer) {
|
|
||||||
let text = resultDisplay;
|
|
||||||
if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
|
|
||||||
text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
|
||||||
}
|
|
||||||
if (maxLines) {
|
|
||||||
const hasTrailingNewline = text.endsWith('\n');
|
|
||||||
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
|
|
||||||
const lines = contentText.split('\n');
|
|
||||||
if (lines.length > maxLines) {
|
|
||||||
// We will have a label from MaxSizedBox. Reserve space for it.
|
|
||||||
const targetLines = Math.max(1, maxLines - 1);
|
|
||||||
hiddenLines = lines.length - targetLines;
|
|
||||||
text =
|
|
||||||
lines.slice(-targetLines).join('\n') +
|
|
||||||
(hasTrailingNewline ? '\n' : '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(resultDisplay) && !isAlternateBuffer && maxLines) {
|
|
||||||
if (resultDisplay.length > maxLines) {
|
|
||||||
// We will have a label from MaxSizedBox. Reserve space for it.
|
|
||||||
const targetLines = Math.max(1, maxLines - 1);
|
|
||||||
return {
|
|
||||||
truncatedResultDisplay: resultDisplay.slice(-targetLines),
|
|
||||||
hiddenLinesCount: resultDisplay.length - targetLines,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { truncatedResultDisplay: resultDisplay, hiddenLinesCount: 0 };
|
|
||||||
}, [resultDisplay, isAlternateBuffer, maxLines]);
|
|
||||||
|
|
||||||
if (!truncatedResultDisplay) return null;
|
|
||||||
|
|
||||||
// 1. Early return for background tools (Todos)
|
// 1. Early return for background tools (Todos)
|
||||||
if (
|
if (typeof resultDisplay === 'object' && 'todos' in resultDisplay) {
|
||||||
typeof truncatedResultDisplay === 'object' &&
|
|
||||||
'todos' in truncatedResultDisplay
|
|
||||||
) {
|
|
||||||
// display nothing, as the TodoTray will handle rendering todos
|
// display nothing, as the TodoTray will handle rendering todos
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. High-performance path: Virtualized ANSI in interactive mode
|
const renderContent = (contentData: string | object | undefined) => {
|
||||||
if (isAlternateBuffer && Array.isArray(truncatedResultDisplay)) {
|
|
||||||
// If availableHeight is undefined, fallback to a safe default to prevents infinite loop
|
|
||||||
// where Container grows -> List renders more -> Container grows.
|
|
||||||
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
|
|
||||||
const listHeight = Math.min(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
||||||
(truncatedResultDisplay as AnsiOutput).length,
|
|
||||||
limit,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
|
||||||
<ScrollableList
|
|
||||||
width={childWidth}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
||||||
data={truncatedResultDisplay as AnsiOutput}
|
|
||||||
renderItem={renderVirtualizedAnsiLine}
|
|
||||||
estimatedItemHeight={() => 1}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
|
||||||
hasFocus={hasFocus}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Compute content node for non-virtualized paths
|
|
||||||
// Check if string content is valid JSON and pretty-print it
|
// Check if string content is valid JSON and pretty-print it
|
||||||
const prettyJSON =
|
const prettyJSON =
|
||||||
typeof truncatedResultDisplay === 'string'
|
typeof contentData === 'string' ? tryParseJSON(contentData) : null;
|
||||||
? tryParseJSON(truncatedResultDisplay)
|
const formattedJSON = prettyJSON
|
||||||
|
? JSON.stringify(prettyJSON, null, 2)
|
||||||
: null;
|
: null;
|
||||||
const formattedJSON = prettyJSON ? JSON.stringify(prettyJSON, null, 2) : null;
|
|
||||||
|
|
||||||
let content: React.ReactNode;
|
let content: React.ReactNode;
|
||||||
|
|
||||||
@@ -172,39 +101,32 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
|||||||
{formattedJSON}
|
{formattedJSON}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
} else if (isSubagentProgress(truncatedResultDisplay)) {
|
} else if (isSubagentProgress(contentData)) {
|
||||||
content = <SubagentProgressDisplay progress={truncatedResultDisplay} />;
|
content = <SubagentProgressDisplay progress={contentData} />;
|
||||||
} else if (
|
} else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
|
||||||
typeof truncatedResultDisplay === 'string' &&
|
|
||||||
renderOutputAsMarkdown
|
|
||||||
) {
|
|
||||||
content = (
|
content = (
|
||||||
<MarkdownDisplay
|
<MarkdownDisplay
|
||||||
text={truncatedResultDisplay}
|
text={contentData}
|
||||||
terminalWidth={childWidth}
|
terminalWidth={childWidth}
|
||||||
renderMarkdown={renderMarkdown}
|
renderMarkdown={renderMarkdown}
|
||||||
isPending={false}
|
isPending={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (typeof contentData === 'string' && !renderOutputAsMarkdown) {
|
||||||
typeof truncatedResultDisplay === 'string' &&
|
|
||||||
!renderOutputAsMarkdown
|
|
||||||
) {
|
|
||||||
content = (
|
content = (
|
||||||
<Text wrap="wrap" color={theme.text.primary}>
|
<Text wrap="wrap" color={theme.text.primary}>
|
||||||
{truncatedResultDisplay}
|
{contentData}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
|
||||||
typeof truncatedResultDisplay === 'object' &&
|
|
||||||
'fileDiff' in truncatedResultDisplay
|
|
||||||
) {
|
|
||||||
content = (
|
content = (
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
|
diffContent={
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
diffContent={(truncatedResultDisplay as FileDiffResult).fileDiff}
|
(contentData as FileDiffResult).fileDiff
|
||||||
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
filename={(truncatedResultDisplay as FileDiffResult).fileName}
|
filename={(contentData as FileDiffResult).fileName}
|
||||||
availableTerminalHeight={availableHeight}
|
availableTerminalHeight={availableHeight}
|
||||||
terminalWidth={childWidth}
|
terminalWidth={childWidth}
|
||||||
/>
|
/>
|
||||||
@@ -217,7 +139,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
|||||||
content = (
|
content = (
|
||||||
<AnsiOutputText
|
<AnsiOutputText
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
data={truncatedResultDisplay as AnsiOutput}
|
data={contentData as AnsiOutput}
|
||||||
availableTerminalHeight={
|
availableTerminalHeight={
|
||||||
isAlternateBuffer ? undefined : availableHeight
|
isAlternateBuffer ? undefined : availableHeight
|
||||||
}
|
}
|
||||||
@@ -228,7 +150,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Final render based on session mode
|
// Final render based on session mode
|
||||||
if (isAlternateBuffer) {
|
if (isAlternateBuffer) {
|
||||||
return (
|
return (
|
||||||
<Scrollable
|
<Scrollable
|
||||||
@@ -243,15 +165,58 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ASB Mode Handling (Interactive/Fullscreen)
|
||||||
|
if (isAlternateBuffer) {
|
||||||
|
// Virtualized path for large ANSI arrays
|
||||||
|
if (Array.isArray(resultDisplay)) {
|
||||||
|
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
|
||||||
|
const listHeight = Math.min(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
(resultDisplay as AnsiOutput).length,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||||
|
<ScrollableList
|
||||||
|
width={childWidth}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
data={resultDisplay as AnsiOutput}
|
||||||
|
renderItem={renderVirtualizedAnsiLine}
|
||||||
|
estimatedItemHeight={() => 1}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||||
|
hasFocus={hasFocus}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard path for strings/diffs in ASB
|
||||||
return (
|
return (
|
||||||
<Box width={childWidth} flexDirection="column">
|
<Box width={childWidth} flexDirection="column">
|
||||||
<MaxSizedBox
|
{renderContent(resultDisplay)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard Mode Handling (History/Scrollback)
|
||||||
|
// We use SlicingMaxSizedBox which includes MaxSizedBox for precision truncation + hidden labels
|
||||||
|
return (
|
||||||
|
<Box width={childWidth} flexDirection="column">
|
||||||
|
<SlicingMaxSizedBox
|
||||||
|
data={resultDisplay}
|
||||||
|
maxLines={maxLines}
|
||||||
|
isAlternateBuffer={isAlternateBuffer}
|
||||||
maxHeight={availableHeight}
|
maxHeight={availableHeight}
|
||||||
maxWidth={childWidth}
|
maxWidth={childWidth}
|
||||||
additionalHiddenLinesCount={hiddenLinesCount}
|
overflowDirection={overflowDirection}
|
||||||
>
|
>
|
||||||
{content}
|
{(truncatedResultDisplay) => renderContent(truncatedResultDisplay)}
|
||||||
</MaxSizedBox>
|
</SlicingMaxSizedBox>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||||
|
import { ToolResultDisplay } from './ToolResultDisplay.js';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { type AnsiOutput } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
describe('ToolResultDisplay Overflow', () => {
|
||||||
|
it('shows the head of the content when overflowDirection is bottom (string)', async () => {
|
||||||
|
const content = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<ToolResultDisplay
|
||||||
|
resultDisplay={content}
|
||||||
|
terminalWidth={80}
|
||||||
|
maxLines={3}
|
||||||
|
overflowDirection="bottom"
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
uiState: { constrainHeight: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const output = lastFrame();
|
||||||
|
|
||||||
|
expect(output).toContain('Line 1');
|
||||||
|
expect(output).toContain('Line 2');
|
||||||
|
expect(output).not.toContain('Line 3'); // Line 3 is replaced by the "hidden" label
|
||||||
|
expect(output).not.toContain('Line 4');
|
||||||
|
expect(output).not.toContain('Line 5');
|
||||||
|
expect(output).toContain('hidden');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the tail of the content when overflowDirection is top (string default)', async () => {
|
||||||
|
const content = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<ToolResultDisplay
|
||||||
|
resultDisplay={content}
|
||||||
|
terminalWidth={80}
|
||||||
|
maxLines={3}
|
||||||
|
overflowDirection="top"
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
uiState: { constrainHeight: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const output = lastFrame();
|
||||||
|
|
||||||
|
expect(output).not.toContain('Line 1');
|
||||||
|
expect(output).not.toContain('Line 2');
|
||||||
|
expect(output).not.toContain('Line 3');
|
||||||
|
expect(output).toContain('Line 4');
|
||||||
|
expect(output).toContain('Line 5');
|
||||||
|
expect(output).toContain('hidden');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the head of the content when overflowDirection is bottom (ANSI)', async () => {
|
||||||
|
const ansiResult: AnsiOutput = Array.from({ length: 5 }, (_, i) => [
|
||||||
|
{
|
||||||
|
text: `Line ${i + 1}`,
|
||||||
|
fg: '',
|
||||||
|
bg: '',
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
dim: false,
|
||||||
|
inverse: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<ToolResultDisplay
|
||||||
|
resultDisplay={ansiResult}
|
||||||
|
terminalWidth={80}
|
||||||
|
maxLines={3}
|
||||||
|
overflowDirection="bottom"
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
useAlternateBuffer: false,
|
||||||
|
uiState: { constrainHeight: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const output = lastFrame();
|
||||||
|
|
||||||
|
expect(output).toContain('Line 1');
|
||||||
|
expect(output).toContain('Line 2');
|
||||||
|
expect(output).not.toContain('Line 3');
|
||||||
|
expect(output).not.toContain('Line 4');
|
||||||
|
expect(output).not.toContain('Line 5');
|
||||||
|
expect(output).toContain('hidden');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
||||||
|
|
||||||
exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = `
|
|
||||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
|
||||||
│ ✓ test-tool a test tool │
|
|
||||||
│ │
|
|
||||||
│ line 45 │
|
|
||||||
│ line 46 │
|
|
||||||
│ line 47 │
|
|
||||||
│ line 48 │
|
|
||||||
│ line 49 │
|
|
||||||
│ line 50 █ │
|
|
||||||
╰──────────────────────────────────────────────────────────────────────────╯
|
|
||||||
Press Ctrl+O to show more lines
|
|
||||||
"
|
|
||||||
`;
|
|
||||||
@@ -20,7 +20,7 @@ import { formatCommand } from '../../utils/keybindingUtils.js';
|
|||||||
*/
|
*/
|
||||||
export const MINIMUM_MAX_HEIGHT = 2;
|
export const MINIMUM_MAX_HEIGHT = 2;
|
||||||
|
|
||||||
interface MaxSizedBoxProps {
|
export interface MaxSizedBoxProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render } from '../../../test-utils/render.js';
|
||||||
|
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||||
|
import { SlicingMaxSizedBox } from './SlicingMaxSizedBox.js';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('<SlicingMaxSizedBox />', () => {
|
||||||
|
it('renders string data without slicing when it fits', async () => {
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = render(
|
||||||
|
<OverflowProvider>
|
||||||
|
<SlicingMaxSizedBox data="Hello World" maxWidth={80}>
|
||||||
|
{(truncatedData) => <Text>{truncatedData}</Text>}
|
||||||
|
</SlicingMaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
expect(lastFrame()).toContain('Hello World');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slices string data by characters when very long', async () => {
|
||||||
|
const veryLongString = 'A'.repeat(25000);
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = render(
|
||||||
|
<OverflowProvider>
|
||||||
|
<SlicingMaxSizedBox
|
||||||
|
data={veryLongString}
|
||||||
|
maxWidth={80}
|
||||||
|
overflowDirection="bottom"
|
||||||
|
>
|
||||||
|
{(truncatedData) => <Text>{truncatedData.length}</Text>}
|
||||||
|
</SlicingMaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
// 20000 characters + 3 for '...'
|
||||||
|
expect(lastFrame()).toContain('20003');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slices string data by lines when maxLines is provided', async () => {
|
||||||
|
const multilineString = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = render(
|
||||||
|
<OverflowProvider>
|
||||||
|
<SlicingMaxSizedBox
|
||||||
|
data={multilineString}
|
||||||
|
maxLines={3}
|
||||||
|
maxWidth={80}
|
||||||
|
maxHeight={10}
|
||||||
|
overflowDirection="bottom"
|
||||||
|
>
|
||||||
|
{(truncatedData) => <Text>{truncatedData}</Text>}
|
||||||
|
</SlicingMaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
// maxLines=3, so it should keep 3-1 = 2 lines
|
||||||
|
expect(lastFrame()).toContain('Line 1');
|
||||||
|
expect(lastFrame()).toContain('Line 2');
|
||||||
|
expect(lastFrame()).not.toContain('Line 3');
|
||||||
|
expect(lastFrame()).toContain(
|
||||||
|
'... last 3 lines hidden (Ctrl+O to show) ...',
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slices array data when maxLines is provided', async () => {
|
||||||
|
const dataArray = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'];
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = render(
|
||||||
|
<OverflowProvider>
|
||||||
|
<SlicingMaxSizedBox
|
||||||
|
data={dataArray}
|
||||||
|
maxLines={3}
|
||||||
|
maxWidth={80}
|
||||||
|
maxHeight={10}
|
||||||
|
overflowDirection="bottom"
|
||||||
|
>
|
||||||
|
{(truncatedData) => (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{truncatedData.map((item, i) => (
|
||||||
|
<Text key={i}>{item}</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</SlicingMaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
// maxLines=3, so it should keep 3-1 = 2 items
|
||||||
|
expect(lastFrame()).toContain('Item 1');
|
||||||
|
expect(lastFrame()).toContain('Item 2');
|
||||||
|
expect(lastFrame()).not.toContain('Item 3');
|
||||||
|
expect(lastFrame()).toContain(
|
||||||
|
'... last 3 lines hidden (Ctrl+O to show) ...',
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not slice when isAlternateBuffer is true', async () => {
|
||||||
|
const multilineString = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = render(
|
||||||
|
<OverflowProvider>
|
||||||
|
<SlicingMaxSizedBox
|
||||||
|
data={multilineString}
|
||||||
|
maxLines={3}
|
||||||
|
maxWidth={80}
|
||||||
|
isAlternateBuffer={true}
|
||||||
|
>
|
||||||
|
{(truncatedData) => <Text>{truncatedData}</Text>}
|
||||||
|
</SlicingMaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
expect(lastFrame()).toContain('Line 5');
|
||||||
|
expect(lastFrame()).not.toContain('hidden');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { MaxSizedBox, type MaxSizedBoxProps } from './MaxSizedBox.js';
|
||||||
|
|
||||||
|
// Large threshold to ensure we don't cause performance issues for very large
|
||||||
|
// outputs that will get truncated further MaxSizedBox anyway.
|
||||||
|
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000;
|
||||||
|
|
||||||
|
export interface SlicingMaxSizedBoxProps<T>
|
||||||
|
extends Omit<MaxSizedBoxProps, 'children'> {
|
||||||
|
data: T;
|
||||||
|
maxLines?: number;
|
||||||
|
isAlternateBuffer?: boolean;
|
||||||
|
children: (truncatedData: T) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An extension of MaxSizedBox that performs explicit slicing of the input data
|
||||||
|
* (string or array) before rendering. This is useful for performance and to
|
||||||
|
* ensure consistent truncation behavior for large outputs.
|
||||||
|
*/
|
||||||
|
export function SlicingMaxSizedBox<T>({
|
||||||
|
data,
|
||||||
|
maxLines,
|
||||||
|
isAlternateBuffer,
|
||||||
|
children,
|
||||||
|
...boxProps
|
||||||
|
}: SlicingMaxSizedBoxProps<T>) {
|
||||||
|
const { truncatedData, hiddenLinesCount } = useMemo(() => {
|
||||||
|
let hiddenLines = 0;
|
||||||
|
const overflowDirection = boxProps.overflowDirection ?? 'top';
|
||||||
|
|
||||||
|
// Only truncate string output if not in alternate buffer mode to ensure
|
||||||
|
// we can scroll through the full output.
|
||||||
|
if (typeof data === 'string' && !isAlternateBuffer) {
|
||||||
|
let text: string = data as string;
|
||||||
|
if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
|
||||||
|
if (overflowDirection === 'bottom') {
|
||||||
|
text = text.slice(0, MAXIMUM_RESULT_DISPLAY_CHARACTERS) + '...';
|
||||||
|
} else {
|
||||||
|
text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxLines) {
|
||||||
|
const hasTrailingNewline = text.endsWith('\n');
|
||||||
|
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
|
||||||
|
const lines = contentText.split('\n');
|
||||||
|
if (lines.length > maxLines) {
|
||||||
|
// We will have a label from MaxSizedBox. Reserve space for it.
|
||||||
|
const targetLines = Math.max(1, maxLines - 1);
|
||||||
|
hiddenLines = lines.length - targetLines;
|
||||||
|
if (overflowDirection === 'bottom') {
|
||||||
|
text =
|
||||||
|
lines.slice(0, targetLines).join('\n') +
|
||||||
|
(hasTrailingNewline ? '\n' : '');
|
||||||
|
} else {
|
||||||
|
text =
|
||||||
|
lines.slice(-targetLines).join('\n') +
|
||||||
|
(hasTrailingNewline ? '\n' : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
truncatedData: text,
|
||||||
|
hiddenLinesCount: hiddenLines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data) && !isAlternateBuffer && maxLines) {
|
||||||
|
if (data.length > maxLines) {
|
||||||
|
// We will have a label from MaxSizedBox. Reserve space for it.
|
||||||
|
const targetLines = Math.max(1, maxLines - 1);
|
||||||
|
const hiddenCount = data.length - targetLines;
|
||||||
|
return {
|
||||||
|
truncatedData:
|
||||||
|
overflowDirection === 'bottom'
|
||||||
|
? data.slice(0, targetLines)
|
||||||
|
: data.slice(-targetLines),
|
||||||
|
hiddenLinesCount: hiddenCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { truncatedData: data, hiddenLinesCount: 0 };
|
||||||
|
}, [data, isAlternateBuffer, maxLines, boxProps.overflowDirection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MaxSizedBox
|
||||||
|
{...boxProps}
|
||||||
|
additionalHiddenLinesCount={
|
||||||
|
(boxProps.additionalHiddenLinesCount ?? 0) + hiddenLinesCount
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
|
||||||
|
{children(truncatedData as unknown as T)}
|
||||||
|
</MaxSizedBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@ export const ACTIVE_SHELL_MAX_LINES = 15;
|
|||||||
// Max lines to preserve in history for completed shell commands
|
// Max lines to preserve in history for completed shell commands
|
||||||
export const COMPLETED_SHELL_MAX_LINES = 15;
|
export const COMPLETED_SHELL_MAX_LINES = 15;
|
||||||
|
|
||||||
|
// Max lines to show for subagent results before collapsing
|
||||||
|
export const SUBAGENT_MAX_LINES = 15;
|
||||||
|
|
||||||
/** Minimum terminal width required to show the full context used label */
|
/** Minimum terminal width required to show the full context used label */
|
||||||
export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
|
export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export function mapToDisplay(
|
|||||||
...baseDisplayProperties,
|
...baseDisplayProperties,
|
||||||
status: call.status,
|
status: call.status,
|
||||||
isClientInitiated: !!call.request.isClientInitiated,
|
isClientInitiated: !!call.request.isClientInitiated,
|
||||||
|
kind: call.tool?.kind,
|
||||||
resultDisplay,
|
resultDisplay,
|
||||||
confirmationDetails,
|
confirmationDetails,
|
||||||
outputFile,
|
outputFile,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type SkillDefinition,
|
type SkillDefinition,
|
||||||
type AgentDefinition,
|
type AgentDefinition,
|
||||||
type ApprovalMode,
|
type ApprovalMode,
|
||||||
|
type Kind,
|
||||||
CoreToolCallStatus,
|
CoreToolCallStatus,
|
||||||
checkExhaustive,
|
checkExhaustive,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
@@ -105,6 +106,7 @@ export interface IndividualToolCallDisplay {
|
|||||||
status: CoreToolCallStatus;
|
status: CoreToolCallStatus;
|
||||||
// True when the tool was initiated directly by the user (slash/@/shell flows).
|
// True when the tool was initiated directly by the user (slash/@/shell flows).
|
||||||
isClientInitiated?: boolean;
|
isClientInitiated?: boolean;
|
||||||
|
kind?: Kind;
|
||||||
confirmationDetails: SerializableConfirmationDetails | undefined;
|
confirmationDetails: SerializableConfirmationDetails | undefined;
|
||||||
renderOutputAsMarkdown?: boolean;
|
renderOutputAsMarkdown?: boolean;
|
||||||
ptyId?: number;
|
ptyId?: number;
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
|
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
|
||||||
const prefixWidth = prefix.length;
|
const prefixWidth = prefix.length;
|
||||||
|
// Account for leading whitespace (indentation level) plus the standard prefix padding
|
||||||
const indentation = leadingWhitespace.length;
|
const indentation = leadingWhitespace.length;
|
||||||
const listResponseColor = theme.text.response ?? theme.text.primary;
|
const listResponseColor = theme.text.response ?? theme.text.primary;
|
||||||
|
|
||||||
@@ -422,7 +423,7 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
|
|||||||
paddingLeft={indentation + LIST_ITEM_PREFIX_PADDING}
|
paddingLeft={indentation + LIST_ITEM_PREFIX_PADDING}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
>
|
>
|
||||||
<Box width={prefixWidth}>
|
<Box width={prefixWidth} flexShrink={0}>
|
||||||
<Text color={listResponseColor}>{prefix}</Text>
|
<Text color={listResponseColor}>{prefix}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
|
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function calculateToolContentMaxLines(options: {
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (maxLinesLimit) {
|
if (maxLinesLimit !== undefined) {
|
||||||
contentHeight =
|
contentHeight =
|
||||||
contentHeight !== undefined
|
contentHeight !== undefined
|
||||||
? Math.min(contentHeight, maxLinesLimit)
|
? Math.min(contentHeight, maxLinesLimit)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Config } from '../../config/config.js';
|
import type { Config } from '../../config/config.js';
|
||||||
import { LocalAgentExecutor } from '../local-executor.js';
|
import { LocalAgentExecutor } from '../local-executor.js';
|
||||||
|
import { safeJsonToMarkdown } from '../../utils/markdownUtils.js';
|
||||||
import {
|
import {
|
||||||
BaseToolInvocation,
|
BaseToolInvocation,
|
||||||
type ToolResult,
|
type ToolResult,
|
||||||
@@ -414,6 +415,8 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
|||||||
|
|
||||||
const output = await executor.run(this.params, signal);
|
const output = await executor.run(this.params, signal);
|
||||||
|
|
||||||
|
const displayResult = safeJsonToMarkdown(output.result);
|
||||||
|
|
||||||
const resultContent = `Browser agent finished.
|
const resultContent = `Browser agent finished.
|
||||||
Termination Reason: ${output.terminate_reason}
|
Termination Reason: ${output.terminate_reason}
|
||||||
Result:
|
Result:
|
||||||
@@ -425,7 +428,7 @@ Browser Agent Finished
|
|||||||
Termination Reason: ${output.terminate_reason}
|
Termination Reason: ${output.terminate_reason}
|
||||||
|
|
||||||
Result:
|
Result:
|
||||||
${output.result}
|
${displayResult}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (updateOutput) {
|
if (updateOutput) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import { LocalAgentExecutor } from './local-executor.js';
|
import { LocalAgentExecutor } from './local-executor.js';
|
||||||
|
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
|
||||||
import {
|
import {
|
||||||
BaseToolInvocation,
|
BaseToolInvocation,
|
||||||
type ToolResult,
|
type ToolResult,
|
||||||
@@ -245,6 +246,8 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
|||||||
throw cancelError;
|
throw cancelError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayResult = safeJsonToMarkdown(output.result);
|
||||||
|
|
||||||
const resultContent = `Subagent '${this.definition.name}' finished.
|
const resultContent = `Subagent '${this.definition.name}' finished.
|
||||||
Termination Reason: ${output.terminate_reason}
|
Termination Reason: ${output.terminate_reason}
|
||||||
Result:
|
Result:
|
||||||
@@ -256,7 +259,7 @@ Subagent ${this.definition.name} Finished
|
|||||||
Termination Reason:\n ${output.terminate_reason}
|
Termination Reason:\n ${output.terminate_reason}
|
||||||
|
|
||||||
Result:
|
Result:
|
||||||
${output.result}
|
${displayResult}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
|
|||||||
import { GoogleAuth } from 'google-auth-library';
|
import { GoogleAuth } from 'google-auth-library';
|
||||||
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
|
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
|
||||||
import { debugLogger } from '../utils/debugLogger.js';
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
|
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
|
||||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||||
|
|
||||||
@@ -222,7 +223,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
llmContent: [{ text: finalOutput }],
|
llmContent: [{ text: finalOutput }],
|
||||||
returnDisplay: finalOutput,
|
returnDisplay: safeJsonToMarkdown(finalOutput),
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const partialOutput = reassembler.toString();
|
const partialOutput = reassembler.toString();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
KeychainSchema,
|
KeychainSchema,
|
||||||
KEYCHAIN_TEST_PREFIX,
|
KEYCHAIN_TEST_PREFIX,
|
||||||
} from './keychainTypes.js';
|
} from './keychainTypes.js';
|
||||||
|
import { isRecord } from '../utils/markdownUtils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for interacting with OS-level secure storage (e.g. keytar).
|
* Service for interacting with OS-level secure storage (e.g. keytar).
|
||||||
@@ -111,7 +112,7 @@ export class KeychainService {
|
|||||||
private async loadKeychainModule(): Promise<Keychain | null> {
|
private async loadKeychainModule(): Promise<Keychain | null> {
|
||||||
const moduleName = 'keytar';
|
const moduleName = 'keytar';
|
||||||
const module: unknown = await import(moduleName);
|
const module: unknown = await import(moduleName);
|
||||||
const potential = (this.isRecord(module) && module['default']) || module;
|
const potential = (isRecord(module) && module['default']) || module;
|
||||||
|
|
||||||
const result = KeychainSchema.safeParse(potential);
|
const result = KeychainSchema.safeParse(potential);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -126,10 +127,6 @@ export class KeychainService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isRecord(obj: unknown): obj is Record<string, unknown> {
|
|
||||||
return typeof obj === 'object' && obj !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Performs a set-get-delete cycle to verify keychain functionality.
|
// Performs a set-get-delete cycle to verify keychain functionality.
|
||||||
private async isKeychainFunctional(keychain: Keychain): Promise<boolean> {
|
private async isKeychainFunctional(keychain: Keychain): Promise<boolean> {
|
||||||
const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;
|
const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { jsonToMarkdown, safeJsonToMarkdown } from './markdownUtils.js';
|
||||||
|
|
||||||
|
describe('markdownUtils', () => {
|
||||||
|
describe('jsonToMarkdown', () => {
|
||||||
|
it('should handle primitives', () => {
|
||||||
|
expect(jsonToMarkdown('hello')).toBe('hello');
|
||||||
|
expect(jsonToMarkdown(123)).toBe('123');
|
||||||
|
expect(jsonToMarkdown(true)).toBe('true');
|
||||||
|
expect(jsonToMarkdown(null)).toBe('null');
|
||||||
|
expect(jsonToMarkdown(undefined)).toBe('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle simple arrays', () => {
|
||||||
|
const data = ['a', 'b', 'c'];
|
||||||
|
expect(jsonToMarkdown(data)).toBe('- a\n- b\n- c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle simple objects and convert camelCase to Space Case', () => {
|
||||||
|
const data = { userName: 'Alice', userAge: 30 };
|
||||||
|
expect(jsonToMarkdown(data)).toBe(
|
||||||
|
'- **User Name**: Alice\n- **User Age**: 30',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty structures', () => {
|
||||||
|
expect(jsonToMarkdown([])).toBe('[]');
|
||||||
|
expect(jsonToMarkdown({})).toBe('{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested structures with proper indentation', () => {
|
||||||
|
const data = {
|
||||||
|
userInfo: {
|
||||||
|
fullName: 'Bob Smith',
|
||||||
|
userRoles: ['admin', 'user'],
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
const result = jsonToMarkdown(data);
|
||||||
|
expect(result).toBe(
|
||||||
|
'- **User Info**:\n' +
|
||||||
|
' - **Full Name**: Bob Smith\n' +
|
||||||
|
' - **User Roles**:\n' +
|
||||||
|
' - admin\n' +
|
||||||
|
' - user\n' +
|
||||||
|
'- **Is Active**: true',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render tables for arrays of similar objects with Space Case keys', () => {
|
||||||
|
const data = [
|
||||||
|
{ userId: 1, userName: 'Item 1' },
|
||||||
|
{ userId: 2, userName: 'Item 2' },
|
||||||
|
];
|
||||||
|
const result = jsonToMarkdown(data);
|
||||||
|
expect(result).toBe(
|
||||||
|
'| User Id | User Name |\n| --- | --- |\n| 1 | Item 1 |\n| 2 | Item 2 |',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pipe characters, backslashes, and newlines in table data', () => {
|
||||||
|
const data = [
|
||||||
|
{ colInfo: 'val|ue', otherInfo: 'line\nbreak', pathInfo: 'C:\\test' },
|
||||||
|
];
|
||||||
|
const result = jsonToMarkdown(data);
|
||||||
|
expect(result).toBe(
|
||||||
|
'| Col Info | Other Info | Path Info |\n| --- | --- | --- |\n| val\\|ue | line break | C:\\\\test |',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to lists for arrays with mixed objects', () => {
|
||||||
|
const data = [
|
||||||
|
{ userId: 1, userName: 'Item 1' },
|
||||||
|
{ userId: 2, somethingElse: 'Item 2' },
|
||||||
|
];
|
||||||
|
const result = jsonToMarkdown(data);
|
||||||
|
expect(result).toContain('- **User Id**: 1');
|
||||||
|
expect(result).toContain('- **Something Else**: Item 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly indent nested tables', () => {
|
||||||
|
const data = {
|
||||||
|
items: [
|
||||||
|
{ id: 1, name: 'A' },
|
||||||
|
{ id: 2, name: 'B' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = jsonToMarkdown(data);
|
||||||
|
const lines = result.split('\n');
|
||||||
|
expect(lines[0]).toBe('- **Items**:');
|
||||||
|
expect(lines[1]).toBe(' | Id | Name |');
|
||||||
|
expect(lines[2]).toBe(' | --- | --- |');
|
||||||
|
expect(lines[3]).toBe(' | 1 | A |');
|
||||||
|
expect(lines[4]).toBe(' | 2 | B |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should indent subsequent lines of multiline strings', () => {
|
||||||
|
const data = {
|
||||||
|
description: 'Line 1\nLine 2\nLine 3',
|
||||||
|
};
|
||||||
|
const result = jsonToMarkdown(data);
|
||||||
|
expect(result).toBe('- **Description**: Line 1\n Line 2\n Line 3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeJsonToMarkdown', () => {
|
||||||
|
it('should convert valid JSON', () => {
|
||||||
|
const json = JSON.stringify({ keyName: 'value' });
|
||||||
|
expect(safeJsonToMarkdown(json)).toBe('- **Key Name**: value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original string for invalid JSON', () => {
|
||||||
|
const notJson = 'Not a JSON string';
|
||||||
|
expect(safeJsonToMarkdown(notJson)).toBe(notJson);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle plain strings that look like numbers or booleans but are valid JSON', () => {
|
||||||
|
expect(safeJsonToMarkdown('123')).toBe('123');
|
||||||
|
expect(safeJsonToMarkdown('true')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a camelCase string to a Space Case string.
|
||||||
|
* e.g., "camelCaseString" -> "Camel Case String"
|
||||||
|
*/
|
||||||
|
function camelToSpace(text: string): string {
|
||||||
|
const result = text.replace(/([A-Z])/g, ' $1');
|
||||||
|
return result.charAt(0).toUpperCase() + result.slice(1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a JSON-compatible value into a readable Markdown representation.
|
||||||
|
*
|
||||||
|
* @param data The data to convert.
|
||||||
|
* @param indent The current indentation level (for internal recursion).
|
||||||
|
* @returns A Markdown string representing the data.
|
||||||
|
*/
|
||||||
|
export function jsonToMarkdown(data: unknown, indent = 0): string {
|
||||||
|
const spacing = ' '.repeat(indent);
|
||||||
|
|
||||||
|
if (data === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data === undefined) {
|
||||||
|
return 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArrayOfSimilarObjects(data)) {
|
||||||
|
return renderTable(data, indent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
.map((item) => {
|
||||||
|
if (
|
||||||
|
typeof item === 'object' &&
|
||||||
|
item !== null &&
|
||||||
|
Object.keys(item).length > 0
|
||||||
|
) {
|
||||||
|
const rendered = jsonToMarkdown(item, indent + 1);
|
||||||
|
return `${spacing}-\n${rendered}`;
|
||||||
|
}
|
||||||
|
const rendered = jsonToMarkdown(item, indent + 1).trimStart();
|
||||||
|
return `${spacing}- ${rendered}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
const entries = Object.entries(data);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const displayKey = camelToSpace(key);
|
||||||
|
if (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
Object.keys(value).length > 0
|
||||||
|
) {
|
||||||
|
const renderedValue = jsonToMarkdown(value, indent + 1);
|
||||||
|
return `${spacing}- **${displayKey}**:\n${renderedValue}`;
|
||||||
|
}
|
||||||
|
const renderedValue = jsonToMarkdown(value, indent + 1).trimStart();
|
||||||
|
return `${spacing}- **${displayKey}**: ${renderedValue}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return data
|
||||||
|
.split('\n')
|
||||||
|
.map((line, i) => (i === 0 ? line : spacing + line))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely attempts to parse a string as JSON and convert it to Markdown.
|
||||||
|
* If parsing fails, returns the original string.
|
||||||
|
*
|
||||||
|
* @param text The text to potentially convert.
|
||||||
|
* @returns The Markdown representation or the original text.
|
||||||
|
*/
|
||||||
|
export function safeJsonToMarkdown(text: string): string {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
return jsonToMarkdown(parsed);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArrayOfSimilarObjects(
|
||||||
|
data: unknown[],
|
||||||
|
): data is Array<Record<string, unknown>> {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!data.every(isRecord)) return false;
|
||||||
|
const firstKeys = Object.keys(data[0]).sort().join(',');
|
||||||
|
return data.every((item) => Object.keys(item).sort().join(',') === firstKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(data: Array<Record<string, unknown>>, indent = 0): string {
|
||||||
|
const spacing = ' '.repeat(indent);
|
||||||
|
const keys = Object.keys(data[0]);
|
||||||
|
const displayKeys = keys.map(camelToSpace);
|
||||||
|
const header = `${spacing}| ${displayKeys.join(' | ')} |`;
|
||||||
|
const separator = `${spacing}| ${keys.map(() => '---').join(' | ')} |`;
|
||||||
|
const rows = data.map(
|
||||||
|
(item) =>
|
||||||
|
`${spacing}| ${keys
|
||||||
|
.map((key) => {
|
||||||
|
const val = item[key];
|
||||||
|
if (typeof val === 'object' && val !== null) {
|
||||||
|
return JSON.stringify(val)
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\|/g, '\\|');
|
||||||
|
}
|
||||||
|
return String(val)
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\|/g, '\\|')
|
||||||
|
.replace(/\n/g, ' ');
|
||||||
|
})
|
||||||
|
.join(' | ')} |`,
|
||||||
|
);
|
||||||
|
return [header, separator, ...rows].join('\n');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user