diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx
index 635790af9..f8ffb165e 100644
--- a/packages/app/src/components/agent-activity.tsx
+++ b/packages/app/src/components/agent-activity.tsx
@@ -149,7 +149,7 @@ function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Dat
{formatTimestamp(timestamp)}
- 📋 Plan ({update.entries.length} tasks)
+ 📋 Tasks ({update.entries.length})
{isExpanded ? 'â–¼' : 'â–¶'}
@@ -217,7 +217,7 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
const activity = item as AgentActivity;
const update = activity.update;
- // Plan
+ // Tasks
if (update.kind === 'plan') {
return ;
}
diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index f2c4565ec..f9c388a7d 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -757,7 +757,10 @@ function PermissionRequestCard({
const { theme } = useUnistyles();
const { request } = permission;
- const title = request.title ?? request.name ?? "Permission Required";
+ const isPlanRequest = request.kind === "plan";
+ const title = isPlanRequest
+ ? "Plan"
+ : request.title ?? request.name ?? "Permission Required";
const description = request.description ?? "";
const planMarkdown = useMemo(() => {
@@ -778,10 +781,12 @@ function PermissionRequestCard({
return undefined;
}, [request]);
- const toolCallDisplay = useMemo(
- () => parseToolCallDisplay(request.name ?? "unknown", request.input, null),
- [request.name, request.input]
- );
+ const toolCallDisplay = useMemo(() => {
+ if (isPlanRequest) {
+ return null;
+ }
+ return parseToolCallDisplay(request.name ?? "unknown", request.input, null);
+ }, [isPlanRequest, request.name, request.input]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
@@ -978,7 +983,7 @@ function PermissionRequestCard({
{ color: theme.colors.foregroundMuted },
]}
>
- Proposed Plan
+ {isPlanRequest ? "Plan" : "Proposed Plan"}
) : null}
-
+ {!isPlanRequest && toolCallDisplay ? (
+
+ ) : null}
formatPlanTimestamp(timestamp),
+ () => formatTasksTimestamp(timestamp),
[timestamp]
);
@@ -1013,7 +1013,7 @@ export const TodoListCard = memo(function TodoListCard({
- Plan
+ Tasks
{timestampLabel}
@@ -1032,7 +1032,7 @@ export const TodoListCard = memo(function TodoListCard({
{items.length === 0 ? (
- No todo items shared yet.
+ No tasks shared yet.
) : (
items.map((item, idx) => (
diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts
index a91084aaa..5e69a3828 100644
--- a/packages/app/src/types/stream.test.ts
+++ b/packages/app/src/types/stream.test.ts
@@ -65,6 +65,26 @@ function toolTimeline(
};
}
+function toolTimelineWithInput(options: {
+ provider: TestAgentProvider;
+ name: string;
+ status: string;
+ callId?: string;
+ input: unknown;
+}): AgentStreamEventPayload {
+ return {
+ type: "timeline",
+ provider: options.provider,
+ item: {
+ type: "tool_call",
+ name: options.name,
+ status: options.status,
+ callId: options.callId ?? options.name,
+ input: options.input,
+ },
+ };
+}
+
function todoTimeline(items: { text: string; completed: boolean }[]): AgentStreamEventPayload {
return {
type: "timeline",
@@ -664,6 +684,44 @@ function testTodoListConsolidation() {
);
}
+function testTodoWriteToolCallCreatesTodoList() {
+ const timestamp = new Date("2025-01-01T12:32:00Z");
+ const event = toolTimelineWithInput({
+ provider: "claude",
+ name: "TodoWrite",
+ status: "completed",
+ input: {
+ todos: [
+ { content: "First task", status: "pending" },
+ { content: "Second task", status: "completed" },
+ ],
+ },
+ });
+
+ const state = reduceStreamUpdate([], event, timestamp);
+ const todoEntries = state.filter(
+ (item): item is TodoListItem => item.kind === "todo_list"
+ );
+ const toolCalls = state.filter((item) => item.kind === "tool_call");
+
+ assert.strictEqual(todoEntries.length, 1);
+ assert.strictEqual(
+ toolCalls.length,
+ 0,
+ "TodoWrite should render as a task list, not a tool call"
+ );
+ assert.ok(
+ todoEntries[0].items.some(
+ (entry) => entry.text === "First task" && !entry.completed
+ )
+ );
+ assert.ok(
+ todoEntries[0].items.some(
+ (entry) => entry.text === "Second task" && entry.completed
+ )
+ );
+}
+
function testTimelineIdStabilityAfterRemovals() {
const timestamp = new Date('2025-01-01T12:35:00Z');
@@ -1038,6 +1096,7 @@ describe('stream timeline reducers', () => {
it('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration);
it('retains hydrated user messages across providers', testHydratedUserMessagesPersist);
it('consolidates todo list updates', testTodoListConsolidation);
+ it('renders TodoWrite as a task list', testTodoWriteToolCallCreatesTodoList);
it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
it('deduplicates live tool call entries', testToolCallDeduplicationLive);
it('deduplicates hydrated tool call entries', testToolCallDeduplicationHydrated);
diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts
index a9f0b8b93..4c340f0c1 100644
--- a/packages/app/src/types/stream.ts
+++ b/packages/app/src/types/stream.ts
@@ -4,6 +4,7 @@ import {
extractCommandDetails,
extractEditEntries,
extractReadEntries,
+ extractTaskEntriesFromToolCall,
type CommandDetails,
type EditEntry,
type ReadEntry,
@@ -754,6 +755,32 @@ export function reduceStreamUpdate(
case "reasoning":
return appendThought(state, item.text, timestamp);
case "tool_call": {
+ const normalizedToolName = item.name
+ .trim()
+ .replace(/[.\s-]+/g, "_")
+ .toLowerCase();
+ if (event.provider === "claude" && normalizedToolName === "exitplanmode") {
+ // ExitPlanMode is rendered via the plan permission prompt; avoid duplicating it in the timeline.
+ break;
+ }
+
+ const tasks = extractTaskEntriesFromToolCall(
+ item.name,
+ item.input
+ );
+ if (tasks) {
+ nextState = appendTodoList(
+ state,
+ event.provider,
+ tasks.map((entry) => ({
+ text: entry.text,
+ completed: entry.completed,
+ })),
+ timestamp
+ );
+ break;
+ }
+
nextState = appendAgentToolCall(
state,
{
diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts
index 07a832851..05146598b 100644
--- a/packages/app/src/utils/tool-call-parsers.ts
+++ b/packages/app/src/utils/tool-call-parsers.ts
@@ -1224,6 +1224,85 @@ export function parseToolCallDisplay(toolName: string, input: unknown, result: u
return ToolCallDisplaySchema.parse({ toolName, input, result });
}
+// ---- Task Extraction (cross-provider) ----
+
+export type TaskStatus = "pending" | "in_progress" | "completed";
+
+export type TaskEntry = {
+ text: string;
+ status: TaskStatus;
+ completed: boolean;
+};
+
+const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"]);
+
+const ClaudeTodoWriteSchema = z.object({
+ todos: z.array(
+ z.object({
+ content: z.string(),
+ status: TaskStatusSchema,
+ activeForm: z.string().optional(),
+ })
+ ),
+});
+
+const UpdatePlanSchema = z.object({
+ plan: z.array(
+ z.object({
+ step: z.string(),
+ status: TaskStatusSchema.catch("pending"),
+ })
+ ),
+});
+
+function normalizeToolName(toolName: string): string {
+ return toolName.trim().replace(/[.\s-]+/g, "_").toLowerCase();
+}
+
+export function extractTaskEntriesFromToolCall(
+ toolName: string,
+ input: unknown
+): TaskEntry[] | null {
+ const normalized = normalizeToolName(toolName);
+
+ // Claude's plan mode uses ExitPlanMode for the approval prompt; it is not a task list.
+ if (normalized === "exitplanmode") {
+ return null;
+ }
+
+ if (normalized === "todowrite" || normalized === "todo_write") {
+ const parsed = ClaudeTodoWriteSchema.safeParse(input);
+ if (!parsed.success) {
+ return null;
+ }
+ return parsed.data.todos.map((todo) => {
+ const status = todo.status;
+ const text = todo.activeForm?.trim() || todo.content.trim();
+ return {
+ text: text.length ? text : todo.content,
+ status,
+ completed: status === "completed",
+ };
+ });
+ }
+
+ if (normalized === "update_plan") {
+ const parsed = UpdatePlanSchema.safeParse(input);
+ if (!parsed.success) {
+ return null;
+ }
+ return parsed.data.plan
+ .map((entry) => ({
+ text: entry.step.trim(),
+ status: entry.status,
+ completed: entry.status === "completed",
+ }))
+ .filter((entry) => entry.text.length > 0);
+ }
+
+ return null;
+}
+
// ---- Principal Parameter Extraction ----
// Re-export from server to avoid drift
export {
diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts
index 01d0b66b1..a0e10b615 100644
--- a/packages/server/src/server/agent/activity-curator.test.ts
+++ b/packages/server/src/server/agent/activity-curator.test.ts
@@ -66,7 +66,7 @@ describe("curateAgentActivity", () => {
expect(result).toBe("[ListFiles]");
});
- test("serializes todo items as [Plan]", () => {
+ test("serializes todo items as [Tasks]", () => {
const timeline: AgentTimelineItem[] = [
{
type: "todo",
@@ -80,7 +80,7 @@ describe("curateAgentActivity", () => {
const result = curateAgentActivity(timeline);
- expect(result).toContain("[Plan]");
+ expect(result).toContain("[Tasks]");
expect(result).toContain("- [x] Read the file");
expect(result).toContain("- [ ] Fix the bug");
expect(result).toContain("- [ ] Run tests");
diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts
index bfa29bce9..806ad6e2e 100644
--- a/packages/server/src/server/agent/activity-curator.ts
+++ b/packages/server/src/server/agent/activity-curator.ts
@@ -137,7 +137,7 @@ export function curateAgentActivity(
}
case "todo":
flushBuffers(lines, buffers);
- lines.push("[Plan]");
+ lines.push("[Tasks]");
for (const entry of item.items) {
const checkbox = entry.completed ? "[x]" : "[ ]";
lines.push(`- ${checkbox} ${entry.text}`);
diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts
index f2dbf64b6..88238a1ce 100644
--- a/packages/server/src/server/agent/providers/claude-agent.test.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.test.ts
@@ -865,15 +865,19 @@ async function startAgentMcpServer(): Promise {
"Devise a plan to create a file named dummy.txt containing the word plan-test. After planning, proceed to execute your plan."
);
- let sawPlan = false;
+ let capturedPlan: string | null = null;
for await (const event of events) {
await autoApprove(session, event);
- if (
- event.type === "timeline" &&
- event.item.type === "todo" &&
- event.item.items.some((entry) => entry.text.includes("dummy.txt"))
- ) {
- sawPlan = true;
+ if (event.type === "permission_requested" && event.request.kind === "plan") {
+ const planFromMetadata =
+ typeof event.request.metadata?.planText === "string"
+ ? event.request.metadata.planText
+ : null;
+ const planFromInput =
+ typeof (event.request.input as any)?.plan === "string"
+ ? ((event.request.input as any)?.plan as string)
+ : null;
+ capturedPlan = planFromMetadata ?? planFromInput ?? capturedPlan;
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
@@ -881,7 +885,8 @@ async function startAgentMcpServer(): Promise {
}
}
- expect(sawPlan).toBe(true);
+ expect(capturedPlan).not.toBeNull();
+ expect(capturedPlan?.includes("dummy.txt")).toBe(true);
expect(await session.getCurrentMode()).toBe("acceptEdits");
const filePath = path.join(cwd, "dummy.txt");
diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts
index 91b81720a..5eb63ce98 100644
--- a/packages/server/src/server/agent/providers/claude-agent.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.ts
@@ -1159,10 +1159,6 @@ class ClaudeAgentSession implements AgentSession {
input,
options
): Promise => {
- if (toolName === "ExitPlanMode") {
- this.emitPlanTodoItems(input);
- }
-
const requestId = `permission-${randomUUID()}`;
const metadata: AgentMetadata = {};
if (options.toolUseID) {
@@ -1234,19 +1230,6 @@ class ClaudeAgentSession implements AgentSession {
});
};
- private emitPlanTodoItems(input: AgentMetadata) {
- const planText = typeof input.plan === "string" ? input.plan : JSON.stringify(input);
- const todoItems = planText
- .split("\n")
- .map((line) => line.trim())
- .filter(Boolean)
- .map((text) => ({ text, completed: false }));
- this.enqueueTimeline({
- type: "todo",
- items: todoItems.length > 0 ? todoItems : [{ text: planText, completed: false }],
- });
- }
-
private enqueueTimeline(item: AgentTimelineItem) {
this.pushEvent({ type: "timeline", item, provider: "claude" });
}