@danypops/pi-papyrus 0.56.2 → 0.57.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.
@@ -13,20 +13,24 @@ import {
13
13
  type Artifact,
14
14
  type GateResult,
15
15
  NOTE_LIST_MAX_LIMIT,
16
+ NOTE_WIDGET_OPEN_LIMIT,
16
17
  NOTE_WIDGET_POLL_INTERVAL_MS,
18
+ NOTE_WIDGET_VISIBLE_ROWS,
17
19
  PAPYRUS_CONTEXT_INJECTION_CHANNEL,
18
20
  PAPYRUS_VEHICLE_NAME,
19
21
  TASK_DRIVER_MAX_TURNS,
20
22
  TASK_DRIVER_MAX_UNCHANGED_TURNS,
21
23
  TASK_WIDGET_POLL_INTERVAL_MS,
24
+ TASK_WIDGET_VISIBLE_ROWS,
22
25
  type TaskGraph,
23
26
  type TaskStatus,
27
+ WIDGET_CARD_ROTATION_INTERVAL_MS,
24
28
  } from "@danypops/papyrus";
25
29
  import type { PushChannelClient } from "@danypops/vehicle-client/daemon-client";
26
30
  import { vehicleWidgetOwner } from "@danypops/vehicle-client-pi/widget-header";
27
31
  import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
28
32
  import { truncateToWidth } from "@earendil-works/pi-tui";
29
- import { renderWidgetSectionGroup, type WidgetSection } from "malevich-tui-components";
33
+ import { AutoRotatingWindow, renderCardRow, type WidgetSection } from "malevich-tui-components";
30
34
  import { Type } from "typebox";
31
35
  import { formatMetadata } from "./artifact/artifact-format.ts";
32
36
  import { BoundedPoll } from "./bounded-poll.ts";
@@ -35,7 +39,7 @@ import { PAPYRUS_CONTEXT_HUB_PRODUCER_NAME, papyrusContextSegment } from "./cont
35
39
  import { buildContextInjection } from "./context/context-injection-telemetry.ts";
36
40
  import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss/discuss-ask-view.ts";
37
41
  import { resolveNameFields } from "./domain-tools.ts";
38
- import { buildNoteWidgetSection } from "./note/note-widget.ts";
42
+ import { buildNoteWidgetSection, type NoteWidgetRow } from "./note/note-widget.ts";
39
43
  import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook/playbook-bridge.ts";
40
44
  import { callService, subscribeTaskPushChannel } from "./service-client.ts";
41
45
  import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
@@ -93,15 +97,22 @@ async function artifactNamesById(ids: readonly string[]): Promise<Map<string, st
93
97
  }
94
98
 
95
99
  // ---------------------------------------------------------------------------
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)
100
+ // Task + Notes widget (one shared aboveEditor CardRow grid -- PapyrusWidgetGroup below
101
+ // tiles both overlays' own sections as bordered cards, instead of each registering
102
+ // its own separate "Papyrus · <Label>" flat header)
99
103
  // ---------------------------------------------------------------------------
100
104
 
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[] {
105
+ /** Renders the page bounded by `rotation`'s own currentPageBounds() -- tree-connector glyphs
106
+ * are computed against the FULL row list at each row's own absolute index. */
107
+ export function renderTaskSectionBodyLines(
108
+ theme: Theme,
109
+ projection: TaskWidgetProjection,
110
+ width: number,
111
+ rotation?: AutoRotatingWindow,
112
+ ): string[] {
113
+ const { start, end } = rotation?.currentPageBounds() ?? { start: 0, end: projection.rows.length };
103
114
  const lines: string[] = [];
104
- for (let index = 0; index < projection.rows.length; index++) {
115
+ for (let index = start; index < end; index++) {
105
116
  const row = projection.rows[index]!;
106
117
  const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
107
118
  const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
@@ -118,14 +129,16 @@ export function renderTaskSectionBodyLines(theme: Theme, projection: TaskWidgetP
118
129
  return lines;
119
130
  }
120
131
 
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";
132
+ /** "Tasks" or "Tasks · <scope>", plus a "page/total " suffix once genuinely paging. */
133
+ export function taskSectionLabel(projection: TaskWidgetProjection, rotation?: AutoRotatingWindow): string {
134
+ const base = projection.scopeLabel ? `Tasks · ${projection.scopeLabel}` : "Tasks";
135
+ return rotation?.isPaging ? `${base} · ${rotation.pageIndex + 1}/${rotation.pageCount} ⟳` : base;
124
136
  }
125
137
 
126
- export function buildTaskWidgetSection(theme: Theme, projection: TaskWidgetProjection): WidgetSection | undefined {
138
+ export function buildTaskWidgetSection(theme: Theme, projection: TaskWidgetProjection, rotation: AutoRotatingWindow): WidgetSection | undefined {
127
139
  if (projection.openTotal === 0) return undefined;
128
- return { label: taskSectionLabel(projection), render: (width) => renderTaskSectionBodyLines(theme, projection, width) };
140
+ rotation.setTotalRows(projection.rows.length);
141
+ return { label: taskSectionLabel(projection, rotation), render: (width) => renderTaskSectionBodyLines(theme, projection, width, rotation) };
129
142
  }
130
143
 
131
144
  export class TaskOverlay {
@@ -135,6 +148,11 @@ export class TaskOverlay {
135
148
  private sessionId: string | undefined;
136
149
  private readonly poll = new BoundedPoll();
137
150
  private pushChannel: PushChannelClient | undefined;
151
+ private readonly rotation = new AutoRotatingWindow({
152
+ totalRows: 0,
153
+ pageSize: TASK_WIDGET_VISIBLE_ROWS,
154
+ intervalMs: WIDGET_CARD_ROTATION_INTERVAL_MS,
155
+ });
138
156
 
139
157
  setWidgetGroup(group: PapyrusWidgetGroup): void {
140
158
  this.widgetGroup = group;
@@ -196,7 +214,7 @@ export class TaskOverlay {
196
214
 
197
215
  /** undefined when there is no open work to show -- PapyrusWidgetGroup's own signal to omit this section entirely. */
198
216
  buildSection(theme: Theme): WidgetSection | undefined {
199
- return buildTaskWidgetSection(theme, buildTaskWidgetProjection(this.snapshot));
217
+ return buildTaskWidgetSection(theme, buildTaskWidgetProjection(this.snapshot), this.rotation);
200
218
  }
201
219
 
202
220
  /**
@@ -224,16 +242,21 @@ export class TaskOverlay {
224
242
  }
225
243
 
226
244
  /**
227
- * Deliberately simple, unlike TaskOverlay's tree: just an open-note count for this session's own
228
- * CWD -- notes.list already scopes to project_root exactly (a note's projectRoot is fixed at
229
- * capture time), so passing this overlay's projectRoot is what makes the count CWD-aware by
230
- * default.
245
+ * notes.list already scopes to project_root exactly (a note's projectRoot is fixed at capture
246
+ * time), so passing this overlay's projectRoot is what makes it CWD-aware by default. Keeps a
247
+ * bounded set of actual note titles (NOTE_WIDGET_OPEN_LIMIT), not just a count.
231
248
  */
232
249
  export class NoteOverlay {
233
250
  private widgetGroup: PapyrusWidgetGroup | undefined;
234
- private openCount = 0;
251
+ private notes: NoteWidgetRow[] = [];
252
+ private totalOpenCount = 0;
235
253
  private projectRoot: string | undefined;
236
254
  private readonly poll = new BoundedPoll();
255
+ private readonly rotation = new AutoRotatingWindow({
256
+ totalRows: 0,
257
+ pageSize: NOTE_WIDGET_VISIBLE_ROWS,
258
+ intervalMs: WIDGET_CARD_ROTATION_INTERVAL_MS,
259
+ });
237
260
 
238
261
  setWidgetGroup(group: PapyrusWidgetGroup): void {
239
262
  this.widgetGroup = group;
@@ -250,9 +273,11 @@ export class NoteOverlay {
250
273
  project_root: this.projectRoot,
251
274
  limit: NOTE_LIST_MAX_LIMIT,
252
275
  });
253
- this.openCount = rows.length;
276
+ this.totalOpenCount = rows.length;
277
+ this.notes = rows.slice(0, NOTE_WIDGET_OPEN_LIMIT).map((row) => ({ id: row.id, title: row.title }));
254
278
  } catch {
255
- this.openCount = 0;
279
+ this.totalOpenCount = 0;
280
+ this.notes = [];
256
281
  }
257
282
  try {
258
283
  this.widgetGroup?.requestUpdate();
@@ -263,12 +288,12 @@ export class NoteOverlay {
263
288
 
264
289
  /** Theme-free existence check -- see TaskOverlay's own hasOpenWork() for why this needs to stay theme-free. */
265
290
  hasOpenNotes(): boolean {
266
- return this.openCount > 0;
291
+ return this.totalOpenCount > 0;
267
292
  }
268
293
 
269
294
  /** undefined when there are no open notes -- PapyrusWidgetGroup's own signal to omit this section entirely. */
270
295
  buildSection(): WidgetSection | undefined {
271
- return buildNoteWidgetSection(this.openCount);
296
+ return buildNoteWidgetSection(this.notes, this.totalOpenCount, this.rotation);
272
297
  }
273
298
 
274
299
  startPolling(intervalMs: number = NOTE_WIDGET_POLL_INTERVAL_MS): void {
@@ -291,12 +316,11 @@ export class NoteOverlay {
291
316
  const PAPYRUS_WIDGET_KEY = "pi-papyrus";
292
317
 
293
318
  /**
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.
319
+ * Owns the ONE real aboveEditor widget registration for both TaskOverlay and NoteOverlay, tiling
320
+ * their own current sections as bordered CardRow cards, instead of each overlay registering its
321
+ * own separate "Papyrus · <Label>" flat-header widget. Hidden entirely (fully unregistered, not
322
+ * just empty lines) only once NEITHER overlay has anything to show -- either overlay alone still
323
+ * renders its own section normally.
300
324
  */
301
325
  export class PapyrusWidgetGroup {
302
326
  private uiCtx: ExtensionUIContext | undefined;
@@ -304,6 +328,10 @@ export class PapyrusWidgetGroup {
304
328
  private tui: any | undefined;
305
329
  private taskOverlay: TaskOverlay | undefined;
306
330
  private noteOverlay: NoteOverlay | undefined;
331
+ /** Repaint-only ticker (no data refetch) so a card's own auto-rotating overflow page visibly
332
+ * advances even when nothing else has changed -- pageIndex is time-based, so a plain repaint is
333
+ * all a page transition needs. */
334
+ private readonly rotationPoll = new BoundedPoll();
307
335
 
308
336
  setUI(ctx: ExtensionUIContext): void {
309
337
  if (ctx !== this.uiCtx) {
@@ -331,6 +359,7 @@ export class PapyrusWidgetGroup {
331
359
  this.uiCtx.setWidget(PAPYRUS_WIDGET_KEY, undefined);
332
360
  this.registered = false;
333
361
  this.tui = undefined;
362
+ this.rotationPoll.stop();
334
363
  }
335
364
  return;
336
365
  }
@@ -352,6 +381,7 @@ export class PapyrusWidgetGroup {
352
381
  { placement: "aboveEditor" },
353
382
  );
354
383
  this.registered = true;
384
+ this.rotationPoll.start(WIDGET_CARD_ROTATION_INTERVAL_MS, () => this.tui?.requestRender?.());
355
385
  } else {
356
386
  this.tui?.requestRender?.();
357
387
  }
@@ -368,22 +398,24 @@ export class PapyrusWidgetGroup {
368
398
  this.uiCtx.setWidget(PAPYRUS_WIDGET_KEY, undefined);
369
399
  this.registered = false;
370
400
  this.tui = undefined;
401
+ this.rotationPoll.stop();
371
402
  }
372
403
  return [];
373
404
  }
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.
405
+ // Each card carries its own full "Papyrus · <label>" title -- there's no separate shared
406
+ // owner header in a card grid the way there was in the old tree/split layout.
407
+ const owner = vehicleWidgetOwner(PAPYRUS_VEHICLE_NAME);
408
+ const cards: WidgetSection[] = sections.map((section) => ({
409
+ label: `${owner} · ${section.label}`,
410
+ render: section.render,
411
+ style: (s: string) => theme.fg("muted", s),
412
+ }));
413
+ return renderCardRow(cards, width, {
414
+ // ANSI-aware measure: every task row is real ANSI-colored content (theme.fg() for
415
+ // focus/status glyphs). See tool-rendering/artifact-card.ts's own identical measure.
385
416
  measure: artifactCardMeasure,
386
- }).render(width);
417
+ frameStyle: (s) => theme.fg("borderMuted", s),
418
+ });
387
419
  }
388
420
 
389
421
  dispose(): void {
@@ -393,6 +425,7 @@ export class PapyrusWidgetGroup {
393
425
  this.uiCtx = undefined;
394
426
  this.taskOverlay = undefined;
395
427
  this.noteOverlay = undefined;
428
+ this.rotationPoll.stop();
396
429
  }
397
430
  }
398
431
 
@@ -1,14 +1,34 @@
1
- import type { WidgetSection } from "malevich-tui-components";
1
+ import type { AutoRotatingWindow, WidgetSection } from "malevich-tui-components";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ export interface NoteWidgetRow {
5
+ id: string;
6
+ title: string;
7
+ }
8
+
9
+ /** "Notes <total>" (the TRUE open count, even when fewer titles are actually kept/paged through),
10
+ * plus a "page/total ⟳" suffix once genuinely paging -- never shown when everything already fits
11
+ * on one page. */
12
+ function noteSectionLabel(totalOpenCount: number, rotation: AutoRotatingWindow): string {
13
+ const base = `Notes ${totalOpenCount}`;
14
+ return rotation.isPaging ? `${base} · ${rotation.pageIndex + 1}/${rotation.pageCount} ⟳` : base;
15
+ }
16
+
17
+ export function renderNoteSectionBodyLines(notes: readonly NoteWidgetRow[], width: number, rotation: AutoRotatingWindow): string[] {
18
+ const { start, end } = rotation.currentPageBounds();
19
+ return notes.slice(start, end).map((note) => truncateToWidth(`· ${note.title}`, width, "…"));
20
+ }
2
21
 
3
22
  /**
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.
23
+ * `undefined` (hide the section entirely) once totalOpenCount is 0. `rotation`'s own row count is
24
+ * kept in sync with `notes.length` here, so a caller only ever needs to pass its own current data.
10
25
  */
11
- export function buildNoteWidgetSection(openCount: number): WidgetSection | undefined {
12
- if (openCount === 0) return undefined;
13
- return { label: `Notes ${openCount}`, render: () => [] };
26
+ export function buildNoteWidgetSection(
27
+ notes: readonly NoteWidgetRow[],
28
+ totalOpenCount: number,
29
+ rotation: AutoRotatingWindow,
30
+ ): WidgetSection | undefined {
31
+ if (totalOpenCount === 0) return undefined;
32
+ rotation.setTotalRows(notes.length);
33
+ return { label: noteSectionLabel(totalOpenCount, rotation), render: (width) => renderNoteSectionBodyLines(notes, width, rotation) };
14
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.56.2",
3
+ "version": "0.57.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"],
@@ -19,12 +19,12 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@danypops/jittor": "^0.19.2",
22
- "@danypops/papyrus": "^0.59.0",
22
+ "@danypops/papyrus": "^0.60.0",
23
23
  "@danypops/vehicle-client": "^0.10.3",
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.31.1"
27
+ "malevich-tui-components": "^0.32.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@danypops/pi-tui-harness": "^0.0.2",