@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.
Files changed (57) hide show
  1. package/README.md +16 -2
  2. package/extension/src/active-task-continuation.ts +6 -0
  3. package/extension/src/artifact-browser.ts +13 -7
  4. package/extension/src/artifact-status-presentation.ts +53 -0
  5. package/extension/src/context-budget.ts +173 -0
  6. package/extension/src/context-view.ts +172 -0
  7. package/extension/src/docs.ts +6 -5
  8. package/extension/src/domain-tools.ts +108 -52
  9. package/extension/src/index.ts +124 -38
  10. package/extension/src/notes.ts +16 -4
  11. package/extension/src/rules.ts +7 -7
  12. package/extension/src/skill-catalog-footprint.ts +183 -0
  13. package/extension/src/skills.ts +2 -3
  14. package/extension/src/task-focus-events.ts +57 -0
  15. package/extension/src/task-widget.ts +13 -1
  16. package/extension/src/tasks.ts +51 -15
  17. package/extension/src/tool-rendering/artifact-card.ts +117 -0
  18. package/extension/src/tool-rendering/artifact-list.ts +179 -0
  19. package/extension/src/tool-rendering/index.ts +107 -0
  20. package/extension/src/tool-rendering/render-model.ts +406 -0
  21. package/package.json +4 -2
  22. package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
  23. package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
  24. package/src/adapters/sqlite-artifact-store.ts +20 -11
  25. package/src/adapters/sqlite-discourse-store.ts +325 -0
  26. package/src/adapters/sqlite-graph-projection-store.ts +41 -0
  27. package/src/adapters/sqlite-task-focus-store.ts +34 -15
  28. package/src/authority-registry.ts +115 -0
  29. package/src/cli.ts +904 -124
  30. package/src/constants.ts +77 -5
  31. package/src/conversation-journal-service.ts +87 -0
  32. package/src/db.ts +285 -33
  33. package/src/domain/artifact-event.ts +99 -0
  34. package/src/domain/conversation-journal.ts +168 -0
  35. package/src/domain/discourse-store.ts +142 -0
  36. package/src/domain/graph-projection.ts +74 -0
  37. package/src/domain/skill-definition.ts +57 -8
  38. package/src/domain/task-event.ts +4 -0
  39. package/src/domain-services.ts +201 -40
  40. package/src/graph-projection-service.ts +103 -0
  41. package/src/id-migration.ts +200 -0
  42. package/src/module-registry.ts +53 -0
  43. package/src/modules/docs.ts +77 -0
  44. package/src/modules/graph-projection.ts +82 -0
  45. package/src/modules/notes.ts +76 -0
  46. package/src/modules/rules.ts +81 -0
  47. package/src/modules/skills.ts +113 -0
  48. package/src/modules/tasks.ts +164 -0
  49. package/src/ops.ts +142 -15
  50. package/src/ports/artifact-scope-store.ts +20 -0
  51. package/src/ports/artifact-store.ts +10 -5
  52. package/src/ports/conversation-journal-store.ts +17 -0
  53. package/src/ports/graph-projection-store.ts +15 -0
  54. package/src/ports/task-focus-store.ts +62 -20
  55. package/src/service.ts +218 -223
  56. package/src/skill-execution.ts +169 -75
  57. 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
  };
@@ -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
- let graph = await loadTaskGraph(ctx.cwd);
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 active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
111
- const focusStatus = graph.nodes.find((node) => node.task.id === action.row!.id)?.focusStatus;
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
- const operation = choice === "Pause focus" ? "tasks.pause" : choice === "Resume focus" ? "tasks.unpause" : "tasks.clear_focus";
157
- await callService(operation, { actor: "user", source: "tasks-tui" });
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
+ }