Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ function _MessageListItem({
canContinue={toolCallSummary?.canContinue ?? false}
onContinue={handleContinue}
readonly={readonly}
streaming={streaming === true}
toolCall={toolCall}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ToolCallInput } from "@llm-space/core";

type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";

export interface TodoItem {
readonly content: string;
readonly status: TodoStatus;
}

/**
* Parse a todo_write input for its current rendering phase.
*
* Streaming input keeps the specialized view mounted and omits incomplete
* items. Static malformed input returns null so callers can use the generic
* tool input view.
*/
export function parseTodoWriteInput(
input: ToolCallInput,
streaming: boolean
): readonly TodoItem[] | null {
if (input.name !== "todo_write") {
return null;
}
const rawTodos = (input.arguments as Record<string, unknown>)?.todos;
if (!Array.isArray(rawTodos)) {
return streaming ? [] : null;
}

const todos: TodoItem[] = [];
for (const rawTodo of rawTodos) {
const todo = _parseTodo(rawTodo);
if (todo === null) {
if (streaming) {
continue;
}
return null;
}
todos.push(todo);
}
if (todos.length === 0 && !streaming) {
return null;
}
return todos;
}

function _parseTodo(rawTodo: unknown): TodoItem | null {
if (typeof rawTodo !== "object" || rawTodo === null) {
return null;
}
const todo = rawTodo as Record<string, unknown>;
if (typeof todo.content !== "string" || todo.content === "") {
return null;
}
if (
todo.status !== "pending" &&
todo.status !== "in_progress" &&
todo.status !== "completed" &&
todo.status !== "cancelled"
) {
return null;
}
return { content: todo.content, status: todo.status };
}
Original file line number Diff line number Diff line change
@@ -1,130 +1,69 @@
import { type ToolCallInput } from "@llm-space/core";
import { CheckIcon, EyeIcon } from "lucide-react";
import { memo, useMemo, useState } from "react";
import { CheckIcon, XIcon } from "lucide-react";
import { memo } from "react";

import { PreviewDialog } from "@llm-space/ui/components/preview-dialog-lazy";
import { Tooltip } from "@llm-space/ui/components/tooltip";
import { cn } from "@llm-space/ui/lib/utils";
import { Button } from "@llm-space/ui/ui/button";

import type { TodoItem } from "./todo-write-input";

type TodoStatus = "pending" | "in_progress" | "completed";

interface TodoItem {
content: string;
status: TodoStatus;
}
type TodoStatus = TodoItem["status"];

/**
* Validate a tool call's input against the `todo_write` shape and, on success,
* return the normalized todo list. Returns `null` for any other tool or a
* malformed payload, so the caller falls back to the default input view.
* A read-only, card-style rendering of a `todo_write` call.
*/
export function parseTodoWriteInput(input: ToolCallInput): TodoItem[] | null {
if (input.name !== "todo_write") {
return null;
}
const rawTodos = (input.arguments as Record<string, unknown>)?.todos;
if (!Array.isArray(rawTodos) || rawTodos.length === 0) {
return null;
}

const todos: TodoItem[] = [];
for (const rawTodo of rawTodos) {
if (typeof rawTodo !== "object" || rawTodo === null) {
return null;
}
const t = rawTodo as Record<string, unknown>;
if (typeof t.content !== "string" || t.content === "") {
return null;
}
todos.push({
content: t.content,
status: _normalizeStatus(t.status),
});
}
return todos;
}

function _normalizeStatus(value: unknown): TodoStatus {
return value === "completed" || value === "in_progress" ? value : "pending";
}

/**
* A read-only, card-style rendering of a `todo_write` call: each todo is a row
* with a leading status affordance — an empty circle (pending), a spinner
* (in_progress), or a filled check (completed, struck through and dimmed).
*/
function _TodoWriteView({
todos,
input,
}: {
todos: TodoItem[];
input: ToolCallInput;
}) {
const [previewOpen, setPreviewOpen] = useState(false);
const previewValue = useMemo(
() => JSON.stringify(input.arguments, null, 2) ?? "",
[input.arguments]
);
function _TodoWriteView({ todos }: { todos: readonly TodoItem[] }) {
return (
<div className="flex w-full flex-col gap-2 rounded-lg bg-(--textarea) px-3 py-2.5 select-auto">
<div className="text-muted-foreground flex items-center justify-between text-xs">
<div
className="flex w-full flex-col gap-2 rounded-lg bg-(--textarea) px-3 py-2.5 select-auto"
data-slot="todo-write-view"
>
<div className="text-muted-foreground text-xs">
<span className="font-mono">
<span className="text-primary">todo_write</span>
<span>()</span>
</span>
<div className="flex items-center gap-2">
<Tooltip content="View arguments">
<Button
className="invisible shrink-0 group-hover/message:visible"
size="xs"
variant="ghost"
onClick={() => setPreviewOpen(true)}
>
<EyeIcon className="size-3" />
</Button>
</Tooltip>
</div>
</div>
<PreviewDialog
open={previewOpen}
title="Arguments of todo_write()"
type="json"
value={previewValue}
onOpenChange={setPreviewOpen}
/>
<ul className="flex flex-col gap-0.5">
{todos.map((todo, index) => (
<TodoRow key={index} todo={todo} />
<TodoRow key={index} content={todo.content} status={todo.status} />
))}
</ul>
</div>
);
}
export const TodoWriteView = memo(_TodoWriteView);
export const TodoWriteView = memo(_TodoWriteView, (previous, next) =>
_areTodosEqual(previous.todos, next.todos)
);

function TodoRow({ todo }: { todo: TodoItem }) {
const completed = todo.status === "completed";
const inProgress = todo.status === "in_progress";
function _TodoRow({
content,
status,
}: {
content: string;
status: TodoStatus;
}) {
const completed = status === "completed";
const cancelled = status === "cancelled";
const inProgress = status === "in_progress";
return (
<li className="flex items-start gap-2 py-1 text-sm">
<TodoStatusIcon status={todo.status} />
<_TodoStatusIcon status={status} />
<span
className={cn(
"min-w-0 leading-5",
completed && "text-muted-foreground line-through opacity-70",
(completed || cancelled) &&
"text-muted-foreground line-through opacity-70",
inProgress && "text-primary font-medium",
!completed && !inProgress && "text-foreground/90"
!completed && !cancelled && !inProgress && "text-foreground/90"
)}
>
{todo.content}
{content}
</span>
</li>
);
}
const TodoRow = memo(_TodoRow);

function TodoStatusIcon({ status }: { status: TodoStatus }) {
function _TodoStatusIcon({ status }: { status: TodoStatus }) {
if (status === "completed") {
return (
<span className="border-muted-foreground/40 bg-muted-foreground/30 text-background mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border">
Expand All @@ -139,7 +78,28 @@ function TodoStatusIcon({ status }: { status: TodoStatus }) {
</span>
);
}
if (status === "cancelled") {
return (
<span className="border-muted-foreground/40 text-muted-foreground mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border">
<XIcon className="size-3" />
</span>
);
}
return (
<span className="border-muted-foreground/50 mt-0.5 size-4 shrink-0 rounded-full border" />
);
}

function _areTodosEqual(
previous: readonly TodoItem[],
next: readonly TodoItem[]
): boolean {
return (
previous.length === next.length &&
previous.every(
(todo, index) =>
todo.content === next[index]?.content &&
todo.status === next[index]?.status
)
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
} from "@llm-space/ui/ui/dropdown-menu";


import { parseTodoWriteInput, TodoWriteView } from "./todo-write-view";
import { parseTodoWriteInput } from "./todo-write-input";
import { TodoWriteView } from "./todo-write-view";

/**
* Built-in `fs` tools whose `path` argument is an absolute on-disk path worth
Expand Down Expand Up @@ -88,10 +89,16 @@ function _linkKindFor(
return undefined;
}

function _ToolCallInputView({ input }: { input: ToolCallInput }) {
const todos = parseTodoWriteInput(input);
if (todos) {
return <TodoWriteView todos={todos} input={input} />;
function _ToolCallInputView({
input,
streaming,
}: {
input: ToolCallInput;
streaming: boolean;
}) {
const todos = parseTodoWriteInput(input, streaming);
if (todos !== null) {
return <TodoWriteView todos={todos} />;
}

const args = input.arguments as Record<string, unknown>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ function _ToolCallListItem({
canContinue,
onContinue,
readonly = false,
streaming,
}: {
context?: ThreadContext;
messageId: string;
toolCall: ToolCall;
canContinue: boolean;
onContinue: () => void;
readonly?: boolean;
streaming: boolean;
}) {
const { fidelity } = useRenderingFidelity();
const { presentational } = useHostServices();
Expand Down Expand Up @@ -139,7 +141,10 @@ function _ToolCallListItem({
return (
<div className="bg-foreground/4 flex w-full flex-col gap-2 rounded-md px-3 pt-2 pb-3">
<div className="relative flex min-w-0 items-start">
<ToolCallInputView input={toolCall.input} />
<ToolCallInputView
input={toolCall.input}
streaming={streaming && toolCall.output === undefined}
/>
<div className="absolute top-0 right-0 flex items-center">
<Tooltip content="Preview arguments">
<Button
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test";

import type { ToolCallInput } from "@llm-space/core";

import { parseTodoWriteInput } from "../../../../src/components/thread-playground/message/todo-write-input";

function _createInput(
name: string,
argumentsValue: Record<string, unknown>,
partialArguments?: string
): ToolCallInput {
return {
name,
arguments: argumentsValue,
partialArguments,
};
}

describe("parseTodoWriteInput", () => {
test("preserves every supported todo status", () => {
const todos = [
{ content: "Pending", status: "pending" },
{ content: "Active", status: "in_progress" },
{ content: "Done", status: "completed" },
{ content: "Stopped", status: "cancelled" },
] satisfies NonNullable<ReturnType<typeof parseTodoWriteInput>>;
const input = _createInput("todo_write", { todos });

expect(parseTodoWriteInput(input, false)).toEqual(todos);
});

test("keeps only semantically complete todos while streaming", () => {
const input = _createInput("todo_write", {
todos: [
{ content: "Ready", status: "pending" },
{ content: "Missing status" },
{ status: "cancelled" },
null,
],
});

expect(parseTodoWriteInput(input, true)).toEqual([
{ content: "Ready", status: "pending" },
]);
});

test("does not specialize non-todo tool calls", () => {
const input = _createInput("other_tool", { todos: [] });

expect(parseTodoWriteInput(input, true)).toBeNull();
});

test("does not treat partialArguments as streaming state", () => {
const input = _createInput(
"todo_write",
{ todos: [{ content: "Interrupted" }] },
'{"todos":[{"content":"Interrupted"'
);

expect(parseTodoWriteInput(input, false)).toBeNull();
expect(parseTodoWriteInput(input, true)).toEqual([]);
});

test("falls back for persisted malformed input", () => {
const input = _createInput("todo_write", {
todos: [
{ content: "Complete", status: "completed" },
{ content: "Interrupted" },
],
});

expect(parseTodoWriteInput(input, false)).toBeNull();
});
});
Loading