@danypops/pi-papyrus 0.55.9 → 0.56.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.
@@ -23,9 +23,10 @@ import {
23
23
  type TaskStatus,
24
24
  } from "@danypops/papyrus";
25
25
  import type { PushChannelClient } from "@danypops/vehicle-client/daemon-client";
26
- import { vehicleWidgetTitle } from "@danypops/vehicle-client-pi/widget-header";
26
+ import { vehicleWidgetOwner } from "@danypops/vehicle-client-pi/widget-header";
27
27
  import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
28
28
  import { truncateToWidth } from "@earendil-works/pi-tui";
29
+ import { renderWidgetSectionGroup, type WidgetSection } from "malevich-tui-components";
29
30
  import { Type } from "typebox";
30
31
  import { formatMetadata } from "./artifact/artifact-format.ts";
31
32
  import { BoundedPoll } from "./bounded-poll.ts";
@@ -34,7 +35,7 @@ import { PAPYRUS_CONTEXT_HUB_PRODUCER_NAME, papyrusContextSegment } from "./cont
34
35
  import { buildContextInjection } from "./context/context-injection-telemetry.ts";
35
36
  import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss/discuss-ask-view.ts";
36
37
  import { resolveNameFields } from "./domain-tools.ts";
37
- import { renderNoteWidgetLines } from "./note/note-widget.ts";
38
+ import { buildNoteWidgetSection } from "./note/note-widget.ts";
38
39
  import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook/playbook-bridge.ts";
39
40
  import { callService, subscribeTaskPushChannel } from "./service-client.ts";
40
41
  import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
@@ -91,15 +92,14 @@ async function artifactNamesById(ids: readonly string[]): Promise<Map<string, st
91
92
  }
92
93
 
93
94
  // ---------------------------------------------------------------------------
94
- // Task widget (TodoOverlay pattern from rpiv-todo: factory form, requestRender)
95
+ // Task + Notes widget (one shared aboveEditor tree -- PapyrusWidgetGroup below
96
+ // composes both overlays' own sections via renderWidgetSectionGroup, instead of
97
+ // each registering its own separate "Papyrus · <Label>" flat header)
95
98
  // ---------------------------------------------------------------------------
96
99
 
97
- const WIDGET_KEY = "pi-papyrus";
98
-
99
- export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjection, width: number): string[] {
100
- if (projection.openTotal === 0) return [];
101
- const header = truncateToWidth(theme.fg("muted", vehicleWidgetTitle(PAPYRUS_VEHICLE_NAME, "Tasks", projection.scopeLabel)), width, "…");
102
- const lines: string[] = [header];
100
+ /** The rows only -- no header line; PapyrusWidgetGroup's own owner header covers that now. */
101
+ export function renderTaskSectionBodyLines(theme: Theme, projection: TaskWidgetProjection, width: number): string[] {
102
+ const lines: string[] = [];
103
103
  for (let index = 0; index < projection.rows.length; index++) {
104
104
  const row = projection.rows[index]!;
105
105
  const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
@@ -117,22 +117,26 @@ export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjec
117
117
  return lines;
118
118
  }
119
119
 
120
+ /** "Tasks" or "Tasks · <scope>" -- no more "Papyrus ·" prefix; the group's own owner header covers that. */
121
+ export function taskSectionLabel(projection: TaskWidgetProjection): string {
122
+ return projection.scopeLabel ? `Tasks · ${projection.scopeLabel}` : "Tasks";
123
+ }
124
+
125
+ export function buildTaskWidgetSection(theme: Theme, projection: TaskWidgetProjection): WidgetSection | undefined {
126
+ if (projection.openTotal === 0) return undefined;
127
+ return { label: taskSectionLabel(projection), render: (width) => renderTaskSectionBodyLines(theme, projection, width) };
128
+ }
129
+
120
130
  export class TaskOverlay {
121
- private uiCtx: ExtensionUIContext | undefined;
122
- private registered = false;
123
- private tui: any | undefined;
131
+ private widgetGroup: PapyrusWidgetGroup | undefined;
124
132
  private snapshot: TaskGraph = { nodes: [], rootIds: [] };
125
133
  private projectRoot: string | undefined;
126
134
  private sessionId: string | undefined;
127
135
  private readonly poll = new BoundedPoll();
128
136
  private pushChannel: PushChannelClient | undefined;
129
137
 
130
- setUI(ctx: ExtensionUIContext): void {
131
- if (ctx !== this.uiCtx) {
132
- this.uiCtx = ctx;
133
- this.registered = false;
134
- this.tui = undefined;
135
- }
138
+ setWidgetGroup(group: PapyrusWidgetGroup): void {
139
+ this.widgetGroup = group;
136
140
  }
137
141
 
138
142
  setProjectRoot(projectRoot: string): void {
@@ -162,7 +166,7 @@ export class TaskOverlay {
162
166
  this.snapshot = { nodes: [], rootIds: [] };
163
167
  }
164
168
  try {
165
- this.render();
169
+ this.widgetGroup?.requestUpdate();
166
170
  } catch {
167
171
  // A rendering bug must not crash the extension host over a best-effort status widget.
168
172
  }
@@ -183,43 +187,15 @@ export class TaskOverlay {
183
187
  });
184
188
  }
185
189
 
186
- private render(): void {
187
- if (!this.uiCtx) return;
188
-
189
- // Hide widget when no tasks
190
- if (this.snapshot.nodes.length === 0) {
191
- if (this.registered) {
192
- this.uiCtx.setWidget(WIDGET_KEY, undefined);
193
- this.registered = false;
194
- this.tui = undefined;
195
- }
196
- return;
197
- }
198
-
199
- if (!this.registered) {
200
- this.uiCtx.setWidget(
201
- WIDGET_KEY,
202
- (tui: any, theme: Theme) => {
203
- this.tui = tui;
204
- return {
205
- render: (width: number) => this.renderLines(theme, width),
206
- invalidate: () => {
207
- // Theme changed — force re-registration
208
- this.registered = false;
209
- this.tui = undefined;
210
- },
211
- };
212
- },
213
- { placement: "aboveEditor" },
214
- );
215
- this.registered = true;
216
- } else {
217
- this.tui?.requestRender?.();
218
- }
190
+ /** Theme-free existence check -- PapyrusWidgetGroup's own eager (pre-paint) hide decision needs
191
+ * this without needing a theme, which is only ever available inside the widget's own render(width). */
192
+ hasOpenWork(): boolean {
193
+ return buildTaskWidgetProjection(this.snapshot).openTotal > 0;
219
194
  }
220
195
 
221
- private renderLines(theme: Theme, width: number): string[] {
222
- return renderTaskWidgetLines(theme, buildTaskWidgetProjection(this.snapshot), width);
196
+ /** undefined when there is no open work to show -- PapyrusWidgetGroup's own signal to omit this section entirely. */
197
+ buildSection(theme: Theme): WidgetSection | undefined {
198
+ return buildTaskWidgetSection(theme, buildTaskWidgetProjection(this.snapshot));
223
199
  }
224
200
 
225
201
  /**
@@ -240,17 +216,12 @@ export class TaskOverlay {
240
216
  this.stopPolling();
241
217
  this.pushChannel?.close();
242
218
  this.pushChannel = undefined;
243
- this.uiCtx?.setWidget(WIDGET_KEY, undefined);
244
- this.registered = false;
245
- this.tui = undefined;
246
- this.uiCtx = undefined;
219
+ this.widgetGroup = undefined;
247
220
  this.projectRoot = undefined;
248
221
  this.sessionId = undefined;
249
222
  }
250
223
  }
251
224
 
252
- const NOTE_WIDGET_KEY = "pi-papyrus-notes";
253
-
254
225
  /**
255
226
  * Deliberately simple, unlike TaskOverlay's tree: just an open-note count for this session's own
256
227
  * CWD -- notes.list already scopes to project_root exactly (a note's projectRoot is fixed at
@@ -258,19 +229,13 @@ const NOTE_WIDGET_KEY = "pi-papyrus-notes";
258
229
  * default.
259
230
  */
260
231
  export class NoteOverlay {
261
- private uiCtx: ExtensionUIContext | undefined;
262
- private registered = false;
263
- private tui: any | undefined;
232
+ private widgetGroup: PapyrusWidgetGroup | undefined;
264
233
  private openCount = 0;
265
234
  private projectRoot: string | undefined;
266
235
  private readonly poll = new BoundedPoll();
267
236
 
268
- setUI(ctx: ExtensionUIContext): void {
269
- if (ctx !== this.uiCtx) {
270
- this.uiCtx = ctx;
271
- this.registered = false;
272
- this.tui = undefined;
273
- }
237
+ setWidgetGroup(group: PapyrusWidgetGroup): void {
238
+ this.widgetGroup = group;
274
239
  }
275
240
 
276
241
  setProjectRoot(projectRoot: string): void {
@@ -289,18 +254,80 @@ export class NoteOverlay {
289
254
  this.openCount = 0;
290
255
  }
291
256
  try {
292
- this.render();
257
+ this.widgetGroup?.requestUpdate();
293
258
  } catch {
294
259
  // A rendering bug must not crash the extension host over a best-effort status widget.
295
260
  }
296
261
  }
297
262
 
298
- private render(): void {
263
+ /** Theme-free existence check -- see TaskOverlay's own hasOpenWork() for why this needs to stay theme-free. */
264
+ hasOpenNotes(): boolean {
265
+ return this.openCount > 0;
266
+ }
267
+
268
+ /** undefined when there are no open notes -- PapyrusWidgetGroup's own signal to omit this section entirely. */
269
+ buildSection(): WidgetSection | undefined {
270
+ return buildNoteWidgetSection(this.openCount);
271
+ }
272
+
273
+ startPolling(intervalMs: number = NOTE_WIDGET_POLL_INTERVAL_MS): void {
274
+ this.poll.start(intervalMs, () => {
275
+ void this.refresh();
276
+ });
277
+ }
278
+
279
+ stopPolling(): void {
280
+ this.poll.stop();
281
+ }
282
+
283
+ dispose(): void {
284
+ this.stopPolling();
285
+ this.widgetGroup = undefined;
286
+ this.projectRoot = undefined;
287
+ }
288
+ }
289
+
290
+ const PAPYRUS_WIDGET_KEY = "pi-papyrus";
291
+
292
+ /**
293
+ * Owns the ONE real aboveEditor widget registration for both TaskOverlay and NoteOverlay, composing
294
+ * their own current sections via renderWidgetSectionGroup -- "Papyrus" once, with "Tasks"/"Notes"
295
+ * as its own indented children (or side-by-side columns once the terminal is wide enough), instead
296
+ * of each overlay registering its own separate "Papyrus · <Label>" flat-header widget. Hidden
297
+ * entirely (fully unregistered, not just empty lines) only once NEITHER overlay has anything to
298
+ * show -- either overlay alone still renders its own section normally.
299
+ */
300
+ export class PapyrusWidgetGroup {
301
+ private uiCtx: ExtensionUIContext | undefined;
302
+ private registered = false;
303
+ private tui: any | undefined;
304
+ private taskOverlay: TaskOverlay | undefined;
305
+ private noteOverlay: NoteOverlay | undefined;
306
+
307
+ setUI(ctx: ExtensionUIContext): void {
308
+ if (ctx !== this.uiCtx) {
309
+ this.uiCtx = ctx;
310
+ this.registered = false;
311
+ this.tui = undefined;
312
+ }
313
+ }
314
+
315
+ setOverlays(taskOverlay: TaskOverlay, noteOverlay: NoteOverlay): void {
316
+ this.taskOverlay = taskOverlay;
317
+ this.noteOverlay = noteOverlay;
318
+ }
319
+
320
+ /** Called by either overlay after its own refresh() -- re-renders (or registers/unregisters) the shared widget. */
321
+ requestUpdate(): void {
299
322
  if (!this.uiCtx) return;
300
323
 
301
- if (this.openCount === 0) {
324
+ // Eager (pre-paint) hide check, theme-free -- matches both overlays' own prior convention of
325
+ // deciding this immediately after a data refresh, not waiting for Pi's own next repaint to
326
+ // notice via renderLines()'s own (still-present, defense-in-depth) empty check below.
327
+ const hasContent = (this.taskOverlay?.hasOpenWork() ?? false) || (this.noteOverlay?.hasOpenNotes() ?? false);
328
+ if (!hasContent) {
302
329
  if (this.registered) {
303
- this.uiCtx.setWidget(NOTE_WIDGET_KEY, undefined);
330
+ this.uiCtx.setWidget(PAPYRUS_WIDGET_KEY, undefined);
304
331
  this.registered = false;
305
332
  this.tui = undefined;
306
333
  }
@@ -309,12 +336,13 @@ export class NoteOverlay {
309
336
 
310
337
  if (!this.registered) {
311
338
  this.uiCtx.setWidget(
312
- NOTE_WIDGET_KEY,
339
+ PAPYRUS_WIDGET_KEY,
313
340
  (tui: any, theme: Theme) => {
314
341
  this.tui = tui;
315
342
  return {
316
- render: (width: number) => renderNoteWidgetLines(theme, this.openCount, width),
343
+ render: (width: number) => this.renderLines(theme, width),
317
344
  invalidate: () => {
345
+ // Theme changed — force re-registration
318
346
  this.registered = false;
319
347
  this.tui = undefined;
320
348
  },
@@ -328,23 +356,34 @@ export class NoteOverlay {
328
356
  }
329
357
  }
330
358
 
331
- startPolling(intervalMs: number = NOTE_WIDGET_POLL_INTERVAL_MS): void {
332
- this.poll.start(intervalMs, () => {
333
- void this.refresh();
334
- });
335
- }
336
-
337
- stopPolling(): void {
338
- this.poll.stop();
359
+ private renderLines(theme: Theme, width: number): string[] {
360
+ const sections = [this.taskOverlay?.buildSection(theme), this.noteOverlay?.buildSection()].filter(
361
+ (section): section is WidgetSection => section !== undefined,
362
+ );
363
+ if (sections.length === 0) {
364
+ // Hide entirely (unregister), not just an empty-but-registered widget -- matches both
365
+ // overlays' own prior "nothing open" convention.
366
+ if (this.uiCtx && this.registered) {
367
+ this.uiCtx.setWidget(PAPYRUS_WIDGET_KEY, undefined);
368
+ this.registered = false;
369
+ this.tui = undefined;
370
+ }
371
+ return [];
372
+ }
373
+ return renderWidgetSectionGroup({
374
+ owner: vehicleWidgetOwner(PAPYRUS_VEHICLE_NAME),
375
+ sections,
376
+ ownerStyle: (s) => theme.fg("muted", s),
377
+ }).render(width);
339
378
  }
340
379
 
341
380
  dispose(): void {
342
- this.stopPolling();
343
- this.uiCtx?.setWidget(NOTE_WIDGET_KEY, undefined);
381
+ this.uiCtx?.setWidget(PAPYRUS_WIDGET_KEY, undefined);
344
382
  this.registered = false;
345
383
  this.tui = undefined;
346
384
  this.uiCtx = undefined;
347
- this.projectRoot = undefined;
385
+ this.taskOverlay = undefined;
386
+ this.noteOverlay = undefined;
348
387
  }
349
388
  }
350
389
 
@@ -639,6 +678,7 @@ export default async function (pi: ExtensionAPI) {
639
678
  ]);
640
679
  let overlay: TaskOverlay | undefined;
641
680
  let noteOverlay: NoteOverlay | undefined;
681
+ let widgetGroup: PapyrusWidgetGroup | undefined;
642
682
 
643
683
  pi.registerCommand("tasks", {
644
684
  description: "Browse and manage Papyrus tasks (interactive)",
@@ -728,16 +768,22 @@ export default async function (pi: ExtensionAPI) {
728
768
  // keystrokes from the moment that tool call happens to begin, missing typing already in
729
769
  // progress when it started (the exact case Discuss's typing-courtesy wait protects against).
730
770
  ensureTypingCourtesyTracking(ctx.ui);
771
+ widgetGroup ??= new PapyrusWidgetGroup();
772
+ widgetGroup.setUI(ctx.ui);
773
+
731
774
  overlay ??= new TaskOverlay();
732
- overlay.setUI(ctx.ui);
775
+ overlay.setWidgetGroup(widgetGroup);
733
776
  overlay.setProjectRoot(ctx.cwd);
734
777
  overlay.setSessionId(ctx.sessionManager.getSessionId());
735
- await overlay.refresh();
736
- overlay.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
737
778
 
738
779
  noteOverlay ??= new NoteOverlay();
739
- noteOverlay.setUI(ctx.ui);
780
+ noteOverlay.setWidgetGroup(widgetGroup);
740
781
  noteOverlay.setProjectRoot(ctx.cwd);
782
+
783
+ widgetGroup.setOverlays(overlay, noteOverlay);
784
+
785
+ await overlay.refresh();
786
+ overlay.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
741
787
  await noteOverlay.refresh();
742
788
  noteOverlay.startPolling(NOTE_WIDGET_POLL_INTERVAL_MS);
743
789
  });
@@ -756,6 +802,8 @@ export default async function (pi: ExtensionAPI) {
756
802
  overlay = undefined;
757
803
  noteOverlay?.dispose();
758
804
  noteOverlay = undefined;
805
+ widgetGroup?.dispose();
806
+ widgetGroup = undefined;
759
807
  try {
760
808
  const sessionId = ctx.sessionManager.getSessionId();
761
809
  await callService("session.release", { session_id: sessionId, ...sessionSecretField(sessionId) });
@@ -1,11 +1,14 @@
1
- import { PAPYRUS_VEHICLE_NAME } from "@danypops/papyrus";
2
- import { vehicleWidgetTitle } from "@danypops/vehicle-client-pi/widget-header";
3
- import type { Theme } from "@earendil-works/pi-coding-agent";
4
- import { truncateToWidth } from "@earendil-works/pi-tui";
1
+ import type { WidgetSection } from "malevich-tui-components";
5
2
 
6
- /** Hidden at 0, matching TaskOverlay's own "nothing open" hiding rule. */
7
- export function renderNoteWidgetLines(theme: Theme, openCount: number, width: number): string[] {
8
- if (openCount === 0) return [];
9
- const header = theme.fg("muted", vehicleWidgetTitle(PAPYRUS_VEHICLE_NAME, "Notes"));
10
- return [truncateToWidth(`${header} ${theme.fg("accent", String(openCount))}`, width, "…")];
3
+ /**
4
+ * Notes has no body rows of its own -- the open count lives on the section's own label. undefined
5
+ * (hide the section entirely) at 0, matching TaskOverlay's own "nothing open" hiding rule.
6
+ * Deliberately one uniform label string, not an embedded differently-colored count: the caller's
7
+ * own section `style` wraps this whole branch line, and nesting a second color inside it here
8
+ * would risk incorrect ANSI reset composition -- every other section label in this widget group
9
+ * stays one tone for the same reason.
10
+ */
11
+ export function buildNoteWidgetSection(openCount: number): WidgetSection | undefined {
12
+ if (openCount === 0) return undefined;
13
+ return { label: `Notes ${openCount}`, render: () => [] };
11
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.55.9",
3
+ "version": "0.56.0",
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"],
@@ -24,7 +24,7 @@
24
24
  "@danypops/vehicle-core": "^0.17.1",
25
25
  "@danypops/vehicle-server": "^0.25.2",
26
26
  "beautiful-mermaid": "1.1.3",
27
- "malevich-tui-components": "^0.24.0"
27
+ "malevich-tui-components": "^0.31.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@danypops/pi-tui-harness": "^0.0.2",