@danypops/papyrus 0.11.4 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +173 -0
- package/extension/src/context-view.ts +172 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +124 -38
- package/extension/src/notes.ts +16 -4
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/task-widget.ts +13 -1
- package/extension/src/tasks.ts +51 -15
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +77 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +285 -33
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/skill-definition.ts +57 -8
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +201 -40
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/skill-execution.ts +169 -75
- package/src/task-service.ts +70 -38
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PAPYRUS_TASK_FOCUS_CHANNEL, PAPYRUS_TASK_FOCUS_SCHEMA } from "../../src/constants.ts";
|
|
3
|
+
|
|
4
|
+
export type TaskFocusStatus = "focused" | "paused" | "unpaused" | "cleared";
|
|
5
|
+
|
|
6
|
+
export interface TaskFocusEvent {
|
|
7
|
+
schema: typeof PAPYRUS_TASK_FOCUS_SCHEMA;
|
|
8
|
+
taskId: string | null;
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
status: TaskFocusStatus;
|
|
11
|
+
observedAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TaskFocusEventInput {
|
|
15
|
+
taskId: string | null;
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
status: TaskFocusStatus;
|
|
18
|
+
observedAt?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pure event builder, mirroring buildContextInjection's shape: no task title, body, or any other
|
|
23
|
+
* artifact content -- only the id, session, lifecycle status, and timestamp, which are already
|
|
24
|
+
* public metadata a caller with the id could look up directly. This is the payload emitted on
|
|
25
|
+
* papyrus.task-focus.v1, the analogue of papyrus.context-injection.v1, so extensions such as a
|
|
26
|
+
* token-cost router can correlate their own telemetry with the currently focused task without
|
|
27
|
+
* Papyrus depending on them.
|
|
28
|
+
*/
|
|
29
|
+
export function buildTaskFocusEvent(input: TaskFocusEventInput): TaskFocusEvent {
|
|
30
|
+
if (input.status !== "cleared" && input.taskId === null) throw new Error(`task-focus event of status "${input.status}" requires a taskId`);
|
|
31
|
+
return {
|
|
32
|
+
schema: PAPYRUS_TASK_FOCUS_SCHEMA,
|
|
33
|
+
taskId: input.taskId,
|
|
34
|
+
status: input.status,
|
|
35
|
+
observedAt: input.observedAt ?? Date.now(),
|
|
36
|
+
...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type EventBusHost = Pick<ExtensionAPI, "events">;
|
|
41
|
+
|
|
42
|
+
let bus: EventBusHost | undefined;
|
|
43
|
+
|
|
44
|
+
/** Call once from the extension entry point so call sites that only receive `ctx` (not `pi`) can still emit. */
|
|
45
|
+
export function setTaskFocusEventBus(host: EventBusHost): void {
|
|
46
|
+
bus = host;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resetTaskFocusEventBusForTests(): void {
|
|
50
|
+
bus = undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Best-effort broadcast: never throws, since a missing bus (e.g. an uninitialized test harness) must not break the focus operation it accompanies. */
|
|
54
|
+
export function emitTaskFocusEvent(input: TaskFocusEventInput): void {
|
|
55
|
+
if (!bus) return;
|
|
56
|
+
bus.events.emit(PAPYRUS_TASK_FOCUS_CHANNEL, buildTaskFocusEvent(input));
|
|
57
|
+
}
|
|
@@ -8,6 +8,18 @@ export interface TaskWidgetRow {
|
|
|
8
8
|
hasOpenChildren: boolean;
|
|
9
9
|
active: boolean;
|
|
10
10
|
focusStatus?: "active" | "paused";
|
|
11
|
+
/**
|
|
12
|
+
* Task containment is a DAG, not a tree: a task may have more than one parent (design
|
|
13
|
+
* decision -- see decide-and-execute... no single-parent enforcement was ever wanted).
|
|
14
|
+
* This bounded widget still renders one spanning tree (it only has room for one position
|
|
15
|
+
* per task), so a multi-parent task is only ever shown once, under whichever parent this
|
|
16
|
+
* walk reaches first -- exactly the git-log-graph / npm-ls-dedup pattern of picking one
|
|
17
|
+
* canonical position and flagging the rest, rather than silently dropping the information.
|
|
18
|
+
* parentCount > 1 means "this task also lives under other parents not shown here" --
|
|
19
|
+
* the full DAG (every parent edge, not just one) is always available via the task graph's
|
|
20
|
+
* composition view, which renders true multi-parent edges through Mermaid's flowchart layout.
|
|
21
|
+
*/
|
|
22
|
+
parentCount: number;
|
|
11
23
|
}
|
|
12
24
|
|
|
13
25
|
export interface TaskWidgetProjection {
|
|
@@ -36,7 +48,7 @@ export function buildTaskWidgetProjection(
|
|
|
36
48
|
if (!node) return;
|
|
37
49
|
visited.add(id);
|
|
38
50
|
const open = isOpen(node.task);
|
|
39
|
-
if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true, focusStatus: node.focusStatus });
|
|
51
|
+
if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true, focusStatus: node.focusStatus, parentCount: node.parentIds.length });
|
|
40
52
|
const childDepth = open ? openDepth + 1 : openDepth;
|
|
41
53
|
for (const childId of node.childIds) visit(childId, childDepth);
|
|
42
54
|
};
|
package/extension/src/tasks.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
|
7
7
|
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Container, Input, Spacer, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
9
|
import { callService } from "./service-client.ts";
|
|
10
|
+
import { emitTaskFocusEvent } from "./task-focus-events.ts";
|
|
10
11
|
import { showTaskDetails } from "./task-detail-view.ts";
|
|
11
12
|
import { showTaskGraph } from "./task-graph.ts";
|
|
12
13
|
|
|
@@ -56,10 +57,11 @@ export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
|
|
|
56
57
|
return result;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
async function loadTaskGraph(projectRoot: string, scope?: "project" | "graph" | "all", rootTaskId?: string): Promise<TaskGraph> {
|
|
60
|
+
async function loadTaskGraph(projectRoot: string, sessionId: string, scope?: "project" | "graph" | "all", rootTaskId?: string): Promise<TaskGraph> {
|
|
60
61
|
return callService<Record<string, unknown>, TaskGraph>("tasks.graph", {
|
|
61
62
|
limit: 200,
|
|
62
63
|
project_root: projectRoot,
|
|
64
|
+
session_id: sessionId,
|
|
63
65
|
...(scope ? { scope } : {}),
|
|
64
66
|
...(rootTaskId ? { root_task_id: rootTaskId } : {}),
|
|
65
67
|
});
|
|
@@ -70,14 +72,17 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
70
72
|
ctx.ui.notify("/tasks requires interactive mode", "warning");
|
|
71
73
|
return;
|
|
72
74
|
}
|
|
73
|
-
|
|
75
|
+
// Scopes this panel's "active"/Focus reads and writes to this Pi session, so a second
|
|
76
|
+
// concurrent agent working the same project never appears as (or is overridden by) this one.
|
|
77
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
78
|
+
let graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
74
79
|
if (graph.nodes.length === 0) {
|
|
75
80
|
const create = await ctx.ui.select("No tasks yet", ["Create a task", "Cancel"]);
|
|
76
81
|
if (create === "Create a task") {
|
|
77
82
|
const title = await ctx.ui.input("Task title:", "");
|
|
78
83
|
if (title) {
|
|
79
|
-
await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui" });
|
|
80
|
-
graph = await loadTaskGraph(ctx.cwd);
|
|
84
|
+
await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
85
|
+
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
if (graph.nodes.length === 0) return;
|
|
@@ -86,14 +91,14 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
86
91
|
for (;;) {
|
|
87
92
|
const action = await renderPanel(ctx, graph);
|
|
88
93
|
if (!action) return;
|
|
89
|
-
if (action.type === "refresh") { graph = await loadTaskGraph(ctx.cwd); continue; }
|
|
94
|
+
if (action.type === "refresh") { graph = await loadTaskGraph(ctx.cwd, sessionId); continue; }
|
|
90
95
|
if (action.type === "scope") {
|
|
91
96
|
const choice = await ctx.ui.select("Task scope", ["Current project", "Focused graph", "All projects"]);
|
|
92
97
|
if (!choice) continue;
|
|
93
98
|
const scope: "project" | "graph" | "all" = choice === "Current project" ? "project" : choice === "All projects" ? "all" : "graph";
|
|
94
99
|
let rootTaskId: string | undefined;
|
|
95
100
|
if (scope === "graph") {
|
|
96
|
-
const projectGraph = await loadTaskGraph(ctx.cwd, "project");
|
|
101
|
+
const projectGraph = await loadTaskGraph(ctx.cwd, sessionId, "project");
|
|
97
102
|
const roots = projectGraph.rootIds.map((id) => projectGraph.nodes.find((node) => node.task.id === id)?.task).filter((task): task is Artifact => task !== undefined);
|
|
98
103
|
const selected = await ctx.ui.select("Focused root or epic", roots.map((task) => `${task.title} · ${task.id}`));
|
|
99
104
|
if (!selected) continue;
|
|
@@ -101,25 +106,49 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
101
106
|
if (!rootTaskId) continue;
|
|
102
107
|
}
|
|
103
108
|
await callService("tasks.set_scope", { project_root: ctx.cwd, scope, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) });
|
|
104
|
-
graph = await loadTaskGraph(ctx.cwd);
|
|
109
|
+
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
105
110
|
continue;
|
|
106
111
|
}
|
|
107
112
|
if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
|
|
108
113
|
if (action.type !== "action" || !action.row) continue;
|
|
109
114
|
|
|
110
|
-
const
|
|
111
|
-
const
|
|
115
|
+
const node = graph.nodes.find((entry) => entry.task.id === action.row!.id);
|
|
116
|
+
const active = node?.active === true;
|
|
117
|
+
const focusStatus = node?.focusStatus;
|
|
112
118
|
const choices = [
|
|
113
119
|
"Show details",
|
|
114
120
|
"Edit task",
|
|
115
121
|
...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
|
|
116
122
|
...(active ? [focusStatus === "paused" ? "Resume focus" : "Pause focus", "Clear focus"] : []),
|
|
117
123
|
...(action.row.status === "review" ? ["Run gates"] : []),
|
|
124
|
+
...((node?.dependencyIds.length ?? 0) > 0 ? ["Remove dependency"] : []),
|
|
125
|
+
...((node?.parentIds.length ?? 0) > 0 ? ["Remove from parent"] : []),
|
|
118
126
|
...(STATUS_ACTIONS[action.row.status] ?? []),
|
|
119
127
|
];
|
|
120
128
|
const choice = await ctx.ui.select(action.row.title, choices);
|
|
121
129
|
if (!choice) continue;
|
|
122
130
|
|
|
131
|
+
if (choice === "Remove dependency" || choice === "Remove from parent") {
|
|
132
|
+
const relatedIds = choice === "Remove dependency" ? node!.dependencyIds : node!.parentIds;
|
|
133
|
+
const relatedTitles = relatedIds.map((relatedId) => `${graph.nodes.find((entry) => entry.task.id === relatedId)?.task.title ?? relatedId} · ${relatedId}`);
|
|
134
|
+
const selected = await ctx.ui.select(choice === "Remove dependency" ? "Remove which dependency?" : "Remove from which parent?", relatedTitles);
|
|
135
|
+
if (!selected) continue;
|
|
136
|
+
const relatedId = relatedIds[relatedTitles.indexOf(selected)]!;
|
|
137
|
+
try {
|
|
138
|
+
if (choice === "Remove dependency") {
|
|
139
|
+
await callService("tasks.undepend", { id: action.row.id, dependency_id: relatedId, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
140
|
+
ctx.ui.notify(`Removed dependency on ${relatedId}`, "info");
|
|
141
|
+
} else {
|
|
142
|
+
await callService("tasks.uncontain", { parent_id: relatedId, child_id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
143
|
+
ctx.ui.notify(`Removed from parent ${relatedId}`, "info");
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
ctx.ui.notify(`Relationship removal failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
147
|
+
}
|
|
148
|
+
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
123
152
|
if (choice === "Show details") {
|
|
124
153
|
const art = await callService<Record<string, unknown>, Artifact | null>("tasks.show", { id: action.row.id });
|
|
125
154
|
if (!art) { ctx.ui.notify("Not found", "error"); continue; }
|
|
@@ -146,15 +175,22 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
146
175
|
}
|
|
147
176
|
} else if (choice === "Make active") {
|
|
148
177
|
try {
|
|
149
|
-
await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
178
|
+
const focused = await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
179
|
+
emitTaskFocusEvent({ taskId: focused.id, sessionId, status: "focused" });
|
|
150
180
|
ctx.ui.notify(`Active: ${action.row.title}`, "info");
|
|
151
181
|
} catch (error) {
|
|
152
182
|
ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
153
183
|
}
|
|
154
184
|
} else if (choice === "Pause focus" || choice === "Resume focus" || choice === "Clear focus") {
|
|
155
185
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
186
|
+
if (choice === "Clear focus") {
|
|
187
|
+
await callService("tasks.clear_focus", { actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
188
|
+
emitTaskFocusEvent({ taskId: null, sessionId, status: "cleared" });
|
|
189
|
+
} else {
|
|
190
|
+
const operation = choice === "Pause focus" ? "tasks.pause" : "tasks.unpause";
|
|
191
|
+
const result = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, { actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
192
|
+
emitTaskFocusEvent({ taskId: result.artifact.id, sessionId, status: choice === "Pause focus" ? "paused" : "unpaused" });
|
|
193
|
+
}
|
|
158
194
|
ctx.ui.notify(choice === "Clear focus" ? "Task focus cleared" : choice, "info");
|
|
159
195
|
} catch (error) {
|
|
160
196
|
ctx.ui.notify(`Focus action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
@@ -180,7 +216,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
180
216
|
? "tasks.cancel"
|
|
181
217
|
: "tasks.complete";
|
|
182
218
|
if (operation === "tasks.complete") {
|
|
183
|
-
const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
219
|
+
const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
184
220
|
action.row.status = result.artifact.status;
|
|
185
221
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
|
|
186
222
|
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}`).join("\n");
|
|
@@ -195,7 +231,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
195
231
|
result.completed ? "info" : "warning",
|
|
196
232
|
);
|
|
197
233
|
} else {
|
|
198
|
-
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
234
|
+
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
199
235
|
action.row.status = updated.status;
|
|
200
236
|
ctx.ui.notify(`${updated.id} → [${updated.status}]`, "info");
|
|
201
237
|
}
|
|
@@ -203,7 +239,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
203
239
|
ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
204
240
|
}
|
|
205
241
|
}
|
|
206
|
-
graph = await loadTaskGraph(ctx.cwd);
|
|
242
|
+
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
207
243
|
}
|
|
208
244
|
}
|
|
209
245
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ArtifactToolDetails } from "./render-model.ts";
|
|
4
|
+
|
|
5
|
+
const KIND_GLYPHS: Readonly<Record<string, string>> = {
|
|
6
|
+
task: "◇",
|
|
7
|
+
doc: "▤",
|
|
8
|
+
rule: "◆",
|
|
9
|
+
skill: "✦",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const STATUS_GLYPHS: Readonly<Record<string, string>> = {
|
|
13
|
+
done: "✓",
|
|
14
|
+
active: "●",
|
|
15
|
+
"in-progress": "●",
|
|
16
|
+
review: "◐",
|
|
17
|
+
rejected: "✗",
|
|
18
|
+
canceled: "×",
|
|
19
|
+
todo: "○",
|
|
20
|
+
draft: "○",
|
|
21
|
+
archived: "·",
|
|
22
|
+
deprecated: "·",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type SemanticColor = "success" | "error" | "warning" | "accent" | "muted";
|
|
26
|
+
|
|
27
|
+
function statusColor(status: string): SemanticColor {
|
|
28
|
+
if (status === "done" || status === "active") return "success";
|
|
29
|
+
if (status === "rejected" || status === "canceled") return "error";
|
|
30
|
+
if (status === "review") return "warning";
|
|
31
|
+
if (status === "in-progress") return "accent";
|
|
32
|
+
return "muted";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function kindGlyph(kind: string): string {
|
|
36
|
+
return KIND_GLYPHS[kind] ?? "•";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function statusGlyph(status: string): string {
|
|
40
|
+
return STATUS_GLYPHS[status] ?? "•";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function countSummary(returned: number, total: number): string {
|
|
44
|
+
return returned === total ? String(total) : `${returned} of ${total}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function emptyState(noun: string): string {
|
|
48
|
+
return `No ${noun}.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function treeConnector(last: boolean): string {
|
|
52
|
+
return last ? "└─" : "├─";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function expandHint(): string {
|
|
56
|
+
return "expand for details";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Reusable width-safe artifact card for native tool result rows. */
|
|
60
|
+
export class ArtifactCard implements Component {
|
|
61
|
+
private details: ArtifactToolDetails;
|
|
62
|
+
private theme: Theme;
|
|
63
|
+
private expanded: boolean;
|
|
64
|
+
private cachedWidth: number | undefined;
|
|
65
|
+
private cachedLines: string[] | undefined;
|
|
66
|
+
|
|
67
|
+
constructor(details: ArtifactToolDetails, theme: Theme, expanded: boolean) {
|
|
68
|
+
this.details = details;
|
|
69
|
+
this.theme = theme;
|
|
70
|
+
this.expanded = expanded;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
update(details: ArtifactToolDetails, theme: Theme, expanded: boolean): void {
|
|
74
|
+
this.details = details;
|
|
75
|
+
this.theme = theme;
|
|
76
|
+
this.expanded = expanded;
|
|
77
|
+
this.invalidate();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
render(width: number): string[] {
|
|
81
|
+
const safeWidth = Math.max(1, width);
|
|
82
|
+
if (this.cachedLines && this.cachedWidth === safeWidth) return this.cachedLines;
|
|
83
|
+
|
|
84
|
+
const artifact = this.details.artifact;
|
|
85
|
+
const status = `${statusGlyph(artifact.status)} ${artifact.status}`;
|
|
86
|
+
const header = [
|
|
87
|
+
this.theme.fg("toolTitle", this.theme.bold(`${kindGlyph(artifact.kind)} ${artifact.kind.toUpperCase()}`)),
|
|
88
|
+
this.theme.fg("accent", artifact.id),
|
|
89
|
+
this.theme.fg(statusColor(artifact.status), status),
|
|
90
|
+
].join(" ");
|
|
91
|
+
const lines = [truncateToWidth(header, safeWidth)];
|
|
92
|
+
lines.push(truncateToWidth(this.theme.fg("text", artifact.title), safeWidth));
|
|
93
|
+
|
|
94
|
+
if (this.expanded) {
|
|
95
|
+
const metadata = [artifact.subtype, ...artifact.labels].filter(Boolean).join(" · ");
|
|
96
|
+
if (metadata) lines.push(truncateToWidth(this.theme.fg("muted", metadata), safeWidth));
|
|
97
|
+
if (artifact.body) lines.push(...wrapTextWithAnsi(artifact.body, safeWidth));
|
|
98
|
+
if (this.details.completeness.truncated) {
|
|
99
|
+
lines.push(truncateToWidth(
|
|
100
|
+
this.theme.fg("warning", `[truncated ${this.details.completeness.omitted} characters]`),
|
|
101
|
+
safeWidth,
|
|
102
|
+
));
|
|
103
|
+
}
|
|
104
|
+
} else if (artifact.body || artifact.labels.length > 0) {
|
|
105
|
+
lines.push(truncateToWidth(this.theme.fg("dim", expandHint()), safeWidth));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.cachedWidth = safeWidth;
|
|
109
|
+
this.cachedLines = lines;
|
|
110
|
+
return lines;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
invalidate(): void {
|
|
114
|
+
this.cachedWidth = undefined;
|
|
115
|
+
this.cachedLines = undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { TOOL_COLLAPSED_ROW_LIMIT } from "../../../src/constants.ts";
|
|
4
|
+
import { countSummary, expandHint, kindGlyph, statusGlyph, treeConnector } from "./artifact-card.ts";
|
|
5
|
+
import type {
|
|
6
|
+
ArtifactListToolDetails,
|
|
7
|
+
GraphToolDetails,
|
|
8
|
+
ToolArtifactSummary,
|
|
9
|
+
} from "./render-model.ts";
|
|
10
|
+
|
|
11
|
+
function pluralKind(rows: readonly ToolArtifactSummary[]): string {
|
|
12
|
+
const kind = rows[0]?.kind ?? "artifact";
|
|
13
|
+
if (kind === "task") return "tasks";
|
|
14
|
+
if (kind === "doc") return "documents";
|
|
15
|
+
if (kind === "skill") return "skills";
|
|
16
|
+
if (kind === "rule") return "rules";
|
|
17
|
+
return "artifacts";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function statusSummary(rows: readonly ToolArtifactSummary[]): string {
|
|
21
|
+
const counts = new Map<string, number>();
|
|
22
|
+
for (const row of rows) counts.set(row.status, (counts.get(row.status) ?? 0) + 1);
|
|
23
|
+
return [...counts.entries()].map(([status, count]) => `${status} ${count}`).join(" · ");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rowLine(row: ToolArtifactSummary, expanded: boolean, theme: Theme): string {
|
|
27
|
+
const identity = expanded ? `${row.id} ` : "";
|
|
28
|
+
return [
|
|
29
|
+
theme.fg("muted", `${statusGlyph(row.status)} ${row.status}`),
|
|
30
|
+
theme.fg("accent", identity),
|
|
31
|
+
theme.fg("text", row.title),
|
|
32
|
+
].join(" ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rowMetadata(row: ToolArtifactSummary): string {
|
|
36
|
+
return [row.subtype, ...row.labels].filter(Boolean).join(" · ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Bounded collapsed/expanded artifact collection presentation. */
|
|
40
|
+
export class ArtifactListCard implements Component {
|
|
41
|
+
private details: ArtifactListToolDetails;
|
|
42
|
+
private theme: Theme;
|
|
43
|
+
private expanded: boolean;
|
|
44
|
+
private cachedWidth: number | undefined;
|
|
45
|
+
private cachedLines: string[] | undefined;
|
|
46
|
+
|
|
47
|
+
constructor(details: ArtifactListToolDetails, theme: Theme, expanded: boolean) {
|
|
48
|
+
this.details = details;
|
|
49
|
+
this.theme = theme;
|
|
50
|
+
this.expanded = expanded;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
update(details: ArtifactListToolDetails, theme: Theme, expanded: boolean): void {
|
|
54
|
+
this.details = details;
|
|
55
|
+
this.theme = theme;
|
|
56
|
+
this.expanded = expanded;
|
|
57
|
+
this.invalidate();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
render(width: number): string[] {
|
|
61
|
+
const safeWidth = Math.max(1, width);
|
|
62
|
+
if (this.cachedLines && this.cachedWidth === safeWidth) return this.cachedLines;
|
|
63
|
+
const rows = this.details.rows;
|
|
64
|
+
const noun = pluralKind(rows);
|
|
65
|
+
const lines = [truncateToWidth(
|
|
66
|
+
this.theme.fg("toolTitle", this.theme.bold(`${countSummary(rows.length, this.details.total)} ${noun}`)),
|
|
67
|
+
safeWidth,
|
|
68
|
+
)];
|
|
69
|
+
if (rows.length === 0) {
|
|
70
|
+
lines.push(truncateToWidth(this.theme.fg("dim", `No ${noun}.`), safeWidth));
|
|
71
|
+
} else {
|
|
72
|
+
lines.push(truncateToWidth(this.theme.fg("muted", statusSummary(rows)), safeWidth));
|
|
73
|
+
const display = this.expanded ? rows : rows.slice(0, TOOL_COLLAPSED_ROW_LIMIT);
|
|
74
|
+
for (const row of display) {
|
|
75
|
+
lines.push(truncateToWidth(rowLine(row, this.expanded, this.theme), safeWidth));
|
|
76
|
+
if (this.expanded) {
|
|
77
|
+
const metadata = rowMetadata(row);
|
|
78
|
+
if (metadata) lines.push(truncateToWidth(this.theme.fg("dim", ` ${metadata}`), safeWidth));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const omitted = Math.max(0, this.details.total - display.length);
|
|
82
|
+
if (omitted > 0) lines.push(truncateToWidth(this.theme.fg("dim", `${omitted} more · ${expandHint()}`), safeWidth));
|
|
83
|
+
}
|
|
84
|
+
this.cachedWidth = safeWidth;
|
|
85
|
+
this.cachedLines = lines;
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
invalidate(): void {
|
|
90
|
+
this.cachedWidth = undefined;
|
|
91
|
+
this.cachedLines = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface HierarchyRow {
|
|
96
|
+
node: ToolArtifactSummary;
|
|
97
|
+
prefix: string;
|
|
98
|
+
connector: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hierarchyRows(details: GraphToolDetails): HierarchyRow[] {
|
|
102
|
+
const byId = new Map(details.nodes.map((node) => [node.id, node]));
|
|
103
|
+
const childIds = new Map<string, string[]>();
|
|
104
|
+
const contained = new Set<string>();
|
|
105
|
+
for (const edge of details.edges) {
|
|
106
|
+
if (edge.relation !== "contains" || !byId.has(edge.from) || !byId.has(edge.to)) continue;
|
|
107
|
+
const children = childIds.get(edge.from) ?? [];
|
|
108
|
+
children.push(edge.to);
|
|
109
|
+
childIds.set(edge.from, children);
|
|
110
|
+
contained.add(edge.to);
|
|
111
|
+
}
|
|
112
|
+
const roots = details.nodes.filter((node) => !contained.has(node.id));
|
|
113
|
+
const rows: HierarchyRow[] = [];
|
|
114
|
+
const visited = new Set<string>();
|
|
115
|
+
const visit = (node: ToolArtifactSummary, prefix: string, connector: string): void => {
|
|
116
|
+
if (visited.has(node.id)) return;
|
|
117
|
+
visited.add(node.id);
|
|
118
|
+
rows.push({ node, prefix, connector });
|
|
119
|
+
const children = (childIds.get(node.id) ?? []).map((id) => byId.get(id)).filter((child): child is ToolArtifactSummary => child !== undefined);
|
|
120
|
+
children.forEach((child, index) => {
|
|
121
|
+
const last = index === children.length - 1;
|
|
122
|
+
visit(child, `${prefix}${connector ? (connector === "└─" ? " " : "│ ") : ""}`, treeConnector(last));
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
for (const root of roots) visit(root, "", "");
|
|
126
|
+
for (const node of details.nodes) visit(node, "", "");
|
|
127
|
+
return rows;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Bounded task containment preview; dependency graphs use the dedicated graph renderer. */
|
|
131
|
+
export class TaskHierarchyPreview implements Component {
|
|
132
|
+
private details: GraphToolDetails;
|
|
133
|
+
private theme: Theme;
|
|
134
|
+
private expanded: boolean;
|
|
135
|
+
private cachedWidth: number | undefined;
|
|
136
|
+
private cachedLines: string[] | undefined;
|
|
137
|
+
|
|
138
|
+
constructor(details: GraphToolDetails, theme: Theme, expanded: boolean) {
|
|
139
|
+
this.details = details;
|
|
140
|
+
this.theme = theme;
|
|
141
|
+
this.expanded = expanded;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
update(details: GraphToolDetails, theme: Theme, expanded: boolean): void {
|
|
145
|
+
this.details = details;
|
|
146
|
+
this.theme = theme;
|
|
147
|
+
this.expanded = expanded;
|
|
148
|
+
this.invalidate();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
render(width: number): string[] {
|
|
152
|
+
const safeWidth = Math.max(1, width);
|
|
153
|
+
if (this.cachedLines && this.cachedWidth === safeWidth) return this.cachedLines;
|
|
154
|
+
const rows = hierarchyRows(this.details);
|
|
155
|
+
const lines = [truncateToWidth(
|
|
156
|
+
this.theme.fg("toolTitle", this.theme.bold(`${this.details.nodes.length} tasks · ${this.details.edges.length} edges`)),
|
|
157
|
+
safeWidth,
|
|
158
|
+
)];
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
const identity = this.expanded ? `${row.node.id} ` : "";
|
|
161
|
+
lines.push(truncateToWidth(
|
|
162
|
+
`${row.prefix}${row.connector}${row.connector ? " " : ""}${this.theme.fg("accent", kindGlyph(row.node.kind))} ${this.theme.fg("muted", statusGlyph(row.node.status))} ${this.theme.fg("accent", identity)}${this.theme.fg("text", row.node.title)}`,
|
|
163
|
+
safeWidth,
|
|
164
|
+
));
|
|
165
|
+
if (this.expanded) {
|
|
166
|
+
const metadata = rowMetadata(row.node);
|
|
167
|
+
if (metadata) lines.push(truncateToWidth(this.theme.fg("dim", `${row.prefix} ${metadata}`), safeWidth));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this.cachedWidth = safeWidth;
|
|
171
|
+
this.cachedLines = lines;
|
|
172
|
+
return lines;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
invalidate(): void {
|
|
176
|
+
this.cachedWidth = undefined;
|
|
177
|
+
this.cachedLines = undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentToolResult,
|
|
3
|
+
Theme,
|
|
4
|
+
ToolRenderResultOptions,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { type Component, Text } from "@earendil-works/pi-tui";
|
|
7
|
+
import { ArtifactCard } from "./artifact-card.ts";
|
|
8
|
+
import { ArtifactListCard, TaskHierarchyPreview } from "./artifact-list.ts";
|
|
9
|
+
import { parsePapyrusToolDetails, type PapyrusToolDetails } from "./render-model.ts";
|
|
10
|
+
|
|
11
|
+
const CALL_VALUE_MAX_CHARACTERS = 80;
|
|
12
|
+
|
|
13
|
+
export interface PapyrusToolRenderContext {
|
|
14
|
+
lastComponent: Component | undefined;
|
|
15
|
+
isError: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function primaryArgument(args: Record<string, unknown>): string | undefined {
|
|
19
|
+
for (const key of ["id", "title", "text", "query", "kind", "template_id"]) {
|
|
20
|
+
const value = args[key];
|
|
21
|
+
if (typeof value === "string" && value.trim()) return value.slice(0, CALL_VALUE_MAX_CHARACTERS);
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Compact native call header that never echoes bodies or structured payloads. */
|
|
27
|
+
export function renderPapyrusToolCall(label: string, args: Record<string, unknown>, theme: Theme): Component {
|
|
28
|
+
const action = typeof args.action === "string" ? args.action : "call";
|
|
29
|
+
const primary = primaryArgument(args);
|
|
30
|
+
const text = [
|
|
31
|
+
theme.fg("toolTitle", theme.bold(label)),
|
|
32
|
+
theme.fg("muted", action),
|
|
33
|
+
...(primary ? [theme.fg("accent", primary)] : []),
|
|
34
|
+
].join(" ");
|
|
35
|
+
return new Text(text, 0, 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function textContent(result: AgentToolResult<unknown>): string {
|
|
39
|
+
return result.content
|
|
40
|
+
.filter((entry): entry is { type: "text"; text: string } => entry.type === "text")
|
|
41
|
+
.map((entry) => entry.text)
|
|
42
|
+
.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function simpleDetailsText(details: Exclude<PapyrusToolDetails, { kind: "artifact" | "artifact-list" | "graph" }>): string {
|
|
46
|
+
switch (details.kind) {
|
|
47
|
+
case "transition":
|
|
48
|
+
return `✓ ${details.artifact.id} ${details.fromStatus} → ${details.toStatus}\n${details.artifact.title}`;
|
|
49
|
+
case "gate-run": {
|
|
50
|
+
const passed = details.gates.filter((gate) => gate.passed).length;
|
|
51
|
+
return [
|
|
52
|
+
`${passed}/${details.gates.length} gates passed for ${details.artifactId}`,
|
|
53
|
+
...details.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.type}: ${gate.target}${gate.output ? ` — ${gate.output}` : ""}`),
|
|
54
|
+
].join("\n");
|
|
55
|
+
}
|
|
56
|
+
case "invocation":
|
|
57
|
+
return [
|
|
58
|
+
`✓ Run ${details.runId}`,
|
|
59
|
+
`${details.created.tasks.length} tasks · ${details.created.docs.length} docs · ${details.created.rules.length} rules`,
|
|
60
|
+
...(details.created.roots.length ? [`Roots: ${details.created.roots.join(", ")}`] : []),
|
|
61
|
+
].join("\n");
|
|
62
|
+
case "preview":
|
|
63
|
+
return `${details.title}\n${details.content}${details.completeness.truncated ? `\n[truncated ${details.completeness.omitted} characters]` : ""}`;
|
|
64
|
+
case "error":
|
|
65
|
+
return `${details.code}: ${details.message}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Render structured details for humans while preserving compact model content as fallback. */
|
|
70
|
+
export function renderPapyrusToolResult(
|
|
71
|
+
result: AgentToolResult<unknown>,
|
|
72
|
+
options: ToolRenderResultOptions,
|
|
73
|
+
theme: Theme,
|
|
74
|
+
context: PapyrusToolRenderContext,
|
|
75
|
+
): Component {
|
|
76
|
+
if (options.isPartial) return new Text(theme.fg("warning", "Working…"), 0, 0);
|
|
77
|
+
const details = parsePapyrusToolDetails(result.details);
|
|
78
|
+
if (!details) return new Text(theme.fg("toolOutput", textContent(result)), 0, 0);
|
|
79
|
+
|
|
80
|
+
if (details.kind === "artifact") {
|
|
81
|
+
const previous = context.lastComponent instanceof ArtifactCard ? context.lastComponent : undefined;
|
|
82
|
+
if (previous) {
|
|
83
|
+
previous.update(details, theme, options.expanded);
|
|
84
|
+
return previous;
|
|
85
|
+
}
|
|
86
|
+
return new ArtifactCard(details, theme, options.expanded);
|
|
87
|
+
}
|
|
88
|
+
if (details.kind === "artifact-list") {
|
|
89
|
+
const previous = context.lastComponent instanceof ArtifactListCard ? context.lastComponent : undefined;
|
|
90
|
+
if (previous) {
|
|
91
|
+
previous.update(details, theme, options.expanded);
|
|
92
|
+
return previous;
|
|
93
|
+
}
|
|
94
|
+
return new ArtifactListCard(details, theme, options.expanded);
|
|
95
|
+
}
|
|
96
|
+
if (details.kind === "graph") {
|
|
97
|
+
const previous = context.lastComponent instanceof TaskHierarchyPreview ? context.lastComponent : undefined;
|
|
98
|
+
if (previous) {
|
|
99
|
+
previous.update(details, theme, options.expanded);
|
|
100
|
+
return previous;
|
|
101
|
+
}
|
|
102
|
+
return new TaskHierarchyPreview(details, theme, options.expanded);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const color = details.kind === "error" || context.isError ? "error" : "toolOutput";
|
|
106
|
+
return new Text(theme.fg(color, simpleDetailsText(details)), 0, 0);
|
|
107
|
+
}
|