chore: Improve subagents UI (#205)
This commit is contained in:
@@ -8,9 +8,9 @@
|
|||||||
* when users install via npm/npx. Bun can still run this file.
|
* when users install via npm/npx. Bun can still run this file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "node:child_process";
|
||||||
import path from "path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { getClient } from "../src/agent/client";
|
|
||||||
import { sendMessageStream } from "../src/agent/message";
|
import { sendMessageStream } from "../src/agent/message";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
@@ -21,151 +21,199 @@ export type ApprovalDecision =
|
|||||||
export type ApprovalResult = ToolReturn | ApprovalReturn;
|
export type ApprovalResult = ToolReturn | ApprovalReturn;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a batch of approval decisions and format results for the backend.
|
* Execute a single approval decision and return the result.
|
||||||
*
|
* Extracted to allow parallel execution of Task tools.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
export async function executeApprovalBatch(
|
async function executeSingleDecision(
|
||||||
decisions: ApprovalDecision[],
|
decision: ApprovalDecision,
|
||||||
onChunk?: (chunk: ToolReturnMessage) => void,
|
onChunk?: (chunk: ToolReturnMessage) => void,
|
||||||
options?: { abortSignal?: AbortSignal },
|
options?: { abortSignal?: AbortSignal },
|
||||||
): Promise<ApprovalResult[]> {
|
): Promise<ApprovalResult> {
|
||||||
const results: ApprovalResult[] = [];
|
// 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 (decision.type === "approve") {
|
||||||
// If aborted before starting this decision, record an interrupted result
|
// If fancy UI already computed the result, use it directly
|
||||||
if (options?.abortSignal?.aborted) {
|
if (decision.precomputedResult) {
|
||||||
// Emit an interrupted chunk for visibility if callback provided
|
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) {
|
if (onChunk) {
|
||||||
onChunk({
|
onChunk({
|
||||||
message_type: "tool_return_message",
|
message_type: "tool_return_message",
|
||||||
id: "dummy",
|
id: "dummy",
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
tool_call_id: decision.approval.toolCallId,
|
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,
|
tool_return: toolResult.toolReturn,
|
||||||
status: toolResult.status,
|
status: toolResult.status,
|
||||||
stdout: toolResult.stdout,
|
stdout: toolResult.stdout,
|
||||||
stderr: toolResult.stderr,
|
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
|
return {
|
||||||
// Update UI if callback provided
|
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) {
|
if (onChunk) {
|
||||||
onChunk({
|
onChunk({
|
||||||
message_type: "tool_return_message",
|
message_type: "tool_return_message",
|
||||||
id: "dummy",
|
id: "dummy",
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
tool_call_id: decision.approval.toolCallId,
|
tool_call_id: decision.approval.toolCallId,
|
||||||
tool_return: `Error: request to call tool denied. User reason: ${decision.reason}`,
|
tool_return: errorMessage,
|
||||||
status: "error",
|
status: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push({
|
return {
|
||||||
type: "approval",
|
type: "tool",
|
||||||
tool_call_id: decision.approval.toolCallId,
|
tool_call_id: decision.approval.toolCallId,
|
||||||
approve: false,
|
tool_return: errorMessage,
|
||||||
reason: decision.reason,
|
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<ApprovalResult[]> {
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,17 @@
|
|||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
|
import {
|
||||||
|
addToolCall,
|
||||||
|
updateSubagent,
|
||||||
|
} from "../../cli/helpers/subagentState.js";
|
||||||
import { cliPermissions } from "../../permissions/cli";
|
import { cliPermissions } from "../../permissions/cli";
|
||||||
import { permissionMode } from "../../permissions/mode";
|
import { permissionMode } from "../../permissions/mode";
|
||||||
|
import { sessionPermissions } from "../../permissions/session";
|
||||||
import { settingsManager } from "../../settings-manager";
|
import { settingsManager } from "../../settings-manager";
|
||||||
import { getErrorMessage } from "../../utils/error";
|
import { getErrorMessage } from "../../utils/error";
|
||||||
import { getAllSubagentConfigs, type SubagentConfig } from ".";
|
import { getAllSubagentConfigs, type SubagentConfig } from ".";
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Constants
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/** ANSI escape codes for console output */
|
|
||||||
const ANSI_DIM = "\x1b[2m";
|
|
||||||
const ANSI_RESET = "\x1b[0m";
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// 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 {
|
function recordToolCall(
|
||||||
try {
|
subagentId: string,
|
||||||
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(
|
|
||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
toolName: string,
|
toolName: string,
|
||||||
toolArgs: string,
|
toolArgs: string,
|
||||||
@@ -90,35 +62,7 @@ function displayToolCall(
|
|||||||
): void {
|
): void {
|
||||||
if (!toolCallId || !toolName || displayedToolCalls.has(toolCallId)) return;
|
if (!toolCallId || !toolName || displayedToolCalls.has(toolCallId)) return;
|
||||||
displayedToolCalls.add(toolCallId);
|
displayedToolCalls.add(toolCallId);
|
||||||
|
addToolCall(subagentId, toolCallId, toolName, toolArgs);
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,11 +72,12 @@ function handleInitEvent(
|
|||||||
event: { agent_id?: string },
|
event: { agent_id?: string },
|
||||||
state: ExecutionState,
|
state: ExecutionState,
|
||||||
baseURL: string,
|
baseURL: string,
|
||||||
|
subagentId: string,
|
||||||
): void {
|
): void {
|
||||||
if (event.agent_id) {
|
if (event.agent_id) {
|
||||||
state.agentId = event.agent_id;
|
state.agentId = event.agent_id;
|
||||||
const agentURL = `${baseURL}/agents/${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(
|
function handleAutoApprovalEvent(
|
||||||
event: { tool_call_id?: string; tool_name?: string; tool_args?: string },
|
event: { tool_call_id?: string; tool_name?: string; tool_args?: string },
|
||||||
state: ExecutionState,
|
state: ExecutionState,
|
||||||
|
subagentId: string,
|
||||||
): void {
|
): void {
|
||||||
const { tool_call_id, tool_name, tool_args = "{}" } = event;
|
const { tool_call_id, tool_name, tool_args = "{}" } = event;
|
||||||
if (tool_call_id && tool_name) {
|
if (tool_call_id && tool_name) {
|
||||||
displayToolCall(
|
recordToolCall(
|
||||||
|
subagentId,
|
||||||
tool_call_id,
|
tool_call_id,
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_args,
|
tool_args,
|
||||||
@@ -194,6 +141,7 @@ function handleResultEvent(
|
|||||||
usage?: { total_tokens?: number };
|
usage?: { total_tokens?: number };
|
||||||
},
|
},
|
||||||
state: ExecutionState,
|
state: ExecutionState,
|
||||||
|
subagentId: string,
|
||||||
): void {
|
): void {
|
||||||
state.finalResult = event.result || "";
|
state.finalResult = event.result || "";
|
||||||
state.resultStats = {
|
state.resultStats = {
|
||||||
@@ -204,21 +152,25 @@ function handleResultEvent(
|
|||||||
if (event.is_error) {
|
if (event.is_error) {
|
||||||
state.finalError = event.result || "Unknown error";
|
state.finalError = event.result || "Unknown error";
|
||||||
} else {
|
} 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()) {
|
for (const [id, { name, args }] of state.pendingToolCalls.entries()) {
|
||||||
if (name && !state.displayedToolCalls.has(id)) {
|
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,
|
line: string,
|
||||||
state: ExecutionState,
|
state: ExecutionState,
|
||||||
baseURL: string,
|
baseURL: string,
|
||||||
|
subagentId: string,
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(line);
|
const event = JSON.parse(line);
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "init":
|
case "init":
|
||||||
handleInitEvent(event, state, baseURL);
|
handleInitEvent(event, state, baseURL, subagentId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "message":
|
case "message":
|
||||||
@@ -244,11 +197,11 @@ function processStreamEvent(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "auto_approval":
|
case "auto_approval":
|
||||||
handleAutoApprovalEvent(event, state);
|
handleAutoApprovalEvent(event, state, subagentId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "result":
|
case "result":
|
||||||
handleResultEvent(event, state);
|
handleResultEvent(event, state, subagentId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
@@ -329,10 +282,14 @@ function buildSubagentArgs(
|
|||||||
args.push("--permission-mode", currentMode);
|
args.push("--permission-mode", currentMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inherit permission rules from parent (--allowedTools/--disallowedTools)
|
// Inherit permission rules from parent (CLI + session rules)
|
||||||
const parentAllowedTools = cliPermissions.getAllowedTools();
|
const parentAllowedTools = cliPermissions.getAllowedTools();
|
||||||
if (parentAllowedTools.length > 0) {
|
const sessionAllowRules = sessionPermissions.getRules().allow || [];
|
||||||
args.push("--allowedTools", parentAllowedTools.join(","));
|
const combinedAllowedTools = [
|
||||||
|
...new Set([...parentAllowedTools, ...sessionAllowRules]),
|
||||||
|
];
|
||||||
|
if (combinedAllowedTools.length > 0) {
|
||||||
|
args.push("--allowedTools", combinedAllowedTools.join(","));
|
||||||
}
|
}
|
||||||
const parentDisallowedTools = cliPermissions.getDisallowedTools();
|
const parentDisallowedTools = cliPermissions.getDisallowedTools();
|
||||||
if (parentDisallowedTools.length > 0) {
|
if (parentDisallowedTools.length > 0) {
|
||||||
@@ -370,6 +327,7 @@ async function executeSubagent(
|
|||||||
model: string,
|
model: string,
|
||||||
userPrompt: string,
|
userPrompt: string,
|
||||||
baseURL: string,
|
baseURL: string,
|
||||||
|
subagentId: string,
|
||||||
): Promise<SubagentResult> {
|
): Promise<SubagentResult> {
|
||||||
try {
|
try {
|
||||||
const cliArgs = buildSubagentArgs(type, config, model, userPrompt);
|
const cliArgs = buildSubagentArgs(type, config, model, userPrompt);
|
||||||
@@ -401,7 +359,7 @@ async function executeSubagent(
|
|||||||
|
|
||||||
rl.on("line", (line: string) => {
|
rl.on("line", (line: string) => {
|
||||||
stdoutChunks.push(Buffer.from(`${line}\n`));
|
stdoutChunks.push(Buffer.from(`${line}\n`));
|
||||||
processStreamEvent(line, state, baseURL);
|
processStreamEvent(line, state, baseURL, subagentId);
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.stderr.on("data", (data: Buffer) => {
|
proc.stderr.on("data", (data: Buffer) => {
|
||||||
@@ -483,14 +441,14 @@ function getBaseURL(): string {
|
|||||||
*
|
*
|
||||||
* @param type - Subagent type (e.g., "code-reviewer", "explore")
|
* @param type - Subagent type (e.g., "code-reviewer", "explore")
|
||||||
* @param prompt - The task prompt for the subagent
|
* @param prompt - The task prompt for the subagent
|
||||||
* @param description - Short description for display
|
|
||||||
* @param userModel - Optional model override from the parent agent
|
* @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(
|
export async function spawnSubagent(
|
||||||
type: string,
|
type: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
description: string,
|
userModel: string | undefined,
|
||||||
userModel?: string,
|
subagentId: string,
|
||||||
): Promise<SubagentResult> {
|
): Promise<SubagentResult> {
|
||||||
const allConfigs = await getAllSubagentConfigs();
|
const allConfigs = await getAllSubagentConfigs();
|
||||||
const config = allConfigs[type];
|
const config = allConfigs[type];
|
||||||
@@ -507,14 +465,15 @@ export async function spawnSubagent(
|
|||||||
const model = userModel || config.recommendedModel;
|
const model = userModel || config.recommendedModel;
|
||||||
const baseURL = getBaseURL();
|
const baseURL = getBaseURL();
|
||||||
|
|
||||||
// Print subagent header before execution starts
|
// Execute subagent - state updates are handled via the state store
|
||||||
console.log(`${ANSI_DIM}✻ ${type}(${description})${ANSI_RESET}`);
|
const result = await executeSubagent(
|
||||||
|
type,
|
||||||
const result = await executeSubagent(type, config, model, prompt, baseURL);
|
config,
|
||||||
|
model,
|
||||||
if (!result.success && result.error) {
|
prompt,
|
||||||
console.log(`${ANSI_DIM} ⎿ Error: ${result.error}${ANSI_RESET}`);
|
baseURL,
|
||||||
}
|
subagentId,
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import { ReasoningMessage } from "./components/ReasoningMessageRich";
|
|||||||
import { ResumeSelector } from "./components/ResumeSelector";
|
import { ResumeSelector } from "./components/ResumeSelector";
|
||||||
import { SessionStats as SessionStatsComponent } from "./components/SessionStats";
|
import { SessionStats as SessionStatsComponent } from "./components/SessionStats";
|
||||||
import { StatusMessage } from "./components/StatusMessage";
|
import { StatusMessage } from "./components/StatusMessage";
|
||||||
|
import { SubagentGroupDisplay } from "./components/SubagentGroupDisplay";
|
||||||
|
import { SubagentGroupStatic } from "./components/SubagentGroupStatic";
|
||||||
import { SubagentManager } from "./components/SubagentManager";
|
import { SubagentManager } from "./components/SubagentManager";
|
||||||
import { SystemPromptSelector } from "./components/SystemPromptSelector";
|
import { SystemPromptSelector } from "./components/SystemPromptSelector";
|
||||||
import { ToolCallMessage } from "./components/ToolCallMessageRich";
|
import { ToolCallMessage } from "./components/ToolCallMessageRich";
|
||||||
@@ -81,7 +83,17 @@ import {
|
|||||||
import { generatePlanFilePath } from "./helpers/planName";
|
import { generatePlanFilePath } from "./helpers/planName";
|
||||||
import { safeJsonParseOr } from "./helpers/safeJsonParse";
|
import { safeJsonParseOr } from "./helpers/safeJsonParse";
|
||||||
import { type ApprovalRequest, drainStreamWithResume } from "./helpers/stream";
|
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 { getRandomThinkingMessage } from "./helpers/thinkingMessages";
|
||||||
|
import { isFancyUITool, isTaskTool } from "./helpers/toolNameMapping.js";
|
||||||
import { useSuspend } from "./hooks/useSuspend/useSuspend.ts";
|
import { useSuspend } from "./hooks/useSuspend/useSuspend.ts";
|
||||||
import { useTerminalWidth } from "./hooks/useTerminalWidth";
|
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
|
// Extract questions from AskUserQuestion tool args
|
||||||
function getQuestionsFromApproval(approval: ApprovalRequest) {
|
function getQuestionsFromApproval(approval: ApprovalRequest) {
|
||||||
const parsed = safeJsonParseOr<Record<string, unknown>>(
|
const parsed = safeJsonParseOr<Record<string, unknown>>(
|
||||||
@@ -230,6 +233,20 @@ type StaticItem =
|
|||||||
terminalWidth: number;
|
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;
|
| Line;
|
||||||
|
|
||||||
export default function App({
|
export default function App({
|
||||||
@@ -463,6 +480,24 @@ export default function App({
|
|||||||
// Commit immutable/finished lines into the historical log
|
// Commit immutable/finished lines into the historical log
|
||||||
const commitEligibleLines = useCallback((b: Buffers) => {
|
const commitEligibleLines = useCallback((b: Buffers) => {
|
||||||
const newlyCommitted: StaticItem[] = [];
|
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) {
|
for (const id of b.order) {
|
||||||
if (emittedIdsRef.current.has(id)) continue;
|
if (emittedIdsRef.current.has(id)) continue;
|
||||||
const ln = b.byId.get(id);
|
const ln = b.byId.get(id);
|
||||||
@@ -480,11 +515,39 @@ export default function App({
|
|||||||
}
|
}
|
||||||
continue;
|
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") {
|
if ("phase" in ln && ln.phase === "finished") {
|
||||||
emittedIdsRef.current.add(id);
|
emittedIdsRef.current.add(id);
|
||||||
newlyCommitted.push({ ...ln });
|
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) {
|
if (newlyCommitted.length > 0) {
|
||||||
setStaticItems((prev) => [...prev, ...newlyCommitted]);
|
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
|
// If we're sending a new message, old pending state is no longer relevant
|
||||||
markIncompleteToolsAsCancelled(buffersRef.current);
|
markIncompleteToolsAsCancelled(buffersRef.current);
|
||||||
|
|
||||||
|
// Clear completed subagents from the UI when starting a new turn
|
||||||
|
clearCompletedSubagents();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Check if cancelled before starting new stream
|
// Check if cancelled before starting new stream
|
||||||
if (abortControllerRef.current?.signal.aborted) {
|
if (abortControllerRef.current?.signal.aborted) {
|
||||||
@@ -926,6 +992,7 @@ export default function App({
|
|||||||
const result = await executeTool(
|
const result = await executeTool(
|
||||||
ac.approval.toolName,
|
ac.approval.toolName,
|
||||||
parsedArgs,
|
parsedArgs,
|
||||||
|
{ toolCallId: ac.approval.toolCallId },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update buffers with tool return for UI
|
// Update buffers with tool return for UI
|
||||||
@@ -3515,7 +3582,11 @@ Plan file path: ${planFilePath}`;
|
|||||||
return ln.phase === "running";
|
return ln.phase === "running";
|
||||||
}
|
}
|
||||||
if (ln.kind === "tool_call") {
|
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";
|
return ln.phase !== "finished";
|
||||||
}
|
}
|
||||||
if (!tokenStreamingEnabled && ln.phase === "streaming") return false;
|
if (!tokenStreamingEnabled && ln.phase === "streaming") return false;
|
||||||
@@ -3617,6 +3688,8 @@ Plan file path: ${planFilePath}`;
|
|||||||
<AssistantMessage line={item} />
|
<AssistantMessage line={item} />
|
||||||
) : item.kind === "tool_call" ? (
|
) : item.kind === "tool_call" ? (
|
||||||
<ToolCallMessage line={item} />
|
<ToolCallMessage line={item} />
|
||||||
|
) : item.kind === "subagent_group" ? (
|
||||||
|
<SubagentGroupStatic agents={item.agents} />
|
||||||
) : item.kind === "error" ? (
|
) : item.kind === "error" ? (
|
||||||
<ErrorMessage line={item} />
|
<ErrorMessage line={item} />
|
||||||
) : item.kind === "status" ? (
|
) : item.kind === "status" ? (
|
||||||
@@ -3667,6 +3740,9 @@ Plan file path: ${planFilePath}`;
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Subagent group display - shows running/completed subagents */}
|
||||||
|
<SubagentGroupDisplay />
|
||||||
|
|
||||||
{/* Ensure 1 blank line above input when there are no live items */}
|
{/* Ensure 1 blank line above input when there are no live items */}
|
||||||
{liveItems.length === 0 && <Box height={1} />}
|
{liveItems.length === 0 && <Box height={1} />}
|
||||||
|
|
||||||
|
|||||||
20
src/cli/components/BlinkDot.tsx
Normal file
20
src/cli/components/BlinkDot.tsx
Normal 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";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Text } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { memo, useEffect, useState } from "react";
|
import { memo } from "react";
|
||||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
|
import { BlinkDot } from "./BlinkDot.js";
|
||||||
import { colors } from "./colors.js";
|
import { colors } from "./colors.js";
|
||||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||||
|
|
||||||
@@ -13,17 +14,6 @@ type CommandLine = {
|
|||||||
success?: boolean;
|
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
|
* CommandMessage - Rich formatting version with two-column layout
|
||||||
* Matches the formatting pattern used by other message types
|
* Matches the formatting pattern used by other message types
|
||||||
|
|||||||
222
src/cli/components/SubagentGroupDisplay.tsx
Normal file
222
src/cli/components/SubagentGroupDisplay.tsx
Normal 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";
|
||||||
132
src/cli/components/SubagentGroupStatic.tsx
Normal file
132
src/cli/components/SubagentGroupStatic.tsx
Normal 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";
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import { Box, Text } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { memo, useEffect, useState } from "react";
|
import { memo } from "react";
|
||||||
import { clipToolReturn } from "../../tools/manager.js";
|
import { clipToolReturn } from "../../tools/manager.js";
|
||||||
import { formatArgsDisplay } from "../helpers/formatArgsDisplay.js";
|
import { formatArgsDisplay } from "../helpers/formatArgsDisplay.js";
|
||||||
|
import {
|
||||||
|
getDisplayToolName,
|
||||||
|
isPlanTool,
|
||||||
|
isTaskTool,
|
||||||
|
isTodoTool,
|
||||||
|
} from "../helpers/toolNameMapping.js";
|
||||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
|
import { BlinkDot } from "./BlinkDot.js";
|
||||||
import { colors } from "./colors.js";
|
import { colors } from "./colors.js";
|
||||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||||
import { PlanRenderer } from "./PlanRenderer.js";
|
import { PlanRenderer } from "./PlanRenderer.js";
|
||||||
@@ -19,19 +26,6 @@ type ToolCallLine = {
|
|||||||
phase: "streaming" | "ready" | "running" | "finished";
|
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
|
* ToolCallMessageRich - Rich formatting version with old layout logic
|
||||||
* This preserves the exact wrapping and spacing logic from the old codebase
|
* 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 rawName = line.name ?? "?";
|
||||||
const argsText = line.argsText ?? "...";
|
const argsText = line.argsText ?? "...";
|
||||||
|
|
||||||
// Task tool handles its own display via console.log - suppress UI rendering entirely
|
// Task tool - handled by SubagentGroupDisplay, don't render here
|
||||||
if (rawName === "Task" || rawName === "task") {
|
if (isTaskTool(rawName)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply tool name remapping from old codebase
|
// Apply tool name remapping
|
||||||
let displayName = rawName;
|
const displayName = getDisplayToolName(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";
|
|
||||||
|
|
||||||
// Format arguments for display using the old formatting logic
|
// Format arguments for display using the old formatting logic
|
||||||
const formatted = formatArgsDisplay(argsText);
|
const formatted = formatArgsDisplay(argsText);
|
||||||
@@ -182,14 +126,11 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
|
|||||||
typeof v === "object" && v !== null;
|
typeof v === "object" && v !== null;
|
||||||
|
|
||||||
// Check if this is a todo_write tool with successful result
|
// Check if this is a todo_write tool with successful result
|
||||||
const isTodoTool =
|
if (
|
||||||
rawName === "todo_write" ||
|
isTodoTool(rawName, displayName) &&
|
||||||
rawName === "TodoWrite" ||
|
line.resultOk !== false &&
|
||||||
rawName === "write_todos" ||
|
line.argsText
|
||||||
rawName === "WriteTodos" ||
|
) {
|
||||||
displayName === "TODO";
|
|
||||||
|
|
||||||
if (isTodoTool && line.resultOk !== false && line.argsText) {
|
|
||||||
try {
|
try {
|
||||||
const parsedArgs = JSON.parse(line.argsText);
|
const parsedArgs = JSON.parse(line.argsText);
|
||||||
if (parsedArgs.todos && Array.isArray(parsedArgs.todos)) {
|
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
|
// Check if this is an update_plan tool with successful result
|
||||||
const isPlanTool =
|
if (
|
||||||
rawName === "update_plan" ||
|
isPlanTool(rawName, displayName) &&
|
||||||
rawName === "UpdatePlan" ||
|
line.resultOk !== false &&
|
||||||
displayName === "Planning";
|
line.argsText
|
||||||
|
) {
|
||||||
if (isPlanTool && line.resultOk !== false && line.argsText) {
|
|
||||||
try {
|
try {
|
||||||
const parsedArgs = JSON.parse(line.argsText);
|
const parsedArgs = JSON.parse(line.argsText);
|
||||||
if (parsedArgs.plan && Array.isArray(parsedArgs.plan)) {
|
if (parsedArgs.plan && Array.isArray(parsedArgs.plan)) {
|
||||||
|
|||||||
@@ -113,6 +113,17 @@ export const colors = {
|
|||||||
inProgress: brandColors.primaryAccent,
|
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/modal views
|
||||||
info: {
|
info: {
|
||||||
border: brandColors.primaryAccent,
|
border: brandColors.primaryAccent,
|
||||||
|
|||||||
120
src/cli/helpers/subagentAggregation.ts
Normal file
120
src/cli/helpers/subagentAggregation.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
41
src/cli/helpers/subagentDisplay.ts
Normal file
41
src/cli/helpers/subagentDisplay.ts
Normal 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 ? " " : "│ ",
|
||||||
|
};
|
||||||
|
}
|
||||||
298
src/cli/helpers/subagentState.ts
Normal file
298
src/cli/helpers/subagentState.ts
Normal 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;
|
||||||
|
}
|
||||||
108
src/cli/helpers/toolNameMapping.ts
Normal file
108
src/cli/helpers/toolNameMapping.ts
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@
|
|||||||
|
|
||||||
import { getAllSubagentConfigs } from "../../agent/subagents";
|
import { getAllSubagentConfigs } from "../../agent/subagents";
|
||||||
import { spawnSubagent } from "../../agent/subagents/manager";
|
import { spawnSubagent } from "../../agent/subagents/manager";
|
||||||
|
import {
|
||||||
|
completeSubagent,
|
||||||
|
generateSubagentId,
|
||||||
|
registerSubagent,
|
||||||
|
} from "../../cli/helpers/subagentState.js";
|
||||||
import { validateRequiredParams } from "./validation";
|
import { validateRequiredParams } from "./validation";
|
||||||
|
|
||||||
interface TaskArgs {
|
interface TaskArgs {
|
||||||
@@ -14,21 +19,7 @@ interface TaskArgs {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
description: string;
|
description: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
}
|
toolCallId?: string; // Injected by executeTool for linking subagent to parent tool call
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(", ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,10 +33,7 @@ export async function task(args: TaskArgs): Promise<string> {
|
|||||||
"Task",
|
"Task",
|
||||||
);
|
);
|
||||||
|
|
||||||
const { subagent_type, prompt, description, model } = args;
|
const { subagent_type, prompt, description, model, toolCallId } = args;
|
||||||
|
|
||||||
// Print Task header FIRST so subagent output appears below it
|
|
||||||
console.log(`\n● Task(${formatTaskArgs(args)})\n`);
|
|
||||||
|
|
||||||
// Get all available subagent configs (built-in + custom)
|
// Get all available subagent configs (built-in + custom)
|
||||||
const allConfigs = await getAllSubagentConfigs();
|
const allConfigs = await getAllSubagentConfigs();
|
||||||
@@ -56,20 +44,32 @@ export async function task(args: TaskArgs): Promise<string> {
|
|||||||
return `Error: Invalid subagent type "${subagent_type}". Available types: ${available}`;
|
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 {
|
try {
|
||||||
const result = await spawnSubagent(
|
const result = await spawnSubagent(
|
||||||
subagent_type,
|
subagent_type,
|
||||||
prompt,
|
prompt,
|
||||||
description,
|
|
||||||
model,
|
model,
|
||||||
|
subagentId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mark subagent as completed in state store
|
||||||
|
completeSubagent(subagentId, {
|
||||||
|
success: result.success,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return `Error: ${result.error || "Subagent execution failed"}`;
|
return `Error: ${result.error || "Subagent execution failed"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.report;
|
return result.report;
|
||||||
} catch (error) {
|
} 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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -829,12 +829,13 @@ function flattenToolResponse(result: unknown): string {
|
|||||||
*
|
*
|
||||||
* @param name - The name of the tool to execute
|
* @param name - The name of the tool to execute
|
||||||
* @param args - Arguments object to pass to the tool
|
* @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
|
* @returns Promise with the tool's execution result including status and optional stdout/stderr
|
||||||
*/
|
*/
|
||||||
export async function executeTool(
|
export async function executeTool(
|
||||||
name: string,
|
name: string,
|
||||||
args: ToolArgs,
|
args: ToolArgs,
|
||||||
options?: { signal?: AbortSignal },
|
options?: { signal?: AbortSignal; toolCallId?: string },
|
||||||
): Promise<ToolExecutionResult> {
|
): Promise<ToolExecutionResult> {
|
||||||
const internalName = resolveInternalToolName(name);
|
const internalName = resolveInternalToolName(name);
|
||||||
if (!internalName) {
|
if (!internalName) {
|
||||||
@@ -853,13 +854,20 @@ export async function executeTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Inject abort signal for tools that support it (currently Bash) without altering schemas
|
// Inject options for tools that support them without altering schemas
|
||||||
const argsWithSignal =
|
let enhancedArgs = args;
|
||||||
internalName === "Bash" && options?.signal
|
|
||||||
? { ...args, signal: options.signal }
|
|
||||||
: 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)
|
// Extract stdout/stderr if present (for bash tools)
|
||||||
const recordResult = isRecord(result) ? result : undefined;
|
const recordResult = isRecord(result) ? result : undefined;
|
||||||
|
|||||||
4
vendor/ink-text-input/build/index.js
vendored
4
vendor/ink-text-input/build/index.js
vendored
@@ -17,6 +17,10 @@ function isControlSequence(input, key) {
|
|||||||
// Ctrl+W (delete word) - handled by parent component
|
// Ctrl+W (delete word) - handled by parent component
|
||||||
if (key.ctrl && (input === 'w' || input === 'W')) return true;
|
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'
|
// 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;
|
if (key.meta && (input === 'b' || input === 'B' || input === 'f' || input === 'F')) return true;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user