mirror of
https://github.com/getpaseo/paseo.git
synced 2026-09-14 20:36:44 +08:00
feat(plugins): let plugins transform and render timeline items (#3940)
* feat(plugins): add timeline item contributions Keep canonical daemon history unchanged while plugins project and render custom timeline items in clients. Live matches refresh authoritative projected history before replacement so lifecycle deltas stay collapsed. * fix(nix): update npm dependency hash The plugin SDK lockfile change invalidated the fixed-output npm dependency derivation. Use the hash calculated by the macOS Nix build for the current lockfile.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Pi tasks timeline plugin example
|
||||
|
||||
This example implements the behavior proposed in Paseo PR #3751 as a plugin. It recognizes
|
||||
completed Pi `todo` tool calls from both `@juicesharp/rpiv-todo` and Pi's
|
||||
`examples/extensions/todo.ts`, replaces the tool-call entry with a versioned `pi-task-list` item,
|
||||
and renders the current task snapshot with a native React Native component.
|
||||
|
||||
The rpiv shape preserves `pending`, `in_progress`, and `completed`; deleted tombstones are omitted.
|
||||
Pi's example shape maps `done` to `completed` or `pending`. Malformed results and unrelated tool
|
||||
calls return `undefined`, leaving Paseo's original timeline entry unchanged.
|
||||
|
||||
The transformer is a pure client contribution. It receives the daemon's projected timeline item,
|
||||
returns plain plugin item objects, and runs against projected history. Matching live events refresh
|
||||
the authoritative projected tail first. The renderer validates `data` before Paseo mounts the
|
||||
component.
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { PiTaskList } from "./pi-tasks.client";
|
||||
import { piTaskListSchema, transformPiTodoToolCall } from "./pi-tasks";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.addTimelineTransformer({
|
||||
id: "pi-tasks",
|
||||
query: { itemType: "tool_call" },
|
||||
transform: transformPiTodoToolCall,
|
||||
});
|
||||
plugin.addTimelineRenderer({
|
||||
kind: "pi-task-list",
|
||||
version: 1,
|
||||
schema: piTaskListSchema,
|
||||
Component: PiTaskList,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "pi-tasks-timeline"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { PluginTimelineItemProps } from "@getpaseo/plugin";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import type { z } from "zod";
|
||||
import { piTaskListSchema } from "./pi-tasks";
|
||||
|
||||
type TaskListData = z.output<typeof piTaskListSchema>;
|
||||
|
||||
const taskMarker = {
|
||||
completed: "✓",
|
||||
in_progress: "◐",
|
||||
pending: "○",
|
||||
} as const;
|
||||
|
||||
export function PiTaskList({ item, theme }: PluginTimelineItemProps<TaskListData>) {
|
||||
const completed = item.data.tasks.filter((task) => task.status === "completed").length;
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
card: {
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
},
|
||||
title: { color: theme.colors.foreground, fontWeight: "600" as const },
|
||||
progress: { color: theme.colors.foregroundMuted },
|
||||
task: { flexDirection: "row" as const, gap: 8 },
|
||||
completed: { color: theme.colors.statusSuccess },
|
||||
inProgress: { color: theme.colors.accent },
|
||||
pending: { color: theme.colors.foregroundMuted },
|
||||
taskText: { color: theme.colors.foreground, flex: 1 },
|
||||
completedText: { color: theme.colors.foregroundMuted, flex: 1 },
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Pi tasks</Text>
|
||||
<Text style={styles.progress}>
|
||||
{completed}/{item.data.tasks.length}
|
||||
</Text>
|
||||
</View>
|
||||
{item.data.tasks.map((task) => {
|
||||
let markerStyle = styles.pending;
|
||||
if (task.status === "completed") markerStyle = styles.completed;
|
||||
if (task.status === "in_progress") markerStyle = styles.inProgress;
|
||||
return (
|
||||
<View key={task.text} style={styles.task}>
|
||||
<Text style={markerStyle}>{taskMarker[task.status]}</Text>
|
||||
<Text style={task.status === "completed" ? styles.completedText : styles.taskText}>
|
||||
{task.text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transformPiTodoToolCall } from "./pi-tasks";
|
||||
|
||||
function completedTodo(output: unknown) {
|
||||
return {
|
||||
type: "tool_call" as const,
|
||||
callId: "todo-1",
|
||||
name: "todo",
|
||||
status: "completed" as const,
|
||||
error: null,
|
||||
detail: { type: "unknown" as const, input: {}, output },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Pi task timeline example", () => {
|
||||
it("maps @juicesharp/rpiv-todo and drops deleted tasks", () => {
|
||||
const result = transformPiTodoToolCall({
|
||||
item: completedTodo({
|
||||
content: [{ type: "text", text: "Created #2" }],
|
||||
details: {
|
||||
tasks: [
|
||||
{ id: 1, subject: "alpha task", status: "completed", owner: "agent" },
|
||||
{ id: 2, subject: "beta task", status: "in_progress" },
|
||||
{ id: 3, subject: "gamma task", status: "pending" },
|
||||
{ id: 4, subject: "deleted task", status: "deleted" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
type: "plugin",
|
||||
kind: "pi-task-list",
|
||||
version: 1,
|
||||
data: {
|
||||
tasks: [
|
||||
{ text: "alpha task", status: "completed" },
|
||||
{ text: "beta task", status: "in_progress" },
|
||||
{ text: "gamma task", status: "pending" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Pi's example todo extension", () => {
|
||||
const result = transformPiTodoToolCall({
|
||||
item: completedTodo({
|
||||
details: {
|
||||
todos: [
|
||||
{ id: 1, text: "alpha task", done: true },
|
||||
{ id: 2, text: "beta task", done: false },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result?.items[0]?.data).toEqual({
|
||||
tasks: [
|
||||
{ text: "alpha task", status: "completed" },
|
||||
{ text: "beta task", status: "pending" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unrelated and malformed tool calls unchanged", () => {
|
||||
expect(
|
||||
transformPiTodoToolCall({
|
||||
item: { ...completedTodo({ details: { todos: [] } }), name: "write" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
transformPiTodoToolCall({ item: completedTodo({ details: { phases: [] } }) }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { PluginTimelineTransformerContribution } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
const taskStatusSchema = z.enum(["pending", "in_progress", "completed"]);
|
||||
|
||||
const rpivDetailsSchema = z.object({
|
||||
tasks: z.array(
|
||||
z
|
||||
.object({
|
||||
subject: z.string(),
|
||||
status: z.enum(["pending", "in_progress", "completed", "deleted"]),
|
||||
})
|
||||
.passthrough(),
|
||||
),
|
||||
});
|
||||
|
||||
const piExampleDetailsSchema = z.object({
|
||||
todos: z.array(
|
||||
z
|
||||
.object({
|
||||
id: z.number().int().optional(),
|
||||
text: z.string(),
|
||||
done: z.boolean(),
|
||||
})
|
||||
.passthrough(),
|
||||
),
|
||||
});
|
||||
|
||||
export const piTaskListSchema = z.object({
|
||||
tasks: z.array(
|
||||
z.object({
|
||||
text: z.string(),
|
||||
status: taskStatusSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type PiTask = z.output<typeof piTaskListSchema>["tasks"][number];
|
||||
type ToolCallTransformer = PluginTimelineTransformerContribution<"tool_call">["transform"];
|
||||
|
||||
function replacement(tasks: PiTask[]) {
|
||||
if (tasks.length === 0) return;
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "pi-task-list",
|
||||
version: 1,
|
||||
data: { tasks },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const transformPiTodoToolCall: ToolCallTransformer = ({ item }) => {
|
||||
if (item.name !== "todo" || item.status !== "completed" || item.detail.type !== "unknown") {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = item.detail.output;
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) return;
|
||||
const details = Reflect.get(result, "details");
|
||||
|
||||
const rpiv = rpivDetailsSchema.safeParse(details);
|
||||
if (rpiv.success) {
|
||||
return replacement(
|
||||
rpiv.data.tasks.flatMap((task): PiTask[] =>
|
||||
task.status === "deleted" ? [] : [{ text: task.subject, status: task.status }],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const piExample = piExampleDetailsSchema.safeParse(details);
|
||||
if (piExample.success) {
|
||||
return replacement(
|
||||
piExample.data.todos.map((todo) => ({
|
||||
text: todo.text,
|
||||
status: todo.done ? "completed" : "pending",
|
||||
})),
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user