@danypops/pi-papyrus 0.55.9 → 0.56.1

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";
@@ -47,6 +48,7 @@ import {
47
48
  import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task/task-focus-events.ts";
48
49
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task/task-presentation.ts";
49
50
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task/task-widget.ts";
51
+ import { measure as artifactCardMeasure } from "./tool-rendering/artifact-card.ts";
50
52
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
51
53
  import {
52
54
  createArtifactDetails,
@@ -91,15 +93,14 @@ async function artifactNamesById(ids: readonly string[]): Promise<Map<string, st
91
93
  }
92
94
 
93
95
  // ---------------------------------------------------------------------------
94
- // Task widget (TodoOverlay pattern from rpiv-todo: factory form, requestRender)
96
+ // Task + Notes widget (one shared aboveEditor tree -- PapyrusWidgetGroup below
97
+ // composes both overlays' own sections via renderWidgetSectionGroup, instead of
98
+ // each registering its own separate "Papyrus · <Label>" flat header)
95
99
  // ---------------------------------------------------------------------------
96
100
 
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];
101
+ /** The rows only -- no header line; PapyrusWidgetGroup's own owner header covers that now. */
102
+ export function renderTaskSectionBodyLines(theme: Theme, projection: TaskWidgetProjection, width: number): string[] {
103
+ const lines: string[] = [];
103
104
  for (let index = 0; index < projection.rows.length; index++) {
104
105
  const row = projection.rows[index]!;
105
106
  const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
@@ -117,22 +118,26 @@ export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjec
117
118
  return lines;
118
119
  }
119
120
 
121
+ /** "Tasks" or "Tasks · <scope>" -- no more "Papyrus ·" prefix; the group's own owner header covers that. */
122
+ export function taskSectionLabel(projection: TaskWidgetProjection): string {
123
+ return projection.scopeLabel ? `Tasks · ${projection.scopeLabel}` : "Tasks";
124
+ }
125
+
126
+ export function buildTaskWidgetSection(theme: Theme, projection: TaskWidgetProjection): WidgetSection | undefined {
127
+ if (projection.openTotal === 0) return undefined;
128
+ return { label: taskSectionLabel(projection), render: (width) => renderTaskSectionBodyLines(theme, projection, width) };
129
+ }
130
+
120
131
  export class TaskOverlay {
121
- private uiCtx: ExtensionUIContext | undefined;
122
- private registered = false;
123
- private tui: any | undefined;
132
+ private widgetGroup: PapyrusWidgetGroup | undefined;
124
133
  private snapshot: TaskGraph = { nodes: [], rootIds: [] };
125
134
  private projectRoot: string | undefined;
126
135
  private sessionId: string | undefined;
127
136
  private readonly poll = new BoundedPoll();
128
137
  private pushChannel: PushChannelClient | undefined;
129
138
 
130
- setUI(ctx: ExtensionUIContext): void {
131
- if (ctx !== this.uiCtx) {
132
- this.uiCtx = ctx;
133
- this.registered = false;
134
- this.tui = undefined;
135
- }
139
+ setWidgetGroup(group: PapyrusWidgetGroup): void {
140
+ this.widgetGroup = group;
136
141
  }
137
142
 
138
143
  setProjectRoot(projectRoot: string): void {
@@ -162,7 +167,7 @@ export class TaskOverlay {
162
167
  this.snapshot = { nodes: [], rootIds: [] };
163
168
  }
164
169
  try {
165
- this.render();
170
+ this.widgetGroup?.requestUpdate();
166
171
  } catch {
167
172
  // A rendering bug must not crash the extension host over a best-effort status widget.
168
173
  }
@@ -183,43 +188,15 @@ export class TaskOverlay {
183
188
  });
184
189
  }
185
190
 
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
- }
191
+ /** Theme-free existence check -- PapyrusWidgetGroup's own eager (pre-paint) hide decision needs
192
+ * this without needing a theme, which is only ever available inside the widget's own render(width). */
193
+ hasOpenWork(): boolean {
194
+ return buildTaskWidgetProjection(this.snapshot).openTotal > 0;
219
195
  }
220
196
 
221
- private renderLines(theme: Theme, width: number): string[] {
222
- return renderTaskWidgetLines(theme, buildTaskWidgetProjection(this.snapshot), width);
197
+ /** undefined when there is no open work to show -- PapyrusWidgetGroup's own signal to omit this section entirely. */
198
+ buildSection(theme: Theme): WidgetSection | undefined {
199
+ return buildTaskWidgetSection(theme, buildTaskWidgetProjection(this.snapshot));
223
200
  }
224
201
 
225
202
  /**
@@ -240,17 +217,12 @@ export class TaskOverlay {
240
217
  this.stopPolling();
241
218
  this.pushChannel?.close();
242
219
  this.pushChannel = undefined;
243
- this.uiCtx?.setWidget(WIDGET_KEY, undefined);
244
- this.registered = false;
245
- this.tui = undefined;
246
- this.uiCtx = undefined;
220
+ this.widgetGroup = undefined;
247
221
  this.projectRoot = undefined;
248
222
  this.sessionId = undefined;
249
223
  }
250
224
  }
251
225
 
252
- const NOTE_WIDGET_KEY = "pi-papyrus-notes";
253
-
254
226
  /**
255
227
  * Deliberately simple, unlike TaskOverlay's tree: just an open-note count for this session's own
256
228
  * CWD -- notes.list already scopes to project_root exactly (a note's projectRoot is fixed at
@@ -258,19 +230,13 @@ const NOTE_WIDGET_KEY = "pi-papyrus-notes";
258
230
  * default.
259
231
  */
260
232
  export class NoteOverlay {
261
- private uiCtx: ExtensionUIContext | undefined;
262
- private registered = false;
263
- private tui: any | undefined;
233
+ private widgetGroup: PapyrusWidgetGroup | undefined;
264
234
  private openCount = 0;
265
235
  private projectRoot: string | undefined;
266
236
  private readonly poll = new BoundedPoll();
267
237
 
268
- setUI(ctx: ExtensionUIContext): void {
269
- if (ctx !== this.uiCtx) {
270
- this.uiCtx = ctx;
271
- this.registered = false;
272
- this.tui = undefined;
273
- }
238
+ setWidgetGroup(group: PapyrusWidgetGroup): void {
239
+ this.widgetGroup = group;
274
240
  }
275
241
 
276
242
  setProjectRoot(projectRoot: string): void {
@@ -289,18 +255,80 @@ export class NoteOverlay {
289
255
  this.openCount = 0;
290
256
  }
291
257
  try {
292
- this.render();
258
+ this.widgetGroup?.requestUpdate();
293
259
  } catch {
294
260
  // A rendering bug must not crash the extension host over a best-effort status widget.
295
261
  }
296
262
  }
297
263
 
298
- private render(): void {
264
+ /** Theme-free existence check -- see TaskOverlay's own hasOpenWork() for why this needs to stay theme-free. */
265
+ hasOpenNotes(): boolean {
266
+ return this.openCount > 0;
267
+ }
268
+
269
+ /** undefined when there are no open notes -- PapyrusWidgetGroup's own signal to omit this section entirely. */
270
+ buildSection(): WidgetSection | undefined {
271
+ return buildNoteWidgetSection(this.openCount);
272
+ }
273
+
274
+ startPolling(intervalMs: number = NOTE_WIDGET_POLL_INTERVAL_MS): void {
275
+ this.poll.start(intervalMs, () => {
276
+ void this.refresh();
277
+ });
278
+ }
279
+
280
+ stopPolling(): void {
281
+ this.poll.stop();
282
+ }
283
+
284
+ dispose(): void {
285
+ this.stopPolling();
286
+ this.widgetGroup = undefined;
287
+ this.projectRoot = undefined;
288
+ }
289
+ }
290
+
291
+ const PAPYRUS_WIDGET_KEY = "pi-papyrus";
292
+
293
+ /**
294
+ * Owns the ONE real aboveEditor widget registration for both TaskOverlay and NoteOverlay, composing
295
+ * their own current sections via renderWidgetSectionGroup -- "Papyrus" once, with "Tasks"/"Notes"
296
+ * as its own indented children (or side-by-side columns once the terminal is wide enough), instead
297
+ * of each overlay registering its own separate "Papyrus · <Label>" flat-header widget. Hidden
298
+ * entirely (fully unregistered, not just empty lines) only once NEITHER overlay has anything to
299
+ * show -- either overlay alone still renders its own section normally.
300
+ */
301
+ export class PapyrusWidgetGroup {
302
+ private uiCtx: ExtensionUIContext | undefined;
303
+ private registered = false;
304
+ private tui: any | undefined;
305
+ private taskOverlay: TaskOverlay | undefined;
306
+ private noteOverlay: NoteOverlay | undefined;
307
+
308
+ setUI(ctx: ExtensionUIContext): void {
309
+ if (ctx !== this.uiCtx) {
310
+ this.uiCtx = ctx;
311
+ this.registered = false;
312
+ this.tui = undefined;
313
+ }
314
+ }
315
+
316
+ setOverlays(taskOverlay: TaskOverlay, noteOverlay: NoteOverlay): void {
317
+ this.taskOverlay = taskOverlay;
318
+ this.noteOverlay = noteOverlay;
319
+ }
320
+
321
+ /** Called by either overlay after its own refresh() -- re-renders (or registers/unregisters) the shared widget. */
322
+ requestUpdate(): void {
299
323
  if (!this.uiCtx) return;
300
324
 
301
- if (this.openCount === 0) {
325
+ // Eager (pre-paint) hide check, theme-free -- matches both overlays' own prior convention of
326
+ // deciding this immediately after a data refresh, not waiting for Pi's own next repaint to
327
+ // notice via renderLines()'s own (still-present, defense-in-depth) empty check below.
328
+ const hasContent = (this.taskOverlay?.hasOpenWork() ?? false) || (this.noteOverlay?.hasOpenNotes() ?? false);
329
+ if (!hasContent) {
302
330
  if (this.registered) {
303
- this.uiCtx.setWidget(NOTE_WIDGET_KEY, undefined);
331
+ this.uiCtx.setWidget(PAPYRUS_WIDGET_KEY, undefined);
304
332
  this.registered = false;
305
333
  this.tui = undefined;
306
334
  }
@@ -309,12 +337,13 @@ export class NoteOverlay {
309
337
 
310
338
  if (!this.registered) {
311
339
  this.uiCtx.setWidget(
312
- NOTE_WIDGET_KEY,
340
+ PAPYRUS_WIDGET_KEY,
313
341
  (tui: any, theme: Theme) => {
314
342
  this.tui = tui;
315
343
  return {
316
- render: (width: number) => renderNoteWidgetLines(theme, this.openCount, width),
344
+ render: (width: number) => this.renderLines(theme, width),
317
345
  invalidate: () => {
346
+ // Theme changed — force re-registration
318
347
  this.registered = false;
319
348
  this.tui = undefined;
320
349
  },
@@ -328,23 +357,42 @@ export class NoteOverlay {
328
357
  }
329
358
  }
330
359
 
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();
360
+ private renderLines(theme: Theme, width: number): string[] {
361
+ const sections = [this.taskOverlay?.buildSection(theme), this.noteOverlay?.buildSection()].filter(
362
+ (section): section is WidgetSection => section !== undefined,
363
+ );
364
+ if (sections.length === 0) {
365
+ // Hide entirely (unregister), not just an empty-but-registered widget -- matches both
366
+ // overlays' own prior "nothing open" convention.
367
+ if (this.uiCtx && this.registered) {
368
+ this.uiCtx.setWidget(PAPYRUS_WIDGET_KEY, undefined);
369
+ this.registered = false;
370
+ this.tui = undefined;
371
+ }
372
+ return [];
373
+ }
374
+ return renderWidgetSectionGroup({
375
+ owner: vehicleWidgetOwner(PAPYRUS_VEHICLE_NAME),
376
+ sections,
377
+ ownerStyle: (s) => theme.fg("muted", s),
378
+ // Without this, renderWidgetSectionGroup falls back to malevich's own asciiTextMeasure,
379
+ // which is documented as having no ANSI-escape awareness -- every task row here is real
380
+ // ANSI-colored content (theme.fg() for focus/status glyphs), so the naive measure
381
+ // mismeasures its true visible width, breaking the two-column grid's own alignment (a
382
+ // real, live incident: the SplitPane border landed mid-line instead of after the actual
383
+ // column boundary). See tool-rendering/artifact-card.ts's own identical measure -- same
384
+ // fix, reused rather than re-derived.
385
+ measure: artifactCardMeasure,
386
+ }).render(width);
339
387
  }
340
388
 
341
389
  dispose(): void {
342
- this.stopPolling();
343
- this.uiCtx?.setWidget(NOTE_WIDGET_KEY, undefined);
390
+ this.uiCtx?.setWidget(PAPYRUS_WIDGET_KEY, undefined);
344
391
  this.registered = false;
345
392
  this.tui = undefined;
346
393
  this.uiCtx = undefined;
347
- this.projectRoot = undefined;
394
+ this.taskOverlay = undefined;
395
+ this.noteOverlay = undefined;
348
396
  }
349
397
  }
350
398
 
@@ -639,6 +687,7 @@ export default async function (pi: ExtensionAPI) {
639
687
  ]);
640
688
  let overlay: TaskOverlay | undefined;
641
689
  let noteOverlay: NoteOverlay | undefined;
690
+ let widgetGroup: PapyrusWidgetGroup | undefined;
642
691
 
643
692
  pi.registerCommand("tasks", {
644
693
  description: "Browse and manage Papyrus tasks (interactive)",
@@ -728,16 +777,22 @@ export default async function (pi: ExtensionAPI) {
728
777
  // keystrokes from the moment that tool call happens to begin, missing typing already in
729
778
  // progress when it started (the exact case Discuss's typing-courtesy wait protects against).
730
779
  ensureTypingCourtesyTracking(ctx.ui);
780
+ widgetGroup ??= new PapyrusWidgetGroup();
781
+ widgetGroup.setUI(ctx.ui);
782
+
731
783
  overlay ??= new TaskOverlay();
732
- overlay.setUI(ctx.ui);
784
+ overlay.setWidgetGroup(widgetGroup);
733
785
  overlay.setProjectRoot(ctx.cwd);
734
786
  overlay.setSessionId(ctx.sessionManager.getSessionId());
735
- await overlay.refresh();
736
- overlay.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
737
787
 
738
788
  noteOverlay ??= new NoteOverlay();
739
- noteOverlay.setUI(ctx.ui);
789
+ noteOverlay.setWidgetGroup(widgetGroup);
740
790
  noteOverlay.setProjectRoot(ctx.cwd);
791
+
792
+ widgetGroup.setOverlays(overlay, noteOverlay);
793
+
794
+ await overlay.refresh();
795
+ overlay.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
741
796
  await noteOverlay.refresh();
742
797
  noteOverlay.startPolling(NOTE_WIDGET_POLL_INTERVAL_MS);
743
798
  });
@@ -756,6 +811,8 @@ export default async function (pi: ExtensionAPI) {
756
811
  overlay = undefined;
757
812
  noteOverlay?.dispose();
758
813
  noteOverlay = undefined;
814
+ widgetGroup?.dispose();
815
+ widgetGroup = undefined;
759
816
  try {
760
817
  const sessionId = ctx.sessionManager.getSessionId();
761
818
  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.1",
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",