diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
index 40e5a7e781..b650ee4d9d 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
@@ -191,49 +191,63 @@ describe('', () => {
10,
8,
false,
+ true,
],
[
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
100,
ACTIVE_SHELL_MAX_LINES - 3,
false,
+ true,
],
[
'uses full availableTerminalHeight when focused in alternate buffer mode',
100,
98, // 100 - 2
true,
+ false,
],
[
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
undefined,
ACTIVE_SHELL_MAX_LINES - 3,
false,
+ false,
],
- ])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => {
- const { lastFrame, waitUntilReady, unmount } = renderShell(
- {
- resultDisplay: LONG_OUTPUT,
- renderOutputAsMarkdown: false,
- availableTerminalHeight,
- ptyId: 1,
- status: CoreToolCallStatus.Executing,
- },
- {
- useAlternateBuffer: true,
- uiState: {
- activePtyId: focused ? 1 : 2,
- embeddedShellFocused: focused,
+ ])(
+ '%s',
+ async (
+ _,
+ availableTerminalHeight,
+ expectedMaxLines,
+ focused,
+ constrainHeight,
+ ) => {
+ const { lastFrame, waitUntilReady, unmount } = renderShell(
+ {
+ resultDisplay: LONG_OUTPUT,
+ renderOutputAsMarkdown: false,
+ availableTerminalHeight,
+ ptyId: 1,
+ status: CoreToolCallStatus.Executing,
},
- },
- );
+ {
+ useAlternateBuffer: true,
+ uiState: {
+ activePtyId: focused ? 1 : 2,
+ embeddedShellFocused: focused,
+ constrainHeight,
+ },
+ },
+ );
- await waitUntilReady();
- const frame = lastFrame();
- expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
- expect(frame).toMatchSnapshot();
- unmount();
- });
+ await waitUntilReady();
+ const frame = lastFrame();
+ expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
+ expect(frame).toMatchSnapshot();
+ unmount();
+ },
+ );
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
const { lastFrame, unmount } = renderShell(
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
index df4354b1c4..e3869b6e1b 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
@@ -9,7 +9,11 @@ import { ToolMessage, type ToolMessageProps } from './ToolMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { StreamingState } from '../../types.js';
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 { tryParseJSON } from '../../../utils/jsonoutput.js';
@@ -435,4 +439,99 @@ describe('', () => {
expect(output).toMatchSnapshot();
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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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();
+ });
+ });
});
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 7c2277d4be..5747f7677f 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -21,8 +21,9 @@ import {
useFocusHint,
FocusHint,
} 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 { SUBAGENT_MAX_LINES } from '../../constants.js';
export type { TextEmphasis };
@@ -45,6 +46,7 @@ export const ToolMessage: React.FC = ({
description,
resultDisplay,
status,
+ kind,
availableTerminalHeight,
terminalWidth,
emphasis = 'medium',
@@ -133,6 +135,12 @@ export const ToolMessage: React.FC = ({
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
+ maxLines={
+ kind === Kind.Agent && availableTerminalHeight !== undefined
+ ? SUBAGENT_MAX_LINES
+ : undefined
+ }
+ overflowDirection={kind === Kind.Agent ? 'bottom' : 'top'}
/>
{isThisShellFocused && config && (
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
index f7d158d68c..02f466e72f 100644
--- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
@@ -6,35 +6,15 @@
import { renderWithProviders } from '../../../test-utils/render.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';
-// Mock UIStateContext partially
-const mockUseUIState = vi.fn();
-vi.mock('../../contexts/UIStateContext.js', async (importOriginal) => {
- const actual =
- await importOriginal();
- return {
- ...actual,
- useUIState: () => mockUseUIState(),
- };
-});
-
-// Mock useAlternateBuffer
-const mockUseAlternateBuffer = vi.fn();
-vi.mock('../../hooks/useAlternateBuffer.js', () => ({
- useAlternateBuffer: () => mockUseAlternateBuffer(),
-}));
-
describe('ToolResultDisplay', () => {
beforeEach(() => {
vi.clearAllMocks();
- mockUseUIState.mockReturnValue({ renderMarkdown: true });
- mockUseAlternateBuffer.mockReturnValue(false);
});
it('uses ScrollableList for ANSI output in alternate buffer mode', async () => {
- mockUseAlternateBuffer.mockReturnValue(true);
const content = 'ansi content';
const ansiResult: AnsiOutput = [
[
@@ -56,6 +36,7 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
maxLines={10}
/>,
+ { useAlternateBuffer: true },
);
await waitUntilReady();
const output = lastFrame();
@@ -65,13 +46,13 @@ describe('ToolResultDisplay', () => {
});
it('uses Scrollable for non-ANSI output in alternate buffer mode', async () => {
- mockUseAlternateBuffer.mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
+ { useAlternateBuffer: true },
);
await waitUntilReady();
const output = lastFrame();
@@ -82,13 +63,13 @@ describe('ToolResultDisplay', () => {
});
it('passes hasFocus prop to scrollable components', async () => {
- mockUseAlternateBuffer.mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
+ { useAlternateBuffer: true },
);
await waitUntilReady();
@@ -99,6 +80,7 @@ describe('ToolResultDisplay', () => {
it('renders string result as markdown by default', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
+ { useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
@@ -115,6 +97,10 @@ describe('ToolResultDisplay', () => {
availableTerminalHeight={20}
renderOutputAsMarkdown={false}
/>,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
);
await waitUntilReady();
const output = lastFrame();
@@ -131,6 +117,10 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
);
await waitUntilReady();
const output = lastFrame();
@@ -150,6 +140,7 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
+ { useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
@@ -179,6 +170,7 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
+ { useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
@@ -197,6 +189,7 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
+ { useAlternateBuffer: false },
);
await waitUntilReady();
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 () => {
- mockUseAlternateBuffer.mockReturnValue(false);
// availableHeight calculation: 20 - 1 - 5 = 14 > 3
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
{
availableTerminalHeight={20}
renderOutputAsMarkdown={true}
/>,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
);
await waitUntilReady();
const output = lastFrame();
@@ -223,7 +219,6 @@ describe('ToolResultDisplay', () => {
});
it('keeps markdown if in alternate buffer even with availableHeight', async () => {
- mockUseAlternateBuffer.mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
{
availableTerminalHeight={20}
renderOutputAsMarkdown={true}
/>,
+ { useAlternateBuffer: true },
);
await waitUntilReady();
const output = lastFrame();
@@ -309,6 +305,10 @@ describe('ToolResultDisplay', () => {
availableTerminalHeight={20}
maxLines={3}
/>,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
);
await waitUntilReady();
const output = lastFrame();
@@ -341,6 +341,10 @@ describe('ToolResultDisplay', () => {
maxLines={25}
availableTerminalHeight={undefined}
/>,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
);
await waitUntilReady();
const output = lastFrame();
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
index 05b94442db..0bbe3446e0 100644
--- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
@@ -9,7 +9,7 @@ import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.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 {
type AnsiOutput,
@@ -26,10 +26,6 @@ import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.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 {
resultDisplay: string | object | undefined;
availableTerminalHeight?: number;
@@ -37,6 +33,7 @@ export interface ToolResultDisplayProps {
renderOutputAsMarkdown?: boolean;
maxLines?: number;
hasFocus?: boolean;
+ overflowDirection?: 'top' | 'bottom';
}
interface FileDiffResult {
@@ -51,6 +48,7 @@ export const ToolResultDisplay: React.FC = ({
renderOutputAsMarkdown = true,
maxLines,
hasFocus = false,
+ overflowDirection = 'top',
}) => {
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
@@ -78,180 +76,147 @@ export const ToolResultDisplay: React.FC = ({
[],
);
- const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => {
- 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;
+ if (!resultDisplay) return null;
// 1. Early return for background tools (Todos)
- if (
- typeof truncatedResultDisplay === 'object' &&
- 'todos' in truncatedResultDisplay
- ) {
+ if (typeof resultDisplay === 'object' && 'todos' in resultDisplay) {
// display nothing, as the TodoTray will handle rendering todos
return null;
}
- // 2. High-performance path: Virtualized ANSI in interactive mode
- 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,
- );
+ const renderContent = (contentData: string | object | undefined) => {
+ // Check if string content is valid JSON and pretty-print it
+ const prettyJSON =
+ typeof contentData === 'string' ? tryParseJSON(contentData) : null;
+ const formattedJSON = prettyJSON
+ ? JSON.stringify(prettyJSON, null, 2)
+ : null;
- return (
-
- 1}
- keyExtractor={keyExtractor}
- initialScrollIndex={SCROLL_TO_ITEM_END}
- hasFocus={hasFocus}
+ let content: React.ReactNode;
+
+ if (formattedJSON) {
+ // Render pretty-printed JSON
+ content = (
+
+ {formattedJSON}
+
+ );
+ } else if (isSubagentProgress(contentData)) {
+ content = ;
+ } else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
+ content = (
+
+ );
+ } else if (typeof contentData === 'string' && !renderOutputAsMarkdown) {
+ content = (
+
+ {contentData}
+
+ );
+ } else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
+ content = (
+
+ );
+ } else {
+ const shouldDisableTruncation =
+ isAlternateBuffer ||
+ (availableTerminalHeight === undefined && maxLines === undefined);
+
+ content = (
+
+ );
+ }
+
+ // Final render based on session mode
+ if (isAlternateBuffer) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ 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 (
+
+ 1}
+ keyExtractor={keyExtractor}
+ initialScrollIndex={SCROLL_TO_ITEM_END}
+ hasFocus={hasFocus}
+ />
+
+ );
+ }
+
+ // Standard path for strings/diffs in ASB
+ return (
+
+ {renderContent(resultDisplay)}
);
}
- // 3. Compute content node for non-virtualized paths
- // Check if string content is valid JSON and pretty-print it
- const prettyJSON =
- typeof truncatedResultDisplay === 'string'
- ? tryParseJSON(truncatedResultDisplay)
- : null;
- const formattedJSON = prettyJSON ? JSON.stringify(prettyJSON, null, 2) : null;
-
- let content: React.ReactNode;
-
- if (formattedJSON) {
- // Render pretty-printed JSON
- content = (
-
- {formattedJSON}
-
- );
- } else if (isSubagentProgress(truncatedResultDisplay)) {
- content = ;
- } else if (
- typeof truncatedResultDisplay === 'string' &&
- renderOutputAsMarkdown
- ) {
- content = (
-
- );
- } else if (
- typeof truncatedResultDisplay === 'string' &&
- !renderOutputAsMarkdown
- ) {
- content = (
-
- {truncatedResultDisplay}
-
- );
- } else if (
- typeof truncatedResultDisplay === 'object' &&
- 'fileDiff' in truncatedResultDisplay
- ) {
- content = (
-
- );
- } else {
- const shouldDisableTruncation =
- isAlternateBuffer ||
- (availableTerminalHeight === undefined && maxLines === undefined);
-
- content = (
-
- );
- }
-
- // 4. Final render based on session mode
- if (isAlternateBuffer) {
- return (
-
- {content}
-
- );
- }
-
+ // Standard Mode Handling (History/Scrollback)
+ // We use SlicingMaxSizedBox which includes MaxSizedBox for precision truncation + hidden labels
return (
-
- {content}
-
+ {(truncatedResultDisplay) => renderContent(truncatedResultDisplay)}
+
);
};
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx
new file mode 100644
index 0000000000..b809e89748
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx
@@ -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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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();
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap
deleted file mode 100644
index aab4b690a1..0000000000
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap
+++ /dev/null
@@ -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
-"
-`;
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
index ee91d34f57..e88dcd4b76 100644
--- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
@@ -20,7 +20,7 @@ import { formatCommand } from '../../utils/keybindingUtils.js';
*/
export const MINIMUM_MAX_HEIGHT = 2;
-interface MaxSizedBoxProps {
+export interface MaxSizedBoxProps {
children?: React.ReactNode;
maxWidth?: number;
maxHeight?: number;
diff --git a/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.test.tsx
new file mode 100644
index 0000000000..184c968836
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.test.tsx
@@ -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('', () => {
+ it('renders string data without slicing when it fits', async () => {
+ const { lastFrame, waitUntilReady, unmount } = render(
+
+
+ {(truncatedData) => {truncatedData}}
+
+ ,
+ );
+ 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(
+
+
+ {(truncatedData) => {truncatedData.length}}
+
+ ,
+ );
+ 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(
+
+
+ {(truncatedData) => {truncatedData}}
+
+ ,
+ );
+ 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(
+
+
+ {(truncatedData) => (
+
+ {truncatedData.map((item, i) => (
+ {item}
+ ))}
+
+ )}
+
+ ,
+ );
+ 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(
+
+
+ {(truncatedData) => {truncatedData}}
+
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toContain('Line 5');
+ expect(lastFrame()).not.toContain('hidden');
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx
new file mode 100644
index 0000000000..b756c40ee2
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx
@@ -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
+ extends Omit {
+ 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({
+ data,
+ maxLines,
+ isAlternateBuffer,
+ children,
+ ...boxProps
+}: SlicingMaxSizedBoxProps) {
+ 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 (
+
+ {/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
+ {children(truncatedData as unknown as T)}
+
+ );
+}
diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts
index 448dc37523..db52be1105 100644
--- a/packages/cli/src/ui/constants.ts
+++ b/packages/cli/src/ui/constants.ts
@@ -50,6 +50,9 @@ export const ACTIVE_SHELL_MAX_LINES = 15;
// Max lines to preserve in history for completed shell commands
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 */
export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts
index 1bc6d09903..e06ebf5bb5 100644
--- a/packages/cli/src/ui/hooks/toolMapping.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.ts
@@ -103,6 +103,7 @@ export function mapToDisplay(
...baseDisplayProperties,
status: call.status,
isClientInitiated: !!call.request.isClientInitiated,
+ kind: call.tool?.kind,
resultDisplay,
confirmationDetails,
outputFile,
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index c9910179a5..3898461fb0 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -15,6 +15,7 @@ import {
type SkillDefinition,
type AgentDefinition,
type ApprovalMode,
+ type Kind,
CoreToolCallStatus,
checkExhaustive,
} from '@google/gemini-cli-core';
@@ -105,6 +106,7 @@ export interface IndividualToolCallDisplay {
status: CoreToolCallStatus;
// True when the tool was initiated directly by the user (slash/@/shell flows).
isClientInitiated?: boolean;
+ kind?: Kind;
confirmationDetails: SerializableConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
ptyId?: number;
diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
index 0200fbcb00..b3e88d9a01 100644
--- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
@@ -414,6 +414,7 @@ const RenderListItemInternal: React.FC = ({
}) => {
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
const prefixWidth = prefix.length;
+ // Account for leading whitespace (indentation level) plus the standard prefix padding
const indentation = leadingWhitespace.length;
const listResponseColor = theme.text.response ?? theme.text.primary;
@@ -422,7 +423,7 @@ const RenderListItemInternal: React.FC = ({
paddingLeft={indentation + LIST_ITEM_PREFIX_PADDING}
flexDirection="row"
>
-
+
{prefix}
diff --git a/packages/cli/src/ui/utils/toolLayoutUtils.ts b/packages/cli/src/ui/utils/toolLayoutUtils.ts
index 6ba1b85c5e..c91919cffa 100644
--- a/packages/cli/src/ui/utils/toolLayoutUtils.ts
+++ b/packages/cli/src/ui/utils/toolLayoutUtils.ts
@@ -53,7 +53,7 @@ export function calculateToolContentMaxLines(options: {
)
: undefined;
- if (maxLinesLimit) {
+ if (maxLinesLimit !== undefined) {
contentHeight =
contentHeight !== undefined
? Math.min(contentHeight, maxLinesLimit)
diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts
index 3bdb4fa2d5..a2ae2b9c9b 100644
--- a/packages/core/src/agents/browser/browserAgentInvocation.ts
+++ b/packages/core/src/agents/browser/browserAgentInvocation.ts
@@ -17,6 +17,7 @@
import { randomUUID } from 'node:crypto';
import type { Config } from '../../config/config.js';
import { LocalAgentExecutor } from '../local-executor.js';
+import { safeJsonToMarkdown } from '../../utils/markdownUtils.js';
import {
BaseToolInvocation,
type ToolResult,
@@ -414,6 +415,8 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
const output = await executor.run(this.params, signal);
+ const displayResult = safeJsonToMarkdown(output.result);
+
const resultContent = `Browser agent finished.
Termination Reason: ${output.terminate_reason}
Result:
@@ -425,7 +428,7 @@ Browser Agent Finished
Termination Reason: ${output.terminate_reason}
Result:
-${output.result}
+${displayResult}
`;
if (updateOutput) {
diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts
index 4bd2bc171a..02bfb4efe0 100644
--- a/packages/core/src/agents/local-invocation.ts
+++ b/packages/core/src/agents/local-invocation.ts
@@ -6,6 +6,7 @@
import type { Config } from '../config/config.js';
import { LocalAgentExecutor } from './local-executor.js';
+import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
import {
BaseToolInvocation,
type ToolResult,
@@ -245,6 +246,8 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
throw cancelError;
}
+ const displayResult = safeJsonToMarkdown(output.result);
+
const resultContent = `Subagent '${this.definition.name}' finished.
Termination Reason: ${output.terminate_reason}
Result:
@@ -256,7 +259,7 @@ Subagent ${this.definition.name} Finished
Termination Reason:\n ${output.terminate_reason}
Result:
-${output.result}
+${displayResult}
`;
return {
diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts
index a8c75ec51c..40dd142638 100644
--- a/packages/core/src/agents/remote-invocation.ts
+++ b/packages/core/src/agents/remote-invocation.ts
@@ -25,6 +25,7 @@ import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
import { GoogleAuth } from 'google-auth-library';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { debugLogger } from '../utils/debugLogger.js';
+import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
@@ -222,7 +223,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
return {
llmContent: [{ text: finalOutput }],
- returnDisplay: finalOutput,
+ returnDisplay: safeJsonToMarkdown(finalOutput),
};
} catch (error: unknown) {
const partialOutput = reassembler.toString();
diff --git a/packages/core/src/services/keychainService.ts b/packages/core/src/services/keychainService.ts
index ed28218c11..a43890f89b 100644
--- a/packages/core/src/services/keychainService.ts
+++ b/packages/core/src/services/keychainService.ts
@@ -13,6 +13,7 @@ import {
KeychainSchema,
KEYCHAIN_TEST_PREFIX,
} from './keychainTypes.js';
+import { isRecord } from '../utils/markdownUtils.js';
/**
* Service for interacting with OS-level secure storage (e.g. keytar).
@@ -111,7 +112,7 @@ export class KeychainService {
private async loadKeychainModule(): Promise {
const moduleName = 'keytar';
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);
if (result.success) {
@@ -126,10 +127,6 @@ export class KeychainService {
return null;
}
- private isRecord(obj: unknown): obj is Record {
- return typeof obj === 'object' && obj !== null;
- }
-
// Performs a set-get-delete cycle to verify keychain functionality.
private async isKeychainFunctional(keychain: Keychain): Promise {
const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;
diff --git a/packages/core/src/utils/markdownUtils.test.ts b/packages/core/src/utils/markdownUtils.test.ts
new file mode 100644
index 0000000000..246198c1d2
--- /dev/null
+++ b/packages/core/src/utils/markdownUtils.test.ts
@@ -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');
+ });
+ });
+});
diff --git a/packages/core/src/utils/markdownUtils.ts b/packages/core/src/utils/markdownUtils.ts
new file mode 100644
index 0000000000..ea0fee8eb8
--- /dev/null
+++ b/packages/core/src/utils/markdownUtils.ts
@@ -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 {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isArrayOfSimilarObjects(
+ data: unknown[],
+): data is Array> {
+ 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>, 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');
+}