From 633d52ead9db00fce2a0bf3ecad25cfa83b8dc07 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sun, 4 Jan 2026 21:46:52 -0800 Subject: [PATCH] fix: repair Task tool (subagent) rendering (#465) Co-authored-by: Letta --- src/cli/App.tsx | 80 +++++- src/cli/components/InlineTaskApproval.tsx | 297 ++++++++++++++++++++ src/cli/components/SubagentGroupDisplay.tsx | 2 +- src/cli/components/ToolCallMessageRich.tsx | 15 +- src/permissions/checker.ts | 30 +- 5 files changed, 411 insertions(+), 13 deletions(-) create mode 100644 src/cli/components/InlineTaskApproval.tsx diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 7703ec6..3ac132a 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -76,6 +76,7 @@ import { InlineEnterPlanModeApproval } from "./components/InlineEnterPlanModeApp import { InlineFileEditApproval } from "./components/InlineFileEditApproval"; import { InlineGenericApproval } from "./components/InlineGenericApproval"; import { InlineQuestionApproval } from "./components/InlineQuestionApproval"; +import { InlineTaskApproval } from "./components/InlineTaskApproval"; import { Input } from "./components/InputRich"; import { McpSelector } from "./components/McpSelector"; import { MemoryViewer } from "./components/MemoryViewer"; @@ -848,9 +849,23 @@ export default function App({ continue; } // Handle Task tool_calls specially - track position but don't add individually + // (unless there's no subagent data, in which case commit as regular tool call) if (ln.kind === "tool_call" && ln.name && isTaskTool(ln.name)) { - if (firstTaskIndex === -1 && finishedTaskToolCalls.length > 0) { - firstTaskIndex = newlyCommitted.length; + // Check if this specific Task tool has subagent data (will be grouped) + const hasSubagentData = finishedTaskToolCalls.some( + (tc) => tc.lineId === id, + ); + if (hasSubagentData) { + // Has subagent data - will be grouped later + if (firstTaskIndex === -1) { + firstTaskIndex = newlyCommitted.length; + } + continue; + } + // No subagent data (e.g., backfilled from history) - commit as regular tool call + if (ln.phase === "finished") { + emittedIdsRef.current.add(id); + newlyCommitted.push({ ...ln }); } continue; } @@ -5573,9 +5588,12 @@ Plan file path: ${planFilePath}`; return ln.phase === "running"; } if (ln.kind === "tool_call") { - // Skip Task tool_calls - SubagentGroupDisplay handles them + // Task tool_calls need special handling: + // - Only include if pending approval (phase: "ready" or "streaming") + // - Running/finished Task tools are handled by SubagentGroupDisplay if (ln.name && isTaskTool(ln.name)) { - return false; + // Only show Task tools that are awaiting approval (not running/finished) + return ln.phase === "ready" || ln.phase === "streaming"; } // Always show other tool calls in progress return ln.phase !== "finished"; @@ -5772,6 +5790,13 @@ Plan file path: ${planFilePath}`; currentApproval?.toolName === "AskUserQuestion" && ln.toolCallId === currentApproval?.toolCallId; + // Check if this tool call matches a Task tool approval + const isTaskToolApproval = + ln.kind === "tool_call" && + currentApproval && + isTaskTool(currentApproval.toolName) && + ln.toolCallId === currentApproval.toolCallId; + // Parse file edit info from approval args const getFileEditInfo = () => { if (!isFileEditApproval || !currentApproval) return null; @@ -5859,6 +5884,36 @@ Plan file path: ${planFilePath}`; const bashInfo = getBashInfo(); + // Parse Task tool info from approval args + const getTaskInfo = () => { + if (!isTaskToolApproval || !currentApproval) return null; + try { + const args = JSON.parse(currentApproval.toolArgs || "{}"); + return { + subagentType: + typeof args.subagent_type === "string" + ? args.subagent_type + : "unknown", + description: + typeof args.description === "string" + ? args.description + : "(no description)", + prompt: + typeof args.prompt === "string" + ? args.prompt + : "(no prompt)", + model: + typeof args.model === "string" + ? args.model + : undefined, + }; + } catch { + return null; + } + }; + + const taskInfo = getTaskInfo(); + return ( {/* For ExitPlanMode awaiting approval: render StaticPlanApproval */} @@ -5925,6 +5980,23 @@ Plan file path: ${planFilePath}`; onCancel={handleCancelApprovals} isFocused={true} /> + ) : isTaskToolApproval && taskInfo ? ( + handleApproveCurrent()} + onApproveAlways={(scope) => + handleApproveAlways(scope) + } + onDeny={(reason) => handleDenyCurrent(reason)} + onCancel={handleCancelApprovals} + isFocused={true} + approveAlwaysText={ + currentApprovalContext?.approveAlwaysText + } + allowPersistence={ + currentApprovalContext?.allowPersistence ?? true + } + /> ) : ln.kind === "tool_call" && currentApproval && ln.toolCallId === currentApproval.toolCallId ? ( diff --git a/src/cli/components/InlineTaskApproval.tsx b/src/cli/components/InlineTaskApproval.tsx new file mode 100644 index 0000000..facb86c --- /dev/null +++ b/src/cli/components/InlineTaskApproval.tsx @@ -0,0 +1,297 @@ +import { Box, Text, useInput } from "ink"; +import { memo, useMemo, useState } from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; + +type Props = { + taskInfo: { + subagentType: string; + description: string; + prompt: string; + model?: string; + }; + onApprove: () => void; + onApproveAlways: (scope: "project" | "session") => void; + onDeny: (reason: string) => void; + onCancel?: () => void; + isFocused?: boolean; + approveAlwaysText?: string; + allowPersistence?: boolean; +}; + +// Horizontal line character for Claude Code style +const SOLID_LINE = "─"; + +/** + * Truncate text to max length with ellipsis + */ +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 3)}...`; +} + +/** + * InlineTaskApproval - Renders Task tool approval UI inline with pretty formatting + * + * Shows subagent type, description, and prompt in a readable format. + */ +export const InlineTaskApproval = memo( + ({ + taskInfo, + onApprove, + onApproveAlways, + onDeny, + onCancel, + isFocused = true, + approveAlwaysText, + allowPersistence = true, + }: Props) => { + const [selectedOption, setSelectedOption] = useState(0); + const [customReason, setCustomReason] = useState(""); + const columns = useTerminalWidth(); + + // Custom option index depends on whether "always" option is shown + const customOptionIndex = allowPersistence ? 2 : 1; + const maxOptionIndex = customOptionIndex; + const isOnCustomOption = selectedOption === customOptionIndex; + const customOptionPlaceholder = + "No, and tell Letta Code what to do differently"; + + useInput( + (input, key) => { + if (!isFocused) return; + + // CTRL-C: cancel (queue denial, return to input) + if (key.ctrl && input === "c") { + onCancel?.(); + return; + } + + // Arrow navigation always works + if (key.upArrow) { + setSelectedOption((prev) => Math.max(0, prev - 1)); + return; + } + if (key.downArrow) { + setSelectedOption((prev) => Math.min(maxOptionIndex, prev + 1)); + return; + } + + // When on custom input option + if (isOnCustomOption) { + if (key.return) { + if (customReason.trim()) { + onDeny(customReason.trim()); + } + return; + } + if (key.escape) { + if (customReason) { + setCustomReason(""); + } else { + onCancel?.(); + } + return; + } + if (key.backspace || key.delete) { + setCustomReason((prev) => prev.slice(0, -1)); + return; + } + if (input && !key.ctrl && !key.meta && input.length === 1) { + setCustomReason((prev) => prev + input); + } + return; + } + + // When on regular options + if (key.return) { + if (selectedOption === 0) { + onApprove(); + } else if (selectedOption === 1 && allowPersistence) { + onApproveAlways("session"); + } + return; + } + if (key.escape) { + onCancel?.(); + } + }, + { isActive: isFocused }, + ); + + // Generate horizontal line + const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10)); + const contentWidth = Math.max(0, columns - 4); // 2 padding on each side + + // Memoize the static task content so it doesn't re-render on keystroke + const memoizedTaskContent = useMemo(() => { + const { subagentType, description, prompt, model } = taskInfo; + + // Truncate prompt if too long (show first ~200 chars) + const truncatedPrompt = truncate(prompt, 300); + + return ( + <> + {/* Top solid line */} + {solidLine} + + {/* Header */} + + Run Task? + + + {/* Task details */} + + {/* Subagent type */} + + + Type: + + + + {subagentType} + + + + + {/* Model (if specified) */} + {model && ( + + + Model: + + + + {model} + + + + )} + + {/* Description */} + + + Description: + + + {description} + + + + {/* Prompt */} + + + Prompt: + + + + {truncatedPrompt} + + + + + + ); + }, [taskInfo, solidLine, contentWidth]); + + // Hint text based on state + const hintText = isOnCustomOption + ? customReason + ? "Enter to submit · Esc to clear" + : "Type reason · Esc to cancel" + : "Enter to select · Esc to cancel"; + + // Generate "always" text for Task tool + const alwaysText = + approveAlwaysText || "Yes, allow Task operations during this session"; + + return ( + + {/* Static task content - memoized to prevent re-render on keystroke */} + {memoizedTaskContent} + + {/* Options */} + + {/* Option 1: Yes */} + + + + {selectedOption === 0 ? "❯" : " "} 1. + + + + + Yes + + + + + {/* Option 2: Yes, always (only if persistence allowed) */} + {allowPersistence && ( + + + + {selectedOption === 1 ? "❯" : " "} 2. + + + + + {alwaysText} + + + + )} + + {/* Custom input option */} + + + + {isOnCustomOption ? "❯" : " "} {customOptionIndex + 1}. + + + + {customReason ? ( + + {customReason} + {isOnCustomOption && "█"} + + ) : ( + + {customOptionPlaceholder} + {isOnCustomOption && "█"} + + )} + + + + + {/* Hint */} + + {hintText} + + + ); + }, +); + +InlineTaskApproval.displayName = "InlineTaskApproval"; diff --git a/src/cli/components/SubagentGroupDisplay.tsx b/src/cli/components/SubagentGroupDisplay.tsx index f66b4ad..2ea2f26 100644 --- a/src/cli/components/SubagentGroupDisplay.tsx +++ b/src/cli/components/SubagentGroupDisplay.tsx @@ -236,7 +236,7 @@ export const SubagentGroupDisplay = memo(() => { const hasErrors = agents.some((a) => a.status === "error"); return ( - +