chore: Improve subagents UI (#205)

This commit is contained in:
Devansh Jain
2025-12-15 21:23:49 -08:00
committed by GitHub
parent b970bd3e3e
commit 81e91823cb
19 changed files with 1324 additions and 408 deletions

View File

@@ -58,6 +58,8 @@ import { ReasoningMessage } from "./components/ReasoningMessageRich";
import { ResumeSelector } from "./components/ResumeSelector";
import { SessionStats as SessionStatsComponent } from "./components/SessionStats";
import { StatusMessage } from "./components/StatusMessage";
import { SubagentGroupDisplay } from "./components/SubagentGroupDisplay";
import { SubagentGroupStatic } from "./components/SubagentGroupStatic";
import { SubagentManager } from "./components/SubagentManager";
import { SystemPromptSelector } from "./components/SystemPromptSelector";
import { ToolCallMessage } from "./components/ToolCallMessageRich";
@@ -81,7 +83,17 @@ import {
import { generatePlanFilePath } from "./helpers/planName";
import { safeJsonParseOr } from "./helpers/safeJsonParse";
import { type ApprovalRequest, drainStreamWithResume } from "./helpers/stream";
import {
collectFinishedTaskToolCalls,
createSubagentGroupItem,
hasInProgressTaskToolCalls,
} from "./helpers/subagentAggregation";
import {
clearCompletedSubagents,
clearSubagentsByIds,
} from "./helpers/subagentState";
import { getRandomThinkingMessage } from "./helpers/thinkingMessages";
import { isFancyUITool, isTaskTool } from "./helpers/toolNameMapping.js";
import { useSuspend } from "./hooks/useSuspend/useSuspend.ts";
import { useTerminalWidth } from "./hooks/useTerminalWidth";
@@ -183,15 +195,6 @@ function readPlanFile(): string {
}
}
// Fancy UI tools require specialized dialogs instead of the standard ApprovalDialog
function isFancyUITool(name: string): boolean {
return (
name === "AskUserQuestion" ||
name === "EnterPlanMode" ||
name === "ExitPlanMode"
);
}
// Extract questions from AskUserQuestion tool args
function getQuestionsFromApproval(approval: ApprovalRequest) {
const parsed = safeJsonParseOr<Record<string, unknown>>(
@@ -230,6 +233,20 @@ type StaticItem =
terminalWidth: number;
};
}
| {
kind: "subagent_group";
id: string;
agents: Array<{
id: string;
type: string;
description: string;
status: "completed" | "error";
toolCount: number;
totalTokens: number;
agentURL: string | null;
error?: string;
}>;
}
| Line;
export default function App({
@@ -463,6 +480,24 @@ export default function App({
// Commit immutable/finished lines into the historical log
const commitEligibleLines = useCallback((b: Buffers) => {
const newlyCommitted: StaticItem[] = [];
let firstTaskIndex = -1;
// Check if there are any in-progress Task tool_calls
const hasInProgress = hasInProgressTaskToolCalls(
b.order,
b.byId,
emittedIdsRef.current,
);
// Collect finished Task tool_calls for grouping
const finishedTaskToolCalls = collectFinishedTaskToolCalls(
b.order,
b.byId,
emittedIdsRef.current,
hasInProgress,
);
// Commit regular lines (non-Task tools)
for (const id of b.order) {
if (emittedIdsRef.current.has(id)) continue;
const ln = b.byId.get(id);
@@ -480,11 +515,39 @@ export default function App({
}
continue;
}
// Handle Task tool_calls specially - track position but don't add individually
if (ln.kind === "tool_call" && ln.name && isTaskTool(ln.name)) {
if (firstTaskIndex === -1 && finishedTaskToolCalls.length > 0) {
firstTaskIndex = newlyCommitted.length;
}
continue;
}
if ("phase" in ln && ln.phase === "finished") {
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
}
}
// If we collected Task tool_calls (all are finished), create a subagent_group
if (finishedTaskToolCalls.length > 0) {
// Mark all as emitted
for (const tc of finishedTaskToolCalls) {
emittedIdsRef.current.add(tc.lineId);
}
const groupItem = createSubagentGroupItem(finishedTaskToolCalls);
// Insert at the position of the first Task tool_call
newlyCommitted.splice(
firstTaskIndex >= 0 ? firstTaskIndex : newlyCommitted.length,
0,
groupItem,
);
// Clear these agents from the subagent store
clearSubagentsByIds(groupItem.agents.map((a) => a.id));
}
if (newlyCommitted.length > 0) {
setStaticItems((prev) => [...prev, ...newlyCommitted]);
}
@@ -690,6 +753,9 @@ export default function App({
// If we're sending a new message, old pending state is no longer relevant
markIncompleteToolsAsCancelled(buffersRef.current);
// Clear completed subagents from the UI when starting a new turn
clearCompletedSubagents();
while (true) {
// Check if cancelled before starting new stream
if (abortControllerRef.current?.signal.aborted) {
@@ -926,6 +992,7 @@ export default function App({
const result = await executeTool(
ac.approval.toolName,
parsedArgs,
{ toolCallId: ac.approval.toolCallId },
);
// Update buffers with tool return for UI
@@ -3515,7 +3582,11 @@ Plan file path: ${planFilePath}`;
return ln.phase === "running";
}
if (ln.kind === "tool_call") {
// Always show tool calls in progress
// Skip Task tool_calls - SubagentGroupDisplay handles them
if (ln.name && isTaskTool(ln.name)) {
return false;
}
// Always show other tool calls in progress
return ln.phase !== "finished";
}
if (!tokenStreamingEnabled && ln.phase === "streaming") return false;
@@ -3617,6 +3688,8 @@ Plan file path: ${planFilePath}`;
<AssistantMessage line={item} />
) : item.kind === "tool_call" ? (
<ToolCallMessage line={item} />
) : item.kind === "subagent_group" ? (
<SubagentGroupStatic agents={item.agents} />
) : item.kind === "error" ? (
<ErrorMessage line={item} />
) : item.kind === "status" ? (
@@ -3667,6 +3740,9 @@ Plan file path: ${planFilePath}`;
</Box>
)}
{/* Subagent group display - shows running/completed subagents */}
<SubagentGroupDisplay />
{/* Ensure 1 blank line above input when there are no live items */}
{liveItems.length === 0 && <Box height={1} />}

View File

@@ -0,0 +1,20 @@
import { Text } from "ink";
import { memo, useEffect, useState } from "react";
import { colors } from "./colors.js";
/**
* A blinking dot indicator for running/pending states.
* Toggles visibility every 400ms to create a blinking effect.
*/
export const BlinkDot = memo(
({ color = colors.tool.pending }: { color?: string }) => {
const [on, setOn] = useState(true);
useEffect(() => {
const t = setInterval(() => setOn((v) => !v), 400);
return () => clearInterval(t);
}, []);
return <Text color={color}>{on ? "●" : " "}</Text>;
},
);
BlinkDot.displayName = "BlinkDot";

View File

@@ -1,6 +1,7 @@
import { Box, Text } from "ink";
import { memo, useEffect, useState } from "react";
import { memo } from "react";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { BlinkDot } from "./BlinkDot.js";
import { colors } from "./colors.js";
import { MarkdownDisplay } from "./MarkdownDisplay.js";
@@ -13,17 +14,6 @@ type CommandLine = {
success?: boolean;
};
// BlinkDot component for running commands
const BlinkDot: React.FC<{ color?: string }> = ({ color = "yellow" }) => {
const [on, setOn] = useState(true);
useEffect(() => {
const t = setInterval(() => setOn((v) => !v), 400);
return () => clearInterval(t);
}, []);
// Visible = colored dot; Off = space (keeps width/alignment)
return <Text color={color}>{on ? "●" : " "}</Text>;
};
/**
* CommandMessage - Rich formatting version with two-column layout
* Matches the formatting pattern used by other message types

View File

@@ -0,0 +1,222 @@
/**
* 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 { 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 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 + stats */}
<Box flexDirection="row">
<Text color={colors.subagent.treeChar}>{treeChar} </Text>
{getDotElement()}
<Text> {agent.description}</Text>
<Text dimColor> · {agent.type.toLowerCase()}</Text>
<Text color={colors.subagent.stats}> · {stats}</Text>
</Box>
{/* Subagent URL */}
{agent.agentURL && (
<Box flexDirection="row">
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text dimColor>
{" ⎿ Subagent: "}
{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">
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
{agent.status === "completed" ? (
<Text dimColor>{" ⎿ Done"}</Text>
) : agent.status === "error" ? (
<Text color={colors.subagent.error}>
{" ⎿ Error: "}
{agent.error}
</Text>
) : lastTool ? (
<Text dimColor>
{" ⎿ "}
{lastTool.name}
</Text>
) : (
<Text dimColor>{" ⎿ Starting..."}</Text>
)}
</Box>
</Box>
);
});
AgentRow.displayName = "AgentRow";
interface GroupHeaderProps {
count: number;
allCompleted: boolean;
expanded: boolean;
}
const GroupHeader = memo(
({ count, allCompleted, 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)";
return (
<Box flexDirection="row">
{allCompleted ? (
<Text color={colors.subagent.completed}></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",
);
return (
<Box flexDirection="column" marginTop={1}>
<GroupHeader
count={agents.length}
allCompleted={allCompleted}
expanded={expanded}
/>
{agents.map((agent, index) => (
<AgentRow
key={agent.id}
agent={agent}
isLast={index === agents.length - 1}
expanded={expanded}
/>
))}
</Box>
);
});
SubagentGroupDisplay.displayName = "SubagentGroupDisplay";

View File

@@ -0,0 +1,132 @@
/**
* SubagentGroupStatic - Frozen snapshot of completed subagents
*
* Used in Ink's <Static> area for historical/committed items that have
* scrolled up and should no longer re-render. Pure props-based component
* with NO hooks (no store subscriptions, no keyboard handlers).
*
* This separation from SubagentGroupDisplay is necessary because:
* - Static area components shouldn't have active subscriptions (memory leaks)
* - Keyboard handlers would stack up across frozen components
* - We only need a simple snapshot, not live updates
*
* Shows: "Ran N subagents" with final stats (tool count, tokens).
*/
import { Box, Text } from "ink";
import { memo } from "react";
import { formatStats, getTreeChars } from "../helpers/subagentDisplay.js";
import { colors } from "./colors.js";
// ============================================================================
// Types
// ============================================================================
export interface StaticSubagent {
id: string;
type: string;
description: string;
status: "completed" | "error";
toolCount: number;
totalTokens: number;
agentURL: string | null;
error?: string;
}
interface SubagentGroupStaticProps {
agents: StaticSubagent[];
}
// ============================================================================
// Subcomponents
// ============================================================================
interface AgentRowProps {
agent: StaticSubagent;
isLast: boolean;
}
const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
const { treeChar, continueChar } = getTreeChars(isLast);
const dotColor =
agent.status === "completed"
? colors.subagent.completed
: colors.subagent.error;
const stats = formatStats(agent.toolCount, agent.totalTokens);
return (
<Box flexDirection="column">
{/* Main row: tree char + description + type + stats */}
<Box flexDirection="row">
<Text color={colors.subagent.treeChar}>{treeChar} </Text>
<Text color={dotColor}></Text>
<Text> {agent.description}</Text>
<Text dimColor> · {agent.type.toLowerCase()}</Text>
<Text color={colors.subagent.stats}> · {stats}</Text>
</Box>
{/* Subagent URL */}
{agent.agentURL && (
<Box flexDirection="row">
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
<Text dimColor>
{" ⎿ Subagent: "}
{agent.agentURL}
</Text>
</Box>
)}
{/* Status line */}
<Box flexDirection="row">
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
{agent.status === "completed" ? (
<Text dimColor>{" ⎿ Done"}</Text>
) : (
<Text color={colors.subagent.error}>
{" ⎿ Error: "}
{agent.error}
</Text>
)}
</Box>
</Box>
);
});
AgentRow.displayName = "AgentRow";
// ============================================================================
// Main Component
// ============================================================================
export const SubagentGroupStatic = memo(
({ agents }: SubagentGroupStaticProps) => {
if (agents.length === 0) {
return null;
}
const statusText = `Ran ${agents.length} subagent${agents.length !== 1 ? "s" : ""}`;
return (
<Box flexDirection="column">
{/* Header */}
<Box flexDirection="row">
<Text color={colors.subagent.completed}></Text>
<Text color={colors.subagent.header}> {statusText}</Text>
</Box>
{/* Agent rows */}
{agents.map((agent, index) => (
<AgentRow
key={agent.id}
agent={agent}
isLast={index === agents.length - 1}
/>
))}
</Box>
);
},
);
SubagentGroupStatic.displayName = "SubagentGroupStatic";

View File

@@ -1,60 +0,0 @@
import { Box, Text } from "ink";
import { memo } from "react";
type ToolCallLine = {
kind: "tool_call";
id: string;
toolCallId?: string;
name?: string;
argsText?: string;
resultText?: string;
resultOk?: boolean;
phase: "streaming" | "ready" | "running" | "finished";
};
export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
const name = line.name ?? "?";
const args = line.argsText ?? "...";
let dotColor: string | undefined;
if (line.phase === "streaming") {
dotColor = "gray";
} else if (line.phase === "running") {
dotColor = "yellow";
} else if (line.phase === "finished") {
dotColor = line.resultOk === false ? "red" : "green";
}
// Parse and clean up result text for display
const displayText = (() => {
if (!line.resultText) return undefined;
// Try to parse JSON and extract error message for cleaner display
try {
const parsed = JSON.parse(line.resultText);
if (parsed.error && typeof parsed.error === "string") {
return parsed.error;
}
} catch {
// Not JSON or parse failed, use raw text
}
// Truncate long results
return line.resultText.length > 80
? `${line.resultText.slice(0, 80)}...`
: line.resultText;
})();
return (
<Box flexDirection="column">
<Text>
<Text color={dotColor}></Text> {name}({args})
</Text>
{displayText && (
<Text>
{line.resultOk === false ? "Error" : "Success"}: {displayText}
</Text>
)}
</Box>
);
});

View File

@@ -1,8 +1,15 @@
import { Box, Text } from "ink";
import { memo, useEffect, useState } from "react";
import { memo } from "react";
import { clipToolReturn } from "../../tools/manager.js";
import { formatArgsDisplay } from "../helpers/formatArgsDisplay.js";
import {
getDisplayToolName,
isPlanTool,
isTaskTool,
isTodoTool,
} from "../helpers/toolNameMapping.js";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { BlinkDot } from "./BlinkDot.js";
import { colors } from "./colors.js";
import { MarkdownDisplay } from "./MarkdownDisplay.js";
import { PlanRenderer } from "./PlanRenderer.js";
@@ -19,19 +26,6 @@ type ToolCallLine = {
phase: "streaming" | "ready" | "running" | "finished";
};
// BlinkDot component copied verbatim from old codebase
const BlinkDot: React.FC<{ color?: string }> = ({
color = colors.tool.pending,
}) => {
const [on, setOn] = useState(true);
useEffect(() => {
const t = setInterval(() => setOn((v) => !v), 400);
return () => clearInterval(t);
}, []);
// Visible = colored dot; Off = space (keeps width/alignment)
return <Text color={color}>{on ? "●" : " "}</Text>;
};
/**
* ToolCallMessageRich - Rich formatting version with old layout logic
* This preserves the exact wrapping and spacing logic from the old codebase
@@ -49,63 +43,13 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
const rawName = line.name ?? "?";
const argsText = line.argsText ?? "...";
// Task tool handles its own display via console.log - suppress UI rendering entirely
if (rawName === "Task" || rawName === "task") {
// Task tool - handled by SubagentGroupDisplay, don't render here
if (isTaskTool(rawName)) {
return null;
}
// Apply tool name remapping from old codebase
let displayName = rawName;
// Anthropic toolset
if (displayName === "write") displayName = "Write";
else if (displayName === "edit" || displayName === "multi_edit")
displayName = "Edit";
else if (displayName === "read") displayName = "Read";
else if (displayName === "bash") displayName = "Bash";
else if (displayName === "grep") displayName = "Grep";
else if (displayName === "glob") displayName = "Glob";
else if (displayName === "ls") displayName = "LS";
else if (displayName === "todo_write") displayName = "TODO";
else if (displayName === "TodoWrite") displayName = "TODO";
else if (displayName === "EnterPlanMode") displayName = "Planning";
else if (displayName === "ExitPlanMode") displayName = "Planning";
else if (displayName === "AskUserQuestion") displayName = "Question";
// Codex toolset (snake_case)
else if (displayName === "update_plan") displayName = "Planning";
else if (displayName === "shell_command") displayName = "Shell";
else if (displayName === "shell") displayName = "Shell";
else if (displayName === "read_file") displayName = "Read";
else if (displayName === "list_dir") displayName = "LS";
else if (displayName === "grep_files") displayName = "Grep";
else if (displayName === "apply_patch") displayName = "Patch";
// Codex toolset (PascalCase)
else if (displayName === "UpdatePlan") displayName = "Planning";
else if (displayName === "ShellCommand") displayName = "Shell";
else if (displayName === "Shell") displayName = "Shell";
else if (displayName === "ReadFile") displayName = "Read";
else if (displayName === "ListDir") displayName = "LS";
else if (displayName === "GrepFiles") displayName = "Grep";
else if (displayName === "ApplyPatch") displayName = "Patch";
// Gemini toolset (snake_case)
else if (displayName === "run_shell_command") displayName = "Shell";
else if (displayName === "list_directory") displayName = "LS";
else if (displayName === "search_file_content") displayName = "Grep";
else if (displayName === "write_todos") displayName = "TODO";
else if (displayName === "read_many_files") displayName = "Read Multiple";
// Gemini toolset (PascalCase)
else if (displayName === "RunShellCommand") displayName = "Shell";
else if (displayName === "ListDirectory") displayName = "LS";
else if (displayName === "SearchFileContent") displayName = "Grep";
else if (displayName === "WriteTodos") displayName = "TODO";
else if (displayName === "ReadManyFiles") displayName = "Read Multiple";
// Additional tools
else if (displayName === "Replace" || displayName === "replace")
displayName = "Edit";
else if (displayName === "WriteFile" || displayName === "write_file")
displayName = "Write";
else if (displayName === "KillBash") displayName = "Kill Shell";
else if (displayName === "BashOutput") displayName = "Shell Output";
else if (displayName === "MultiEdit") displayName = "Edit";
// Apply tool name remapping
const displayName = getDisplayToolName(rawName);
// Format arguments for display using the old formatting logic
const formatted = formatArgsDisplay(argsText);
@@ -182,14 +126,11 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
typeof v === "object" && v !== null;
// Check if this is a todo_write tool with successful result
const isTodoTool =
rawName === "todo_write" ||
rawName === "TodoWrite" ||
rawName === "write_todos" ||
rawName === "WriteTodos" ||
displayName === "TODO";
if (isTodoTool && line.resultOk !== false && line.argsText) {
if (
isTodoTool(rawName, displayName) &&
line.resultOk !== false &&
line.argsText
) {
try {
const parsedArgs = JSON.parse(line.argsText);
if (parsedArgs.todos && Array.isArray(parsedArgs.todos)) {
@@ -225,12 +166,11 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
}
// Check if this is an update_plan tool with successful result
const isPlanTool =
rawName === "update_plan" ||
rawName === "UpdatePlan" ||
displayName === "Planning";
if (isPlanTool && line.resultOk !== false && line.argsText) {
if (
isPlanTool(rawName, displayName) &&
line.resultOk !== false &&
line.argsText
) {
try {
const parsedArgs = JSON.parse(line.argsText);
if (parsedArgs.plan && Array.isArray(parsedArgs.plan)) {

View File

@@ -113,6 +113,17 @@ export const colors = {
inProgress: brandColors.primaryAccent,
},
// Subagent display
subagent: {
header: brandColors.primaryAccent,
running: brandColors.statusWarning,
completed: brandColors.statusSuccess,
error: brandColors.statusError,
treeChar: brandColors.textDisabled,
stats: brandColors.textSecondary,
hint: brandColors.textDisabled,
},
// Info/modal views
info: {
border: brandColors.primaryAccent,

View File

@@ -0,0 +1,120 @@
/**
* Subagent aggregation utilities for grouping Task tool calls.
* Extracts subagent grouping logic from App.tsx commitEligibleLines.
*/
import type { StaticSubagent } from "../components/SubagentGroupStatic.js";
import type { Line } from "./accumulator.js";
import { getSubagentByToolCallId } from "./subagentState.js";
import { isTaskTool } from "./toolNameMapping.js";
/**
* A finished Task tool call info
*/
export interface TaskToolCallInfo {
lineId: string;
toolCallId: string;
}
/**
* Static item for a group of completed subagents
*/
export interface SubagentGroupItem {
kind: "subagent_group";
id: string;
agents: StaticSubagent[];
}
/**
* Checks if there are any in-progress Task tool calls in the buffer
*/
export function hasInProgressTaskToolCalls(
order: string[],
byId: Map<string, Line>,
emittedIds: Set<string>,
): boolean {
for (const id of order) {
const ln = byId.get(id);
if (!ln) continue;
if (ln.kind === "tool_call" && isTaskTool(ln.name ?? "")) {
if (emittedIds.has(id)) continue;
if (ln.phase !== "finished") {
return true;
}
}
}
return false;
}
/**
* Collects finished Task tool calls that are ready for grouping.
* Only returns results when all Task tool calls are finished.
*/
export function collectFinishedTaskToolCalls(
order: string[],
byId: Map<string, Line>,
emittedIds: Set<string>,
hasInProgress: boolean,
): TaskToolCallInfo[] {
if (hasInProgress) {
return [];
}
const finished: TaskToolCallInfo[] = [];
for (const id of order) {
if (emittedIds.has(id)) continue;
const ln = byId.get(id);
if (!ln) continue;
if (
ln.kind === "tool_call" &&
isTaskTool(ln.name ?? "") &&
ln.phase === "finished" &&
ln.toolCallId
) {
// Check if we have subagent data in the state store
const subagent = getSubagentByToolCallId(ln.toolCallId);
if (subagent) {
finished.push({
lineId: id,
toolCallId: ln.toolCallId,
});
}
}
}
return finished;
}
/**
* Creates a subagent_group static item from collected Task tool calls.
* Looks up subagent data from the state store.
*/
export function createSubagentGroupItem(
taskToolCalls: TaskToolCallInfo[],
): SubagentGroupItem {
const agents: StaticSubagent[] = [];
for (const tc of taskToolCalls) {
const subagent = getSubagentByToolCallId(tc.toolCallId);
if (subagent) {
agents.push({
id: subagent.id,
type: subagent.type,
description: subagent.description,
status: subagent.status as "completed" | "error",
toolCount: subagent.toolCalls.length,
totalTokens: subagent.totalTokens,
agentURL: subagent.agentURL,
error: subagent.error,
});
}
}
return {
kind: "subagent_group",
id: `subagent-group-${Date.now().toString(36)}`,
agents,
};
}

View File

@@ -0,0 +1,41 @@
/**
* Shared utilities for subagent display components
*
* Used by both SubagentGroupDisplay (live) and SubagentGroupStatic (frozen).
*/
/**
* Format tool count and token statistics for display
*
* @param toolCount - Number of tool calls
* @param totalTokens - Total tokens used
* @param isRunning - If true, shows "—" for tokens (since usage is only available at end)
*/
export function formatStats(
toolCount: number,
totalTokens: number,
isRunning = false,
): string {
const tokenStr = isRunning
? "—"
: totalTokens >= 1000
? `${(totalTokens / 1000).toFixed(1)}k`
: String(totalTokens);
return `${toolCount} tool use${toolCount !== 1 ? "s" : ""} · ${tokenStr} tokens`;
}
/**
* Get tree-drawing characters for hierarchical display
*
* @param isLast - Whether this is the last item in the list
* @returns Object with treeChar (branch connector) and continueChar (continuation line)
*/
export function getTreeChars(isLast: boolean): {
treeChar: string;
continueChar: string;
} {
return {
treeChar: isLast ? "└─" : "├─",
continueChar: isLast ? " " : "│ ",
};
}

View File

@@ -0,0 +1,298 @@
/**
* Subagent state management for tracking active subagents
*
* This module provides a centralized state store that bridges non-React code
* (manager.ts) with React components (SubagentGroupDisplay.tsx).
* Uses an event-emitter pattern compatible with React's useSyncExternalStore.
*/
// ============================================================================
// Types
// ============================================================================
export interface ToolCall {
id: string;
name: string;
args: string;
}
export interface SubagentState {
id: string;
type: string; // "Explore", "Plan", "code-reviewer", etc.
description: string;
status: "pending" | "running" | "completed" | "error";
agentURL: string | null;
toolCalls: ToolCall[];
totalTokens: number;
durationMs: number;
error?: string;
startTime: number;
toolCallId?: string; // Links this subagent to its parent Task tool call
}
interface SubagentStore {
agents: Map<string, SubagentState>;
expanded: boolean;
listeners: Set<() => void>;
}
// ============================================================================
// Store
// ============================================================================
const store: SubagentStore = {
agents: new Map(),
expanded: false,
listeners: new Set(),
};
// Cached snapshot for useSyncExternalStore - must return same reference if unchanged
let cachedSnapshot: { agents: SubagentState[]; expanded: boolean } = {
agents: [],
expanded: false,
};
// ============================================================================
// Internal Helpers
// ============================================================================
function updateSnapshot(): void {
cachedSnapshot = {
agents: Array.from(store.agents.values()),
expanded: store.expanded,
};
}
function notifyListeners(): void {
updateSnapshot();
for (const listener of store.listeners) {
listener();
}
}
let subagentCounter = 0;
// ============================================================================
// Public API
// ============================================================================
/**
* Generate a unique subagent ID
*/
export function generateSubagentId(): string {
return `subagent-${Date.now()}-${++subagentCounter}`;
}
/**
* Get a subagent by its parent Task tool call ID
*/
export function getSubagentByToolCallId(
toolCallId: string,
): SubagentState | undefined {
for (const agent of store.agents.values()) {
if (agent.toolCallId === toolCallId) {
return agent;
}
}
return undefined;
}
/**
* Register a new subagent when Task tool starts
*/
export function registerSubagent(
id: string,
type: string,
description: string,
toolCallId?: string,
): void {
// Capitalize type for display (explore -> Explore)
const displayType = type.charAt(0).toUpperCase() + type.slice(1);
const agent: SubagentState = {
id,
type: displayType,
description,
status: "pending",
agentURL: null,
toolCalls: [],
totalTokens: 0,
durationMs: 0,
startTime: Date.now(),
toolCallId,
};
store.agents.set(id, agent);
notifyListeners();
}
/**
* Update a subagent's state
*/
export function updateSubagent(
id: string,
updates: Partial<Omit<SubagentState, "id">>,
): void {
const agent = store.agents.get(id);
if (!agent) return;
// If setting agentURL, also mark as running
if (updates.agentURL && agent.status === "pending") {
updates.status = "running";
}
// Create a new object to ensure React.memo detects the change
const updatedAgent = { ...agent, ...updates };
store.agents.set(id, updatedAgent);
notifyListeners();
}
/**
* Add a tool call to a subagent
*/
export function addToolCall(
subagentId: string,
toolCallId: string,
toolName: string,
toolArgs: string,
): void {
const agent = store.agents.get(subagentId);
if (!agent) return;
// Don't add duplicates
if (agent.toolCalls.some((tc) => tc.id === toolCallId)) return;
// Create a new object to ensure React.memo detects the change
const updatedAgent = {
...agent,
toolCalls: [
...agent.toolCalls,
{ id: toolCallId, name: toolName, args: toolArgs },
],
};
store.agents.set(subagentId, updatedAgent);
notifyListeners();
}
/**
* Mark a subagent as completed
*/
export function completeSubagent(
id: string,
result: { success: boolean; error?: string },
): void {
const agent = store.agents.get(id);
if (!agent) return;
// Create a new object to ensure React.memo detects the change
const updatedAgent = {
...agent,
status: result.success ? "completed" : "error",
error: result.error,
durationMs: Date.now() - agent.startTime,
} as SubagentState;
store.agents.set(id, updatedAgent);
notifyListeners();
}
/**
* Toggle expanded/collapsed state
*/
export function toggleExpanded(): void {
store.expanded = !store.expanded;
notifyListeners();
}
/**
* Get current expanded state
*/
export function isExpanded(): boolean {
return store.expanded;
}
/**
* Get all active subagents (not yet cleared)
*/
export function getSubagents(): SubagentState[] {
return Array.from(store.agents.values());
}
/**
* Get subagents grouped by type
*/
export function getGroupedSubagents(): Map<string, SubagentState[]> {
const grouped = new Map<string, SubagentState[]>();
for (const agent of store.agents.values()) {
const existing = grouped.get(agent.type) || [];
existing.push(agent);
grouped.set(agent.type, existing);
}
return grouped;
}
/**
* Clear all completed subagents (call on new user message)
*/
export function clearCompletedSubagents(): void {
for (const [id, agent] of store.agents.entries()) {
if (agent.status === "completed" || agent.status === "error") {
store.agents.delete(id);
}
}
notifyListeners();
}
/**
* Clear specific subagents by their IDs (call when committing to staticItems)
*/
export function clearSubagentsByIds(ids: string[]): void {
for (const id of ids) {
store.agents.delete(id);
}
notifyListeners();
}
/**
* Clear all subagents
*/
export function clearAllSubagents(): void {
store.agents.clear();
notifyListeners();
}
/**
* Check if there are any active subagents
*/
export function hasActiveSubagents(): boolean {
for (const agent of store.agents.values()) {
if (agent.status === "pending" || agent.status === "running") {
return true;
}
}
return false;
}
// ============================================================================
// React Integration (useSyncExternalStore compatible)
// ============================================================================
/**
* Subscribe to store changes
*/
export function subscribe(listener: () => void): () => void {
store.listeners.add(listener);
return () => {
store.listeners.delete(listener);
};
}
/**
* Get a snapshot of the current state for React
* Returns cached snapshot - only updates when notifyListeners is called
*/
export function getSnapshot(): {
agents: SubagentState[];
expanded: boolean;
} {
return cachedSnapshot;
}

View File

@@ -0,0 +1,108 @@
/**
* Tool name mapping utilities for display purposes.
* Centralizes tool name remapping logic used across the UI.
*/
/**
* Maps internal tool names to user-friendly display names.
* Handles multiple tool naming conventions:
* - Anthropic toolset (snake_case and camelCase)
* - Codex toolset (snake_case and PascalCase)
* - Gemini toolset (snake_case and PascalCase)
*/
export function getDisplayToolName(rawName: string): string {
// Anthropic toolset
if (rawName === "write") return "Write";
if (rawName === "edit" || rawName === "multi_edit") return "Edit";
if (rawName === "read") return "Read";
if (rawName === "bash") return "Bash";
if (rawName === "grep") return "Grep";
if (rawName === "glob") return "Glob";
if (rawName === "ls") return "LS";
if (rawName === "todo_write" || rawName === "TodoWrite") return "TODO";
if (rawName === "EnterPlanMode" || rawName === "ExitPlanMode")
return "Planning";
if (rawName === "AskUserQuestion") return "Question";
// Codex toolset (snake_case)
if (rawName === "update_plan") return "Planning";
if (rawName === "shell_command" || rawName === "shell") return "Shell";
if (rawName === "read_file") return "Read";
if (rawName === "list_dir") return "LS";
if (rawName === "grep_files") return "Grep";
if (rawName === "apply_patch") return "Patch";
// Codex toolset (PascalCase)
if (rawName === "UpdatePlan") return "Planning";
if (rawName === "ShellCommand" || rawName === "Shell") return "Shell";
if (rawName === "ReadFile") return "Read";
if (rawName === "ListDir") return "LS";
if (rawName === "GrepFiles") return "Grep";
if (rawName === "ApplyPatch") return "Patch";
// Gemini toolset (snake_case)
if (rawName === "run_shell_command") return "Shell";
if (rawName === "list_directory") return "LS";
if (rawName === "search_file_content") return "Grep";
if (rawName === "write_todos") return "TODO";
if (rawName === "read_many_files") return "Read Multiple";
// Gemini toolset (PascalCase)
if (rawName === "RunShellCommand") return "Shell";
if (rawName === "ListDirectory") return "LS";
if (rawName === "SearchFileContent") return "Grep";
if (rawName === "WriteTodos") return "TODO";
if (rawName === "ReadManyFiles") return "Read Multiple";
// Additional tools
if (rawName === "Replace" || rawName === "replace") return "Edit";
if (rawName === "WriteFile" || rawName === "write_file") return "Write";
if (rawName === "KillBash") return "Kill Shell";
if (rawName === "BashOutput") return "Shell Output";
if (rawName === "MultiEdit") return "Edit";
// No mapping found, return as-is
return rawName;
}
/**
* Checks if a tool name represents a Task/subagent tool
*/
export function isTaskTool(name: string): boolean {
return name === "Task" || name === "task";
}
/**
* Checks if a tool name represents a TODO/planning tool
*/
export function isTodoTool(rawName: string, displayName?: string): boolean {
return (
rawName === "todo_write" ||
rawName === "TodoWrite" ||
rawName === "write_todos" ||
rawName === "WriteTodos" ||
displayName === "TODO"
);
}
/**
* Checks if a tool name represents a plan update tool
*/
export function isPlanTool(rawName: string, displayName?: string): boolean {
return (
rawName === "update_plan" ||
rawName === "UpdatePlan" ||
displayName === "Planning"
);
}
/**
* Checks if a tool requires a specialized UI dialog instead of standard approval
*/
export function isFancyUITool(name: string): boolean {
return (
name === "AskUserQuestion" ||
name === "EnterPlanMode" ||
name === "ExitPlanMode"
);
}