feat(hub): live TodoWrite checklist on Feishu agent cards

Always expose Claude Agent SDK TodoWrite (todoFeatureEnabled) so multi-step
runs can plan in the open. Parse TodoWrite payloads into a progress panel on
the streaming Feishu card (completed/in_progress/pending) and filter raw
TodoWrite noise out of the tool-use list.
This commit is contained in:
2026-07-23 22:42:02 +08:00
parent 4a03bc1f4a
commit 2f79b7743f
8 changed files with 263 additions and 18 deletions
+81 -9
View File
@@ -2,9 +2,10 @@
* Feishu interactive card builder for agent run output.
*
* Produces card JSON with:
* 1. A collapsible tool-use panel (tool steps with status, params, results)
* 2. A collapsible reasoning panel (thinking text)
* 3. The streaming/final answer text (markdown)
* 1. A live todo checklist when the agent uses TodoWrite (Manus-style progress)
* 2. A collapsible tool-use panel (tool steps with status, params, results)
* 3. A collapsible reasoning panel (thinking text)
* 4. The streaming/final answer text (markdown)
*
* Adapted from openclaw-lark's builder.ts, simplified for our
* message.patch-based approach (no CardKit 2.0 streaming_mode).
@@ -12,6 +13,8 @@
*/
import type { ToolUseTraceStep } from "./trace-store.js";
import type { AgentTodoItem } from "../../agent/todoList.js";
import { todoProgressSummary } from "../../agent/todoList.js";
import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js";
// ---------------------------------------------------------------------------
@@ -39,6 +42,7 @@ const TOOL_ICONS: Record<string, string> = {
glob: "search-filled",
grep: "search-filled",
edit: "edit-filled",
todowrite: "todo-filled",
send_file: "send-filled",
request_approval: "thumb-up-filled",
feishu_read_context: "search-filled",
@@ -61,20 +65,32 @@ export function buildAgentCard(params: {
text: string;
contentSegments?: readonly CardContentSegment[] | undefined;
reasoningText: string | undefined;
todos: readonly AgentTodoItem[] | undefined;
toolUseSteps: ToolUseTraceStep[];
toolUseElapsedMs: number | undefined;
isError: boolean | undefined;
interrupted: boolean | undefined;
runId: string | undefined;
}): Record<string, unknown> {
const { phase, text, contentSegments, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const { phase, text, contentSegments, reasoningText, todos, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const elements: unknown[] = [];
// Tool-use panel (always present if there are steps)
if (toolUseSteps.length > 0) {
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
} else if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
elements.push(buildPendingToolUsePanel());
// Todo checklist panel — primary progress signal; hide bare TodoWrite noise below.
if (todos !== undefined && todos.length > 0) {
elements.push(buildTodoPanel(todos, phase !== "complete"));
}
// Tool-use panel (exclude TodoWrite itself — already shown as checklist)
const visibleToolSteps = toolUseSteps.filter((step) => step.toolName.toLowerCase() !== "todowrite");
if (visibleToolSteps.length > 0) {
elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete"));
} else if (
todos === undefined ||
todos.length === 0
) {
if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
elements.push(buildPendingToolUsePanel());
}
}
// Reasoning panel
@@ -161,6 +177,62 @@ function buildInterruptAction(runId: string): unknown {
};
}
// ---------------------------------------------------------------------------
// Todo checklist panel
// ---------------------------------------------------------------------------
function buildTodoPanel(todos: readonly AgentTodoItem[], expanded: boolean): unknown {
const { completed, total, inProgress } = todoProgressSummary(todos);
const titleParts = [`\u{1F4CB} \u4EFB\u52A1\u8FDB\u5EA6 ${completed}/${total}`];
if (inProgress > 0 && completed < total) titleParts.push(`(\u8FDB\u884C\u4E2D ${inProgress})`);
const lines = todos.map((todo) => formatTodoLine(todo));
return {
tag: "collapsible_panel",
expanded,
header: {
title: {
tag: "plain_text",
content: titleParts.join(" "),
text_color: completed === total && total > 0 ? "green" : "grey",
text_size: "notation",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
color: "grey",
size: "16px 16px",
},
icon_position: "right",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "4px",
padding: "8px 8px 8px 8px",
elements: [
{
tag: "markdown",
content: lines.join("\n"),
text_size: "notation",
},
],
};
}
function formatTodoLine(todo: AgentTodoItem): string {
const label =
todo.status === "in_progress" && todo.activeForm !== undefined && todo.activeForm !== ""
? todo.activeForm
: todo.content;
if (todo.status === "completed") return `- [\u2713] ~~${escapeMd(todo.content)}~~`;
if (todo.status === "in_progress") return `- [\u25B6] **${escapeMd(label)}**`;
return `- [ ] ${escapeMd(todo.content)}`;
}
function escapeMd(text: string): string {
return text.replace(/([\\`*_{}\[\]()#+\-.!>])/g, "\\$1");
}
// ---------------------------------------------------------------------------
// Tool-use panel
// ---------------------------------------------------------------------------
+9
View File
@@ -34,6 +34,7 @@ import {
getToolUseTraceSteps,
} from "./trace-store.js";
import { buildAgentCard, type CardPhase } from "./builder.js";
import { isTodoWriteTool, parseTodoWriteInput, type AgentTodoItem } from "../../agent/todoList.js";
import {
type CardContentSegment,
maskMarkdownImagesForStreaming,
@@ -66,6 +67,7 @@ export class StreamingAgentCard {
private currentMessageId: string | null = null;
private text = "";
private reasoningText = "";
private todos: readonly AgentTodoItem[] = [];
private runStartedAt = Date.now();
private toolUseElapsedMs: number | undefined;
private flushChain: Promise<void> = Promise.resolve();
@@ -123,6 +125,10 @@ export class StreamingAgentCard {
error: string | undefined;
durationMs: number | undefined;
}): void {
if (isTodoWriteTool(params.toolName)) {
const next = parseTodoWriteInput(params.input);
if (next !== null) this.todos = next;
}
recordToolUseEnd({ runId: this.runId, ...params });
this.scheduleFlush();
}
@@ -235,6 +241,7 @@ export class StreamingAgentCard {
? contentSegments
: undefined,
reasoningText: this.reasoningText || undefined,
todos: this.todos.length > 0 ? this.todos : undefined,
toolUseSteps,
toolUseElapsedMs: this.toolUseElapsedMs,
isError,
@@ -253,6 +260,7 @@ export class StreamingAgentCard {
phase,
text: chunk,
reasoningText: undefined,
todos: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
@@ -274,6 +282,7 @@ export class StreamingAgentCard {
phase,
text: chunk,
reasoningText: undefined,
todos: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,