gentle-pi 3.2.1 → 3.4.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.
Files changed (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -1,9 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { CURSOR_MARKER, ScrollView, VStack, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import { CURSOR_MARKER, ScrollView, VStack, visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
4
4
  import { getScrollViewsAt, renderLayoutFrame, type LayoutBox } from "@earendil-works/pi-tui/dist/layout.js";
5
5
  import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
6
- import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
6
+ import { sidebarHeader, sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
7
7
  import { renderShellSidebarBar } from "../lib/shell-bar.ts";
8
8
  import { renderTodoCard, type TodoState } from "../lib/shell-todo.ts";
9
9
 
@@ -23,6 +23,20 @@ function rail(f: ReturnType<typeof fixture>): ScrollView {
23
23
  assert.equal(node.type, "hstack");
24
24
  return node.entries[1].component;
25
25
  }
26
+ type HstackNode = { type: string; gap: number; align: string; entries: { component: unknown; basis: number | string; grow: number; shrink: number; minSize: number }[] };
27
+ // Finds the [left, scroll] hstack regardless of whether it is returned
28
+ // directly (no active header) or nested one level under the header vstack.
29
+ function hstackOf(f: ReturnType<typeof fixture>): HstackNode {
30
+ const node = f.root[NODE]() as unknown as { type: string; entries: { component: { [NODE]?(): HstackNode } }[] };
31
+ if (node.type === "hstack") return node as unknown as HstackNode;
32
+ assert.equal(node.type, "vstack");
33
+ const nested = node.entries[1]?.component[NODE]?.();
34
+ assert.ok(nested, "the vstack's second entry must expose the hstack via NODE");
35
+ return nested!;
36
+ }
37
+ function railWithHeader(f: ReturnType<typeof fixture>): ScrollView {
38
+ return hstackOf(f).entries[1].component as ScrollView;
39
+ }
26
40
 
27
41
  test("grouped Status preserves structured fields and opaque integration text", () => {
28
42
  const lines = renderShellSidebarBar({
@@ -32,13 +46,14 @@ test("grouped Status preserves structured fields and opaque integration text", (
32
46
  }, theme, 46);
33
47
  const text = lines.join("\n");
34
48
  let previous = -1;
35
- for (const heading of ["Status", "Project", "Changes", "Usage", "Integrations"]) {
49
+ for (const heading of ["Status", "Project", "Changes", "Integrations"]) {
36
50
  const index = text.indexOf(heading);
37
51
  assert.ok(index > previous, heading);
38
52
  previous = index;
39
53
  }
40
54
  assert.match(text, /opaque integration/);
41
55
  assert.match(text, /Branch.*main/);
56
+ assert.doesNotMatch(text, /Usage/);
42
57
  });
43
58
 
44
59
  test("scrollable TODO keeps every task while bottom and collapsed cards stay bounded", () => {
@@ -115,7 +130,7 @@ test("wheel scrolls the rail and is consumed at both boundaries and blank space"
115
130
  f.host.terminal.columns = 160;
116
131
  const restored = rail(f);
117
132
  assert.deepEqual(f.bottom.render(80), []);
118
- const layout = f.root[NODE]() as unknown as { gap: number; entries: { basis: number; grow: number; shrink: number; minSize: number }[] };
133
+ const layout = f.root[NODE]() as unknown as { gap: number; entries: { basis: number | string; grow: number; shrink: number; minSize: number }[] };
119
134
  assert.equal(layout.gap, 3);
120
135
  assert.deepEqual(layout.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
121
136
  { basis: 0, grow: 1, shrink: 1, minSize: 1 },
@@ -173,11 +188,13 @@ test("rail dispatches a clipped, scroll-translated left click to only the matchi
173
188
  const hit = scroll.handleMouse(mouse("click", "left", headerY));
174
189
  assert.equal(hit?.handled, true);
175
190
  assert.equal(clicks, 1);
176
- assert.deepEqual(received.at(-1) && { x: received.at(-1)!.x, y: received.at(-1)!.y, width: received.at(-1)!.width, height: received.at(-1)!.height }, { x: 1, y: 0, width: 47, height: 2 });
191
+ // With a transient scrollbar the rail no longer reserves a column, so
192
+ // clipped parts are one column wider than in the always-on geometry.
193
+ assert.deepEqual(received.at(-1) && { x: received.at(-1)!.x, y: received.at(-1)!.y, width: received.at(-1)!.width, height: received.at(-1)!.height }, { x: 1, y: 0, width: 48, height: 2 });
177
194
 
178
195
  const gapY = headerY - 1;
179
196
  assert.equal(scroll.handleMouse(mouse("click", "left", gapY)), undefined, "section gaps do not hit a neighbor");
180
- assert.equal(scroll.handleMouse({ ...mouse("click", "left", headerY), x: 48 }), undefined, "padding outside the clipped part is inert");
197
+ assert.equal(scroll.handleMouse({ ...mouse("click", "left", headerY), x: 49 }), undefined, "padding outside the clipped part is inert");
181
198
  invalidateSidebar(f.tui);
182
199
  assert.equal(scroll.handleMouse(mouse("click", "left", headerY)), undefined, "stale geometry is inert");
183
200
  f.host.terminal.columns = 139;
@@ -265,14 +282,17 @@ test("real layout frames reuse unchanged sidebar output and invalidate at state
265
282
  assert.deepEqual(f.bottom.render(80), ["Status"]);
266
283
  f.host.terminal.columns = 140;
267
284
  renderLayoutFrame(f.root, 140, 20, () => {});
268
- assert.deepEqual(counts, { footer: 3, changes: 0, agents: 3, todo: 3 });
285
+ // Dipping below the breakpoint and back nulls the whole-rail `prepared`
286
+ // memo, but the per-section cache survives underneath it: the revision
287
+ // never moved, so no section actually needed to re-render.
288
+ assert.deepEqual(counts, { footer: 2, changes: 0, agents: 2, todo: 2 });
269
289
 
270
290
  f.host.mode = "regular";
271
291
  renderLayoutFrame(f.root, 140, 20, () => {});
272
292
  assert.deepEqual(f.bottom.render(80), ["Status"]);
273
293
  f.host.mode = "fullscreen";
274
294
  renderLayoutFrame(f.root, 140, 20, () => {});
275
- assert.deepEqual(counts, { footer: 4, changes: 0, agents: 4, todo: 4 });
295
+ assert.deepEqual(counts, { footer: 2, changes: 0, agents: 2, todo: 2 });
276
296
 
277
297
  let replacementRenders = 0;
278
298
  sidebarPart(f.tui, "todo", {
@@ -469,3 +489,276 @@ test("native transcript and sidebar keep separate scroll routing across resize,
469
489
  assert.equal(result.frame.root.children.some(box => box.component === rail), mode === "fullscreen" && width >= 140);
470
490
  }
471
491
  });
492
+
493
+ // T3: per-section render memo. Each rail section caches its rendered lines by
494
+ // its own digest, so a section whose digest did not change is never re-run
495
+ // when a sibling section's digest ticks — only the whole rail's assembled
496
+ // `lines`/`hits` are rebuilt from the (possibly cached) per-section lines.
497
+
498
+ test("a footer digest change does not re-render Agents or TODO", (t) => {
499
+ const f = fixture();
500
+ let footerLabel = "one";
501
+ const counts = { footer: 0, agents: 0, todo: 0 };
502
+ sidebarPart(f.tui, "footer", { render: () => ["Status"], invalidate() {} }, {
503
+ digest: () => footerLabel,
504
+ render: () => { counts.footer++; return [`Footer ${footerLabel}`]; },
505
+ invalidate() {},
506
+ });
507
+ for (const key of ["agents", "todo"] as const) {
508
+ sidebarPart(f.tui, key, { render: () => [key], invalidate() {} }, {
509
+ render: () => { counts[key]++; return [key]; },
510
+ invalidate() {},
511
+ });
512
+ }
513
+ t.after(installSidebar(f.tui, theme));
514
+
515
+ assert.match(rail(f).render(50).join("\n"), /Footer one/);
516
+ assert.deepEqual(counts, { footer: 1, agents: 1, todo: 1 });
517
+
518
+ // Re-render with nothing changed at all: the outer unchanged fast path
519
+ // must not call any section's render either.
520
+ assert.match(rail(f).render(50).join("\n"), /Footer one/);
521
+ assert.deepEqual(counts, { footer: 1, agents: 1, todo: 1 });
522
+
523
+ footerLabel = "two";
524
+ assert.match(rail(f).render(50).join("\n"), /Footer two/);
525
+ assert.deepEqual(counts, { footer: 2, agents: 1, todo: 1 }, "only the section whose digest changed re-renders");
526
+
527
+ // invalidateSidebar bumps the global revision, which is only what a
528
+ // no-digest section keys its cache on: footer's own unchanged digest
529
+ // still protects it from re-rendering.
530
+ invalidateSidebar(f.tui);
531
+ assert.match(rail(f).render(50).join("\n"), /Footer two/);
532
+ assert.deepEqual(counts, { footer: 2, agents: 2, todo: 2 }, "revision invalidation reaches only the sections with no digest of their own");
533
+ });
534
+
535
+ test("a throwing digest still degrades to invalidation-only under the per-section memo", (t) => {
536
+ const f = fixture();
537
+ let todoRenders = 0;
538
+ sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
539
+ digest: () => { throw new Error("broken digest"); },
540
+ render: () => { todoRenders++; return ["Todo card"]; },
541
+ invalidate() {},
542
+ });
543
+ t.after(installSidebar(f.tui, theme));
544
+
545
+ assert.match(rail(f).render(50).join("\n"), /Todo card/);
546
+ assert.equal(todoRenders, 1);
547
+ assert.match(rail(f).render(50).join("\n"), /Todo card/);
548
+ assert.equal(todoRenders, 1, "an unchanged frame reuses the cached section even without a digest");
549
+
550
+ invalidateSidebar(f.tui);
551
+ assert.match(rail(f).render(50).join("\n"), /Todo card/);
552
+ assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest");
553
+ });
554
+
555
+ test("a part replaced under the same key never reuses the previous part's cached lines", (t) => {
556
+ const f = fixture();
557
+ let firstRenders = 0;
558
+ sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
559
+ render: () => { firstRenders++; return ["first"]; },
560
+ invalidate() {},
561
+ });
562
+ t.after(installSidebar(f.tui, theme));
563
+ assert.match(rail(f).render(50).join("\n"), /first/);
564
+ assert.equal(firstRenders, 1);
565
+
566
+ let secondRenders = 0;
567
+ sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
568
+ render: () => { secondRenders++; return ["second"]; },
569
+ invalidate() {},
570
+ });
571
+ const text = rail(f).render(50).join("\n");
572
+ assert.match(text, /second/);
573
+ assert.doesNotMatch(text, /first/);
574
+ assert.equal(secondRenders, 1);
575
+ });
576
+
577
+ // T2: the live header row. A registered "header" part wraps the existing
578
+ // [left, scroll] hstack in a one-row-taller vstack and takes over the brand
579
+ // the banner used to carry inside the rail. Hosts that never register a
580
+ // header (every fixture above) keep the exact old hstack-direct shape.
581
+
582
+ test("an active header wraps the hstack in a vstack and removes the banner from the rail", (t) => {
583
+ const f = fixture();
584
+ sidebarHeader(f.tui, { render: (width: number) => [`HEADER ${width}`], invalidate() {} });
585
+ t.after(installSidebar(f.tui, theme));
586
+
587
+ const node = f.root[NODE]() as unknown as HstackNode;
588
+ assert.equal(node.type, "vstack");
589
+ assert.equal(node.gap, 0);
590
+ assert.equal(node.align, "stretch");
591
+ // basis "auto": pi-tui measures the header leaf from its rendered lines
592
+ // (one line: the status bar; with the rule row: two) instead of hard-pinning the row.
593
+ assert.deepEqual(node.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
594
+ { basis: "auto", grow: 0, shrink: 0, minSize: 1 },
595
+ { basis: 0, grow: 1, shrink: 1, minSize: 1 },
596
+ ]);
597
+ const header = node.entries[0].component as { render(width: number): string[] };
598
+ // The rail card ends two columns before the terminal edge (its padding plus
599
+ // the scrollbar column); the header stops there too so its right group
600
+ // lines up with the card border instead of touching the edge.
601
+ assert.deepEqual(header.render(0), ["HEADER 138"], "the header renders at the terminal width minus the rail's right inset, not the rail width");
602
+
603
+ const hstack = hstackOf(f);
604
+ assert.equal(hstack.type, "hstack");
605
+ const scroll = railWithHeader(f);
606
+ const rail = scroll.render(50);
607
+ assert.doesNotMatch(rail.join("\n"), /✿ Gentle Shell ✿/, "the header carries the brand now, not the banner");
608
+ // The banner used to hold the first card away from the top; with the
609
+ // header in its place the rail keeps one blank row so the first card does
610
+ // not sit flush against the header.
611
+ assert.equal(rail[0]?.trim(), "", "the rail opens with a blank row under the header");
612
+ assert.notEqual(rail[1]?.trim(), "", "the first card starts on the second row");
613
+ });
614
+
615
+ test("the header leaf carries the rule row: a two-line header renders both lines at full width", (t) => {
616
+ const f = fixture();
617
+ sidebarHeader(f.tui, {
618
+ render: (width: number) => [`HEADER ${width}`, `RULE ${width}`],
619
+ invalidate() {},
620
+ });
621
+ t.after(installSidebar(f.tui, theme));
622
+
623
+ const node = f.root[NODE]() as unknown as { type: string; entries: { component: Component }[] };
624
+ assert.equal(node.type, "vstack", "a two-line header still wraps the hstack in a vstack");
625
+ const header = node.entries[0]!.component as { render(width: number): string[] };
626
+ assert.deepEqual(header.render(0), ["HEADER 138", "RULE 138"], "both the status line and the rule row reach the layout, rendered at the terminal width minus the rail's right inset");
627
+ });
628
+
629
+ test("below the sidebar breakpoint a rule-like header paints no rule row", (t) => {
630
+ const f = fixture("fullscreen", 139);
631
+ sidebarHeader(f.tui, { render: (width: number) => [`HEADER ${width}`, `RULE ${width}`], invalidate() {} });
632
+ t.after(installSidebar(f.tui, theme));
633
+
634
+ // Below SIDEBAR_BREAKPOINT prepare() declines, so the replacement hands
635
+ // back the native root's own layout node: neither the header wrapper nor
636
+ // a rule row ever paints.
637
+ assert.deepEqual(f.root[NODE](), f.original(), "below the breakpoint the rule is removed with the whole sidebar");
638
+ });
639
+
640
+ test("without a registered header the rail keeps the banner and the plain hstack", (t) => {
641
+ const f = fixture();
642
+ t.after(installSidebar(f.tui, theme));
643
+ const node = f.root[NODE]() as unknown as { type: string };
644
+ assert.equal(node.type, "hstack");
645
+ const scroll = rail(f);
646
+ assert.match(scroll.render(50).join("\n"), /✿ Gentle Shell ✿/);
647
+ });
648
+
649
+ test("a header too narrow to show anything falls back to the plain hstack and the banner", (t) => {
650
+ const f = fixture();
651
+ sidebarHeader(f.tui, { render: () => [""], invalidate() {} });
652
+ t.after(installSidebar(f.tui, theme));
653
+ const node = f.root[NODE]() as unknown as { type: string };
654
+ assert.equal(node.type, "hstack", "a blank header line does not earn its own row");
655
+ const scroll = rail(f);
656
+ assert.match(scroll.render(50).join("\n"), /✿ Gentle Shell ✿/);
657
+ });
658
+
659
+ test("header content changes reach the header row while an unrelated part is untouched", (t) => {
660
+ const f = fixture();
661
+ let label = "one";
662
+ sidebarHeader(f.tui, { digest: () => label, render: () => [`HEADER ${label}`], invalidate() {} });
663
+ t.after(installSidebar(f.tui, theme));
664
+
665
+ const headerLines = () => (f.root[NODE]() as unknown as HstackNode).entries[0].component as { render(width: number): string[] };
666
+ assert.deepEqual(headerLines().render(0), ["HEADER one"]);
667
+ label = "two";
668
+ assert.deepEqual(headerLines().render(0), ["HEADER two"]);
669
+ });
670
+
671
+ test("rail mouse dispatch still maps clicks correctly with the header row above it", (t) => {
672
+ const f = fixture();
673
+ let clicks = 0;
674
+ const todo = {
675
+ render: () => ["Todo header", "Todo body"],
676
+ invalidate() {},
677
+ handleMouse(event: TuiMouseEvent) {
678
+ if (event.type !== "click" || event.button !== "left" || event.y !== 0) return undefined;
679
+ clicks++;
680
+ return { handled: true, render: true };
681
+ },
682
+ };
683
+ sidebarPart(f.tui, "todo", todo);
684
+ sidebarHeader(f.tui, { render: () => ["HEADER"], invalidate() {} });
685
+ const dispose = installSidebar(f.tui, theme);
686
+ t.after(dispose);
687
+ const scroll = railWithHeader(f);
688
+ const content = scroll.render(50);
689
+ scroll.updateLayout(content.length, 5, () => {});
690
+ const headerY = content.findIndex((line) => line.includes("Todo header"));
691
+ assert.ok(headerY >= 0);
692
+ // The mouse event carries coordinates local to the scroll component itself
693
+ // (pi-tui's layout tree translates screen coordinates before dispatch), so
694
+ // nesting the hstack one level deeper under the header row does not shift
695
+ // what the rail receives.
696
+ const hit = scroll.handleMouse({ type: "click", button: "left", x: 2, y: headerY, screenX: 92, screenY: 31 + headerY, width: 50, height: 5, shift: false, alt: false, ctrl: false });
697
+ assert.equal(hit?.handled, true);
698
+ assert.equal(clicks, 1);
699
+ });
700
+
701
+ // T8(c): the header row is a leaf in the layout tree, not inside the rail's
702
+ // ScrollView, so pi-tui's mouse dispatch (tui-alt-screen.js) finds and calls
703
+ // its own handleMouse directly — it never goes through dispatchPartMouse.
704
+ test("the header component's handleMouse delegates to the registered header part", (t) => {
705
+ const f = fixture();
706
+ const received: TuiMouseEvent[] = [];
707
+ let result: { handled: boolean; render?: boolean } | undefined = { handled: true, render: true };
708
+ sidebarHeader(f.tui, {
709
+ render: () => ["HEADER"],
710
+ invalidate() {},
711
+ handleMouse(event: TuiMouseEvent) {
712
+ received.push(event);
713
+ return result;
714
+ },
715
+ });
716
+ t.after(installSidebar(f.tui, theme));
717
+
718
+ const node = f.root[NODE]() as unknown as { type: string; entries: { component: Component }[] };
719
+ assert.equal(node.type, "vstack", "an active header wraps the hstack in a vstack");
720
+ const header = node.entries[0]!.component as { handleMouse(event: TuiMouseEvent): { handled: boolean; render?: boolean } | undefined };
721
+
722
+ const event = { type: "click", button: "left", x: 5, y: 0, screenX: 5, screenY: 0, width: 140, height: 1, shift: false, alt: false, ctrl: false } as TuiMouseEvent;
723
+ assert.deepEqual(header.handleMouse(event), { handled: true, render: true });
724
+ assert.equal(received.length, 1);
725
+ assert.equal(received[0], event, "the event reaches the registered part unmodified");
726
+
727
+ result = undefined;
728
+ assert.equal(header.handleMouse(event), undefined, "an ignored click returns undefined, same as any other component");
729
+ });
730
+
731
+ test("the header component's handleMouse is a harmless no-op when no header part is registered", (t) => {
732
+ const f = fixture();
733
+ t.after(installSidebar(f.tui, theme));
734
+ const node = f.root[NODE]() as unknown as { type: string; entries?: { component: Component }[] };
735
+ // No header registered: the active node stays the plain hstack (T2's
736
+ // backward-compatible fallback), so there is no header leaf to click at all.
737
+ assert.equal(node.type, "hstack");
738
+ });
739
+
740
+ test("rail scrollbar is transient: hidden by default, visible only while scrolling overflow", (t) => {
741
+ const f = fixture();
742
+ t.mock.timers.enable({ apis: ["setTimeout"] });
743
+ t.after(installSidebar(f.tui, theme));
744
+ const scroll = rail(f);
745
+ // Regression guard: the rail scroll view used to hardcode scrollbar
746
+ // "always", re-slicing its column on every render pass even when idle.
747
+ assert.equal(scroll.scrollbar, "auto");
748
+ sidebarPart(f.tui, "todo", { render: () => Array.from({ length: 60 }, (_, i) => `todo row ${i}`), invalidate() {} });
749
+ renderLayoutFrame(f.root, 140, 20, () => {});
750
+ assert.equal(scroll.isScrollbarVisible, false, "no scroll yet: hidden even with overflow");
751
+ scroll.scrollBy(1);
752
+ assert.equal(scroll.isScrollbarVisible, true, "rail scroll reveals the transient scrollbar");
753
+ t.mock.timers.tick(1000);
754
+ assert.equal(scroll.isScrollbarVisible, false, "hidden again after the transient window");
755
+ });
756
+
757
+ test("rail scrollbar stays hidden when the rail does not overflow", (t) => {
758
+ const f = fixture();
759
+ t.after(installSidebar(f.tui, theme));
760
+ const scroll = rail(f);
761
+ renderLayoutFrame(f.root, 140, 20, () => {});
762
+ scroll.scrollBy(1);
763
+ assert.equal(scroll.isScrollbarVisible, false);
764
+ });
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import type { TUI } from "@earendil-works/pi-tui";
4
- import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
4
+ import { sidebarHeader, sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
5
5
 
6
6
  const host = (terminal?: object) => ({ terminal }) as TUI;
7
7
  const component = () => ({ render: (_width = 80) => ["bottom"], invalidate() {} });
@@ -48,3 +48,27 @@ test("disposing an old part preserves its replacement and releases its bottom",
48
48
  (second as typeof second & { dispose?(): void }).dispose?.();
49
49
  assert.equal(sidebarState(tui).parts.size, 0);
50
50
  });
51
+
52
+ test("sidebarHeader registers the header part under its own key and disposes it without a bottom widget", () => {
53
+ const tui = host({});
54
+ const rail = { render: () => ["header line"], invalidate() {}, digest: () => "d" };
55
+ const dispose = sidebarHeader(tui, rail);
56
+ assert.equal(sidebarState(tui).parts.get("header"), rail);
57
+ dispose();
58
+ assert.equal(sidebarState(tui).parts.get("header"), undefined);
59
+ });
60
+
61
+ test("sidebarHeader on an unsupported host is a harmless no-op", () => {
62
+ const dispose = sidebarHeader(host(), { render: () => [], invalidate() {} });
63
+ assert.doesNotThrow(dispose);
64
+ });
65
+
66
+ test("sidebarHeader dispose does not remove a replacement rail registered after it", () => {
67
+ const tui = host({});
68
+ const first = { render: () => ["first"], invalidate() {} };
69
+ const dispose = sidebarHeader(tui, first);
70
+ const second = { render: () => ["second"], invalidate() {} };
71
+ sidebarHeader(tui, second);
72
+ dispose();
73
+ assert.equal(sidebarState(tui).parts.get("header"), second);
74
+ });
@@ -231,6 +231,42 @@ test("completed titles strike only title cells across wrapped lines, never paddi
231
231
  }
232
232
  });
233
233
 
234
+ test("renderTodoCard paints the sidebar rail and the bottom widget with the same rose INFO frame", () => {
235
+ const taggedTheme = {
236
+ fg(color: string, text: string) {
237
+ return `<${color}>${text}</${color}>`;
238
+ },
239
+ strikethrough(text: string) {
240
+ return text;
241
+ },
242
+ };
243
+ const sidebar = renderTodoCard(seeded(), taggedTheme, 60, { scrollable: true, collapsed: false, staleTurns: 0 });
244
+ assert.match(sidebar[0], /<border>╭<\/border>/);
245
+ assert.match(sidebar[0], /<accent>❀ Todos/);
246
+
247
+ const bottom = renderTodoCard(seeded(), taggedTheme, 60, { collapsed: false, staleTurns: 0 });
248
+ assert.match(bottom[0], /<border>╭<\/border>/);
249
+ assert.match(bottom[0], /<accent>❀ Todos/);
250
+ });
251
+
252
+ // H1 (odd/tasks/usage-click-and-changes-attribution.md): the same shared
253
+ // hover role every other clickable surface uses.
254
+ test("renderTodoCard paints its clickable header control in the shared hover role when hovered", () => {
255
+ const taggedTheme = {
256
+ fg(color: string, text: string) {
257
+ return `<${color}>${text}</${color}>`;
258
+ },
259
+ strikethrough(text: string) {
260
+ return text;
261
+ },
262
+ };
263
+ const idle = renderTodoCard(seeded(), taggedTheme, 70, { collapsed: false, staleTurns: 0 });
264
+ assert.match(idle[0], /<accent>▾ Collapse<\/accent>/, "idle keeps its ordinary accent role");
265
+ const hovered = renderTodoCard(seeded(), taggedTheme, 70, { collapsed: false, staleTurns: 0, hovered: true });
266
+ assert.match(hovered[0], /<warning>▾ Collapse<\/warning>/, "hovering paints the shared hover role");
267
+ assert.doesNotMatch(hovered[0], /<accent>▾ Collapse/);
268
+ });
269
+
234
270
  test("renderTodoCard keeps stale indicators and collapse hints in scrollable lists", () => {
235
271
  const tasks = Array.from({ length: 40 }, (_, index) => ({ title: `Task ${index + 1}`, status: index < 25 ? "done" : "pending" }));
236
272
  const state = applyTodo(emptyTodo(), { action: "write", tasks }, 1).state;
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
4
4
  import { stripAnsi } from "../lib/terminal-theme.ts";
5
5
  import { parseCodexUsage, UsageStore } from "../lib/shell-usage.ts";
6
6
  import { UsageView } from "../lib/shell-usage-view.ts";
@@ -61,3 +61,122 @@ test("UsageView refetches on r and closes on escape or q", async () => {
61
61
  view.handleInput("q");
62
62
  assert.equal(events.filter((event) => event === "close").length, 2);
63
63
  });
64
+
65
+ function click(x: number, y: number, width: number, height: number): TuiMouseEvent {
66
+ return { type: "click", button: "left", x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
67
+ }
68
+
69
+ test("UsageView.handleMouse clicks the footer hints like the matching key", async () => {
70
+ const store = new UsageStore();
71
+ const events: string[] = [];
72
+ let resolveRefresh: (() => void) | undefined;
73
+ const view = new UsageView(store, {
74
+ theme: plainTheme,
75
+ now: () => NOW,
76
+ active: () => undefined,
77
+ onRefresh: () =>
78
+ new Promise<void>((resolve) => {
79
+ events.push("refresh");
80
+ resolveRefresh = resolve;
81
+ }),
82
+ onClose: () => events.push("close"),
83
+ requestRender: () => events.push("render"),
84
+ });
85
+ const width = 90;
86
+ const lines = view.render(width);
87
+ const rowIndex = lines.findIndex((line) => stripAnsi(line).includes("r refresh"));
88
+ assert.ok(rowIndex > 0, "the footer hints row must be present");
89
+ const plain = stripAnsi(lines[rowIndex]);
90
+ const refreshStart = plain.indexOf("r refresh");
91
+ const closeStart = plain.indexOf("esc close");
92
+ assert.ok(refreshStart >= 0 && closeStart >= 0);
93
+
94
+ // A click outside any hint span is ignored, like a click on an empty part of the frame.
95
+ assert.equal(view.handleMouse(click(0, rowIndex, width, lines.length)), undefined);
96
+ assert.deepEqual(events, []);
97
+
98
+ // A click on "r refresh" triggers the same refresh as the r key.
99
+ assert.deepEqual(view.handleMouse(click(refreshStart + 1, rowIndex, width, lines.length)), { handled: true, render: true });
100
+ assert.deepEqual(events, ["render", "refresh"]);
101
+
102
+ // A second click while the refresh is still in flight must not re-enter it.
103
+ assert.deepEqual(view.handleMouse(click(refreshStart + 1, rowIndex, width, lines.length)), { handled: true, render: true });
104
+ assert.deepEqual(events, ["render", "refresh"]);
105
+ resolveRefresh?.();
106
+ await new Promise((resolve) => setTimeout(resolve, 0));
107
+ assert.deepEqual(events, ["render", "refresh", "render"]);
108
+
109
+ // A click on "esc close" closes the overlay, like the esc/q keys.
110
+ assert.deepEqual(view.handleMouse(click(closeStart + 1, rowIndex, width, lines.length)), { handled: true, render: true });
111
+ assert.equal(events.filter((event) => event === "close").length, 1);
112
+ });
113
+
114
+ function move(x: number, y: number, width: number, height: number): TuiMouseEvent {
115
+ return { type: "move", button: "none", x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
116
+ }
117
+
118
+ // H1 (odd/tasks/usage-click-and-changes-attribution.md): the same shared
119
+ // hover role every other clickable surface uses.
120
+ test("UsageView.handleMouse paints the shared hover role over a footer hint, and clears it on leave", () => {
121
+ const store = new UsageStore();
122
+ const taggedTheme = { fg: (role: string, text: string) => `<${role}>${text}</${role}>` };
123
+ const view = new UsageView(store, {
124
+ theme: taggedTheme,
125
+ now: () => NOW,
126
+ active: () => undefined,
127
+ onRefresh: async () => {},
128
+ onClose: () => {},
129
+ requestRender: () => {},
130
+ });
131
+ const width = 90;
132
+ const lines = view.render(width);
133
+ // The frame draws "│ " (2 columns) before the fitted footer content, and
134
+ // hints are laid out as plain "key label" text joined by 3 spaces -- these
135
+ // column offsets are computed purely from that plain text, independent of
136
+ // the theme, so they hold under taggedTheme exactly as under plainTheme.
137
+ const rowIndex = lines.length - 2;
138
+ const refreshStart = 2;
139
+ const closeStart = refreshStart + "r refresh".length + 3;
140
+ const height = lines.length;
141
+
142
+ assert.equal(view.handleMouse(move(0, rowIndex, width, height)), undefined, "a move outside any hint is not this component's gesture");
143
+ assert.doesNotMatch(view.render(width).join("\n"), /<warning>/, "idle: nothing painted yet");
144
+
145
+ const entered = view.handleMouse(move(refreshStart + 1, rowIndex, width, height));
146
+ assert.deepEqual(entered, { handled: true, render: true });
147
+ assert.match(view.render(width).join("\n"), /<warning>r refresh<\/warning>/, "hovering the refresh hint paints it, key and label together");
148
+ assert.doesNotMatch(view.render(width).join("\n"), new RegExp(`<warning>esc close`), "the other hint stays unpainted");
149
+
150
+ // Moving straight to the other hint switches which one is painted.
151
+ const switched = view.handleMouse(move(closeStart + 1, rowIndex, width, height));
152
+ assert.deepEqual(switched, { handled: true, render: true });
153
+ assert.match(view.render(width).join("\n"), /<warning>esc close<\/warning>/);
154
+ assert.doesNotMatch(view.render(width).join("\n"), /<warning>r refresh/);
155
+
156
+ const left = view.handleMouse(move(0, rowIndex, width, height));
157
+ assert.deepEqual(left, { handled: true, render: true }, "leaving the last hovered hint still requests a repaint");
158
+ assert.doesNotMatch(view.render(width).join("\n"), /<warning>/, "nothing stays painted once the pointer leaves");
159
+ });
160
+
161
+ test("a rejected refresh from a click or key never escapes as an unhandled rejection", async () => {
162
+ const store = new UsageStore();
163
+ const events: string[] = [];
164
+ const unhandled: unknown[] = [];
165
+ const onUnhandled = (reason: unknown) => unhandled.push(reason);
166
+ process.on("unhandledRejection", onUnhandled);
167
+ try {
168
+ const view = new UsageView(store, { theme: plainTheme, now: () => NOW, active: () => undefined, onRefresh: async () => { throw new Error("provider down"); }, onClose: () => events.push("close"), requestRender: () => events.push("render") });
169
+ const lines = view.render(80);
170
+ const row = lines.length - 2;
171
+ const column = lines[row]!.indexOf("r refresh") + 1;
172
+ view.handleMouse({ type: "click", button: "left", x: column, y: row, width: 80, height: lines.length, screenX: column, screenY: row, shift: false, alt: false, ctrl: false } as never);
173
+ await new Promise((resolve) => setImmediate(resolve));
174
+ view.handleInput("r");
175
+ await new Promise((resolve) => setImmediate(resolve));
176
+ await new Promise((resolve) => setImmediate(resolve));
177
+ assert.deepEqual(unhandled, [], "a failing usage fetch is reported by the panel, never thrown at the process");
178
+ assert.match(view.render(80).join("\n"), /r refresh/, "the refresh hint is back after the failure");
179
+ } finally {
180
+ process.off("unhandledRejection", onUnhandled);
181
+ }
182
+ });