fix: repair Task tool (subagent) rendering (#465)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-04 21:46:52 -08:00
committed by GitHub
parent e424c6ce0c
commit 633d52ead9
5 changed files with 411 additions and 13 deletions

View File

@@ -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 (
<Box key={ln.id} flexDirection="column" marginTop={1}>
{/* For ExitPlanMode awaiting approval: render StaticPlanApproval */}
@@ -5925,6 +5980,23 @@ Plan file path: ${planFilePath}`;
onCancel={handleCancelApprovals}
isFocused={true}
/>
) : isTaskToolApproval && taskInfo ? (
<InlineTaskApproval
taskInfo={taskInfo}
onApprove={() => 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 ? (

View File

@@ -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 */}
<Text dimColor>{solidLine}</Text>
{/* Header */}
<Text bold color={colors.approval.header}>
Run Task?
</Text>
{/* Task details */}
<Box paddingLeft={2} flexDirection="column" marginTop={1}>
{/* Subagent type */}
<Box flexDirection="row">
<Box width={12} flexShrink={0}>
<Text dimColor>Type:</Text>
</Box>
<Box flexGrow={1} width={contentWidth - 12}>
<Text wrap="wrap" color={colors.subagent.header}>
{subagentType}
</Text>
</Box>
</Box>
{/* Model (if specified) */}
{model && (
<Box flexDirection="row">
<Box width={12} flexShrink={0}>
<Text dimColor>Model:</Text>
</Box>
<Box flexGrow={1} width={contentWidth - 12}>
<Text wrap="wrap" dimColor>
{model}
</Text>
</Box>
</Box>
)}
{/* Description */}
<Box flexDirection="row">
<Box width={12} flexShrink={0}>
<Text dimColor>Description:</Text>
</Box>
<Box flexGrow={1} width={contentWidth - 12}>
<Text wrap="wrap">{description}</Text>
</Box>
</Box>
{/* Prompt */}
<Box flexDirection="row" marginTop={1}>
<Box width={12} flexShrink={0}>
<Text dimColor>Prompt:</Text>
</Box>
<Box flexGrow={1} width={contentWidth - 12}>
<Text wrap="wrap" dimColor>
{truncatedPrompt}
</Text>
</Box>
</Box>
</Box>
</>
);
}, [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 (
<Box flexDirection="column">
{/* Static task content - memoized to prevent re-render on keystroke */}
{memoizedTaskContent}
{/* Options */}
<Box marginTop={1} flexDirection="column">
{/* Option 1: Yes */}
<Box flexDirection="row">
<Box width={5} flexShrink={0}>
<Text
color={
selectedOption === 0 ? colors.approval.header : undefined
}
>
{selectedOption === 0 ? "" : " "} 1.
</Text>
</Box>
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
<Text
wrap="wrap"
color={
selectedOption === 0 ? colors.approval.header : undefined
}
>
Yes
</Text>
</Box>
</Box>
{/* Option 2: Yes, always (only if persistence allowed) */}
{allowPersistence && (
<Box flexDirection="row">
<Box width={5} flexShrink={0}>
<Text
color={
selectedOption === 1 ? colors.approval.header : undefined
}
>
{selectedOption === 1 ? "" : " "} 2.
</Text>
</Box>
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
<Text
wrap="wrap"
color={
selectedOption === 1 ? colors.approval.header : undefined
}
>
{alwaysText}
</Text>
</Box>
</Box>
)}
{/* Custom input option */}
<Box flexDirection="row">
<Box width={5} flexShrink={0}>
<Text
color={isOnCustomOption ? colors.approval.header : undefined}
>
{isOnCustomOption ? "" : " "} {customOptionIndex + 1}.
</Text>
</Box>
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
{customReason ? (
<Text wrap="wrap">
{customReason}
{isOnCustomOption && "█"}
</Text>
) : (
<Text wrap="wrap" dimColor>
{customOptionPlaceholder}
{isOnCustomOption && "█"}
</Text>
)}
</Box>
</Box>
</Box>
{/* Hint */}
<Box marginTop={1}>
<Text dimColor>{hintText}</Text>
</Box>
</Box>
);
},
);
InlineTaskApproval.displayName = "InlineTaskApproval";

View File

@@ -236,7 +236,7 @@ export const SubagentGroupDisplay = memo(() => {
const hasErrors = agents.some((a) => a.status === "error");
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column">
<GroupHeader
count={agents.length}
allCompleted={allCompleted}

View File

@@ -79,15 +79,18 @@ export const ToolCallMessage = memo(
const rawName = line.name ?? "?";
const argsText = line.argsText ?? "...";
// Task tool - handled by SubagentGroupDisplay, don't render here
// Exception: Cancelled/rejected Task tools should be rendered inline
// since they won't appear in SubagentGroupDisplay
// Task tool rendering decision:
// - Cancelled/rejected: render as error tool call (won't appear in SubagentGroupDisplay)
// - Finished with success: render as normal tool call (for backfilled tools without subagent data)
// - In progress: don't render here (SubagentGroupDisplay handles running subagents,
// and liveItems handles pending approvals via InlineGenericApproval)
if (isTaskTool(rawName)) {
const isCancelledOrRejected =
line.phase === "finished" && line.resultOk === false;
if (!isCancelledOrRejected) {
const isFinished = line.phase === "finished";
if (!isFinished) {
// Not finished - SubagentGroupDisplay or approval UI handles this
return null;
}
// Finished Task tools render here (both success and error)
}
// Apply tool name remapping

View File

@@ -218,7 +218,7 @@ export function checkPermission(
// Fall back to tool defaults
return {
decision: getDefaultDecision(toolName),
decision: getDefaultDecision(toolName, toolArgs),
reason: "Default behavior for tool",
};
}
@@ -397,10 +397,25 @@ function matchesPattern(
return matchesToolPattern(toolName, pattern);
}
/**
* Subagent types that are read-only and safe to auto-approve.
* These only have access to read-only tools (Glob, Grep, Read, LS, BashOutput).
* See: src/agent/subagents/builtin/*.md for definitions
*/
const READ_ONLY_SUBAGENT_TYPES = new Set([
"explore", // Codebase exploration - Glob, Grep, Read, LS, BashOutput
"Explore",
"plan", // Planning agent - Glob, Grep, Read, LS, BashOutput
"Plan",
]);
/**
* Get default decision for a tool (when no rules match)
*/
function getDefaultDecision(toolName: string): PermissionDecision {
function getDefaultDecision(
toolName: string,
toolArgs?: ToolArgs,
): PermissionDecision {
// Check TOOL_PERMISSIONS to determine if tool requires approval
// Import is async so we need to do this synchronously - get the permissions from manager
// For now, use a hardcoded check that matches TOOL_PERMISSIONS configuration
@@ -442,6 +457,17 @@ function getDefaultDecision(toolName: string): PermissionDecision {
return "allow";
}
// Task tool: auto-approve read-only subagent types
if (toolName === "Task" || toolName === "task") {
const subagentType =
typeof toolArgs?.subagent_type === "string" ? toolArgs.subagent_type : "";
if (READ_ONLY_SUBAGENT_TYPES.has(subagentType)) {
return "allow";
}
// Non-read-only subagent types require approval
return "ask";
}
// Everything else defaults to ask
return "ask";
}