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;
+}