259 lines
7.6 KiB
TypeScript
259 lines
7.6 KiB
TypeScript
/**
|
|
* SubagentGroupDisplay - Live/interactive subagent status display
|
|
*
|
|
* Used in the ACTIVE render area for subagents that may still be running.
|
|
* Subscribes to external store and handles keyboard input - these hooks
|
|
* require the component to stay "alive" and re-rendering.
|
|
*
|
|
* Features:
|
|
* - Real-time updates via useSyncExternalStore
|
|
* - Blinking dots for running agents
|
|
* - Expand/collapse tool calls (ctrl+o)
|
|
* - Shows "Running N subagents..." while active
|
|
*
|
|
* When agents complete, they get committed to Ink's <Static> area using
|
|
* SubagentGroupStatic instead (a pure props-based snapshot with no hooks).
|
|
*/
|
|
|
|
import { Box, Text, useInput } from "ink";
|
|
import { memo, useSyncExternalStore } from "react";
|
|
import { formatStats, getTreeChars } from "../helpers/subagentDisplay.js";
|
|
import {
|
|
getSnapshot,
|
|
type SubagentState,
|
|
subscribe,
|
|
toggleExpanded,
|
|
} from "../helpers/subagentState.js";
|
|
import { useTerminalWidth } from "../hooks/useTerminalWidth.js";
|
|
import { BlinkDot } from "./BlinkDot.js";
|
|
import { colors } from "./colors.js";
|
|
|
|
function formatToolArgs(argsStr: string): string {
|
|
try {
|
|
const args = JSON.parse(argsStr);
|
|
const entries = Object.entries(args)
|
|
.filter(([_, value]) => value !== undefined && value !== null)
|
|
.slice(0, 2);
|
|
|
|
if (entries.length === 0) return "";
|
|
|
|
return entries
|
|
.map(([key, value]) => {
|
|
let displayValue = String(value);
|
|
if (displayValue.length > 50) {
|
|
displayValue = `${displayValue.slice(0, 47)}...`;
|
|
}
|
|
return `${key}: "${displayValue}"`;
|
|
})
|
|
.join(", ");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Subcomponents
|
|
// ============================================================================
|
|
|
|
interface AgentRowProps {
|
|
agent: SubagentState;
|
|
isLast: boolean;
|
|
expanded: boolean;
|
|
}
|
|
|
|
const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => {
|
|
const { treeChar, continueChar } = getTreeChars(isLast);
|
|
const columns = useTerminalWidth();
|
|
const gutterWidth = 6; // tree char (1) + " ⎿ " (5)
|
|
const contentWidth = Math.max(0, columns - gutterWidth);
|
|
|
|
const getDotElement = () => {
|
|
switch (agent.status) {
|
|
case "pending":
|
|
return <BlinkDot color={colors.subagent.running} />;
|
|
case "running":
|
|
return <BlinkDot color={colors.subagent.running} />;
|
|
case "completed":
|
|
return <Text color={colors.subagent.completed}>●</Text>;
|
|
case "error":
|
|
return <Text color={colors.subagent.error}>●</Text>;
|
|
default:
|
|
return <Text>●</Text>;
|
|
}
|
|
};
|
|
|
|
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 color={colors.subagent.treeChar}>{treeChar} </Text>
|
|
{getDotElement()}
|
|
<Text> {agent.description}</Text>
|
|
<Text dimColor> · {agent.type.toLowerCase()}</Text>
|
|
{agent.model && <Text dimColor> · {agent.model}</Text>}
|
|
<Text dimColor> · {stats}</Text>
|
|
</Box>
|
|
|
|
{/* Subagent URL */}
|
|
{agent.agentURL && (
|
|
<Box flexDirection="row">
|
|
<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" dimColor>
|
|
Subagent: {agent.agentURL}
|
|
</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">
|
|
<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>
|
|
{" ⎿ "}
|
|
{lastTool.name}
|
|
</Text>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
|
<Text dimColor>{" ⎿ Starting..."}</Text>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
});
|
|
AgentRow.displayName = "AgentRow";
|
|
|
|
interface GroupHeaderProps {
|
|
count: number;
|
|
allCompleted: boolean;
|
|
hasErrors: boolean;
|
|
expanded: boolean;
|
|
}
|
|
|
|
const GroupHeader = memo(
|
|
({ count, allCompleted, hasErrors, expanded }: GroupHeaderProps) => {
|
|
const statusText = allCompleted
|
|
? `Ran ${count} subagent${count !== 1 ? "s" : ""}`
|
|
: `Running ${count} subagent${count !== 1 ? "s" : ""}…`;
|
|
|
|
const hint = expanded ? "(ctrl+o to collapse)" : "(ctrl+o to expand)";
|
|
|
|
// Use error color for dot if any subagent errored
|
|
const dotColor = hasErrors
|
|
? colors.subagent.error
|
|
: colors.subagent.completed;
|
|
|
|
return (
|
|
<Box flexDirection="row">
|
|
{allCompleted ? (
|
|
<Text color={dotColor}>⏺</Text>
|
|
) : (
|
|
<BlinkDot color={colors.subagent.header} />
|
|
)}
|
|
<Text color={colors.subagent.header}> {statusText} </Text>
|
|
<Text color={colors.subagent.hint}>{hint}</Text>
|
|
</Box>
|
|
);
|
|
},
|
|
);
|
|
|
|
GroupHeader.displayName = "GroupHeader";
|
|
|
|
// ============================================================================
|
|
// Main Component
|
|
// ============================================================================
|
|
|
|
export const SubagentGroupDisplay = memo(() => {
|
|
const { agents, expanded } = useSyncExternalStore(subscribe, getSnapshot);
|
|
|
|
// Handle ctrl+o for expand/collapse
|
|
useInput((input, key) => {
|
|
if (key.ctrl && input === "o") {
|
|
toggleExpanded();
|
|
}
|
|
});
|
|
|
|
// Don't render if no agents
|
|
if (agents.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const allCompleted = agents.every(
|
|
(a) => a.status === "completed" || a.status === "error",
|
|
);
|
|
const hasErrors = agents.some((a) => a.status === "error");
|
|
|
|
return (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<GroupHeader
|
|
count={agents.length}
|
|
allCompleted={allCompleted}
|
|
hasErrors={hasErrors}
|
|
expanded={expanded}
|
|
/>
|
|
{agents.map((agent, index) => (
|
|
<AgentRow
|
|
key={agent.id}
|
|
agent={agent}
|
|
isLast={index === agents.length - 1}
|
|
expanded={expanded}
|
|
/>
|
|
))}
|
|
</Box>
|
|
);
|
|
});
|
|
|
|
SubagentGroupDisplay.displayName = "SubagentGroupDisplay";
|