@danypops/pi-papyrus 0.56.3 → 0.57.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.
@@ -1,5 +1,4 @@
1
1
  import type { Artifact, OperationName } from "@danypops/papyrus";
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
2
  import { callService } from "./service-client.ts";
4
3
 
5
4
  /**
@@ -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,23 @@ 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(
139
+ theme: Theme,
140
+ projection: TaskWidgetProjection,
141
+ rotation: AutoRotatingWindow,
142
+ ): WidgetSection | undefined {
127
143
  if (projection.openTotal === 0) return undefined;
128
- return { label: taskSectionLabel(projection), render: (width) => renderTaskSectionBodyLines(theme, projection, width) };
144
+ rotation.setTotalRows(projection.rows.length);
145
+ return {
146
+ label: taskSectionLabel(projection, rotation),
147
+ render: (width) => renderTaskSectionBodyLines(theme, projection, width, rotation),
148
+ };
129
149
  }
130
150
 
131
151
  export class TaskOverlay {
@@ -135,6 +155,11 @@ export class TaskOverlay {
135
155
  private sessionId: string | undefined;
136
156
  private readonly poll = new BoundedPoll();
137
157
  private pushChannel: PushChannelClient | undefined;
158
+ private readonly rotation = new AutoRotatingWindow({
159
+ totalRows: 0,
160
+ pageSize: TASK_WIDGET_VISIBLE_ROWS,
161
+ intervalMs: WIDGET_CARD_ROTATION_INTERVAL_MS,
162
+ });
138
163
 
139
164
  setWidgetGroup(group: PapyrusWidgetGroup): void {
140
165
  this.widgetGroup = group;
@@ -196,7 +221,7 @@ export class TaskOverlay {
196
221
 
197
222
  /** undefined when there is no open work to show -- PapyrusWidgetGroup's own signal to omit this section entirely. */
198
223
  buildSection(theme: Theme): WidgetSection | undefined {
199
- return buildTaskWidgetSection(theme, buildTaskWidgetProjection(this.snapshot));
224
+ return buildTaskWidgetSection(theme, buildTaskWidgetProjection(this.snapshot), this.rotation);
200
225
  }
201
226
 
202
227
  /**
@@ -224,16 +249,21 @@ export class TaskOverlay {
224
249
  }
225
250
 
226
251
  /**
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.
252
+ * notes.list already scopes to project_root exactly (a note's projectRoot is fixed at capture
253
+ * time), so passing this overlay's projectRoot is what makes it CWD-aware by default. Keeps a
254
+ * bounded set of actual note titles (NOTE_WIDGET_OPEN_LIMIT), not just a count.
231
255
  */
232
256
  export class NoteOverlay {
233
257
  private widgetGroup: PapyrusWidgetGroup | undefined;
234
- private openCount = 0;
258
+ private notes: NoteWidgetRow[] = [];
259
+ private totalOpenCount = 0;
235
260
  private projectRoot: string | undefined;
236
261
  private readonly poll = new BoundedPoll();
262
+ private readonly rotation = new AutoRotatingWindow({
263
+ totalRows: 0,
264
+ pageSize: NOTE_WIDGET_VISIBLE_ROWS,
265
+ intervalMs: WIDGET_CARD_ROTATION_INTERVAL_MS,
266
+ });
237
267
 
238
268
  setWidgetGroup(group: PapyrusWidgetGroup): void {
239
269
  this.widgetGroup = group;
@@ -250,9 +280,11 @@ export class NoteOverlay {
250
280
  project_root: this.projectRoot,
251
281
  limit: NOTE_LIST_MAX_LIMIT,
252
282
  });
253
- this.openCount = rows.length;
283
+ this.totalOpenCount = rows.length;
284
+ this.notes = rows.slice(0, NOTE_WIDGET_OPEN_LIMIT).map((row) => ({ id: row.id, title: row.title }));
254
285
  } catch {
255
- this.openCount = 0;
286
+ this.totalOpenCount = 0;
287
+ this.notes = [];
256
288
  }
257
289
  try {
258
290
  this.widgetGroup?.requestUpdate();
@@ -263,12 +295,12 @@ export class NoteOverlay {
263
295
 
264
296
  /** Theme-free existence check -- see TaskOverlay's own hasOpenWork() for why this needs to stay theme-free. */
265
297
  hasOpenNotes(): boolean {
266
- return this.openCount > 0;
298
+ return this.totalOpenCount > 0;
267
299
  }
268
300
 
269
301
  /** undefined when there are no open notes -- PapyrusWidgetGroup's own signal to omit this section entirely. */
270
302
  buildSection(): WidgetSection | undefined {
271
- return buildNoteWidgetSection(this.openCount);
303
+ return buildNoteWidgetSection(this.notes, this.totalOpenCount, this.rotation);
272
304
  }
273
305
 
274
306
  startPolling(intervalMs: number = NOTE_WIDGET_POLL_INTERVAL_MS): void {
@@ -291,12 +323,11 @@ export class NoteOverlay {
291
323
  const PAPYRUS_WIDGET_KEY = "pi-papyrus";
292
324
 
293
325
  /**
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.
326
+ * Owns the ONE real aboveEditor widget registration for both TaskOverlay and NoteOverlay, tiling
327
+ * their own current sections as bordered CardRow cards, instead of each overlay registering its
328
+ * own separate "Papyrus · <Label>" flat-header widget. Hidden entirely (fully unregistered, not
329
+ * just empty lines) only once NEITHER overlay has anything to show -- either overlay alone still
330
+ * renders its own section normally.
300
331
  */
301
332
  export class PapyrusWidgetGroup {
302
333
  private uiCtx: ExtensionUIContext | undefined;
@@ -304,6 +335,10 @@ export class PapyrusWidgetGroup {
304
335
  private tui: any | undefined;
305
336
  private taskOverlay: TaskOverlay | undefined;
306
337
  private noteOverlay: NoteOverlay | undefined;
338
+ /** Repaint-only ticker (no data refetch) so a card's own auto-rotating overflow page visibly
339
+ * advances even when nothing else has changed -- pageIndex is time-based, so a plain repaint is
340
+ * all a page transition needs. */
341
+ private readonly rotationPoll = new BoundedPoll();
307
342
 
308
343
  setUI(ctx: ExtensionUIContext): void {
309
344
  if (ctx !== this.uiCtx) {
@@ -331,6 +366,7 @@ export class PapyrusWidgetGroup {
331
366
  this.uiCtx.setWidget(PAPYRUS_WIDGET_KEY, undefined);
332
367
  this.registered = false;
333
368
  this.tui = undefined;
369
+ this.rotationPoll.stop();
334
370
  }
335
371
  return;
336
372
  }
@@ -352,6 +388,7 @@ export class PapyrusWidgetGroup {
352
388
  { placement: "aboveEditor" },
353
389
  );
354
390
  this.registered = true;
391
+ this.rotationPoll.start(WIDGET_CARD_ROTATION_INTERVAL_MS, () => this.tui?.requestRender?.());
355
392
  } else {
356
393
  this.tui?.requestRender?.();
357
394
  }
@@ -368,22 +405,24 @@ export class PapyrusWidgetGroup {
368
405
  this.uiCtx.setWidget(PAPYRUS_WIDGET_KEY, undefined);
369
406
  this.registered = false;
370
407
  this.tui = undefined;
408
+ this.rotationPoll.stop();
371
409
  }
372
410
  return [];
373
411
  }
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.
412
+ // Each card carries its own full "Papyrus · <label>" title -- there's no separate shared
413
+ // owner header in a card grid the way there was in the old tree/split layout.
414
+ const owner = vehicleWidgetOwner(PAPYRUS_VEHICLE_NAME);
415
+ const cards: WidgetSection[] = sections.map((section) => ({
416
+ label: `${owner} · ${section.label}`,
417
+ render: section.render,
418
+ style: (s: string) => theme.fg("muted", s),
419
+ }));
420
+ return renderCardRow(cards, width, {
421
+ // ANSI-aware measure: every task row is real ANSI-colored content (theme.fg() for
422
+ // focus/status glyphs). See tool-rendering/artifact-card.ts's own identical measure.
385
423
  measure: artifactCardMeasure,
386
- }).render(width);
424
+ frameStyle: (s) => theme.fg("borderMuted", s),
425
+ });
387
426
  }
388
427
 
389
428
  dispose(): void {
@@ -393,6 +432,7 @@ export class PapyrusWidgetGroup {
393
432
  this.uiCtx = undefined;
394
433
  this.taskOverlay = undefined;
395
434
  this.noteOverlay = undefined;
435
+ this.rotationPoll.stop();
396
436
  }
397
437
  }
398
438
 
@@ -1,14 +1,34 @@
1
- import type { WidgetSection } from "malevich-tui-components";
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import type { AutoRotatingWindow, WidgetSection } from "malevich-tui-components";
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.3",
3
+ "version": "0.57.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"],
@@ -12,23 +12,23 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "peerDependencies": {
15
- "@danypops/vehicle-client-pi": "^0.43.4",
15
+ "@danypops/vehicle-client-pi": "^0.44.0",
16
16
  "@earendil-works/pi-coding-agent": "*",
17
17
  "@earendil-works/pi-tui": "*",
18
18
  "typebox": "*"
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.2"
27
+ "malevich-tui-components": "^0.32.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@danypops/pi-tui-harness": "^0.0.2",
31
- "@danypops/vehicle-client-pi": "^0.43.4",
31
+ "@danypops/vehicle-client-pi": "^0.44.0",
32
32
  "@danypops/vehicle-conformance": "^0.3.0",
33
33
  "@earendil-works/pi-coding-agent": "^0.80.10",
34
34
  "bun-types": "latest",