From 3fa18f769994135bcc5b7b980d89eeba6277618a Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sat, 10 Jan 2026 09:59:00 -0800 Subject: [PATCH] fix: patch subagent display (#512) Co-authored-by: Letta --- src/cli/App.tsx | 700 +++++++++++--------- src/cli/components/BlinkDot.tsx | 22 +- src/cli/components/SubagentGroupDisplay.tsx | 258 +++++--- src/cli/contexts/AnimationContext.tsx | 54 ++ src/cli/hooks/useTerminalWidth.ts | 58 +- 5 files changed, 663 insertions(+), 429 deletions(-) create mode 100644 src/cli/contexts/AnimationContext.tsx diff --git a/src/cli/App.tsx b/src/cli/App.tsx index d46bb71..e1004d9 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -13,7 +13,14 @@ import type { import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models"; import type { StopReasonType } from "@letta-ai/letta-client/resources/runs/runs"; import { Box, Static, Text } from "ink"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { type ApprovalResult, executeAutoAllowedTools, @@ -108,6 +115,7 @@ import { ToolCallMessage } from "./components/ToolCallMessageRich"; import { ToolsetSelector } from "./components/ToolsetSelector"; import { UserMessage } from "./components/UserMessageRich"; import { WelcomeScreen } from "./components/WelcomeScreen"; +import { AnimationProvider } from "./contexts/AnimationContext"; import { type Buffers, createBuffers, @@ -144,7 +152,9 @@ import { import { clearCompletedSubagents, clearSubagentsByIds, + getSnapshot as getSubagentSnapshot, interruptActiveSubagents, + subscribe as subscribeToSubagents, } from "./helpers/subagentState"; import { getRandomThinkingVerb } from "./helpers/thinkingMessages"; import { @@ -159,7 +169,7 @@ import { } from "./helpers/toolNameMapping.js"; import { useSuspend } from "./hooks/useSuspend/useSuspend.ts"; import { useSyncedState } from "./hooks/useSyncedState"; -import { useTerminalWidth } from "./hooks/useTerminalWidth"; +import { useTerminalRows, useTerminalWidth } from "./hooks/useTerminalWidth"; // Used only for terminal resize, not for dialog dismissal (see PR for details) const CLEAR_SCREEN_AND_HOME = "\u001B[2J\u001B[H"; @@ -917,8 +927,9 @@ export default function App({ [setCommandRunning], ); - // Track terminal shrink events to refresh static output (prevents wrapped leftovers) + // Track terminal dimensions for layout and overflow detection const columns = useTerminalWidth(); + const terminalRows = useTerminalRows(); const prevColumnsRef = useRef(columns); const [staticRenderEpoch, setStaticRenderEpoch] = useState(0); useEffect(() => { @@ -6406,6 +6417,59 @@ Plan file path: ${planFilePath}`; }); }, [lines, tokenStreamingEnabled]); + // Subscribe to subagent state for reactive overflow detection + const { agents: subagents } = useSyncExternalStore( + subscribeToSubagents, + getSubagentSnapshot, + ); + + // Overflow detection: disable animations when live content exceeds viewport + // This prevents Ink's clearTerminal flicker on every re-render cycle + const shouldAnimate = useMemo(() => { + // Count actual lines in live content by counting newlines + const countLines = (text: string | undefined): number => { + if (!text) return 0; + return (text.match(/\n/g) || []).length + 1; + }; + + // Estimate height for each live item based on actual content + let liveItemsHeight = 0; + for (const item of liveItems) { + // Base height for each item (header line, margins) + let itemHeight = 2; + + if (item.kind === "bash_command" || item.kind === "command") { + // Count lines in command input and output + itemHeight += countLines(item.input); + itemHeight += countLines(item.output); + } else if (item.kind === "tool_call") { + // Count lines in tool args and result + itemHeight += Math.min(countLines(item.argsText), 5); // Cap args display + itemHeight += countLines(item.resultText); + } else if ( + item.kind === "assistant" || + item.kind === "reasoning" || + item.kind === "error" + ) { + itemHeight += countLines(item.text); + } + + liveItemsHeight += itemHeight; + } + + // Subagents: 4 lines each (description + URL + status + margin) + const LINES_PER_SUBAGENT = 4; + const subagentsHeight = subagents.length * LINES_PER_SUBAGENT; + + // Fixed buffer for header, input area, status bar, margins + // Using larger buffer to catch edge cases and account for timing lag + const FIXED_BUFFER = 20; + + const estimatedHeight = liveItemsHeight + subagentsHeight + FIXED_BUFFER; + + return estimatedHeight < terminalRows; + }, [liveItems, terminalRows, subagents.length]); + // Commit welcome snapshot once when ready for fresh sessions (no history) // Wait for agentProvenance to be available for new agents (continueSession=false) useEffect(() => { @@ -6554,340 +6618,350 @@ Plan file path: ${planFilePath}`; {loadingState === "ready" && ( <> - {/* Transcript */} - {/* Show liveItems always - all approvals now render inline */} - {liveItems.length > 0 && ( - - {liveItems.map((ln) => { - // Skip Task tools that don't have a pending approval - // They render as empty Boxes (ToolCallMessage returns null for non-finished Task tools) - // which causes N blank lines when N Task tools are called in parallel - if ( - ln.kind === "tool_call" && - ln.name && - isTaskTool(ln.name) && - ln.toolCallId && - !pendingIds.has(ln.toolCallId) - ) { - return null; - } + {/* Transcript - wrapped in AnimationProvider for overflow-based animation control */} + + {/* Show liveItems always - all approvals now render inline */} + {liveItems.length > 0 && ( + + {liveItems.map((ln) => { + // Skip Task tools that don't have a pending approval + // They render as empty Boxes (ToolCallMessage returns null for non-finished Task tools) + // which causes N blank lines when N Task tools are called in parallel + if ( + ln.kind === "tool_call" && + ln.name && + isTaskTool(ln.name) && + ln.toolCallId && + !pendingIds.has(ln.toolCallId) + ) { + return null; + } - // Check if this tool call matches the current ExitPlanMode approval - const isExitPlanModeApproval = - ln.kind === "tool_call" && - currentApproval?.toolName === "ExitPlanMode" && - ln.toolCallId === currentApproval?.toolCallId; + // Check if this tool call matches the current ExitPlanMode approval + const isExitPlanModeApproval = + ln.kind === "tool_call" && + currentApproval?.toolName === "ExitPlanMode" && + ln.toolCallId === currentApproval?.toolCallId; - // Check if this tool call matches a file edit/write/patch approval - const isFileEditApproval = - ln.kind === "tool_call" && - currentApproval && - (isFileEditTool(currentApproval.toolName) || - isFileWriteTool(currentApproval.toolName) || - isPatchTool(currentApproval.toolName)) && - ln.toolCallId === currentApproval.toolCallId; + // Check if this tool call matches a file edit/write/patch approval + const isFileEditApproval = + ln.kind === "tool_call" && + currentApproval && + (isFileEditTool(currentApproval.toolName) || + isFileWriteTool(currentApproval.toolName) || + isPatchTool(currentApproval.toolName)) && + ln.toolCallId === currentApproval.toolCallId; - // Check if this tool call matches a bash/shell approval - const isBashApproval = - ln.kind === "tool_call" && - currentApproval && - isShellTool(currentApproval.toolName) && - ln.toolCallId === currentApproval.toolCallId; + // Check if this tool call matches a bash/shell approval + const isBashApproval = + ln.kind === "tool_call" && + currentApproval && + isShellTool(currentApproval.toolName) && + ln.toolCallId === currentApproval.toolCallId; - // Check if this tool call matches an EnterPlanMode approval - const isEnterPlanModeApproval = - ln.kind === "tool_call" && - currentApproval?.toolName === "EnterPlanMode" && - ln.toolCallId === currentApproval?.toolCallId; + // Check if this tool call matches an EnterPlanMode approval + const isEnterPlanModeApproval = + ln.kind === "tool_call" && + currentApproval?.toolName === "EnterPlanMode" && + ln.toolCallId === currentApproval?.toolCallId; - // Check if this tool call matches an AskUserQuestion approval - const isAskUserQuestionApproval = - ln.kind === "tool_call" && - currentApproval?.toolName === "AskUserQuestion" && - ln.toolCallId === currentApproval?.toolCallId; + // Check if this tool call matches an AskUserQuestion approval + const isAskUserQuestionApproval = + ln.kind === "tool_call" && + currentApproval?.toolName === "AskUserQuestion" && + ln.toolCallId === currentApproval?.toolCallId; - // Check if this tool call matches a Task tool approval - const isTaskToolApproval = - ln.kind === "tool_call" && - currentApproval && - isTaskTool(currentApproval.toolName) && - ln.toolCallId === currentApproval.toolCallId; + // Check if this tool call matches a Task tool approval + const isTaskToolApproval = + ln.kind === "tool_call" && + currentApproval && + isTaskTool(currentApproval.toolName) && + ln.toolCallId === currentApproval.toolCallId; - // Parse file edit info from approval args - const getFileEditInfo = () => { - if (!isFileEditApproval || !currentApproval) return null; - try { - const args = JSON.parse(currentApproval.toolArgs || "{}"); + // Parse file edit info from approval args + const getFileEditInfo = () => { + if (!isFileEditApproval || !currentApproval) return null; + try { + const args = JSON.parse( + currentApproval.toolArgs || "{}", + ); - // For patch tools, use the input field - if (isPatchTool(currentApproval.toolName)) { + // For patch tools, use the input field + if (isPatchTool(currentApproval.toolName)) { + return { + toolName: currentApproval.toolName, + filePath: "", // Patch can have multiple files + patchInput: args.input as string | undefined, + toolCallId: ln.toolCallId, + }; + } + + // For regular file edit/write tools return { toolName: currentApproval.toolName, - filePath: "", // Patch can have multiple files - patchInput: args.input as string | undefined, + filePath: String(args.file_path || ""), + content: args.content as string | undefined, + oldString: args.old_string as string | undefined, + newString: args.new_string as string | undefined, + replaceAll: args.replace_all as boolean | undefined, + edits: args.edits as + | Array<{ + old_string: string; + new_string: string; + replace_all?: boolean; + }> + | undefined, toolCallId: ln.toolCallId, }; + } catch { + return null; } + }; - // For regular file edit/write tools - return { - toolName: currentApproval.toolName, - filePath: String(args.file_path || ""), - content: args.content as string | undefined, - oldString: args.old_string as string | undefined, - newString: args.new_string as string | undefined, - replaceAll: args.replace_all as boolean | undefined, - edits: args.edits as - | Array<{ - old_string: string; - new_string: string; - replace_all?: boolean; - }> - | undefined, - toolCallId: ln.toolCallId, - }; - } catch { - return null; - } - }; + const fileEditInfo = getFileEditInfo(); - const fileEditInfo = getFileEditInfo(); + // Parse bash info from approval args + const getBashInfo = () => { + if (!isBashApproval || !currentApproval) return null; + try { + const args = JSON.parse( + currentApproval.toolArgs || "{}", + ); + const t = currentApproval.toolName.toLowerCase(); - // Parse bash info from approval args - const getBashInfo = () => { - if (!isBashApproval || !currentApproval) return null; - try { - const args = JSON.parse(currentApproval.toolArgs || "{}"); - const t = currentApproval.toolName.toLowerCase(); + // Handle different bash tool arg formats + let command = ""; + let description = ""; - // Handle different bash tool arg formats - let command = ""; - let description = ""; + if (t === "shell") { + // Shell tool uses command array and justification + const cmdVal = args.command; + command = Array.isArray(cmdVal) + ? cmdVal.join(" ") + : typeof cmdVal === "string" + ? cmdVal + : "(no command)"; + description = + typeof args.justification === "string" + ? args.justification + : ""; + } else { + // Bash/shell_command uses command string and description + command = + typeof args.command === "string" + ? args.command + : "(no command)"; + description = + typeof args.description === "string" + ? args.description + : ""; + } - if (t === "shell") { - // Shell tool uses command array and justification - const cmdVal = args.command; - command = Array.isArray(cmdVal) - ? cmdVal.join(" ") - : typeof cmdVal === "string" - ? cmdVal - : "(no command)"; - description = - typeof args.justification === "string" - ? args.justification - : ""; - } else { - // Bash/shell_command uses command string and description - command = - typeof args.command === "string" - ? args.command - : "(no command)"; - description = - typeof args.description === "string" - ? args.description - : ""; + return { + toolName: currentApproval.toolName, + command, + description, + }; + } catch { + return null; } + }; - return { - toolName: currentApproval.toolName, - command, - description, - }; - } catch { - return null; - } - }; + const bashInfo = getBashInfo(); - const bashInfo = getBashInfo(); + // Parse Task tool info from approval args + const getTaskInfo = () => { + if (!isTaskToolApproval || !currentApproval) return null; + try { + const args = JSON.parse( + currentApproval.toolArgs || "{}", + ); + return { + subagentType: + typeof args.subagent_type === "string" + ? args.subagent_type + : "unknown", + description: + typeof args.description === "string" + ? args.description + : "(no description)", + prompt: + typeof args.prompt === "string" + ? args.prompt + : "(no prompt)", + model: + typeof args.model === "string" + ? args.model + : undefined, + }; + } catch { + return null; + } + }; - // Parse Task tool info from approval args - const getTaskInfo = () => { - if (!isTaskToolApproval || !currentApproval) return null; - try { - const args = JSON.parse(currentApproval.toolArgs || "{}"); - return { - subagentType: - typeof args.subagent_type === "string" - ? args.subagent_type - : "unknown", - description: - typeof args.description === "string" - ? args.description - : "(no description)", - prompt: - typeof args.prompt === "string" - ? args.prompt - : "(no prompt)", - model: - typeof args.model === "string" - ? args.model - : undefined, - }; - } catch { - return null; - } - }; + const taskInfo = getTaskInfo(); - const taskInfo = getTaskInfo(); + return ( + + {/* For ExitPlanMode awaiting approval: render StaticPlanApproval */} + {/* Plan preview is eagerly committed to staticItems, so this only shows options */} + {isExitPlanModeApproval ? ( + handlePlanApprove(false)} + onApproveAndAcceptEdits={() => + handlePlanApprove(true) + } + onKeepPlanning={handlePlanKeepPlanning} + isFocused={true} + /> + ) : isFileEditApproval && fileEditInfo ? ( + handleApproveCurrent(diffs)} + onApproveAlways={(scope, diffs) => + handleApproveAlways(scope, diffs) + } + onDeny={(reason) => handleDenyCurrent(reason)} + onCancel={handleCancelApprovals} + isFocused={true} + approveAlwaysText={ + currentApprovalContext?.approveAlwaysText + } + allowPersistence={ + currentApprovalContext?.allowPersistence ?? true + } + /> + ) : isBashApproval && bashInfo ? ( + handleApproveCurrent()} + onApproveAlways={(scope) => + handleApproveAlways(scope) + } + onDeny={(reason) => handleDenyCurrent(reason)} + onCancel={handleCancelApprovals} + isFocused={true} + approveAlwaysText={ + currentApprovalContext?.approveAlwaysText + } + allowPersistence={ + currentApprovalContext?.allowPersistence ?? true + } + /> + ) : isEnterPlanModeApproval ? ( + + ) : isAskUserQuestionApproval ? ( + + ) : isTaskToolApproval && taskInfo ? ( + handleApproveCurrent()} + onApproveAlways={(scope) => + handleApproveAlways(scope) + } + onDeny={(reason) => handleDenyCurrent(reason)} + onCancel={handleCancelApprovals} + isFocused={true} + approveAlwaysText={ + currentApprovalContext?.approveAlwaysText + } + allowPersistence={ + currentApprovalContext?.allowPersistence ?? true + } + /> + ) : ln.kind === "tool_call" && + currentApproval && + ln.toolCallId === currentApproval.toolCallId ? ( + // Generic fallback for any other tool needing approval + handleApproveCurrent()} + onApproveAlways={(scope) => + handleApproveAlways(scope) + } + onDeny={(reason) => handleDenyCurrent(reason)} + onCancel={handleCancelApprovals} + isFocused={true} + approveAlwaysText={ + currentApprovalContext?.approveAlwaysText + } + allowPersistence={ + currentApprovalContext?.allowPersistence ?? true + } + /> + ) : ln.kind === "user" ? ( + + ) : ln.kind === "reasoning" ? ( + + ) : ln.kind === "assistant" ? ( + + ) : ln.kind === "tool_call" && + ln.toolCallId && + queuedIds.has(ln.toolCallId) ? ( + // Render stub for queued (decided but not executed) approval + + ) : ln.kind === "tool_call" && + ln.toolCallId && + pendingIds.has(ln.toolCallId) ? ( + // Render stub for pending (undecided) approval + + ) : ln.kind === "tool_call" ? ( + + ) : ln.kind === "error" ? ( + + ) : ln.kind === "status" ? ( + + ) : ln.kind === "command" ? ( + + ) : ln.kind === "bash_command" ? ( + + ) : null} + + ); + })} + + )} - return ( - - {/* For ExitPlanMode awaiting approval: render StaticPlanApproval */} - {/* Plan preview is eagerly committed to staticItems, so this only shows options */} - {isExitPlanModeApproval ? ( - handlePlanApprove(false)} - onApproveAndAcceptEdits={() => - handlePlanApprove(true) - } - onKeepPlanning={handlePlanKeepPlanning} - isFocused={true} - /> - ) : isFileEditApproval && fileEditInfo ? ( - handleApproveCurrent(diffs)} - onApproveAlways={(scope, diffs) => - handleApproveAlways(scope, diffs) - } - onDeny={(reason) => handleDenyCurrent(reason)} - onCancel={handleCancelApprovals} - isFocused={true} - approveAlwaysText={ - currentApprovalContext?.approveAlwaysText - } - allowPersistence={ - currentApprovalContext?.allowPersistence ?? true - } - /> - ) : isBashApproval && bashInfo ? ( - handleApproveCurrent()} - onApproveAlways={(scope) => - handleApproveAlways(scope) - } - onDeny={(reason) => handleDenyCurrent(reason)} - onCancel={handleCancelApprovals} - isFocused={true} - approveAlwaysText={ - currentApprovalContext?.approveAlwaysText - } - allowPersistence={ - currentApprovalContext?.allowPersistence ?? true - } - /> - ) : isEnterPlanModeApproval ? ( - - ) : isAskUserQuestionApproval ? ( - - ) : isTaskToolApproval && taskInfo ? ( - handleApproveCurrent()} - onApproveAlways={(scope) => - handleApproveAlways(scope) - } - onDeny={(reason) => handleDenyCurrent(reason)} - onCancel={handleCancelApprovals} - isFocused={true} - approveAlwaysText={ - currentApprovalContext?.approveAlwaysText - } - allowPersistence={ - currentApprovalContext?.allowPersistence ?? true - } - /> - ) : ln.kind === "tool_call" && - currentApproval && - ln.toolCallId === currentApproval.toolCallId ? ( - // Generic fallback for any other tool needing approval - handleApproveCurrent()} - onApproveAlways={(scope) => - handleApproveAlways(scope) - } - onDeny={(reason) => handleDenyCurrent(reason)} - onCancel={handleCancelApprovals} - isFocused={true} - approveAlwaysText={ - currentApprovalContext?.approveAlwaysText - } - allowPersistence={ - currentApprovalContext?.allowPersistence ?? true - } - /> - ) : ln.kind === "user" ? ( - - ) : ln.kind === "reasoning" ? ( - - ) : ln.kind === "assistant" ? ( - - ) : ln.kind === "tool_call" && - ln.toolCallId && - queuedIds.has(ln.toolCallId) ? ( - // Render stub for queued (decided but not executed) approval - - ) : ln.kind === "tool_call" && - ln.toolCallId && - pendingIds.has(ln.toolCallId) ? ( - // Render stub for pending (undecided) approval - - ) : ln.kind === "tool_call" ? ( - - ) : ln.kind === "error" ? ( - - ) : ln.kind === "status" ? ( - - ) : ln.kind === "command" ? ( - - ) : ln.kind === "bash_command" ? ( - - ) : null} - - ); - })} - - )} - - {/* Subagent group display - shows running/completed subagents */} - + {/* Subagent group display - shows running/completed subagents */} + + {/* Exit stats - shown when exiting via double Ctrl+C */} {showExitStats && ( diff --git a/src/cli/components/BlinkDot.tsx b/src/cli/components/BlinkDot.tsx index a1d0071..b04e2c9 100644 --- a/src/cli/components/BlinkDot.tsx +++ b/src/cli/components/BlinkDot.tsx @@ -1,25 +1,43 @@ import { Text } from "ink"; import { memo, useEffect, useState } from "react"; +import { useAnimation } from "../contexts/AnimationContext.js"; import { colors } from "./colors.js"; /** * A blinking dot indicator for running/pending states. * Toggles visibility every 400ms to create a blinking effect. + * + * Animation is automatically disabled when: + * - The AnimationContext's shouldAnimate is false (overflow detected) + * - The shouldAnimate prop is explicitly set to false (local override) + * + * This prevents Ink's clearTerminal flicker when content exceeds viewport. */ export const BlinkDot = memo( ({ color = colors.tool.pending, symbol = "●", + shouldAnimate: shouldAnimateProp, }: { color?: string; symbol?: string; + /** Optional override. If not provided, uses AnimationContext. */ + shouldAnimate?: boolean; }) => { + const { shouldAnimate: shouldAnimateContext } = useAnimation(); + + // Prop takes precedence if explicitly set to false, otherwise use context + const shouldAnimate = + shouldAnimateProp === false ? false : shouldAnimateContext; + const [on, setOn] = useState(true); useEffect(() => { + if (!shouldAnimate) return; // Skip interval when animation disabled const t = setInterval(() => setOn((v) => !v), 400); return () => clearInterval(t); - }, []); - return {on ? symbol : " "}; + }, [shouldAnimate]); + // Always show symbol when animation disabled (static indicator) + return {on || !shouldAnimate ? symbol : " "}; }, ); diff --git a/src/cli/components/SubagentGroupDisplay.tsx b/src/cli/components/SubagentGroupDisplay.tsx index 605d2b1..2072386 100644 --- a/src/cli/components/SubagentGroupDisplay.tsx +++ b/src/cli/components/SubagentGroupDisplay.tsx @@ -17,6 +17,7 @@ import { Box, Text, useInput } from "ink"; import { memo, useSyncExternalStore } from "react"; +import { useAnimation } from "../contexts/AnimationContext.js"; import { formatStats, getTreeChars } from "../helpers/subagentDisplay.js"; import { getSnapshot, @@ -59,123 +60,167 @@ interface AgentRowProps { agent: SubagentState; isLast: boolean; expanded: boolean; + condensed?: boolean; } -const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => { - const { treeChar, continueChar } = getTreeChars(isLast); - const columns = useTerminalWidth(); - const gutterWidth = 8; // indent (3) + continueChar (2) + status indent (3) - const contentWidth = Math.max(0, columns - gutterWidth); +const AgentRow = memo( + ({ agent, isLast, expanded, condensed = false }: AgentRowProps) => { + const { treeChar, continueChar } = getTreeChars(isLast); + const columns = useTerminalWidth(); + const gutterWidth = 8; // indent (3) + continueChar (2) + status indent (3) + const contentWidth = Math.max(0, columns - gutterWidth); - const isRunning = agent.status === "pending" || agent.status === "running"; - const stats = formatStats( - agent.toolCalls.length, - agent.totalTokens, - isRunning, - ); - const lastTool = agent.toolCalls[agent.toolCalls.length - 1]; + const isRunning = agent.status === "pending" || agent.status === "running"; + const stats = formatStats( + agent.toolCalls.length, + agent.totalTokens, + isRunning, + ); + const lastTool = agent.toolCalls[agent.toolCalls.length - 1]; - return ( - - {/* Main row: tree char + description + type + model + stats */} - - - - {" "} - {treeChar}{" "} - - {agent.description} - - {" · "} - {agent.type.toLowerCase()} - {agent.model ? ` · ${agent.model}` : ""} - {" · "} - {stats} - - - - - {/* Subagent URL */} - {agent.agentURL && ( - - - {" "} - {continueChar} ⎿{" "} - - {"Subagent: "} - {agent.agentURL} + // Condensed mode: simplified view to reduce re-renders when overflowing + // Shows: "Description · type · model" + "Running..." or "Done" + // Full details are shown in SubagentGroupStatic when flushed to static area + if (condensed) { + const isComplete = + agent.status === "completed" || agent.status === "error"; + return ( + + {/* Main row: tree char + description + type + model (no stats) */} + + + + {" "} + {treeChar}{" "} + + {agent.description} + + {" · "} + {agent.type.toLowerCase()} + {agent.model ? ` · ${agent.model}` : ""} + + + + {/* Simple status line */} + + + {" "} + {continueChar} + + {" "} + {agent.status === "error" ? ( + Error + ) : ( + {isComplete ? "Done" : "Running..."} + )} + - )} + ); + } - {/* Expanded: show all tool calls */} - {expanded && - agent.toolCalls.map((tc) => { - const formattedArgs = formatToolArgs(tc.args); - return ( - + // Full mode: all details including live tool calls + return ( + + {/* Main row: tree char + description + type + model + stats */} + + + + {" "} + {treeChar}{" "} + + {agent.description} + + {" · "} + {agent.type.toLowerCase()} + {agent.model ? ` · ${agent.model}` : ""} + {" · "} + {stats} + + + + + {/* Subagent URL */} + {agent.agentURL && ( + + + {" "} + {continueChar} ⎿{" "} + + {"Subagent: "} + {agent.agentURL} + + )} + + {/* Expanded: show all tool calls */} + {expanded && + agent.toolCalls.map((tc) => { + const formattedArgs = formatToolArgs(tc.args); + return ( + + + {" "} + {continueChar} + + + {" "} + {tc.name}({formattedArgs}) + + + ); + })} + + {/* Status line */} + + {agent.status === "completed" ? ( + <> + + {" "} + {continueChar} + + {" Done"} + + ) : agent.status === "error" ? ( + <> + + + + {" "} + {continueChar} + + {" "} + + + + + {agent.error} + + + + ) : lastTool ? ( + <> {" "} {continueChar} - {" "} - {tc.name}({formattedArgs}) + {" "} + {lastTool.name} - - ); - })} - - {/* Status line */} - - {agent.status === "completed" ? ( - <> - - {" "} - {continueChar} - - {" Done"} - - ) : agent.status === "error" ? ( - <> - - - - {" "} - {continueChar} - - {" "} + + ) : ( + <> + + {" "} + {continueChar} - - - - {agent.error} - - - - ) : lastTool ? ( - <> - - {" "} - {continueChar} - - - {" "} - {lastTool.name} - - - ) : ( - <> - - {" "} - {continueChar} - - {" Starting..."} - - )} + {" Starting..."} + + )} + - - ); -}); + ); + }, +); AgentRow.displayName = "AgentRow"; interface GroupHeaderProps { @@ -203,6 +248,7 @@ const GroupHeader = memo( {allCompleted ? ( ) : ( + // BlinkDot now gets shouldAnimate from AnimationContext )} {statusText} @@ -220,6 +266,7 @@ GroupHeader.displayName = "GroupHeader"; export const SubagentGroupDisplay = memo(() => { const { agents, expanded } = useSyncExternalStore(subscribe, getSnapshot); + const { shouldAnimate } = useAnimation(); // Handle ctrl+o for expand/collapse useInput((input, key) => { @@ -233,6 +280,10 @@ export const SubagentGroupDisplay = memo(() => { return null; } + // Use condensed mode when animation is disabled (overflow detected by AnimationContext) + // This ensures consistent behavior - when we disable animation, we also simplify the view + const condensed = !shouldAnimate; + const allCompleted = agents.every( (a) => a.status === "completed" || a.status === "error", ); @@ -252,6 +303,7 @@ export const SubagentGroupDisplay = memo(() => { agent={agent} isLast={index === agents.length - 1} expanded={expanded} + condensed={condensed} /> ))} diff --git a/src/cli/contexts/AnimationContext.tsx b/src/cli/contexts/AnimationContext.tsx new file mode 100644 index 0000000..c870f08 --- /dev/null +++ b/src/cli/contexts/AnimationContext.tsx @@ -0,0 +1,54 @@ +/** + * AnimationContext - Global context for controlling animations based on overflow + * + * When the live content area exceeds the terminal viewport, Ink's clearTerminal + * behavior causes severe flickering on every re-render. This context provides + * a global `shouldAnimate` flag that components (like BlinkDot) can consume + * to disable animations when content would overflow. + * + * The parent (App.tsx) calculates total live content height and determines + * if animations should be disabled, then provides this via context. + */ + +import { createContext, type ReactNode, useContext } from "react"; + +interface AnimationContextValue { + /** + * Whether animations should be enabled. + * False when live content would overflow the viewport. + */ + shouldAnimate: boolean; +} + +const AnimationContext = createContext({ + shouldAnimate: true, +}); + +/** + * Hook to access the animation context. + * Returns { shouldAnimate: true } if used outside of a provider. + */ +export function useAnimation(): AnimationContextValue { + return useContext(AnimationContext); +} + +interface AnimationProviderProps { + children: ReactNode; + shouldAnimate: boolean; +} + +/** + * Provider component that controls animation state for all descendants. + * Wrap the live content area with this and pass shouldAnimate based on + * overflow detection. + */ +export function AnimationProvider({ + children, + shouldAnimate, +}: AnimationProviderProps) { + return ( + + {children} + + ); +} diff --git a/src/cli/hooks/useTerminalWidth.ts b/src/cli/hooks/useTerminalWidth.ts index 993bdba..722a33a 100644 --- a/src/cli/hooks/useTerminalWidth.ts +++ b/src/cli/hooks/useTerminalWidth.ts @@ -7,21 +7,33 @@ const getStdout = () => { }; const getTerminalWidth = () => getStdout()?.columns ?? 80; +const getTerminalRows = () => getStdout()?.rows ?? 24; -type Listener = (columns: number) => void; +type WidthListener = (columns: number) => void; +type RowsListener = (rows: number) => void; -const listeners = new Set(); +const widthListeners = new Set(); +const rowsListeners = new Set(); let resizeHandlerRegistered = false; let trackedColumns = getTerminalWidth(); +let trackedRows = getTerminalRows(); const resizeHandler = () => { const nextColumns = getTerminalWidth(); - if (nextColumns === trackedColumns) { - return; + const nextRows = getTerminalRows(); + + if (nextColumns !== trackedColumns) { + trackedColumns = nextColumns; + for (const listener of widthListeners) { + listener(nextColumns); + } } - trackedColumns = nextColumns; - for (const listener of listeners) { - listener(nextColumns); + + if (nextRows !== trackedRows) { + trackedRows = nextRows; + for (const listener of rowsListeners) { + listener(nextRows); + } } }; @@ -34,7 +46,8 @@ const ensureResizeHandler = () => { }; const removeResizeHandlerIfIdle = () => { - if (!resizeHandlerRegistered || listeners.size > 0) return; + if (!resizeHandlerRegistered) return; + if (widthListeners.size > 0 || rowsListeners.size > 0) return; const stdout = getStdout(); if (!stdout) return; stdout.off("resize", resizeHandler); @@ -50,16 +63,39 @@ export function useTerminalWidth(): number { useEffect(() => { ensureResizeHandler(); - const listener: Listener = (value) => { + const listener: WidthListener = (value) => { setColumns(value); }; - listeners.add(listener); + widthListeners.add(listener); return () => { - listeners.delete(listener); + widthListeners.delete(listener); removeResizeHandlerIfIdle(); }; }, []); return columns; } + +/** + * Hook to get terminal rows and reactively update on resize. + * Uses the same shared resize listener as useTerminalWidth. + */ +export function useTerminalRows(): number { + const [rows, setRows] = useState(trackedRows); + + useEffect(() => { + ensureResizeHandler(); + const listener: RowsListener = (value) => { + setRows(value); + }; + rowsListeners.add(listener); + + return () => { + rowsListeners.delete(listener); + removeResizeHandlerIfIdle(); + }; + }, []); + + return rows; +}