@danypops/pi-papyrus 0.38.3 → 0.38.5

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,5 +1,6 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
3
+ import { buildDetailLines, type DetailField, type DetailSection } from "malevich-tui-components";
3
4
  import {
4
5
  ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS,
5
6
  ARTIFACT_DETAIL_MAX_VISIBLE_LINES,
@@ -90,12 +91,34 @@ class ArtifactDetailViewport {
90
91
  { text: "", wide: false },
91
92
  ];
92
93
  const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, wide: false }));
93
- const labels = this.content.labels.length > 0
94
- ? [{ text: "", wide: false }, ...wrap("Labels:", "muted"), ...wrap(this.content.labels.join(", "))]
94
+
95
+ // Labels + Metadata are plain field/flat-line shapes -- delegated to malevich's
96
+ // buildDetailLines. The body (markdown-rendered) and relationships (horizontally
97
+ // pannable "wide" lines) stay hand-rolled: buildDetailLines has no concept of
98
+ // either, and forcing them through it would either drop markdown formatting or
99
+ // lose the pan feature.
100
+ const fields: DetailField[] = this.content.labels.length > 0 ? [{ label: "Labels", value: this.content.labels.join(", ") }] : [];
101
+ const sections: DetailSection[] = this.content.metadata.length > 0
102
+ ? [{ heading: "Metadata:", lines: this.content.metadata.map((line) => ` ${line}`) }]
95
103
  : [];
96
- const metadata = this.content.metadata.length > 0
97
- ? [{ text: "", wide: false }, ...wrap("Metadata:", "muted"), ...this.content.metadata.flatMap((line) => wrap(` ${line}`, "dim"))]
104
+ const labelsAndMetadata = (fields.length > 0 || sections.length > 0)
105
+ ? buildDetailLines(width, {
106
+ fields,
107
+ sections,
108
+ theme: {
109
+ field: (s) => theme.fg("muted", s),
110
+ heading: (s) => theme.fg("muted", s),
111
+ byline: (s) => theme.fg("dim", s),
112
+ body: (s) => theme.fg("text", s),
113
+ line: (s) => theme.fg("dim", s),
114
+ },
115
+ }).map((text) => ({ text, wide: false }))
98
116
  : [];
117
+ // buildDetailLines' fields/sections don't insert a leading blank before the
118
+ // first field the way the original hand-rolled labels block did -- add it back
119
+ // when either piece rendered anything, matching the original layout exactly.
120
+ const labelsAndMetadataWithLeadingBlank = fields.length > 0 ? [{ text: "", wide: false }, ...labelsAndMetadata] : labelsAndMetadata;
121
+
99
122
  const relationships = this.content.relationships.length > 0
100
123
  ? [
101
124
  { text: "", wide: false },
@@ -103,7 +126,7 @@ class ArtifactDetailViewport {
103
126
  ...this.content.relationships.map((text) => ({ text: theme.fg("text", text), wide: true })),
104
127
  ]
105
128
  : [];
106
- this.lines = [...identity, ...body, ...labels, ...metadata, ...relationships];
129
+ this.lines = [...identity, ...body, ...labelsAndMetadataWithLeadingBlank, ...relationships];
107
130
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
108
131
  }
109
132
  }
@@ -8,6 +8,8 @@ import {
8
8
  type DiscussionRound,
9
9
  type GateResult,
10
10
  type OperationName,
11
+ type PlaybookInvocationResult,
12
+ type PlaybookMissingArguments,
11
13
  type WorkflowRunResult,
12
14
  type TaskCompletion,
13
15
  type TaskExecutionPlan,
@@ -138,16 +140,49 @@ export function matchArtifactByName(candidates: Artifact[], name: string): strin
138
140
  return matches[0]!.id;
139
141
  }
140
142
 
143
+ /**
144
+ * tasks.list is the one list operation that requires `project_root` and separately supports a
145
+ * `scope` ("project" | "graph" | "all") to widen or narrow the search. Every other list operation
146
+ * (docs.list, rules.list, skills.list, playbooks.list, artifact.query, ...) instead treats an
147
+ * omitted `project_root` as an unscoped/global search (domain-services.ts's listScoped) and has
148
+ * no `scope` concept at all -- so "search everywhere" means something different for each.
149
+ */
150
+ const SCOPE_AWARE_LIST_OPERATIONS = new Set<OperationName>(["tasks.list"]);
151
+
152
+ /** The widened-scope request tried once when a name isn't found under the caller's current scope. */
153
+ function widenedRequest(listOperation: OperationName, baseRequest: Record<string, unknown>): Record<string, unknown> {
154
+ return SCOPE_AWARE_LIST_OPERATIONS.has(listOperation)
155
+ ? { ...baseRequest, scope: "all" }
156
+ : { ...baseRequest, project_root: undefined };
157
+ }
158
+
141
159
  /**
142
160
  * Resolves a name to its id via `listOperation` (whichever kind's list call is the right search
143
161
  * scope -- tasks.list, docs.list, rules.list, skills.list, notes.list, discuss.list, or the
144
162
  * kind-agnostic artifact.query for a cross-kind reference like a link target). `baseRequest`
145
163
  * should mirror whatever scoping (project_root, etc.) that operation's own "list" action already
146
164
  * uses, so resolution never searches a wider or narrower scope than a plain list call would.
165
+ *
166
+ * A two-artifact action (depend/contain/gate/link) routinely names artifacts that live in two
167
+ * different projects, and one call has no way to give two different name fields two different
168
+ * scopes. When the first lookup finds nothing under the caller's current scope, retry exactly
169
+ * once against a global search before giving up -- but never when the caller already pinned an
170
+ * explicit `scope`, so a genuine "not found in the scope I asked for" stays a real error instead
171
+ * of being silently papered over. `notes`, when given, records that a name only resolved after
172
+ * widening, so the caller can surface that a search went wider than the caller's default scope
173
+ * rather than resolving silently.
147
174
  */
148
- async function resolveArtifactIdByName(listOperation: OperationName, baseRequest: Record<string, unknown>, name: string): Promise<string> {
175
+ async function resolveArtifactIdByName(listOperation: OperationName, baseRequest: Record<string, unknown>, name: string, notes?: string[]): Promise<string> {
149
176
  const candidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...baseRequest, text: name });
150
- return matchArtifactByName(candidates, name);
177
+ try {
178
+ return matchArtifactByName(candidates, name);
179
+ } catch (error) {
180
+ if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || baseRequest["scope"] !== undefined) throw error;
181
+ const widenedCandidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...widenedRequest(listOperation, baseRequest), text: name });
182
+ const id = matchArtifactByName(widenedCandidates, name);
183
+ notes?.push(`"${name}" was not found in the current project scope; resolved across all projects instead.`);
184
+ return id;
185
+ }
151
186
  }
152
187
 
153
188
  /**
@@ -167,15 +202,21 @@ export function normalizeJsonEncodedField(params: Record<string, unknown>, key:
167
202
  }
168
203
  }
169
204
 
170
- /** Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in place. */
205
+ /**
206
+ * Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in
207
+ * place. `notes`, when given, collects a message for each name that only resolved by widening
208
+ * past the caller's own scope (see resolveArtifactIdByName) -- callers that want that surfaced
209
+ * to the model/human pass an array here and append it to their own response text.
210
+ */
171
211
  export async function resolveNameFields(
172
212
  params: Record<string, unknown>,
173
213
  fields: ReadonlyArray<{ nameKey: string; idKey: string; listOperation: OperationName; baseRequest: Record<string, unknown> }>,
214
+ notes?: string[],
174
215
  ): Promise<void> {
175
216
  for (const { nameKey, idKey, listOperation, baseRequest } of fields) {
176
217
  const nameValue = params[nameKey];
177
218
  if (typeof nameValue === "string" && nameValue.length > 0 && !params[idKey]) {
178
- params[idKey] = await resolveArtifactIdByName(listOperation, baseRequest, nameValue);
219
+ params[idKey] = await resolveArtifactIdByName(listOperation, baseRequest, nameValue, notes);
179
220
  }
180
221
  }
181
222
  }
@@ -187,10 +228,11 @@ async function resolveNameArrayField(
187
228
  idsKey: string,
188
229
  listOperation: OperationName,
189
230
  baseRequest: Record<string, unknown>,
231
+ notes?: string[],
190
232
  ): Promise<void> {
191
233
  const names = params[namesKey];
192
234
  if (Array.isArray(names) && names.length > 0 && !params[idsKey]) {
193
- params[idsKey] = await Promise.all(names.map((entry) => resolveArtifactIdByName(listOperation, baseRequest, String(entry))));
235
+ params[idsKey] = await Promise.all(names.map((entry) => resolveArtifactIdByName(listOperation, baseRequest, String(entry), notes)));
194
236
  }
195
237
  }
196
238
 
@@ -248,7 +290,7 @@ export function registerTasksTool(pi: ExtensionAPI): void {
248
290
  pi.registerTool({
249
291
  name: "tasks",
250
292
  label: "Tasks",
251
- description: "Task domain tool. ACTIONS: create, update, list, show, history, context, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, cancel_subtree, run_gates, set_checklist, set_gates, depend, undepend, contain, uncontain, remove, remove_subtree, restore, claim, heartbeat_lease, release_lease, lease, event_feed. Lifecycle: todo → in-progress → review → done; review failure → rejected → retry → in-progress; canceled is terminal. Focus and lease are independent of lifecycle and of each other -- multiple sessions can focus the same task while only one holds its lease (claim throws if a different owner already holds one; release/heartbeat need the exact token claim returned; owner defaults to this session's id). context returns the full plan (the system prompt itself only carries a one-line pointer) -- call it explicitly after a compaction or before reconciling. complete runs gates + checklist-proof review, then focuses one ready successor. cancel_subtree cancels a task and its whole containment subtree in one call, skipping tasks already done/canceled. remove/restore use a time-gated trash (refuses the live Focus); remove_subtree trashes a whole `contains` subtree in one call; undepend/uncontain are idempotent no-ops when the edge is already absent. update recovers an accidentally-terminal task via status=todo + reason, without rewriting real history; update never touches gates (title/body/labels/status only) -- use set_gates to replace a task's gate commands after creation. Prefer `name` (exact title) over `id` -- id is a backend detail, resolved automatically, needed only to disambiguate a shared title; `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the same pattern for their `_id` counterparts.",
293
+ description: "Task domain tool. ACTIONS: create, update, list, show, history, context, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, cancel_subtree, run_gates, set_checklist, set_gates, depend, undepend, contain, uncontain, remove, remove_subtree, restore, claim, heartbeat_lease, release_lease, lease, event_feed. Lifecycle: todo → in-progress → review → done; review failure → rejected → retry → in-progress; canceled is terminal. Focus and lease are independent of lifecycle and of each other -- multiple sessions can focus the same task while only one holds its lease (claim throws if a different owner already holds one; release/heartbeat need the exact token claim returned; owner defaults to this session's id). context returns the full plan (the system prompt itself only carries a one-line pointer) -- call it explicitly after a compaction or before reconciling. complete runs gates + checklist-proof review, then focuses one ready successor. cancel_subtree cancels a task and its whole containment subtree in one call, skipping tasks already done/canceled. remove/restore use a time-gated trash (refuses the live Focus); remove_subtree trashes a whole `contains` subtree in one call; undepend/uncontain are idempotent no-ops when the edge is already absent. update recovers an accidentally-terminal task via status=todo + reason, without rewriting real history; update never touches gates (title/body/labels/status only) -- use set_gates to replace a task's gate commands after creation. Prefer `name` (exact title) over `id` -- id is a backend detail, resolved automatically, needed only to disambiguate a shared title; `parent_name`/`child_name`/`root_task_name` are the same pattern for their `_id` counterparts. For a prerequisite, use `dependency_name` (singular, resolved to `dependency_id`) with the `depend`/`undepend` actions; `depends_on_names` (plural array, resolved to `depends_on`) is only for `create`'s initial dependency set -- passing the wrong one of the two to `depend` leaves `dependency_id` unset and fails with a `dependency_id is required` error. A name resolved outside this call's own project scope (e.g. depending on a task in a different project) is retried once against every project before failing, and the response notes when that happened.",
252
294
  parameters: Type.Object({
253
295
  action: Type.String(),
254
296
  id: Type.Optional(Type.String()),
@@ -299,12 +341,17 @@ export function registerTasksTool(pi: ExtensionAPI): void {
299
341
  // ever holds this extension's own registered session anyway (see session-identity.ts).
300
342
  const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
301
343
  const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
344
+ // Collects a note whenever a name field below only resolved by widening past this call's
345
+ // own project scope (see resolveArtifactIdByName) -- surfaced at the end of this action's
346
+ // own response text rather than resolved silently, since a cross-project depend/contain
347
+ // is exactly the case a shared per-call scope can't otherwise express.
348
+ const notes: string[] = [];
302
349
  // Resolve the graph root first: every other name lookup must use the caller's final
303
350
  // project/scope/root selection, otherwise `scope: all|graph` silently collapses back
304
351
  // to the current project and forces callers to reach for an id.
305
352
  await resolveNameFields(params, [
306
353
  { nameKey: "root_task_name", idKey: "root_task_id", listOperation: "tasks.list", baseRequest: { ...baseRequest, scope: "project" } },
307
- ]);
354
+ ], notes);
308
355
  const resolutionRequest = {
309
356
  ...baseRequest,
310
357
  ...(params.scope === undefined ? {} : { scope: params.scope }),
@@ -317,9 +364,10 @@ export function registerTasksTool(pi: ExtensionAPI): void {
317
364
  { nameKey: "dependency_name", idKey: "dependency_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
318
365
  { nameKey: "parent_name", idKey: "parent_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
319
366
  { nameKey: "child_name", idKey: "child_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
320
- ]);
321
- await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest);
367
+ ], notes);
368
+ await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest, notes);
322
369
  const request = { ...params, ...baseRequest };
370
+ const result = await (async (): Promise<ReturnType<typeof text>> => {
323
371
  if (action === "create") {
324
372
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
325
373
  return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
@@ -482,6 +530,9 @@ export function registerTasksTool(pi: ExtensionAPI): void {
482
530
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
483
531
  if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
484
532
  return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
533
+ })();
534
+ if (notes.length > 0 && result.content[0]?.type === "text") result.content[0].text += `\n\n${notes.join("\n")}`;
535
+ return result;
485
536
  } catch (error) {
486
537
  throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
487
538
  }
@@ -672,13 +723,33 @@ export function registerPlaybooksTool(pi: ExtensionAPI): void {
672
723
  return text(rendered, createPreviewDetails("playbooks.preview", "Playbook preview", rendered));
673
724
  }
674
725
  if (action === "invoke") {
675
- const invocation = await callService<Record<string, unknown>, { entryTaskId?: string; rootTaskIds?: string[]; created?: { tasks: string[] }; missingArguments?: string[] }>("playbooks.invoke", params);
676
- if (invocation.missingArguments) {
726
+ const invocation = await callService<Record<string, unknown>, PlaybookInvocationResult | PlaybookMissingArguments>("playbooks.invoke", params);
727
+ if ("missingArguments" in invocation) {
677
728
  const message = `Missing required argument(s): ${invocation.missingArguments.join(", ")}. Nothing was created -- ask the human for these (discuss tool, live:true), then invoke again.`;
678
- return text(message, createPreviewDetails("playbooks.invoke", "Playbook invocation", message));
729
+ return text(message, createInvocationDetails("playbooks.invoke", invocation.playbookId, { tasks: [], docs: [], rules: [], roots: [] }));
679
730
  }
680
- const message = `Invoked: ${invocation.created?.tasks.length ?? 0} task(s) created, entry task ${invocation.entryTaskId} now focused. Drive it forward with the tasks tool (start/submit/complete) -- contains/depends_on wiring auto-focuses each next step.`;
681
- return text(message, createPreviewDetails("playbooks.invoke", "Playbook invocation", JSON.stringify(invocation, null, 2)));
731
+ const nodeTitleCounts = new Map<string, number>();
732
+ for (const node of invocation.execution.nodes) nodeTitleCounts.set(node.title, (nodeTitleCounts.get(node.title) ?? 0) + 1);
733
+ const execution = invocation.execution.nodes.map((node) => (nodeTitleCounts.get(node.title) ?? 0) > 1
734
+ ? ` [${node.state}] ${node.title} (${node.id})`
735
+ : ` [${node.state}] ${node.title}`).join("\n");
736
+ const nodeById = new Map(invocation.execution.nodes.map((node) => [node.id, node]));
737
+ const rootLabels = invocation.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
738
+ const entryLabel = nodeById.get(invocation.entryTaskId)?.title ?? invocation.entryTaskId;
739
+ const createdLabels = await artifactLabelsById([...invocation.created.docs, ...invocation.created.rules]);
740
+ return text([
741
+ `Invoked playbook run ${invocation.runId}: ${invocation.created.tasks.length} task(s), ${invocation.created.rules.length} rule(s), ${invocation.created.docs.length} doc(s) created.`,
742
+ `Entry task now focused: ${entryLabel}. Drive it forward with the tasks tool (start/submit/complete) -- contains/depends_on wiring auto-focuses each next step.`,
743
+ `Ready roots: ${rootLabels.join(", ") || "none"}.`,
744
+ `Context docs: ${invocation.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
745
+ `Scoped rules: ${invocation.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
746
+ ...(execution ? ["Execution:", execution] : []),
747
+ ].join("\n"), createInvocationDetails("playbooks.invoke", invocation.runId, {
748
+ tasks: invocation.created.tasks,
749
+ docs: invocation.created.docs,
750
+ rules: invocation.created.rules,
751
+ roots: invocation.rootTaskIds,
752
+ }));
682
753
  }
683
754
  const trashResult = await handleArtifactRemoveRestore(action, params);
684
755
  if (trashResult) return trashResult;
@@ -26,7 +26,7 @@ import {
26
26
  } from "@danypops/papyrus";
27
27
  import { formatMetadata } from "./artifact-format.ts";
28
28
  import { callService, subscribeTaskPushChannel } from "./service-client.ts";
29
- import type { PushChannelClient } from "@danypops/daemon-kit/pi-client";
29
+ import type { PushChannelClient } from "@danypops/vehicle-client/daemon-client";
30
30
  import { registerDomainTools, resolveNameFields } from "./domain-tools.ts";
31
31
  import { registerNotesVehicle } from "./vehicle-notes-client.ts";
32
32
  import { BoundedPoll } from "./bounded-poll.ts";
@@ -1,4 +1,4 @@
1
- import { connectPushChannel, createRetryingClient, type PushChannelClient, type PushChannelState, type RetryingClient } from "@danypops/daemon-kit/pi-client";
1
+ import { connectPushChannel, createRetryingClient, type PushChannelClient, type PushChannelState, type RetryingClient } from "@danypops/vehicle-client/daemon-client";
2
2
  import { connectPapyrusClient, resolvePushChannelTarget, resolveVehicleClientTarget, type OperationName, type PapyrusClient, type VehicleClientTarget } from "@danypops/papyrus";
3
3
 
4
4
  type ClientConnector = () => Promise<PapyrusClient>;
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
3
+ import { buildDetailLines, type DetailField, type DetailSection } from "malevich-tui-components";
3
4
  import {
4
5
  TASK_DETAIL_HORIZONTAL_PAN_COLUMNS,
5
6
  TASK_DETAIL_MAX_VISIBLE_LINES,
@@ -92,17 +93,32 @@ class TaskDetailViewport {
92
93
  (text.length === 0 ? [""] : wrapTextWithAnsi(theme.fg(color, text), width)).map((line) => ({ text: line, graph: false }));
93
94
  const status = TASK_STATUS_PRESENTATION[this.status as keyof typeof TASK_STATUS_PRESENTATION];
94
95
  const headline = status ? theme.fg(status.color, theme.bold(this.content.headline)) : theme.bold(this.content.headline);
96
+
97
+ // Labels + the checklist/gates/metadata/history sections are plain field/flat-line
98
+ // shapes -- delegated to malevich's buildDetailLines. The headline/identity (styled
99
+ // per task status), body (markdown-rendered), and the relationship graph
100
+ // (horizontally pannable "wide" lines) stay hand-rolled: buildDetailLines has no
101
+ // concept of any of those.
95
102
  const identity = [
96
103
  ...wrapTextWithAnsi(headline, width).map((text) => ({ text, graph: false })),
97
104
  ...wrap(this.content.identity, "muted"),
98
- ...(this.content.labels.length > 0 ? wrap(`Labels: ${this.content.labels.join(", ")}`, "muted") : []),
99
- { text: "", graph: false },
100
105
  ];
106
+ const detailTheme = {
107
+ field: (s: string) => theme.fg("muted", s),
108
+ heading: (s: string) => theme.fg("muted", s),
109
+ byline: (s: string) => theme.fg("dim", s),
110
+ body: (s: string) => theme.fg("text", s),
111
+ line: (s: string) => theme.fg("dim", s),
112
+ };
113
+ const fields: DetailField[] = this.content.labels.length > 0 ? [{ label: "Labels", value: this.content.labels.join(", ") }] : [];
114
+ const labels = fields.length > 0
115
+ ? [...buildDetailLines(width, { fields, theme: detailTheme }).map((text) => ({ text, graph: false })), { text: "", graph: false }]
116
+ : [{ text: "", graph: false }];
101
117
  const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, graph: false }));
102
- const sections = this.content.sections.flatMap((section) => [
103
- { text: "", graph: false },
104
- ...section.flatMap((line, index) => wrap(line, index === 0 ? "muted" : "dim")),
105
- ]);
118
+ const sections: DetailSection[] = this.content.sections.map((section) => ({ heading: section[0], lines: section.slice(1) }));
119
+ const sectionLines = sections.length > 0
120
+ ? buildDetailLines(width, { sections, theme: detailTheme }).map((text) => ({ text, graph: false }))
121
+ : [];
106
122
  const relationshipHeader = this.graphLines.length > 0
107
123
  ? [
108
124
  { text: "", graph: false },
@@ -112,8 +128,9 @@ class TaskDetailViewport {
112
128
  : [];
113
129
  this.detailLines = [
114
130
  ...identity,
131
+ ...labels,
115
132
  ...body,
116
- ...sections,
133
+ ...sectionLines,
117
134
  ...relationshipHeader,
118
135
  ...this.graphLines.map((text) => ({ text: theme.fg("text", text), graph: true })),
119
136
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.38.3",
3
+ "version": "0.38.5",
4
4
  "description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -17,12 +17,13 @@
17
17
  "typebox": "*"
18
18
  },
19
19
  "dependencies": {
20
- "@danypops/daemon-kit": "^0.10.0",
21
20
  "@danypops/papyrus": "^0.38.1",
22
- "@danypops/vehicle-core": "^0.1.0",
21
+ "@danypops/vehicle-core": "^0.1.1",
22
+ "@danypops/vehicle-server": "^0.1.1",
23
23
  "@danypops/vehicle-client": "^0.1.1",
24
24
  "@danypops/vehicle-client-pi": "^0.1.5",
25
- "beautiful-mermaid": "1.1.3"
25
+ "beautiful-mermaid": "1.1.3",
26
+ "malevich-tui-components": "^0.5.0"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@earendil-works/pi-coding-agent": "^0.80.10",