diff --git a/packages/cli/src/ui/components/TodoDisplay.test.tsx b/packages/cli/src/ui/components/TodoDisplay.test.tsx
index d7198d6e3..5c4b46e14 100644
--- a/packages/cli/src/ui/components/TodoDisplay.test.tsx
+++ b/packages/cli/src/ui/components/TodoDisplay.test.tsx
@@ -5,6 +5,7 @@
*/
import { render } from 'ink-testing-library';
+import { waitFor } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import type { TodoItem } from './TodoDisplay.js';
import { TodoDisplay } from './TodoDisplay.js';
@@ -95,4 +96,30 @@ describe('TodoDisplay', () => {
expect(output).toContain('Task 1');
expect(output).toContain('Task 2');
});
+
+ it('should truncate when max height is provided', async () => {
+ const longTodos: TodoItem[] = Array.from({ length: 10 }).map(
+ (_, index) => ({
+ id: `${index + 1}`,
+ content: `Task ${index + 1}`,
+ status: 'pending',
+ }),
+ );
+
+ const { lastFrame } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ if (!frame) {
+ throw new Error('Expected rendered frame');
+ }
+ const visibleTasks = frame.match(/Task \d+/g) ?? [];
+ expect(visibleTasks.length).toBeLessThan(10);
+ });
+
+ const output = lastFrame();
+ expect(output).not.toContain('Task 1');
+ });
});
diff --git a/packages/cli/src/ui/components/TodoDisplay.tsx b/packages/cli/src/ui/components/TodoDisplay.tsx
index f503316fc..ac985a933 100644
--- a/packages/cli/src/ui/components/TodoDisplay.tsx
+++ b/packages/cli/src/ui/components/TodoDisplay.tsx
@@ -7,6 +7,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
+import { ScrollView } from './shared/ScrollView.js';
export interface TodoItem {
id: string;
@@ -16,6 +17,9 @@ export interface TodoItem {
interface TodoDisplayProps {
todos: TodoItem[];
+ maxHeight?: number;
+ maxWidth?: number;
+ overflowDirection?: 'top' | 'bottom';
}
const STATUS_ICONS = {
@@ -24,30 +28,52 @@ const STATUS_ICONS = {
completed: '●',
} as const;
-export const TodoDisplay: React.FC = ({ todos }) => {
+export const TodoDisplay: React.FC = ({
+ todos,
+ maxHeight,
+ maxWidth,
+ overflowDirection = 'top',
+}) => {
if (!todos || todos.length === 0) {
return null;
}
+ if (typeof maxHeight === 'number' && !Number.isNaN(maxHeight)) {
+ const height = Math.max(2, Math.floor(maxHeight));
+ const renderIndicator = (hiddenCount: number) =>
+ hiddenCount > 0 ? (
+
+
+ ↑ {hiddenCount} hidden task{hiddenCount === 1 ? '' : 's'}
+
+
+ ) : null;
+
+ return (
+
+ height={height}
+ width={typeof maxWidth === 'number' ? maxWidth : undefined}
+ data={todos}
+ stickTo={overflowDirection === 'bottom' ? 'top' : 'bottom'}
+ renderOverflowIndicator={renderIndicator}
+ getItemKey={(item) => item.id}
+ renderItem={(item: TodoItem) => renderTodoItemRow(item)}
+ />
+ );
+ }
+
return (
- {todos.map((todo) => (
-
- ))}
+ {todos.map((todo) => renderTodoItemRow(todo))}
);
};
-interface TodoItemRowProps {
- todo: TodoItem;
-}
-
-const TodoItemRow: React.FC = ({ todo }) => {
+const renderTodoItemRow = (todo: TodoItem) => {
const statusIcon = STATUS_ICONS[todo.status];
const isCompleted = todo.status === 'completed';
const isInProgress = todo.status === 'in_progress';
- // Use the same color for both status icon and text, like RadioButtonSelect
const itemColor = isCompleted
? Colors.Foreground
: isInProgress
@@ -55,18 +81,11 @@ const TodoItemRow: React.FC = ({ todo }) => {
: Colors.Foreground;
return (
-
- {/* Status Icon */}
-
- {statusIcon}
-
-
- {/* Content */}
-
-
- {todo.content}
-
-
+
+ {`${statusIcon} `}
+
+ {todo.content}
+
);
};
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 67e442544..feda7a04b 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -130,9 +130,17 @@ const useResultDisplayRenderer = (
/**
* Component to render todo list results
*/
-const TodoResultRenderer: React.FC<{ data: TodoResultDisplay }> = ({
- data,
-}) => ;
+const TodoResultRenderer: React.FC<{
+ data: TodoResultDisplay;
+ availableHeight?: number;
+ childWidth: number;
+}> = ({ data, availableHeight, childWidth }) => (
+
+);
const PlanResultRenderer: React.FC<{
data: PlanResultDisplay;
@@ -327,7 +335,11 @@ export const ToolMessage: React.FC = ({
{displayRenderer.type === 'todo' && (
-
+
)}
{displayRenderer.type === 'plan' && (
{
+ height: number;
+ width?: number;
+ data: readonly T[];
+ renderItem: (item: T, index: number) => React.ReactElement;
+ getItemKey?: (item: T, index: number) => React.Key;
+ stickTo?: ScrollStickMode;
+ /**
+ * Optional renderer for the overflow indicator.
+ * Receives the number of hidden items.
+ */
+ renderOverflowIndicator?: (hiddenCount: number) => React.ReactNode;
+}
+
+interface ScrollState {
+ startIndex: number;
+ hiddenCount: number;
+}
+
+const getClientHeight = (node: DOMElement | null): number => {
+ const yogaNode = node?.yogaNode;
+ if (!yogaNode) {
+ return 0;
+ }
+
+ const height = yogaNode.getComputedHeight() ?? 0;
+ const borderTop = yogaNode.getComputedBorder(Yoga.EDGE_TOP);
+ const borderBottom = yogaNode.getComputedBorder(Yoga.EDGE_BOTTOM);
+ return Math.max(0, height - borderTop - borderBottom);
+};
+
+const getScrollHeight = (node: DOMElement | null): number => {
+ const yogaNode = node?.yogaNode;
+ if (!yogaNode) {
+ return 0;
+ }
+
+ const topBorder = yogaNode.getComputedBorder(Yoga.EDGE_TOP);
+ let maxBottom = topBorder;
+
+ for (let index = 0; index < yogaNode.getChildCount(); index++) {
+ const child = yogaNode.getChild(index);
+ const childBottom =
+ child.getComputedTop() +
+ child.getComputedHeight() +
+ child.getComputedMargin(Yoga.EDGE_BOTTOM);
+ if (childBottom > maxBottom) {
+ maxBottom = childBottom;
+ }
+ }
+
+ return maxBottom - topBorder + yogaNode.getComputedPadding(Yoga.EDGE_BOTTOM);
+};
+
+const collectChildBottoms = (node: DOMElement | null): number[] => {
+ const yogaNode = node?.yogaNode;
+ if (!yogaNode) {
+ return [];
+ }
+
+ const bottoms: number[] = [];
+ for (let index = 0; index < yogaNode.getChildCount(); index++) {
+ const child = yogaNode.getChild(index);
+ const bottom =
+ child.getComputedTop() +
+ child.getComputedHeight() +
+ child.getComputedMargin(Yoga.EDGE_BOTTOM);
+ bottoms.push(bottom);
+ }
+ return bottoms;
+};
+
+const defaultIndicator = (hiddenCount: number) => {
+ if (hiddenCount <= 0) {
+ return null;
+ }
+
+ return (
+
+
+ ↑ {hiddenCount} hidden item{hiddenCount === 1 ? '' : 's'}
+
+
+ );
+};
+
+export function ScrollView({
+ height,
+ width,
+ data,
+ renderItem,
+ getItemKey,
+ stickTo = 'bottom',
+ renderOverflowIndicator,
+}: ScrollViewProps) {
+ const viewportRef = useRef(null);
+ const contentRef = useRef(null);
+
+ const [scrollState, setScrollState] = useState(null);
+
+ const indicatorRenderer = renderOverflowIndicator ?? defaultIndicator;
+
+ useEffect(() => {
+ // Reset scroll state whenever the data set or height changes so that we
+ // re-measure with the full set of rows.
+ setScrollState(null);
+ }, [data, height]);
+
+ useLayoutEffect(() => {
+ const viewport = viewportRef.current;
+ const content = contentRef.current;
+
+ if (!viewport || !content) {
+ if (scrollState !== null) {
+ setScrollState(null);
+ }
+ return;
+ }
+
+ const clientHeight = getClientHeight(viewport);
+ const scrollHeight = getScrollHeight(content);
+ const childBottoms = collectChildBottoms(content);
+
+ if (childBottoms.length === 0 || clientHeight <= 0) {
+ if (scrollState !== null) {
+ setScrollState(null);
+ }
+ return;
+ }
+
+ const hasOverflow = scrollHeight > clientHeight;
+
+ if (!hasOverflow) {
+ if (scrollState !== null) {
+ setScrollState(null);
+ }
+ return;
+ }
+
+ const targetTop = Math.max(0, scrollHeight - clientHeight);
+
+ let startIndex = 0;
+ if (stickTo === 'bottom') {
+ for (let index = 0; index < childBottoms.length; index++) {
+ if (childBottoms[index] <= targetTop) {
+ startIndex = index + 1;
+ } else {
+ break;
+ }
+ }
+ }
+
+ startIndex = Math.min(startIndex, data.length - 1);
+
+ if (
+ scrollState?.startIndex === startIndex &&
+ scrollState.hiddenCount === startIndex
+ ) {
+ return;
+ }
+
+ setScrollState({
+ startIndex,
+ hiddenCount: startIndex,
+ });
+ }, [data, stickTo, scrollState, height]);
+
+ const visibleItems = useMemo(() => {
+ if (!scrollState) {
+ return data;
+ }
+
+ return data.slice(scrollState.startIndex);
+ }, [data, scrollState]);
+
+ const renderedItems = useMemo(
+ () =>
+ visibleItems.map((item, index) => {
+ const absoluteIndex = scrollState
+ ? scrollState.startIndex + index
+ : index;
+ const key = getItemKey?.(item, absoluteIndex) ?? absoluteIndex;
+ const element = renderItem(item, absoluteIndex);
+ return React.cloneElement(element, { key });
+ }),
+ [visibleItems, renderItem, getItemKey, scrollState],
+ );
+
+ return (
+
+ {scrollState && indicatorRenderer(scrollState.hiddenCount)}
+
+ {renderedItems}
+
+
+ );
+}
diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx
index 9bd9a812f..bb1c4d202 100644
--- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx
+++ b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx
@@ -16,6 +16,7 @@ import { useKeypress } from '../../../hooks/useKeypress.js';
import { COLOR_OPTIONS } from '../constants.js';
import { fmtDuration } from '../utils.js';
import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js';
+import { ScrollView } from '../../shared/ScrollView.js';
export type DisplayMode = 'compact' | 'default' | 'verbose';
@@ -88,20 +89,26 @@ export const AgentExecutionDisplay: React.FC = ({
return colorOption?.value || theme.text.accent;
}, [data.subagentColor]);
+ const hasAdditionalPromptLines =
+ data.taskPrompt.split('\n').length > MAX_TASK_PROMPT_LINES;
+ const hasAdditionalToolCalls = !!(
+ data.toolCalls && data.toolCalls.length > MAX_TOOL_CALLS
+ );
+ const hasMoreDetail = hasAdditionalPromptLines || hasAdditionalToolCalls;
+
const footerText = React.useMemo(() => {
// This component only listens to keyboard shortcut events when the subagent is running
if (data.status !== 'running') return '';
- if (displayMode === 'default') {
- const hasMoreLines =
- data.taskPrompt.split('\n').length > MAX_TASK_PROMPT_LINES;
- const hasMoreToolCalls =
- data.toolCalls && data.toolCalls.length > MAX_TOOL_CALLS;
+ if (displayMode === 'compact') {
+ return 'Press ctrl+e to expand.';
+ }
- if (hasMoreToolCalls || hasMoreLines) {
- return 'Press ctrl+r to show less, ctrl+e to show more.';
+ if (displayMode === 'default') {
+ if (hasMoreDetail) {
+ return 'Press ctrl+e to show more.';
}
- return 'Press ctrl+r to show less.';
+ return 'Press ctrl+e to collapse.';
}
if (displayMode === 'verbose') {
@@ -109,26 +116,122 @@ export const AgentExecutionDisplay: React.FC = ({
}
return '';
- }, [displayMode, data]);
+ }, [displayMode, data.status, hasMoreDetail]);
// Handle keyboard shortcuts to control display mode
useKeypress(
(key) => {
- if (key.ctrl && key.name === 'r') {
- // ctrl+r toggles between compact and default
- setDisplayMode((current) =>
- current === 'compact' ? 'default' : 'compact',
- );
- } else if (key.ctrl && key.name === 'e') {
+ if (key.ctrl && key.name === 'e') {
// ctrl+e toggles between default and verbose
setDisplayMode((current) =>
- current === 'default' ? 'verbose' : 'default',
+ current === 'compact'
+ ? 'default'
+ : current === 'default'
+ ? hasMoreDetail
+ ? 'verbose'
+ : 'compact'
+ : 'default',
);
}
},
{ isActive: true },
);
+ const defaultModeSections = useMemo(() => {
+ if (displayMode === 'compact') {
+ return [] as React.ReactElement[];
+ }
+
+ const sections: React.ReactElement[] = [];
+
+ const addSection = (key: string, element: React.ReactNode) => {
+ sections.push(
+
+ {element}
+ ,
+ );
+ };
+
+ addSection(
+ 'header',
+
+
+ {data.subagentName}
+
+
+
+ ,
+ );
+
+ addSection(
+ 'task-prompt',
+ ,
+ );
+
+ if (
+ data.status === 'running' &&
+ data.toolCalls &&
+ data.toolCalls.length > 0
+ ) {
+ addSection(
+ 'tool-calls',
+ ,
+ );
+ }
+
+ if (data.pendingConfirmation) {
+ addSection(
+ 'confirmation',
+ ,
+ );
+ }
+
+ if (
+ data.status === 'completed' ||
+ data.status === 'failed' ||
+ data.status === 'cancelled'
+ ) {
+ addSection(
+ 'results',
+ ,
+ );
+ }
+
+ if (footerText) {
+ addSection(
+ 'footer',
+
+ {footerText}
+ ,
+ );
+ }
+
+ return sections;
+ }, [
+ agentColor,
+ childWidth,
+ config,
+ data,
+ displayMode,
+ footerText,
+ availableHeight,
+ ]);
+
if (displayMode === 'compact') {
return (
@@ -200,69 +303,46 @@ export const AgentExecutionDisplay: React.FC = ({
)}
+
+ {footerText && (
+
+ {footerText}
+
+ )}
);
}
- // Default and verbose modes use normal layout
- return (
-
- {/* Header with subagent name and status */}
-
-
- {data.subagentName}
+ const overflowIndicator = (hiddenCount: number) =>
+ hiddenCount > 0 ? (
+
+
+ ↑ {hiddenCount} sections hidden
-
-
+ ) : null;
- {/* Task description */}
- 0) {
+ return (
+
+ height={availableHeight}
+ width={childWidth}
+ data={defaultModeSections}
+ stickTo="bottom"
+ renderItem={(item: React.ReactElement) => item}
+ getItemKey={(item, index: number) => {
+ const key = item.key;
+ if (typeof key === 'string' || typeof key === 'number') {
+ return key;
+ }
+ return index;
+ }}
+ renderOverflowIndicator={overflowIndicator}
/>
+ );
+ }
- {/* Progress section for running tasks */}
- {data.status === 'running' &&
- data.toolCalls &&
- data.toolCalls.length > 0 && (
-
-
-
- )}
-
- {/* Inline approval prompt when awaiting confirmation */}
- {data.pendingConfirmation && (
-
-
-
- )}
-
- {/* Results section for completed/failed tasks */}
- {(data.status === 'completed' ||
- data.status === 'failed' ||
- data.status === 'cancelled') && (
-
- )}
-
- {/* Footer with keyboard shortcuts */}
- {footerText && (
-
- {footerText}
-
- )}
-
- );
+ return {defaultModeSections};
};
/**