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}; }; /**