TodoTool

Render task list changes from input.todos, optionally diffed against output.oldTodos.

Audit components
Tighten spacing
Ship updates
Getting Started
$pnpm dlx shadcn@latest add https://agent-elements.21st.dev/r/todo-tool.json
Examples
New list
Audit components
Tighten spacing
Ship updates
Single update
Audit components
Tighten spacing
Ship updates
Multiple updates
Audit components
Tighten spacing
Ship updates
Pending update
Updating to-dos...
Source

Copy and paste the following code into your project, or run the install command above to pull it in automatically.

components/tools/todo-tool.tsx
import { memo, useMemo } from "react";
import { CheckIcon, IconArrowRight } from "../../icons";
import { TextShimmer } from "../text-shimmer";
import { getToolStatus, areToolPropsEqual } from "../../utils/format-tool";
import { cn } from "../../utils/cn";

export type TodoItem = {
  content: string;
  status: "pending" | "in_progress" | "completed";
  activeForm?: string;
};

export type TodoToolProps = {
  part: any;
  chatStatus?: string;
};

export type TodoChange = {
  todo: TodoItem;
  oldStatus?: TodoItem["status"];
  newStatus: TodoItem["status"];
  index: number;
};

type ChangeType = "creation" | "single" | "multiple";

export type DetectedChanges = {
  type: ChangeType;
  items: TodoChange[];
};

function detectChanges(
  oldTodos: TodoItem[],
  newTodos: TodoItem[],
): DetectedChanges {
  if (!oldTodos || oldTodos.length === 0) {
    return {
      type: "creation",
      items: newTodos.map((todo, index) => ({
        todo,
        newStatus: todo.status,
        index,
      })),
    };
  }

  const changes: TodoChange[] = [];
  newTodos.forEach((newTodo, index) => {
    const oldTodo = oldTodos[index];
    if (!oldTodo || oldTodo.status !== newTodo.status) {
      changes.push({
        todo: newTodo,
        oldStatus: oldTodo?.status,
        newStatus: newTodo.status,
        index,
      });
    }
  });

  if (changes.length === 1) return { type: "single", items: changes };
  return { type: "multiple", items: changes };
}

const TodoStatusIcon = ({
  status,
  isPending,
}: {
  status: TodoItem["status"];
  isPending?: boolean;
}) => {
  if (isPending && status === "in_progress") {
    return (
      <div className="w-3.5 h-3.5 rounded-full flex items-center justify-center shrink-0 border border-an-foreground-muted/60">
        <IconArrowRight className="w-2 h-2 text-an-foreground-muted/70" />
      </div>
    );
  }

  switch (status) {
    case "completed":
      return (
        <div className="w-3.5 h-3.5 rounded-full flex items-center justify-center shrink-0 border border-an-foreground-muted/40">
          <CheckIcon className="w-2 h-2 text-an-foreground-muted/70" />
        </div>
      );
    case "in_progress":
      return (
        <div className="w-3.5 h-3.5 rounded-full flex items-center justify-center shrink-0 border border-an-foreground-muted/60">
          <IconArrowRight className="w-2 h-2 text-an-foreground-muted/70" />
        </div>
      );
    default:
      return (
        <div className="w-3.5 h-3.5 rounded-full flex items-center justify-center shrink-0 border border-an-foreground-muted/60" />
      );
  }
};

const TodoListItem = memo(function TodoListItem({
  todo,
  isPending,
}: {
  todo: TodoItem;
  isPending: boolean;
}) {
  return (
    <div className={cn("flex items-start gap-2")}>
      <div className="mt-[2px]">
        <TodoStatusIcon status={todo.status} isPending={isPending} />
      </div>
      <span
        className={cn(
          "text-sm",
          todo.status === "completed" && "line-through",
          isPending || todo.status === "completed" || todo.status === "pending"
            ? "text-an-foreground/60"
            : "text-an-foreground/80",
        )}
      >
        {todo.content}
      </span>
    </div>
  );
});

export const TodoTool = memo(function TodoTool({
  part,
  chatStatus,
}: TodoToolProps) {
  const { isPending } = getToolStatus(part, chatStatus);

  const isStreaming = part.state === "input-streaming";
  const oldTodos: TodoItem[] = part.output?.oldTodos || [];
  const newTodos: TodoItem[] = part.input?.todos || part.output?.newTodos || [];

  const isCreation = oldTodos.length === 0;
  const changes = useMemo(
    () => detectChanges(oldTodos, newTodos),
    [oldTodos, newTodos],
  );

  // Streaming placeholder — always shimmer while in this transient state.
  if (isStreaming || newTodos.length === 0) {
    return (
      <div className="space-y-2 text-sm leading-relaxed text-an-foreground/80">
        <div className="text-an-foreground/60">
          <TextShimmer
            as="span"
            duration={1.2}
            className="inline-flex items-center text-sm leading-none h-4 m-0"
          >
            {isCreation ? "Creating to-do list..." : "Updating to-dos..."}
          </TextShimmer>
        </div>
      </div>
    );
  }

  // Single update - show full list for clarity
  if (changes.type === "single") {
    return (
      <div className="space-y-2 text-sm leading-relaxed text-an-foreground/80">
        {newTodos.map((todo, idx) => (
          <TodoListItem key={idx} todo={todo} isPending={isPending} />
        ))}
      </div>
    );
  }

  // Multiple updates - show full list for clarity
  if (changes.type === "multiple") {
    return (
      <div className="space-y-2 text-sm leading-relaxed text-an-foreground/80">
        {newTodos.map((todo, idx) => (
          <TodoListItem key={idx} todo={todo} isPending={isPending} />
        ))}
      </div>
    );
  }

  const displayTodos = newTodos;
  return (
    <div className="space-y-2 text-sm leading-relaxed text-an-foreground/80">
      {displayTodos.map((todo, idx) => (
        <TodoListItem key={idx} todo={todo} isPending={isPending} />
      ))}
    </div>
  );
}, areToolPropsEqual);
Also installs:
API reference
Prop
Type
Required
part
any
Yes
chatStatus
string
No