fix: patch subagent display (#512)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
700
src/cli/App.tsx
700
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 && (
|
||||
<Box flexDirection="column">
|
||||
{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 */}
|
||||
<AnimationProvider shouldAnimate={shouldAnimate}>
|
||||
{/* Show liveItems always - all approvals now render inline */}
|
||||
{liveItems.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{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 (
|
||||
<Box key={ln.id} flexDirection="column" marginTop={1}>
|
||||
{/* For ExitPlanMode awaiting approval: render StaticPlanApproval */}
|
||||
{/* Plan preview is eagerly committed to staticItems, so this only shows options */}
|
||||
{isExitPlanModeApproval ? (
|
||||
<StaticPlanApproval
|
||||
onApprove={() => handlePlanApprove(false)}
|
||||
onApproveAndAcceptEdits={() =>
|
||||
handlePlanApprove(true)
|
||||
}
|
||||
onKeepPlanning={handlePlanKeepPlanning}
|
||||
isFocused={true}
|
||||
/>
|
||||
) : isFileEditApproval && fileEditInfo ? (
|
||||
<InlineFileEditApproval
|
||||
fileEdit={fileEditInfo}
|
||||
precomputedDiff={
|
||||
ln.toolCallId
|
||||
? precomputedDiffsRef.current.get(ln.toolCallId)
|
||||
: undefined
|
||||
}
|
||||
allDiffs={precomputedDiffsRef.current}
|
||||
onApprove={(diffs) => 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 ? (
|
||||
<InlineBashApproval
|
||||
bashInfo={bashInfo}
|
||||
onApprove={() => handleApproveCurrent()}
|
||||
onApproveAlways={(scope) =>
|
||||
handleApproveAlways(scope)
|
||||
}
|
||||
onDeny={(reason) => handleDenyCurrent(reason)}
|
||||
onCancel={handleCancelApprovals}
|
||||
isFocused={true}
|
||||
approveAlwaysText={
|
||||
currentApprovalContext?.approveAlwaysText
|
||||
}
|
||||
allowPersistence={
|
||||
currentApprovalContext?.allowPersistence ?? true
|
||||
}
|
||||
/>
|
||||
) : isEnterPlanModeApproval ? (
|
||||
<InlineEnterPlanModeApproval
|
||||
onApprove={handleEnterPlanModeApprove}
|
||||
onReject={handleEnterPlanModeReject}
|
||||
isFocused={true}
|
||||
/>
|
||||
) : isAskUserQuestionApproval ? (
|
||||
<InlineQuestionApproval
|
||||
questions={getQuestionsFromApproval(
|
||||
currentApproval,
|
||||
)}
|
||||
onSubmit={handleQuestionSubmit}
|
||||
onCancel={handleCancelApprovals}
|
||||
isFocused={true}
|
||||
/>
|
||||
) : isTaskToolApproval && taskInfo ? (
|
||||
<InlineTaskApproval
|
||||
taskInfo={taskInfo}
|
||||
onApprove={() => 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
|
||||
<InlineGenericApproval
|
||||
toolName={currentApproval.toolName}
|
||||
toolArgs={currentApproval.toolArgs}
|
||||
onApprove={() => handleApproveCurrent()}
|
||||
onApproveAlways={(scope) =>
|
||||
handleApproveAlways(scope)
|
||||
}
|
||||
onDeny={(reason) => handleDenyCurrent(reason)}
|
||||
onCancel={handleCancelApprovals}
|
||||
isFocused={true}
|
||||
approveAlwaysText={
|
||||
currentApprovalContext?.approveAlwaysText
|
||||
}
|
||||
allowPersistence={
|
||||
currentApprovalContext?.allowPersistence ?? true
|
||||
}
|
||||
/>
|
||||
) : ln.kind === "user" ? (
|
||||
<UserMessage line={ln} />
|
||||
) : ln.kind === "reasoning" ? (
|
||||
<ReasoningMessage line={ln} />
|
||||
) : ln.kind === "assistant" ? (
|
||||
<AssistantMessage line={ln} />
|
||||
) : ln.kind === "tool_call" &&
|
||||
ln.toolCallId &&
|
||||
queuedIds.has(ln.toolCallId) ? (
|
||||
// Render stub for queued (decided but not executed) approval
|
||||
<PendingApprovalStub
|
||||
toolName={
|
||||
approvalMap.get(ln.toolCallId)?.toolName ||
|
||||
ln.name ||
|
||||
"Unknown"
|
||||
}
|
||||
description={stubDescriptions.get(ln.toolCallId)}
|
||||
decision={queuedDecisions.get(ln.toolCallId)}
|
||||
/>
|
||||
) : ln.kind === "tool_call" &&
|
||||
ln.toolCallId &&
|
||||
pendingIds.has(ln.toolCallId) ? (
|
||||
// Render stub for pending (undecided) approval
|
||||
<PendingApprovalStub
|
||||
toolName={
|
||||
approvalMap.get(ln.toolCallId)?.toolName ||
|
||||
ln.name ||
|
||||
"Unknown"
|
||||
}
|
||||
description={stubDescriptions.get(ln.toolCallId)}
|
||||
/>
|
||||
) : ln.kind === "tool_call" ? (
|
||||
<ToolCallMessage
|
||||
line={ln}
|
||||
precomputedDiffs={precomputedDiffsRef.current}
|
||||
lastPlanFilePath={lastPlanFilePathRef.current}
|
||||
/>
|
||||
) : ln.kind === "error" ? (
|
||||
<ErrorMessage line={ln} />
|
||||
) : ln.kind === "status" ? (
|
||||
<StatusMessage line={ln} />
|
||||
) : ln.kind === "command" ? (
|
||||
<CommandMessage line={ln} />
|
||||
) : ln.kind === "bash_command" ? (
|
||||
<BashCommandMessage line={ln} />
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
return (
|
||||
<Box key={ln.id} flexDirection="column" marginTop={1}>
|
||||
{/* For ExitPlanMode awaiting approval: render StaticPlanApproval */}
|
||||
{/* Plan preview is eagerly committed to staticItems, so this only shows options */}
|
||||
{isExitPlanModeApproval ? (
|
||||
<StaticPlanApproval
|
||||
onApprove={() => handlePlanApprove(false)}
|
||||
onApproveAndAcceptEdits={() =>
|
||||
handlePlanApprove(true)
|
||||
}
|
||||
onKeepPlanning={handlePlanKeepPlanning}
|
||||
isFocused={true}
|
||||
/>
|
||||
) : isFileEditApproval && fileEditInfo ? (
|
||||
<InlineFileEditApproval
|
||||
fileEdit={fileEditInfo}
|
||||
precomputedDiff={
|
||||
ln.toolCallId
|
||||
? precomputedDiffsRef.current.get(ln.toolCallId)
|
||||
: undefined
|
||||
}
|
||||
allDiffs={precomputedDiffsRef.current}
|
||||
onApprove={(diffs) => 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 ? (
|
||||
<InlineBashApproval
|
||||
bashInfo={bashInfo}
|
||||
onApprove={() => handleApproveCurrent()}
|
||||
onApproveAlways={(scope) =>
|
||||
handleApproveAlways(scope)
|
||||
}
|
||||
onDeny={(reason) => handleDenyCurrent(reason)}
|
||||
onCancel={handleCancelApprovals}
|
||||
isFocused={true}
|
||||
approveAlwaysText={
|
||||
currentApprovalContext?.approveAlwaysText
|
||||
}
|
||||
allowPersistence={
|
||||
currentApprovalContext?.allowPersistence ?? true
|
||||
}
|
||||
/>
|
||||
) : isEnterPlanModeApproval ? (
|
||||
<InlineEnterPlanModeApproval
|
||||
onApprove={handleEnterPlanModeApprove}
|
||||
onReject={handleEnterPlanModeReject}
|
||||
isFocused={true}
|
||||
/>
|
||||
) : isAskUserQuestionApproval ? (
|
||||
<InlineQuestionApproval
|
||||
questions={getQuestionsFromApproval(currentApproval)}
|
||||
onSubmit={handleQuestionSubmit}
|
||||
onCancel={handleCancelApprovals}
|
||||
isFocused={true}
|
||||
/>
|
||||
) : isTaskToolApproval && taskInfo ? (
|
||||
<InlineTaskApproval
|
||||
taskInfo={taskInfo}
|
||||
onApprove={() => 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
|
||||
<InlineGenericApproval
|
||||
toolName={currentApproval.toolName}
|
||||
toolArgs={currentApproval.toolArgs}
|
||||
onApprove={() => handleApproveCurrent()}
|
||||
onApproveAlways={(scope) =>
|
||||
handleApproveAlways(scope)
|
||||
}
|
||||
onDeny={(reason) => handleDenyCurrent(reason)}
|
||||
onCancel={handleCancelApprovals}
|
||||
isFocused={true}
|
||||
approveAlwaysText={
|
||||
currentApprovalContext?.approveAlwaysText
|
||||
}
|
||||
allowPersistence={
|
||||
currentApprovalContext?.allowPersistence ?? true
|
||||
}
|
||||
/>
|
||||
) : ln.kind === "user" ? (
|
||||
<UserMessage line={ln} />
|
||||
) : ln.kind === "reasoning" ? (
|
||||
<ReasoningMessage line={ln} />
|
||||
) : ln.kind === "assistant" ? (
|
||||
<AssistantMessage line={ln} />
|
||||
) : ln.kind === "tool_call" &&
|
||||
ln.toolCallId &&
|
||||
queuedIds.has(ln.toolCallId) ? (
|
||||
// Render stub for queued (decided but not executed) approval
|
||||
<PendingApprovalStub
|
||||
toolName={
|
||||
approvalMap.get(ln.toolCallId)?.toolName ||
|
||||
ln.name ||
|
||||
"Unknown"
|
||||
}
|
||||
description={stubDescriptions.get(ln.toolCallId)}
|
||||
decision={queuedDecisions.get(ln.toolCallId)}
|
||||
/>
|
||||
) : ln.kind === "tool_call" &&
|
||||
ln.toolCallId &&
|
||||
pendingIds.has(ln.toolCallId) ? (
|
||||
// Render stub for pending (undecided) approval
|
||||
<PendingApprovalStub
|
||||
toolName={
|
||||
approvalMap.get(ln.toolCallId)?.toolName ||
|
||||
ln.name ||
|
||||
"Unknown"
|
||||
}
|
||||
description={stubDescriptions.get(ln.toolCallId)}
|
||||
/>
|
||||
) : ln.kind === "tool_call" ? (
|
||||
<ToolCallMessage
|
||||
line={ln}
|
||||
precomputedDiffs={precomputedDiffsRef.current}
|
||||
lastPlanFilePath={lastPlanFilePathRef.current}
|
||||
/>
|
||||
) : ln.kind === "error" ? (
|
||||
<ErrorMessage line={ln} />
|
||||
) : ln.kind === "status" ? (
|
||||
<StatusMessage line={ln} />
|
||||
) : ln.kind === "command" ? (
|
||||
<CommandMessage line={ln} />
|
||||
) : ln.kind === "bash_command" ? (
|
||||
<BashCommandMessage line={ln} />
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Subagent group display - shows running/completed subagents */}
|
||||
<SubagentGroupDisplay />
|
||||
{/* Subagent group display - shows running/completed subagents */}
|
||||
<SubagentGroupDisplay />
|
||||
</AnimationProvider>
|
||||
|
||||
{/* Exit stats - shown when exiting via double Ctrl+C */}
|
||||
{showExitStats && (
|
||||
|
||||
@@ -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 <Text color={color}>{on ? symbol : " "}</Text>;
|
||||
}, [shouldAnimate]);
|
||||
// Always show symbol when animation disabled (static indicator)
|
||||
return <Text color={color}>{on || !shouldAnimate ? symbol : " "}</Text>;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
{/* Main row: tree char + description + type + model + stats */}
|
||||
<Box flexDirection="row">
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{treeChar}{" "}
|
||||
</Text>
|
||||
<Text bold>{agent.description}</Text>
|
||||
<Text dimColor>
|
||||
{" · "}
|
||||
{agent.type.toLowerCase()}
|
||||
{agent.model ? ` · ${agent.model}` : ""}
|
||||
{" · "}
|
||||
{stats}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Subagent URL */}
|
||||
{agent.agentURL && (
|
||||
<Box flexDirection="row">
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar} ⎿{" "}
|
||||
</Text>
|
||||
<Text dimColor>{"Subagent: "}</Text>
|
||||
<Text dimColor>{agent.agentURL}</Text>
|
||||
// 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 (
|
||||
<Box flexDirection="column">
|
||||
{/* Main row: tree char + description + type + model (no stats) */}
|
||||
<Box flexDirection="row">
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{treeChar}{" "}
|
||||
</Text>
|
||||
<Text bold>{agent.description}</Text>
|
||||
<Text dimColor>
|
||||
{" · "}
|
||||
{agent.type.toLowerCase()}
|
||||
{agent.model ? ` · ${agent.model}` : ""}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Simple status line */}
|
||||
<Box flexDirection="row">
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" "}</Text>
|
||||
{agent.status === "error" ? (
|
||||
<Text color={colors.subagent.error}>Error</Text>
|
||||
) : (
|
||||
<Text dimColor>{isComplete ? "Done" : "Running..."}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
{/* Expanded: show all tool calls */}
|
||||
{expanded &&
|
||||
agent.toolCalls.map((tc) => {
|
||||
const formattedArgs = formatToolArgs(tc.args);
|
||||
return (
|
||||
<Box key={tc.id} flexDirection="row">
|
||||
// Full mode: all details including live tool calls
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Main row: tree char + description + type + model + stats */}
|
||||
<Box flexDirection="row">
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{treeChar}{" "}
|
||||
</Text>
|
||||
<Text bold>{agent.description}</Text>
|
||||
<Text dimColor>
|
||||
{" · "}
|
||||
{agent.type.toLowerCase()}
|
||||
{agent.model ? ` · ${agent.model}` : ""}
|
||||
{" · "}
|
||||
{stats}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Subagent URL */}
|
||||
{agent.agentURL && (
|
||||
<Box flexDirection="row">
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar} ⎿{" "}
|
||||
</Text>
|
||||
<Text dimColor>{"Subagent: "}</Text>
|
||||
<Text dimColor>{agent.agentURL}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Expanded: show all tool calls */}
|
||||
{expanded &&
|
||||
agent.toolCalls.map((tc) => {
|
||||
const formattedArgs = formatToolArgs(tc.args);
|
||||
return (
|
||||
<Box key={tc.id} flexDirection="row">
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
{tc.name}({formattedArgs})
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Status line */}
|
||||
<Box flexDirection="row">
|
||||
{agent.status === "completed" ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Done"}</Text>
|
||||
</>
|
||||
) : agent.status === "error" ? (
|
||||
<>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" "}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap" color={colors.subagent.error}>
|
||||
{agent.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : lastTool ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
{tc.name}({formattedArgs})
|
||||
{" "}
|
||||
{lastTool.name}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Status line */}
|
||||
<Box flexDirection="row">
|
||||
{agent.status === "completed" ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Done"}</Text>
|
||||
</>
|
||||
) : agent.status === "error" ? (
|
||||
<>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" "}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap" color={colors.subagent.error}>
|
||||
{agent.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : lastTool ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
{lastTool.name}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Starting..."}</Text>
|
||||
</>
|
||||
)}
|
||||
<Text dimColor>{" Starting..."}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
AgentRow.displayName = "AgentRow";
|
||||
|
||||
interface GroupHeaderProps {
|
||||
@@ -203,6 +248,7 @@ const GroupHeader = memo(
|
||||
{allCompleted ? (
|
||||
<Text color={dotColor}>●</Text>
|
||||
) : (
|
||||
// BlinkDot now gets shouldAnimate from AnimationContext
|
||||
<BlinkDot color={colors.subagent.header} />
|
||||
)}
|
||||
<Text color={colors.subagent.header}> {statusText} </Text>
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
54
src/cli/contexts/AnimationContext.tsx
Normal file
54
src/cli/contexts/AnimationContext.tsx
Normal file
@@ -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<AnimationContextValue>({
|
||||
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 (
|
||||
<AnimationContext.Provider value={{ shouldAnimate }}>
|
||||
{children}
|
||||
</AnimationContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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<Listener>();
|
||||
const widthListeners = new Set<WidthListener>();
|
||||
const rowsListeners = new Set<RowsListener>();
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user