From 81e91823cbcfe5e6c5a4929671262508467d77c9 Mon Sep 17 00:00:00 2001 From: Devansh Jain <31609257+devanshrj@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:23:49 -0800 Subject: [PATCH] chore: Improve subagents UI (#205) --- bin/letta.js | 6 +- examples/send-image.ts | 1 - src/agent/approval-execution.ts | 282 ++++++++++-------- src/agent/subagents/manager.ts | 149 ++++------ src/cli/App.tsx | 96 ++++++- src/cli/components/BlinkDot.tsx | 20 ++ src/cli/components/CommandMessage.tsx | 14 +- src/cli/components/SubagentGroupDisplay.tsx | 222 +++++++++++++++ src/cli/components/SubagentGroupStatic.tsx | 132 +++++++++ src/cli/components/ToolCallMessage.tsx | 60 ---- src/cli/components/ToolCallMessageRich.tsx | 104 ++----- src/cli/components/colors.ts | 11 + src/cli/helpers/subagentAggregation.ts | 120 ++++++++ src/cli/helpers/subagentDisplay.ts | 41 +++ src/cli/helpers/subagentState.ts | 298 ++++++++++++++++++++ src/cli/helpers/toolNameMapping.ts | 108 +++++++ src/tools/impl/Task.ts | 42 +-- src/tools/manager.ts | 22 +- vendor/ink-text-input/build/index.js | 4 + 19 files changed, 1324 insertions(+), 408 deletions(-) create mode 100644 src/cli/components/BlinkDot.tsx create mode 100644 src/cli/components/SubagentGroupDisplay.tsx create mode 100644 src/cli/components/SubagentGroupStatic.tsx delete mode 100644 src/cli/components/ToolCallMessage.tsx create mode 100644 src/cli/helpers/subagentAggregation.ts create mode 100644 src/cli/helpers/subagentDisplay.ts create mode 100644 src/cli/helpers/subagentState.ts create mode 100644 src/cli/helpers/toolNameMapping.ts diff --git a/bin/letta.js b/bin/letta.js index a7a097e..7035bdb 100755 --- a/bin/letta.js +++ b/bin/letta.js @@ -8,9 +8,9 @@ * when users install via npm/npx. Bun can still run this file. */ -import { spawn } from "child_process"; -import path from "path"; -import { fileURLToPath } from "url"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/examples/send-image.ts b/examples/send-image.ts index 2d4a2bd..7d689c5 100644 --- a/examples/send-image.ts +++ b/examples/send-image.ts @@ -10,7 +10,6 @@ */ import { readFileSync } from "node:fs"; -import { getClient } from "../src/agent/client"; import { sendMessageStream } from "../src/agent/message"; async function main() { diff --git a/src/agent/approval-execution.ts b/src/agent/approval-execution.ts index 5eaa1a3..8b44b0f 100644 --- a/src/agent/approval-execution.ts +++ b/src/agent/approval-execution.ts @@ -21,151 +21,199 @@ export type ApprovalDecision = export type ApprovalResult = ToolReturn | ApprovalReturn; /** - * Execute a batch of approval decisions and format results for the backend. - * - * This function handles: - * - Executing approved tools (with error handling) - * - Formatting denials - * - Combining all results into a single batch - * - * Used by both interactive (App.tsx) and headless (headless.ts) modes. - * - * @param decisions - Array of approve/deny decisions for each tool - * @param onChunk - Optional callback to update UI with tool results (for interactive mode) - * @returns Array of formatted results ready to send to backend + * Execute a single approval decision and return the result. + * Extracted to allow parallel execution of Task tools. */ -export async function executeApprovalBatch( - decisions: ApprovalDecision[], +async function executeSingleDecision( + decision: ApprovalDecision, onChunk?: (chunk: ToolReturnMessage) => void, options?: { abortSignal?: AbortSignal }, -): Promise { - const results: ApprovalResult[] = []; +): Promise { + // If aborted, record an interrupted result + if (options?.abortSignal?.aborted) { + if (onChunk) { + onChunk({ + message_type: "tool_return_message", + id: "dummy", + date: new Date().toISOString(), + tool_call_id: decision.approval.toolCallId, + tool_return: "User interrupted tool execution", + status: "error", + }); + } + return { + type: "tool", + tool_call_id: decision.approval.toolCallId, + tool_return: "User interrupted tool execution", + status: "error", + }; + } - for (const decision of decisions) { - // If aborted before starting this decision, record an interrupted result - if (options?.abortSignal?.aborted) { - // Emit an interrupted chunk for visibility if callback provided + if (decision.type === "approve") { + // If fancy UI already computed the result, use it directly + if (decision.precomputedResult) { + return { + type: "tool", + tool_call_id: decision.approval.toolCallId, + tool_return: decision.precomputedResult.toolReturn, + status: decision.precomputedResult.status, + stdout: decision.precomputedResult.stdout, + stderr: decision.precomputedResult.stderr, + }; + } + + // Execute the approved tool + try { + const parsedArgs = + typeof decision.approval.toolArgs === "string" + ? JSON.parse(decision.approval.toolArgs) + : decision.approval.toolArgs || {}; + + const toolResult = await executeTool( + decision.approval.toolName, + parsedArgs, + { + signal: options?.abortSignal, + toolCallId: decision.approval.toolCallId, + }, + ); + + // Update UI if callback provided (interactive mode) if (onChunk) { onChunk({ message_type: "tool_return_message", id: "dummy", date: new Date().toISOString(), tool_call_id: decision.approval.toolCallId, - tool_return: "User interrupted tool execution", - status: "error", - }); - } - - results.push({ - type: "tool", - tool_call_id: decision.approval.toolCallId, - tool_return: "User interrupted tool execution", - status: "error", - }); - continue; - } - - if (decision.type === "approve") { - // If fancy UI already computed the result, use it directly - if (decision.precomputedResult) { - // Don't call onChunk - UI was already updated in the fancy UI handler - results.push({ - type: "tool", - tool_call_id: decision.approval.toolCallId, - tool_return: decision.precomputedResult.toolReturn, - status: decision.precomputedResult.status, - stdout: decision.precomputedResult.stdout, - stderr: decision.precomputedResult.stderr, - }); - continue; - } - - // Execute the approved tool - try { - const parsedArgs = - typeof decision.approval.toolArgs === "string" - ? JSON.parse(decision.approval.toolArgs) - : decision.approval.toolArgs || {}; - - const toolResult = await executeTool( - decision.approval.toolName, - parsedArgs, - { signal: options?.abortSignal }, - ); - - // Update UI if callback provided (interactive mode) - if (onChunk) { - onChunk({ - message_type: "tool_return_message", - id: "dummy", - date: new Date().toISOString(), - tool_call_id: decision.approval.toolCallId, - tool_return: toolResult.toolReturn, - status: toolResult.status, - stdout: toolResult.stdout, - stderr: toolResult.stderr, - }); - } - - results.push({ - type: "tool", - tool_call_id: decision.approval.toolCallId, tool_return: toolResult.toolReturn, status: toolResult.status, stdout: toolResult.stdout, stderr: toolResult.stderr, }); - } catch (e) { - const isAbortError = - e instanceof Error && - (e.name === "AbortError" || - e.message === "The operation was aborted"); - const errorMessage = isAbortError - ? "User interrupted tool execution" - : `Error executing tool: ${String(e)}`; - - // Still need to send error result to backend for this tool - // Update UI if callback provided - if (onChunk) { - onChunk({ - message_type: "tool_return_message", - id: "dummy", - date: new Date().toISOString(), - tool_call_id: decision.approval.toolCallId, - tool_return: errorMessage, - status: "error", - }); - } - - results.push({ - type: "tool", - tool_call_id: decision.approval.toolCallId, - tool_return: errorMessage, - status: "error", - }); } - } else { - // Format denial for backend - // Update UI if callback provided + + return { + type: "tool", + tool_call_id: decision.approval.toolCallId, + tool_return: toolResult.toolReturn, + status: toolResult.status, + stdout: toolResult.stdout, + stderr: toolResult.stderr, + }; + } catch (e) { + const isAbortError = + e instanceof Error && + (e.name === "AbortError" || e.message === "The operation was aborted"); + const errorMessage = isAbortError + ? "User interrupted tool execution" + : `Error executing tool: ${String(e)}`; + if (onChunk) { onChunk({ message_type: "tool_return_message", id: "dummy", date: new Date().toISOString(), tool_call_id: decision.approval.toolCallId, - tool_return: `Error: request to call tool denied. User reason: ${decision.reason}`, + tool_return: errorMessage, status: "error", }); } - results.push({ - type: "approval", + return { + type: "tool", tool_call_id: decision.approval.toolCallId, - approve: false, - reason: decision.reason, - }); + tool_return: errorMessage, + status: "error", + }; } } - return results; + // Format denial for backend + if (onChunk) { + onChunk({ + message_type: "tool_return_message", + id: "dummy", + date: new Date().toISOString(), + tool_call_id: decision.approval.toolCallId, + tool_return: `Error: request to call tool denied. User reason: ${decision.reason}`, + status: "error", + }); + } + + return { + type: "approval", + tool_call_id: decision.approval.toolCallId, + approve: false, + reason: decision.reason, + }; +} + +/** + * Execute a batch of approval decisions and format results for the backend. + * + * This function handles: + * - Executing approved tools (with error handling) + * - Formatting denials + * - Combining all results into a single batch + * - Task tools are executed in parallel for better performance + * + * Used by both interactive (App.tsx) and headless (headless.ts) modes. + * + * @param decisions - Array of approve/deny decisions for each tool + * @param onChunk - Optional callback to update UI with tool results (for interactive mode) + * @returns Array of formatted results ready to send to backend (maintains original order) + */ +export async function executeApprovalBatch( + decisions: ApprovalDecision[], + onChunk?: (chunk: ToolReturnMessage) => void, + options?: { abortSignal?: AbortSignal }, +): Promise { + // Pre-allocate results array to maintain original order + const results: (ApprovalResult | null)[] = new Array(decisions.length).fill( + null, + ); + + // Identify Task tools for parallel execution + const taskIndices: number[] = []; + for (let i = 0; i < decisions.length; i++) { + const decision = decisions[i]; + if ( + decision && + decision.type === "approve" && + decision.approval.toolName === "Task" + ) { + taskIndices.push(i); + } + } + + // Execute non-Task tools sequentially (existing behavior) + for (let i = 0; i < decisions.length; i++) { + const decision = decisions[i]; + if (!decision || taskIndices.includes(i)) continue; // Skip Task tools for now + results[i] = await executeSingleDecision(decision, onChunk, options); + } + + // Execute Task tools in parallel + if (taskIndices.length > 0) { + const taskDecisions = taskIndices + .map((i) => decisions[i]) + .filter((d): d is ApprovalDecision => d !== undefined); + const taskResults = await Promise.all( + taskDecisions.map((decision) => + executeSingleDecision(decision, onChunk, options), + ), + ); + + // Place Task results in original positions + for (let j = 0; j < taskIndices.length; j++) { + const idx = taskIndices[j]; + const result = taskResults[j]; + if (idx !== undefined && result !== undefined) { + results[idx] = result; + } + } + } + + // Filter out nulls (shouldn't happen, but TypeScript needs this) + return results.filter((r): r is ApprovalResult => r !== null); } diff --git a/src/agent/subagents/manager.ts b/src/agent/subagents/manager.ts index b16fb1c..3514d56 100644 --- a/src/agent/subagents/manager.ts +++ b/src/agent/subagents/manager.ts @@ -9,20 +9,17 @@ import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; +import { + addToolCall, + updateSubagent, +} from "../../cli/helpers/subagentState.js"; import { cliPermissions } from "../../permissions/cli"; import { permissionMode } from "../../permissions/mode"; +import { sessionPermissions } from "../../permissions/session"; import { settingsManager } from "../../settings-manager"; import { getErrorMessage } from "../../utils/error"; import { getAllSubagentConfigs, type SubagentConfig } from "."; -// ============================================================================ -// Constants -// ============================================================================ - -/** ANSI escape codes for console output */ -const ANSI_DIM = "\x1b[2m"; -const ANSI_RESET = "\x1b[0m"; - // ============================================================================ // Types // ============================================================================ @@ -54,35 +51,10 @@ interface ExecutionState { // ============================================================================ /** - * Format tool arguments for display (truncated) + * Record a tool call to the state store */ -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); // Show max 2 args - - if (entries.length === 0) return ""; - - return entries - .map(([key, value]) => { - let displayValue = String(value); - if (displayValue.length > 100) { - displayValue = `${displayValue.slice(0, 97)}...`; - } - return `${key}: "${displayValue}"`; - }) - .join(", "); - } catch { - return ""; - } -} - -/** - * Display a tool call to the console - */ -function displayToolCall( +function recordToolCall( + subagentId: string, toolCallId: string, toolName: string, toolArgs: string, @@ -90,35 +62,7 @@ function displayToolCall( ): void { if (!toolCallId || !toolName || displayedToolCalls.has(toolCallId)) return; displayedToolCalls.add(toolCallId); - - const formattedArgs = formatToolArgs(toolArgs); - if (formattedArgs) { - console.log(`${ANSI_DIM} ${toolName}(${formattedArgs})${ANSI_RESET}`); - } else { - console.log(`${ANSI_DIM} ${toolName}()${ANSI_RESET}`); - } -} - -/** - * Format completion stats for display - */ -function formatCompletionStats( - toolCount: number, - totalTokens: number, - durationMs: number, -): string { - const tokenStr = - totalTokens >= 1000 - ? `${(totalTokens / 1000).toFixed(1)}k` - : String(totalTokens); - - const durationSec = durationMs / 1000; - const durationStr = - durationSec >= 60 - ? `${Math.floor(durationSec / 60)}m ${Math.round(durationSec % 60)}s` - : `${durationSec.toFixed(1)}s`; - - return `${toolCount} tool use${toolCount !== 1 ? "s" : ""} · ${tokenStr} tokens · ${durationStr}`; + addToolCall(subagentId, toolCallId, toolName, toolArgs); } /** @@ -128,11 +72,12 @@ function handleInitEvent( event: { agent_id?: string }, state: ExecutionState, baseURL: string, + subagentId: string, ): void { if (event.agent_id) { state.agentId = event.agent_id; const agentURL = `${baseURL}/agents/${event.agent_id}`; - console.log(`${ANSI_DIM} ⎿ Subagent: ${agentURL}${ANSI_RESET}`); + updateSubagent(subagentId, { agentURL }); } } @@ -171,10 +116,12 @@ function handleApprovalRequestEvent( function handleAutoApprovalEvent( event: { tool_call_id?: string; tool_name?: string; tool_args?: string }, state: ExecutionState, + subagentId: string, ): void { const { tool_call_id, tool_name, tool_args = "{}" } = event; if (tool_call_id && tool_name) { - displayToolCall( + recordToolCall( + subagentId, tool_call_id, tool_name, tool_args, @@ -194,6 +141,7 @@ function handleResultEvent( usage?: { total_tokens?: number }; }, state: ExecutionState, + subagentId: string, ): void { state.finalResult = event.result || ""; state.resultStats = { @@ -204,21 +152,25 @@ function handleResultEvent( if (event.is_error) { state.finalError = event.result || "Unknown error"; } else { - // Display any pending tool calls that weren't auto-approved + // Record any pending tool calls that weren't auto-approved for (const [id, { name, args }] of state.pendingToolCalls.entries()) { if (name && !state.displayedToolCalls.has(id)) { - displayToolCall(id, name, args || "{}", state.displayedToolCalls); + recordToolCall( + subagentId, + id, + name, + args || "{}", + state.displayedToolCalls, + ); } } - - // Display completion stats - const statsStr = formatCompletionStats( - state.displayedToolCalls.size, - state.resultStats.totalTokens, - state.resultStats.durationMs, - ); - console.log(`${ANSI_DIM} ⎿ Done (${statsStr})${ANSI_RESET}`); } + + // Update state store with final stats + updateSubagent(subagentId, { + totalTokens: state.resultStats.totalTokens, + durationMs: state.resultStats.durationMs, + }); } /** @@ -228,13 +180,14 @@ function processStreamEvent( line: string, state: ExecutionState, baseURL: string, + subagentId: string, ): void { try { const event = JSON.parse(line); switch (event.type) { case "init": - handleInitEvent(event, state, baseURL); + handleInitEvent(event, state, baseURL, subagentId); break; case "message": @@ -244,11 +197,11 @@ function processStreamEvent( break; case "auto_approval": - handleAutoApprovalEvent(event, state); + handleAutoApprovalEvent(event, state, subagentId); break; case "result": - handleResultEvent(event, state); + handleResultEvent(event, state, subagentId); break; case "error": @@ -329,10 +282,14 @@ function buildSubagentArgs( args.push("--permission-mode", currentMode); } - // Inherit permission rules from parent (--allowedTools/--disallowedTools) + // Inherit permission rules from parent (CLI + session rules) const parentAllowedTools = cliPermissions.getAllowedTools(); - if (parentAllowedTools.length > 0) { - args.push("--allowedTools", parentAllowedTools.join(",")); + const sessionAllowRules = sessionPermissions.getRules().allow || []; + const combinedAllowedTools = [ + ...new Set([...parentAllowedTools, ...sessionAllowRules]), + ]; + if (combinedAllowedTools.length > 0) { + args.push("--allowedTools", combinedAllowedTools.join(",")); } const parentDisallowedTools = cliPermissions.getDisallowedTools(); if (parentDisallowedTools.length > 0) { @@ -370,6 +327,7 @@ async function executeSubagent( model: string, userPrompt: string, baseURL: string, + subagentId: string, ): Promise { try { const cliArgs = buildSubagentArgs(type, config, model, userPrompt); @@ -401,7 +359,7 @@ async function executeSubagent( rl.on("line", (line: string) => { stdoutChunks.push(Buffer.from(`${line}\n`)); - processStreamEvent(line, state, baseURL); + processStreamEvent(line, state, baseURL, subagentId); }); proc.stderr.on("data", (data: Buffer) => { @@ -483,14 +441,14 @@ function getBaseURL(): string { * * @param type - Subagent type (e.g., "code-reviewer", "explore") * @param prompt - The task prompt for the subagent - * @param description - Short description for display * @param userModel - Optional model override from the parent agent + * @param subagentId - ID for tracking in the state store (registered by Task tool) */ export async function spawnSubagent( type: string, prompt: string, - description: string, - userModel?: string, + userModel: string | undefined, + subagentId: string, ): Promise { const allConfigs = await getAllSubagentConfigs(); const config = allConfigs[type]; @@ -507,14 +465,15 @@ export async function spawnSubagent( const model = userModel || config.recommendedModel; const baseURL = getBaseURL(); - // Print subagent header before execution starts - console.log(`${ANSI_DIM}✻ ${type}(${description})${ANSI_RESET}`); - - const result = await executeSubagent(type, config, model, prompt, baseURL); - - if (!result.success && result.error) { - console.log(`${ANSI_DIM} ⎿ Error: ${result.error}${ANSI_RESET}`); - } + // Execute subagent - state updates are handled via the state store + const result = await executeSubagent( + type, + config, + model, + prompt, + baseURL, + subagentId, + ); return result; } diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 64ea80a..1eccc64 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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>( @@ -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}`; ) : item.kind === "tool_call" ? ( + ) : item.kind === "subagent_group" ? ( + ) : item.kind === "error" ? ( ) : item.kind === "status" ? ( @@ -3667,6 +3740,9 @@ Plan file path: ${planFilePath}`; )} + {/* Subagent group display - shows running/completed subagents */} + + {/* Ensure 1 blank line above input when there are no live items */} {liveItems.length === 0 && } diff --git a/src/cli/components/BlinkDot.tsx b/src/cli/components/BlinkDot.tsx new file mode 100644 index 0000000..db76d62 --- /dev/null +++ b/src/cli/components/BlinkDot.tsx @@ -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 {on ? "●" : " "}; + }, +); + +BlinkDot.displayName = "BlinkDot"; diff --git a/src/cli/components/CommandMessage.tsx b/src/cli/components/CommandMessage.tsx index cd83a03..ab42e45 100644 --- a/src/cli/components/CommandMessage.tsx +++ b/src/cli/components/CommandMessage.tsx @@ -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 {on ? "●" : " "}; -}; - /** * CommandMessage - Rich formatting version with two-column layout * Matches the formatting pattern used by other message types diff --git a/src/cli/components/SubagentGroupDisplay.tsx b/src/cli/components/SubagentGroupDisplay.tsx new file mode 100644 index 0000000..be5f3a4 --- /dev/null +++ b/src/cli/components/SubagentGroupDisplay.tsx @@ -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 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 ; + case "running": + return ; + case "completed": + return ; + case "error": + return ; + default: + return ; + } + }; + + 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 + stats */} + + {treeChar} + {getDotElement()} + {agent.description} + · {agent.type.toLowerCase()} + · {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 */} + + {continueChar} + {agent.status === "completed" ? ( + {" ⎿ Done"} + ) : agent.status === "error" ? ( + + {" ⎿ Error: "} + {agent.error} + + ) : lastTool ? ( + + {" ⎿ "} + {lastTool.name} + + ) : ( + {" ⎿ Starting..."} + )} + + + ); +}); +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 ( + + {allCompleted ? ( + + ) : ( + + )} + {statusText} + {hint} + + ); + }, +); + +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 ( + + + {agents.map((agent, index) => ( + + ))} + + ); +}); + +SubagentGroupDisplay.displayName = "SubagentGroupDisplay"; diff --git a/src/cli/components/SubagentGroupStatic.tsx b/src/cli/components/SubagentGroupStatic.tsx new file mode 100644 index 0000000..64abf3b --- /dev/null +++ b/src/cli/components/SubagentGroupStatic.tsx @@ -0,0 +1,132 @@ +/** + * SubagentGroupStatic - Frozen snapshot of completed subagents + * + * Used in Ink's 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 ( + + {/* Main row: tree char + description + type + stats */} + + {treeChar} + + {agent.description} + · {agent.type.toLowerCase()} + · {stats} + + + {/* Subagent URL */} + {agent.agentURL && ( + + {continueChar} + + {" ⎿ Subagent: "} + {agent.agentURL} + + + )} + + {/* Status line */} + + {continueChar} + {agent.status === "completed" ? ( + {" ⎿ Done"} + ) : ( + + {" ⎿ Error: "} + {agent.error} + + )} + + + ); +}); + +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 ( + + {/* Header */} + + + {statusText} + + + {/* Agent rows */} + {agents.map((agent, index) => ( + + ))} + + ); + }, +); + +SubagentGroupStatic.displayName = "SubagentGroupStatic"; diff --git a/src/cli/components/ToolCallMessage.tsx b/src/cli/components/ToolCallMessage.tsx deleted file mode 100644 index d59c40d..0000000 --- a/src/cli/components/ToolCallMessage.tsx +++ /dev/null @@ -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 ( - - - {name}({args}) - - {displayText && ( - - └ {line.resultOk === false ? "Error" : "Success"}: {displayText} - - )} - - ); -}); diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx index 6977c4b..0a8fc86 100644 --- a/src/cli/components/ToolCallMessageRich.tsx +++ b/src/cli/components/ToolCallMessageRich.tsx @@ -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 {on ? "●" : " "}; -}; - /** * 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)) { diff --git a/src/cli/components/colors.ts b/src/cli/components/colors.ts index 9ff1138..1dddc63 100644 --- a/src/cli/components/colors.ts +++ b/src/cli/components/colors.ts @@ -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, diff --git a/src/cli/helpers/subagentAggregation.ts b/src/cli/helpers/subagentAggregation.ts new file mode 100644 index 0000000..0210252 --- /dev/null +++ b/src/cli/helpers/subagentAggregation.ts @@ -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, + emittedIds: Set, +): 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, + emittedIds: Set, + 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, + }; +} diff --git a/src/cli/helpers/subagentDisplay.ts b/src/cli/helpers/subagentDisplay.ts new file mode 100644 index 0000000..ef16afd --- /dev/null +++ b/src/cli/helpers/subagentDisplay.ts @@ -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 ? " " : "│ ", + }; +} diff --git a/src/cli/helpers/subagentState.ts b/src/cli/helpers/subagentState.ts new file mode 100644 index 0000000..3ac0cbc --- /dev/null +++ b/src/cli/helpers/subagentState.ts @@ -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; + 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>, +): 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 { + const grouped = new Map(); + 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; +} diff --git a/src/cli/helpers/toolNameMapping.ts b/src/cli/helpers/toolNameMapping.ts new file mode 100644 index 0000000..abf2599 --- /dev/null +++ b/src/cli/helpers/toolNameMapping.ts @@ -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" + ); +} diff --git a/src/tools/impl/Task.ts b/src/tools/impl/Task.ts index 355fbad..7fc42f6 100644 --- a/src/tools/impl/Task.ts +++ b/src/tools/impl/Task.ts @@ -7,6 +7,11 @@ import { getAllSubagentConfigs } from "../../agent/subagents"; import { spawnSubagent } from "../../agent/subagents/manager"; +import { + completeSubagent, + generateSubagentId, + registerSubagent, +} from "../../cli/helpers/subagentState.js"; import { validateRequiredParams } from "./validation"; interface TaskArgs { @@ -14,21 +19,7 @@ interface TaskArgs { prompt: string; description: string; model?: string; -} - -/** - * Format args for display (truncate prompt) - */ -function formatTaskArgs(args: TaskArgs): string { - const parts: string[] = []; - parts.push(`subagent_type="${args.subagent_type}"`); - parts.push(`description="${args.description}"`); - // Truncate prompt for display - const promptPreview = - args.prompt.length > 20 ? `${args.prompt.slice(0, 17)}...` : args.prompt; - parts.push(`prompt="${promptPreview}"`); - if (args.model) parts.push(`model="${args.model}"`); - return parts.join(", "); + toolCallId?: string; // Injected by executeTool for linking subagent to parent tool call } /** @@ -42,10 +33,7 @@ export async function task(args: TaskArgs): Promise { "Task", ); - const { subagent_type, prompt, description, model } = args; - - // Print Task header FIRST so subagent output appears below it - console.log(`\n● Task(${formatTaskArgs(args)})\n`); + const { subagent_type, prompt, description, model, toolCallId } = args; // Get all available subagent configs (built-in + custom) const allConfigs = await getAllSubagentConfigs(); @@ -56,20 +44,32 @@ export async function task(args: TaskArgs): Promise { return `Error: Invalid subagent type "${subagent_type}". Available types: ${available}`; } + // Register subagent with state store for UI display + const subagentId = generateSubagentId(); + registerSubagent(subagentId, subagent_type, description, toolCallId); + try { const result = await spawnSubagent( subagent_type, prompt, - description, model, + subagentId, ); + // Mark subagent as completed in state store + completeSubagent(subagentId, { + success: result.success, + error: result.error, + }); + if (!result.success) { return `Error: ${result.error || "Subagent execution failed"}`; } return result.report; } catch (error) { - return `Error: ${error instanceof Error ? error.message : String(error)}`; + const errorMessage = error instanceof Error ? error.message : String(error); + completeSubagent(subagentId, { success: false, error: errorMessage }); + return `Error: ${errorMessage}`; } } diff --git a/src/tools/manager.ts b/src/tools/manager.ts index 0b7052f..1ea14a0 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -829,12 +829,13 @@ function flattenToolResponse(result: unknown): string { * * @param name - The name of the tool to execute * @param args - Arguments object to pass to the tool + * @param options - Optional execution options (abort signal, tool call ID) * @returns Promise with the tool's execution result including status and optional stdout/stderr */ export async function executeTool( name: string, args: ToolArgs, - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; toolCallId?: string }, ): Promise { const internalName = resolveInternalToolName(name); if (!internalName) { @@ -853,13 +854,20 @@ export async function executeTool( } try { - // Inject abort signal for tools that support it (currently Bash) without altering schemas - const argsWithSignal = - internalName === "Bash" && options?.signal - ? { ...args, signal: options.signal } - : args; + // Inject options for tools that support them without altering schemas + let enhancedArgs = args; - const result = await tool.fn(argsWithSignal); + // Inject abort signal for Bash tool + if (internalName === "Bash" && options?.signal) { + enhancedArgs = { ...enhancedArgs, signal: options.signal }; + } + + // Inject toolCallId for Task tool (for linking subagents to their parent tool call) + if (internalName === "Task" && options?.toolCallId) { + enhancedArgs = { ...enhancedArgs, toolCallId: options.toolCallId }; + } + + const result = await tool.fn(enhancedArgs); // Extract stdout/stderr if present (for bash tools) const recordResult = isRecord(result) ? result : undefined; diff --git a/vendor/ink-text-input/build/index.js b/vendor/ink-text-input/build/index.js index b9c7c01..a069969 100644 --- a/vendor/ink-text-input/build/index.js +++ b/vendor/ink-text-input/build/index.js @@ -17,6 +17,10 @@ function isControlSequence(input, key) { // Ctrl+W (delete word) - handled by parent component if (key.ctrl && (input === 'w' || input === 'W')) return true; + // Filter out other ctrl+letter combinations that aren't handled below (e.g., ctrl+o for subagent expand) + // The handled ones are: ctrl+a, ctrl+e, ctrl+k, ctrl+u, ctrl+y (see useInput below) + if (key.ctrl && input && /^[a-z]$/i.test(input) && !['a', 'e', 'k', 'u', 'y'].includes(input.toLowerCase())) return true; + // Option+Arrow escape sequences: Ink parses \x1bb as meta=true, input='b' if (key.meta && (input === 'b' || input === 'B' || input === 'f' || input === 'F')) return true;