@danypops/pi-papyrus 0.54.0 → 0.54.2

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.
@@ -1,670 +1,15 @@
1
1
  /**
2
- * Curated result rendering for Papyrus's own Vehicle-projected operations
3
- * (notes.*, tasks.*, docs.*, rules.*, playbooks.*, artifact.*): reuses the
4
- * same ArtifactCard/ArtifactListCard components the pre-Vehicle native tool
5
- * used, instead of the generic Vehicle renderer's raw full-column table
6
- * dump -- a human reading a task/note list doesn't need id/subtype/extra/
7
- * timestamps up front, only title + status, with the rest available on
8
- * expand. Detection is by output shape, not operation name: every operation
9
- * registered through this client is one of Papyrus's own artifact domains,
10
- * so "looks like an Artifact" is a safe, name-independent signal here.
11
- * Falls back to the generic Vehicle renderer for any other output shape
12
- * (progress, transitions, gate runs, errors).
13
- */
14
-
15
- import type { Artifact } from "@danypops/papyrus";
16
- import { TOOL_COLLAPSED_ROW_LIMIT, TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS } from "@danypops/papyrus";
17
- import type { PiVehicleInvocationRequest, PiVehiclePresentationContract, VehicleToolRenderers } from "@danypops/vehicle-client-pi";
18
- import { expandHint } from "@danypops/vehicle-client-pi/expand-hint";
19
- import { renderVehicleCall, renderVehicleResult } from "@danypops/vehicle-client-pi/vehicle-render";
20
- import type { JsonValue, VehicleOperationDescriptor } from "@danypops/vehicle-core";
21
- import type { Theme } from "@earendil-works/pi-coding-agent";
22
- import { type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
23
- import {
24
- buildDetailLines,
25
- type DagEdge,
26
- type DagNode,
27
- DagView,
28
- type DetailField,
29
- type DetailSection,
30
- statelessComponent,
31
- } from "malevich-tui-components";
32
- import { ArtifactCard, detailViewTheme, measure, statusColor, statusGlyph } from "../tool-rendering/artifact-card.ts";
33
- import { ArtifactListCard } from "../tool-rendering/artifact-list.ts";
34
- import {
35
- type ArtifactFocusAnnotation,
36
- createArtifactDetails,
37
- createArtifactListDetails,
38
- createDiscussionDetails,
39
- createExecutionPlanDetails,
40
- createLeaseDetails,
41
- createNoFocusDetails,
42
- createPlaybookInvocationDetails,
43
- createPlaybookMissingArgumentsDetails,
44
- createPreviewDetails,
45
- createTaskCompletionDetails,
46
- parsePapyrusToolDetails,
47
- } from "../tool-rendering/render-model.ts";
48
- import { recordRenderDiagnostic, shapeFingerprint } from "./render-diagnostics.ts";
49
-
50
- /** Every field an Artifact and its lean list-default ArtifactSummary (tasks.list/docs.list/
51
- * rules.list/playbooks.list without full:true -- see summarizeArtifact()) both always carry. */
52
- function hasArtifactCoreFields(value: unknown): value is Omit<Artifact, "body" | "extra"> {
53
- if (typeof value !== "object" || value === null) return false;
54
- const row = value as Record<string, unknown>;
55
- return (
56
- typeof row.id === "string" &&
57
- typeof row.kind === "string" &&
58
- typeof row.title === "string" &&
59
- typeof row.status === "string" &&
60
- typeof row.subtype === "string" &&
61
- Array.isArray(row.labels) &&
62
- typeof row.created_at === "string" &&
63
- typeof row.updated_at === "string"
64
- );
65
- }
66
-
67
- function isArtifact(value: unknown): value is Artifact {
68
- return hasArtifactCoreFields(value) && typeof (value as Record<string, unknown>).body === "string";
69
- }
70
-
71
- /**
72
- * Regression (real, live-observed): tasks.list's own documented default ("Returns a lean
73
- * summary (no body/extra) unless full: true is passed") returns ArtifactSummary rows, which
74
- * omit body entirely. createArtifactListDetails/artifactSummary (render-model.ts) never read
75
- * .body for list rendering -- only single-artifact createArtifactDetails does -- so requiring
76
- * body here (matching isArtifact) silently fell every default (lean, the common case) list
77
- * call for tasks.list/docs.list/rules.list/playbooks.list through to the generic raw Vehicle
78
- * table renderer instead of the curated ArtifactListCard.
79
- */
80
- function isArtifactArray(value: unknown): value is Artifact[] {
81
- return Array.isArray(value) && value.every(hasArtifactCoreFields);
82
- }
83
-
84
- /** tasks.focused/tasks.pause/tasks.unpause's own wrapper shape -- an Artifact
85
- * plus Task Focus's separate active/paused dimension. Detected the same
86
- * name-independent, shape-based way as isArtifact/isArtifactArray above. */
87
- interface TaskFocusOutput {
88
- artifact: Artifact;
89
- status: string;
90
- updatedAt: string;
91
- pauseReason?: string;
92
- }
93
-
94
- function isTaskFocus(value: unknown): value is TaskFocusOutput {
95
- if (typeof value !== "object" || value === null) return false;
96
- const row = value as Record<string, unknown>;
97
- return (
98
- isArtifact(row.artifact) &&
99
- typeof row.status === "string" &&
100
- typeof row.updatedAt === "string" &&
101
- (row.pauseReason === undefined || typeof row.pauseReason === "string")
102
- );
103
- }
104
-
105
- function focusAnnotation(output: TaskFocusOutput): ArtifactFocusAnnotation {
106
- return { status: output.status, updatedAt: output.updatedAt, ...(output.pauseReason ? { pauseReason: output.pauseReason } : {}) };
107
- }
108
-
109
- function renderNoFocusedTask(theme: Theme): Component {
110
- return new Text(theme.fg("dim", "No focused task."), 0, 0);
111
- }
112
-
113
- /** tasks.plan's own TaskExecutionPlan shape (projectTaskExecution) -- a
114
- * genuinely structured topological-execution view, never artifact-shaped,
115
- * detected the same name-independent way as the others in this file. */
116
- interface TaskExecutionNodeOutput {
117
- id: string;
118
- title: string;
119
- status: string;
120
- active: boolean;
121
- state: string;
122
- layer: number | null;
123
- prerequisiteIds: string[];
124
- successorIds: string[];
125
- }
126
-
127
- interface TaskExecutionPlanOutput {
128
- nodes: TaskExecutionNodeOutput[];
129
- layers: string[][];
130
- cycleIds: string[];
131
- }
132
-
133
- function isTaskExecutionNode(value: unknown): value is TaskExecutionNodeOutput {
134
- if (typeof value !== "object" || value === null) return false;
135
- const row = value as Record<string, unknown>;
136
- return (
137
- typeof row.id === "string" &&
138
- typeof row.title === "string" &&
139
- typeof row.status === "string" &&
140
- typeof row.active === "boolean" &&
141
- typeof row.state === "string" &&
142
- (row.layer === null || typeof row.layer === "number") &&
143
- Array.isArray(row.prerequisiteIds) &&
144
- Array.isArray(row.successorIds)
145
- );
146
- }
147
-
148
- function isTaskExecutionPlan(value: unknown): value is TaskExecutionPlanOutput {
149
- if (typeof value !== "object" || value === null) return false;
150
- const row = value as Record<string, unknown>;
151
- return (
152
- Array.isArray(row.nodes) &&
153
- row.nodes.every(isTaskExecutionNode) &&
154
- Array.isArray(row.layers) &&
155
- row.layers.every((layer) => Array.isArray(layer) && layer.every((id) => typeof id === "string")) &&
156
- Array.isArray(row.cycleIds) &&
157
- row.cycleIds.every((id) => typeof id === "string")
158
- );
159
- }
160
-
161
- function dagViewFromExecutionPlan(plan: TaskExecutionPlanOutput, theme: Theme, expanded: boolean): DagView {
162
- const nodes: DagNode[] = plan.nodes.map((node) => ({
163
- id: node.id,
164
- label: `${theme.fg(statusColor(node.state), statusGlyph(node.state))} ${theme.fg("text", node.title)}`,
165
- }));
166
- const edges: DagEdge[] = plan.nodes.flatMap((node) => node.prerequisiteIds.map((from) => ({ from, to: node.id })));
167
- return new DagView({
168
- layers: plan.layers,
169
- nodes,
170
- edges,
171
- cycleIds: plan.cycleIds,
172
- defaultStyle: (s) => theme.fg("text", s),
173
- edgeStyle: (s) => theme.fg("dim", s),
174
- layerHeaderStyle: (s) => theme.fg("toolTitle", theme.bold(s)),
175
- cycleHeaderStyle: (s) => theme.fg("error", theme.bold(s)),
176
- expanded,
177
- visibleNodeCount: TOOL_COLLAPSED_ROW_LIMIT,
178
- moreLine: (hiddenCount) => theme.fg("dim", `${hiddenCount} more · ${expandHint()}`),
179
- });
180
- }
181
-
182
- function renderTaskExecutionPlan(plan: TaskExecutionPlanOutput, theme: Theme, expanded: boolean): Component {
183
- return dagViewFromExecutionPlan(plan, theme, expanded);
184
- }
185
-
186
- /** playbooks.invoke's own PlaybookInvocationResult shape -- a materialized execution plan
187
- * (same shape tasks.plan renders) plus which docs/rules/tasks were created and which one to
188
- * focus. Detected the same name-independent, shape-based way as the others in this file. */
189
- interface PlaybookInvocationResultOutput {
190
- playbookId: string;
191
- runId: string;
192
- created: { docs: string[]; rules: string[]; tasks: string[] };
193
- rootTaskIds: string[];
194
- entryTaskId: string;
195
- execution: TaskExecutionPlanOutput;
196
- }
197
-
198
- interface PlaybookMissingArgumentsOutput {
199
- playbookId: string;
200
- missingArguments: string[];
201
- }
202
-
203
- function isPlaybookInvocationResult(value: unknown): value is PlaybookInvocationResultOutput {
204
- if (typeof value !== "object" || value === null) return false;
205
- const row = value as Record<string, unknown>;
206
- return (
207
- typeof row.playbookId === "string" &&
208
- typeof row.runId === "string" &&
209
- typeof row.entryTaskId === "string" &&
210
- Array.isArray(row.rootTaskIds) &&
211
- typeof row.created === "object" &&
212
- row.created !== null &&
213
- Array.isArray((row.created as Record<string, unknown>).docs) &&
214
- Array.isArray((row.created as Record<string, unknown>).rules) &&
215
- Array.isArray((row.created as Record<string, unknown>).tasks) &&
216
- isTaskExecutionPlan(row.execution)
217
- );
218
- }
219
-
220
- function isPlaybookMissingArguments(value: unknown): value is PlaybookMissingArgumentsOutput {
221
- if (typeof value !== "object" || value === null) return false;
222
- const row = value as Record<string, unknown>;
223
- return (
224
- typeof row.playbookId === "string" &&
225
- Array.isArray(row.missingArguments) &&
226
- row.missingArguments.every((entry) => typeof entry === "string")
227
- );
228
- }
229
-
230
- function renderPlaybookInvocationResult(result: PlaybookInvocationResultOutput, theme: Theme, expanded: boolean): Component {
231
- const dag = dagViewFromExecutionPlan(result.execution, theme, expanded);
232
- const counts = [
233
- ["task", result.created.tasks.length],
234
- ["rule", result.created.rules.length],
235
- ["doc", result.created.docs.length],
236
- ] as const;
237
- const summary = counts
238
- .filter(([, count]) => count > 0)
239
- .map(([noun, count]) => `${count} ${noun}${count === 1 ? "" : "s"}`)
240
- .join(", ");
241
- return {
242
- render: (width: number) => [...dag.render(width), truncateToWidth(theme.fg("dim", summary || "Nothing created."), width)],
243
- invalidate: () => dag.invalidate(),
244
- };
245
- }
246
-
247
- function renderPlaybookMissingArguments(result: PlaybookMissingArgumentsOutput, theme: Theme): Component {
248
- const line = theme.fg("warning", `Missing required argument(s): ${result.missingArguments.join(", ")}`);
249
- return new Text(line, 0, 0);
250
- }
251
-
252
- /** A Discussion round -- discuss.open/reply/show/rounds' own transcript entry. Detected the
253
- * same name-independent, shape-based way as the others in this file. */
254
- interface DiscussionRoundOutput {
255
- roundNumber: number;
256
- actor: string;
257
- content: string;
258
- }
259
-
260
- function isDiscussionRound(value: unknown): value is DiscussionRoundOutput {
261
- if (typeof value !== "object" || value === null) return false;
262
- const row = value as Record<string, unknown>;
263
- return typeof row.roundNumber === "number" && typeof row.actor === "string" && typeof row.content === "string";
264
- }
265
-
266
- function isDiscussionRoundArray(value: unknown): value is DiscussionRoundOutput[] {
267
- return Array.isArray(value) && value.every(isDiscussionRound);
268
- }
269
-
270
- interface DiscussionAndRoundsOutput {
271
- discussion: Artifact;
272
- rounds: DiscussionRoundOutput[];
273
- }
274
-
275
- /** What renderDiscussionAndRounds actually reads -- satisfied by both a raw Artifact (the live duck-typed output path) and the leaner projected ToolArtifactSummary (the typed-DTO path), with no cast needed at either call site. */
276
- interface RenderableDiscussionParent {
277
- title: string;
278
- status: string;
279
- }
280
-
281
- function isDiscussionAndRounds(value: unknown): value is DiscussionAndRoundsOutput {
282
- if (typeof value !== "object" || value === null) return false;
283
- const row = value as Record<string, unknown>;
284
- return isArtifact(row.discussion) && isDiscussionRoundArray(row.rounds);
285
- }
286
-
287
- interface DiscussionRoundsOnlyOutput {
288
- rounds: DiscussionRoundOutput[];
289
- }
290
-
291
- function isDiscussionRoundsOnly(value: unknown): value is DiscussionRoundsOnlyOutput {
292
- if (typeof value !== "object" || value === null) return false;
293
- const row = value as Record<string, unknown>;
294
- return row.discussion === undefined && isDiscussionRoundArray(row.rounds);
295
- }
296
-
297
- interface DiscussionListOutput {
298
- discussions: Artifact[];
299
- }
300
-
301
- function isDiscussionListOutput(value: unknown): value is DiscussionListOutput {
302
- if (typeof value !== "object" || value === null) return false;
303
- const row = value as Record<string, unknown>;
304
- return isArtifactArray(row.discussions);
305
- }
306
-
307
- function roundsSection(rounds: readonly DiscussionRoundOutput[]): DetailSection {
308
- return {
309
- heading: `Rounds (${rounds.length}):`,
310
- items: rounds.map((round) => ({ byline: `${round.actor} · round ${round.roundNumber}`, body: round.content })),
311
- };
312
- }
313
-
314
- function renderDiscussionAndRounds(
315
- output: { discussion: RenderableDiscussionParent; rounds: readonly DiscussionRoundOutput[] },
316
- theme: Theme,
317
- expanded: boolean,
318
- ): Component {
319
- const discussion = output.discussion;
320
- return statelessComponent((width) => {
321
- const safeWidth = Math.max(1, width);
322
- const fields: DetailField[] = [
323
- { label: "Title", value: discussion.title },
324
- { label: "Status", value: theme.fg(statusColor(discussion.status), `${statusGlyph(discussion.status)} ${discussion.status}`) },
325
- ];
326
- const sections: DetailSection[] = expanded && output.rounds.length > 0 ? [roundsSection(output.rounds)] : [];
327
- const lines = buildDetailLines(safeWidth, { fields, sections, alignFields: true, theme: detailViewTheme(theme), measure });
328
- if (!expanded && output.rounds.length > 0) {
329
- const count = output.rounds.length;
330
- lines.push(truncateToWidth(theme.fg("dim", `${count} round${count === 1 ? "" : "s"} · ${expandHint()}`), safeWidth));
331
- }
332
- return lines;
333
- });
334
- }
335
-
336
- function renderDiscussionRoundsOnly(output: DiscussionRoundsOnlyOutput, theme: Theme): Component {
337
- return statelessComponent((width) => {
338
- const sections: DetailSection[] = output.rounds.length > 0 ? [roundsSection(output.rounds)] : [{ lines: ["No rounds."] }];
339
- return buildDetailLines(Math.max(1, width), { sections, theme: detailViewTheme(theme), measure });
340
- });
341
- }
342
-
343
- /** tasks.complete's own TaskCompletion shape -- a completed (or rejected) task plus its own
344
- * gate/checklist proof run and any dependents still left blocked. Detected the same
345
- * name-independent, shape-based way as the others in this file. */
346
- interface TaskGateResultOutput {
347
- gate: unknown;
348
- passed: boolean;
349
- output: string;
350
- }
351
-
352
- interface TaskChecklistReviewOutput {
353
- item: string;
354
- accepted: boolean;
355
- reason?: string;
356
- }
357
-
358
- interface TaskBlockageOutput {
359
- artifact: Artifact;
360
- dependencyIds: string[];
361
- }
362
-
363
- interface TaskCompletionOutput {
364
- artifact: Artifact;
365
- gates: TaskGateResultOutput[];
366
- checklist: TaskChecklistReviewOutput[];
367
- completed: boolean;
368
- focused: Artifact | null;
369
- blocked: TaskBlockageOutput[];
370
- }
371
-
372
- /** What renderTaskCompletion actually reads -- satisfied by both the raw duck-typed TaskCompletionOutput and the leaner projected TaskCompletionToolDetails. */
373
- interface RenderableTaskCompletion {
374
- artifact: RenderableDiscussionParent;
375
- gates: readonly { passed: boolean; output: string }[];
376
- checklist: readonly TaskChecklistReviewOutput[];
377
- completed: boolean;
378
- focused?: RenderableDiscussionParent | null;
379
- blocked: readonly { artifact: RenderableDiscussionParent; dependencyIds: readonly string[] }[];
380
- }
381
-
382
- function isTaskCompletion(value: unknown): value is TaskCompletionOutput {
383
- if (typeof value !== "object" || value === null) return false;
384
- const row = value as Record<string, unknown>;
385
- return (
386
- isArtifact(row.artifact) &&
387
- Array.isArray(row.gates) &&
388
- Array.isArray(row.checklist) &&
389
- typeof row.completed === "boolean" &&
390
- (row.focused === null || isArtifact(row.focused)) &&
391
- Array.isArray(row.blocked)
392
- );
393
- }
394
-
395
- function renderTaskCompletion(result: RenderableTaskCompletion, theme: Theme, expanded: boolean): Component {
396
- const task = result.artifact;
397
- return statelessComponent((width) => {
398
- const safeWidth = Math.max(1, width);
399
- const fields: DetailField[] = [
400
- { label: "Title", value: task.title },
401
- { label: "Status", value: theme.fg(statusColor(task.status), `${statusGlyph(task.status)} ${task.status}`) },
402
- ];
403
- const sections: DetailSection[] = [];
404
- if (result.gates.length > 0) {
405
- sections.push({
406
- heading: "Gates:",
407
- lines: result.gates.map((gate) => theme.fg(gate.passed ? "success" : "error", `${gate.passed ? "✓" : "✗"} ${gate.output}`)),
408
- });
409
- }
410
- if (result.checklist.length > 0) {
411
- sections.push({
412
- heading: "Checklist:",
413
- lines: result.checklist.map((entry) =>
414
- theme.fg(
415
- entry.accepted ? "success" : "error",
416
- `${entry.accepted ? "✓" : "✗"} ${entry.item}${entry.reason ? ` — ${entry.reason}` : ""}`,
417
- ),
418
- ),
419
- });
420
- }
421
- if (result.blocked.length > 0) {
422
- sections.push({
423
- heading: "Still blocked:",
424
- lines: result.blocked.map((entry) => theme.fg("warning", `◼ ${entry.artifact.title}`)),
425
- });
426
- }
427
- const lines = buildDetailLines(safeWidth, { fields, sections, alignFields: true, theme: detailViewTheme(theme), measure });
428
- if (result.focused && expanded) {
429
- lines.push(truncateToWidth(theme.fg("accent", `▶ focus ${result.focused.title}`), safeWidth));
430
- }
431
- return lines;
432
- });
433
- }
434
-
435
- /** tasks.claim/heartbeat_lease/release_lease/lease's own name-first view. Detected the same
436
- * name-independent, shape-based way as the others in this file. */
437
- interface TaskLeaseViewOutput {
438
- taskName: string;
439
- taskTitle: string;
440
- owner: string;
441
- token: string;
442
- claimedAt: string;
443
- leaseExpiresAt: string;
444
- heartbeatAt?: string;
445
- note?: string;
446
- }
447
-
448
- function isTaskLeaseView(value: unknown): value is TaskLeaseViewOutput {
449
- if (typeof value !== "object" || value === null) return false;
450
- const row = value as Record<string, unknown>;
451
- return (
452
- typeof row.taskName === "string" &&
453
- typeof row.taskTitle === "string" &&
454
- typeof row.owner === "string" &&
455
- typeof row.token === "string" &&
456
- typeof row.claimedAt === "string" &&
457
- typeof row.leaseExpiresAt === "string" &&
458
- (row.heartbeatAt === undefined || typeof row.heartbeatAt === "string") &&
459
- (row.note === undefined || typeof row.note === "string")
460
- );
461
- }
462
-
463
- /** Renders a lease's own safe fields -- never the raw token, which the model channel (not this persisted, human-facing one) carries for a later heartbeat/release call. */
464
- function renderLease(
465
- lease: {
466
- taskName: string;
467
- taskTitle: string;
468
- owner: string;
469
- claimedAt: string;
470
- leaseExpiresAt: string;
471
- heartbeatAt?: string;
472
- note?: string;
473
- },
474
- theme: Theme,
475
- ): Component {
476
- return statelessComponent((width) => {
477
- const fields: DetailField[] = [
478
- { label: "Task", value: `${lease.taskName} \u2014 ${lease.taskTitle}` },
479
- { label: "Owner", value: lease.owner },
480
- { label: "Expires", value: lease.leaseExpiresAt },
481
- ...(lease.heartbeatAt ? [{ label: "Last heartbeat", value: lease.heartbeatAt }] : []),
482
- ...(lease.note ? [{ label: "Note", value: lease.note }] : []),
483
- ];
484
- return buildDetailLines(Math.max(1, width), { fields, alignFields: true, theme: detailViewTheme(theme), measure });
485
- });
486
- }
487
-
488
- /** Deliberately does not catch: a value JSON.stringify can't serialize (e.g. a circular
489
- * reference) has no safe textual fallback, so this propagates and the projector's own caller
490
- * (invokeVehicleOperation) fails the whole call closed rather than persisting a placeholder --
491
- * the same "never silently substitute raw/unsafe output" contract every other projection
492
- * failure already carries. */
493
- function boundedJsonPreview(value: unknown): string {
494
- const text = JSON.stringify(value, null, 2);
495
- return typeof text === "string" ? text : String(value);
496
- }
497
-
498
- export function papyrusVehicleRenderers(descriptor: VehicleOperationDescriptor): VehicleToolRenderers {
499
- return {
500
- // Pure pass-through to the generic renderer -- the only reason this exists at all is
501
- // the /reload investigation (papyrus task 4930cd9b): its absence from the diagnostic
502
- // log for a real invocation (see onInvoked in vehicle-notes-client.ts) is itself
503
- // evidence Pi never found ANY renderer -- ours or vehicle-client-pi's generic default
504
- // -- for that specific tool call, distinct from this renderer running and choosing
505
- // the generic path internally (which DOES show up here).
506
- renderCall(args, theme, context) {
507
- recordRenderDiagnostic({ event: "render-call-invoked", operation: descriptor.name });
508
- return renderVehicleCall(descriptor, args, theme, context);
509
- },
510
- renderResult(result, options, theme, context) {
511
- if (!options.isPartial && !context.isError) {
512
- const output = (result.details as { output?: unknown } | undefined)?.output;
513
- // /reload rendering-fallback investigation (papyrus task 4930cd9b) -- correlates
514
- // against vehicle-notes-client.ts's onInvoked/vehicle-ready diagnostics by
515
- // descriptor.name and wall-clock time.
516
- recordRenderDiagnostic({
517
- event: "render-result-dispatch",
518
- operation: descriptor.name,
519
- isArtifact: isArtifact(output),
520
- isArtifactArray: isArtifactArray(output),
521
- output: shapeFingerprint(output),
522
- });
523
- if (isArtifactArray(output)) {
524
- return new ArtifactListCard(createArtifactListDetails(descriptor.name, output), theme, options.expanded);
525
- }
526
- if (isArtifact(output)) {
527
- return new ArtifactCard(createArtifactDetails(descriptor.name, output), theme, options.expanded);
528
- }
529
- if (isTaskFocus(output)) {
530
- return new ArtifactCard(
531
- createArtifactDetails(descriptor.name, output.artifact, focusAnnotation(output)),
532
- theme,
533
- options.expanded,
534
- );
535
- }
536
- // tasks.focused specifically returns null for "nothing focused" --
537
- // scoped to this one operation so an unrelated null-output operation
538
- // (e.g. a not-found lookup) is never mislabeled as a focus state.
539
- if (output === null && descriptor.name === "tasks.focused") {
540
- return renderNoFocusedTask(theme);
541
- }
542
- if (isTaskExecutionPlan(output)) {
543
- return renderTaskExecutionPlan(output, theme, options.expanded);
544
- }
545
- if (isPlaybookInvocationResult(output)) {
546
- return renderPlaybookInvocationResult(output, theme, options.expanded);
547
- }
548
- if (isPlaybookMissingArguments(output)) {
549
- return renderPlaybookMissingArguments(output, theme);
550
- }
551
- if (isDiscussionAndRounds(output)) {
552
- return renderDiscussionAndRounds(output, theme, options.expanded);
553
- }
554
- if (isDiscussionRoundsOnly(output)) {
555
- return renderDiscussionRoundsOnly(output, theme);
556
- }
557
- if (isDiscussionListOutput(output)) {
558
- return new ArtifactListCard(createArtifactListDetails(descriptor.name, output.discussions), theme, options.expanded);
559
- }
560
- if (isTaskCompletion(output)) {
561
- return renderTaskCompletion(output, theme, options.expanded);
562
- }
563
- recordRenderDiagnostic({ event: "render-result-fell-through-to-generic", operation: descriptor.name });
564
- }
565
- return renderVehicleResult(descriptor, result, options, theme, context);
566
- },
567
- };
568
- }
569
-
570
- /**
571
- * Projects a raw Papyrus operation output into a bounded, versioned PapyrusToolDetails DTO
572
- * before Pi ever persists it -- the seam papyrusVehicleRenderers' own renderResult never had
573
- * (it only converts shape at render time, from whatever the legacy {vehicle, output} path
574
- * already persisted verbatim, lease tokens and all). Every branch here mirrors the same
575
- * shape-detection papyrusVehicleRenderers's own renderResult uses, so the two stay in lockstep;
576
- * anything genuinely unmatched still becomes a real, bounded PreviewToolDetails rather than an
577
- * unprojected raw passthrough -- the one requirement this whole seam exists to satisfy.
578
- */
579
- function projectPapyrusPresentation(descriptor: VehicleOperationDescriptor, output: unknown): JsonValue {
580
- if (isArtifactArray(output)) return createArtifactListDetails(descriptor.name, output) as unknown as JsonValue;
581
- if (isArtifact(output)) return createArtifactDetails(descriptor.name, output) as unknown as JsonValue;
582
- if (isTaskFocus(output)) return createArtifactDetails(descriptor.name, output.artifact, focusAnnotation(output)) as unknown as JsonValue;
583
- if (output === null && descriptor.name === "tasks.focused") return createNoFocusDetails(descriptor.name) as unknown as JsonValue;
584
- if (isTaskExecutionPlan(output))
585
- return createExecutionPlanDetails(descriptor.name, output.nodes, output.layers, output.cycleIds) as unknown as JsonValue;
586
- if (isPlaybookInvocationResult(output)) {
587
- return createPlaybookInvocationDetails(descriptor.name, {
588
- playbookId: output.playbookId,
589
- runId: output.runId,
590
- created: output.created,
591
- rootTaskIds: output.rootTaskIds,
592
- entryTaskId: output.entryTaskId,
593
- execution: output.execution,
594
- }) as unknown as JsonValue;
595
- }
596
- if (isPlaybookMissingArguments(output)) {
597
- return createPlaybookMissingArgumentsDetails(descriptor.name, output.playbookId, output.missingArguments) as unknown as JsonValue;
598
- }
599
- if (isDiscussionAndRounds(output))
600
- return createDiscussionDetails(descriptor.name, output.rounds, output.discussion) as unknown as JsonValue;
601
- if (isDiscussionRoundsOnly(output)) return createDiscussionDetails(descriptor.name, output.rounds) as unknown as JsonValue;
602
- if (isDiscussionListOutput(output)) return createArtifactListDetails(descriptor.name, output.discussions) as unknown as JsonValue;
603
- if (isTaskCompletion(output)) return createTaskCompletionDetails(descriptor.name, output) as unknown as JsonValue;
604
- if (isTaskLeaseView(output)) return createLeaseDetails(descriptor.name, output) as unknown as JsonValue;
605
- return createPreviewDetails(descriptor.name, descriptor.name, boundedJsonPreview(output)) as unknown as JsonValue;
606
- }
607
-
608
- function renderFromPapyrusPresentation(
609
- presentation: NonNullable<ReturnType<typeof parsePapyrusToolDetails>>,
610
- theme: Theme,
611
- expanded: boolean,
612
- ): Component {
613
- switch (presentation.kind) {
614
- case "artifact-list":
615
- return new ArtifactListCard(presentation, theme, expanded);
616
- case "artifact":
617
- return new ArtifactCard(presentation, theme, expanded);
618
- case "no-focus":
619
- return renderNoFocusedTask(theme);
620
- case "execution-plan":
621
- return renderTaskExecutionPlan(presentation, theme, expanded);
622
- case "playbook-invocation":
623
- return renderPlaybookInvocationResult(presentation, theme, expanded);
624
- case "playbook-missing-arguments":
625
- return renderPlaybookMissingArguments(presentation, theme);
626
- case "discussion":
627
- return presentation.discussion
628
- ? renderDiscussionAndRounds({ discussion: presentation.discussion, rounds: presentation.rounds }, theme, expanded)
629
- : renderDiscussionRoundsOnly(presentation, theme);
630
- case "task-completion":
631
- return renderTaskCompletion(presentation, theme, expanded);
632
- case "lease":
633
- return renderLease(presentation, theme);
634
- case "preview":
635
- return new Text(theme.fg("toolOutput", presentation.content), 0, 0);
636
- case "transition":
637
- case "graph":
638
- case "gate-run":
639
- case "invocation":
640
- case "error":
641
- // Reachable only if a future caller starts producing these kinds through this seam
642
- // (today's Papyrus Vehicle outputs never do) -- a bounded JSON preview is still a
643
- // real, safe rendering rather than a crash.
644
- return new Text(theme.fg("toolOutput", boundedJsonPreview(presentation)), 0, 0);
645
- }
646
- }
647
-
648
- /**
649
- * Pairs the projector above with a renderResult that reads the already-projected,
650
- * already-bounded `details.presentation` DTO instead of raw `details.output` -- the seam
651
- * pi-papyrus task "project typed bounded render details before Vehicle persists" exists for.
652
- * Falls back to papyrusVehicleRenderers' own renderResult (which still reads `details.output`)
653
- * for a partial/progress update, an error result, or a historical session row persisted before
654
- * this seam existed -- both keep working exactly as before, unchanged.
655
- */
656
- export function papyrusVehiclePresentations(descriptor: VehicleOperationDescriptor): PiVehiclePresentationContract {
657
- return {
658
- projector: {
659
- maxBytes: TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS,
660
- project: (output: unknown, _request: PiVehicleInvocationRequest) => projectPapyrusPresentation(descriptor, output),
661
- },
662
- renderResult(result, options, theme, context) {
663
- if (!options.isPartial && !context.isError) {
664
- const presentation = parsePapyrusToolDetails((result.details as { presentation?: unknown } | undefined)?.presentation);
665
- if (presentation) return renderFromPapyrusPresentation(presentation, theme, options.expanded);
666
- }
667
- return papyrusVehicleRenderers(descriptor).renderResult!(result, options, theme, context);
668
- },
669
- };
670
- }
2
+ * Barrel re-exporting Papyrus's own Vehicle-projected result renderers from ./renderers/. Kept as
3
+ * a real file (not relying on directory-index resolution) so every existing
4
+ * `from "./vehicle-artifact-renderers.ts"` / `from "../extension/src/tools/vehicle-artifact-renderers.ts"`
5
+ * import (vehicle-notes-client.ts plus 3 test files) keeps resolving unchanged -- this codebase's
6
+ * imports always carry an explicit `.ts` extension, which does not implicitly resolve a bare
7
+ * specifier to a directory's own index file the way Node's CJS `require()` does.
8
+ *
9
+ * The real implementation now lives in ./renderers/ (one file per result-kind, plus index.ts as
10
+ * the Registry assembly point) instead of one 670-line file -- see Doc "Modularity playbook:
11
+ * building-block-shaped TypeScript modules for papyrus/pi-papyrus" and the "pi-papyrus
12
+ * vehicle-artifact-renderers.ts split" child of "Epic: Modularize papyrus/pi-papyrus god-files
13
+ * into building-block modules".
14
+ */
15
+ export { papyrusVehiclePresentations, papyrusVehicleRenderers } from "./renderers/index.ts";