gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,387 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { ScrollView, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
4
+ import { renderLayoutFrame } from "@earendil-works/pi-tui/dist/layout.js";
5
+ import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
6
+ import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
7
+ import { renderShellSidebarBar } from "../lib/shell-bar.ts";
8
+ import { renderTodoCard, type TodoState } from "../lib/shell-todo.ts";
9
+
10
+ const NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
11
+ const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
12
+ function fixture(mode = "fullscreen", columns = 140) {
13
+ const original = () => ({ type: "vstack", entries: [] });
14
+ const root = { render: () => ["transcript"], invalidate() {}, [NODE]: original };
15
+ let renders = 0;
16
+ const host = { mode, terminal: { columns }, layoutRoot: root, requestRender() { renders++; } };
17
+ const tui = host as unknown as TUI;
18
+ const bottom = sidebarPart(tui, "footer", { render: (_width: number) => ["Status"], invalidate() {} });
19
+ return { host, tui, root, original, bottom, renders: () => renders };
20
+ }
21
+ function rail(f: ReturnType<typeof fixture>): ScrollView {
22
+ const node = f.root[NODE]() as unknown as { type: string; entries: { component: ScrollView }[] };
23
+ assert.equal(node.type, "hstack");
24
+ return node.entries[1].component;
25
+ }
26
+
27
+ test("grouped Status preserves structured fields and opaque integration text", () => {
28
+ const lines = renderShellSidebarBar({
29
+ cwd: "/project", branch: "main", dirty: 2, sessionName: "session",
30
+ modelId: "model", effort: "high", contextPercent: 45, contextWindow: 1000,
31
+ costTotal: 1, subscription: false, statuses: ["opaque integration"],
32
+ }, theme, 46);
33
+ const text = lines.join("\n");
34
+ let previous = -1;
35
+ for (const heading of ["Status", "Project", "Model", "Context", "Usage", "Integrations"]) {
36
+ const index = text.indexOf(heading);
37
+ assert.ok(index > previous, heading);
38
+ previous = index;
39
+ }
40
+ assert.match(text, /opaque integration/);
41
+ assert.match(text, /Branch.*main/);
42
+ });
43
+
44
+ test("scrollable TODO keeps every task while bottom and collapsed cards stay bounded", () => {
45
+ const state: TodoState = { tasks: Array.from({ length: 20 }, (_, i) => ({ id: i + 1, title: `Task-${i + 1}!`, status: "pending" })), nextId: 21, updatedTurn: 0 };
46
+ const todoTheme = { ...theme, strikethrough: (text: string) => text };
47
+ const render = (scrollable: boolean, collapsed = false) => renderTodoCard(state, todoTheme, 46, { scrollable, collapsed, staleTurns: 0 }).join("\n");
48
+ for (const task of state.tasks) assert.ok(render(true).includes(task.title));
49
+ assert.ok(!render(false).includes("Task-20!"));
50
+ assert.ok(!render(true, true).includes("Task-20!"));
51
+ });
52
+
53
+ test("installation on a missing-terminal host is a harmless no-op", () => {
54
+ const dispose = installSidebar({} as TUI, theme);
55
+ assert.doesNotThrow(dispose);
56
+ });
57
+
58
+ test("only fullscreen at 140 columns activates; shrinking restores bottom paint", (t) => {
59
+ for (const [mode, width, active] of [["regular", 140, false], ["fullscreen", 139, false], ["fullscreen", 140, true]] as const) {
60
+ const f = fixture(mode, width);
61
+ t.after(installSidebar(f.tui, theme));
62
+ assert.equal(f.root[NODE]().type, active ? "hstack" : "vstack");
63
+ assert.deepEqual(f.bottom.render(80), active ? [] : ["Status"]);
64
+ f.host.terminal.columns = 139;
65
+ assert.equal(f.root[NODE]().type, "vstack");
66
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
67
+ }
68
+ });
69
+
70
+ test("rail orders Status, changes, agents, TODO independent of registration order", (t) => {
71
+ const f = fixture();
72
+ for (const key of ["todo", "agents", "changes"]) {
73
+ sidebarPart(f.tui, key, { render: () => [key, ""], invalidate() {} });
74
+ }
75
+ t.after(installSidebar(f.tui, theme));
76
+ assert.deepEqual(rail(f).render(50).map((line) => line.trim()), ["✿ Gentle-Pi ✿", "", "Status", "", "changes", "", "agents", "", "todo"]);
77
+ });
78
+
79
+ test("branding belongs to scroll content before Status, never transcript or narrow bottom", (t) => {
80
+ const f = fixture();
81
+ t.after(installSidebar(f.tui, theme));
82
+ const scroll = rail(f);
83
+ const lines = scroll.render(50);
84
+ const brandIndex = lines.findIndex((line) => line.includes("✿ Gentle-Pi ✿"));
85
+ assert.ok(brandIndex >= 0 && brandIndex < lines.findIndex((line) => line.includes("Status")));
86
+ assert.doesNotMatch(lines.join("\n"), /[\u2800-\u28ff]/);
87
+ const heading = lines[brandIndex];
88
+ const usableWidth = scroll.getContentWidth(50) - 2;
89
+ const spare = usableWidth - visibleWidth("✿ Gentle-Pi ✿");
90
+ const scrollbarWidth = 50 - scroll.getContentWidth(50);
91
+ assert.equal(heading, " ".repeat(1 + Math.floor(spare / 2)) + "✿ Gentle-Pi ✿" + " ".repeat(1 + Math.ceil(spare / 2) + scrollbarWidth));
92
+ assert.deepEqual(f.root.render(), ["transcript"]);
93
+ scroll.updateLayout(lines.length, 2, () => {});
94
+ scroll.scrollBy(9);
95
+ assert.ok(scroll.scrollTop > 0);
96
+ f.host.terminal.columns = 80;
97
+ assert.equal(f.root[NODE]().type, "vstack");
98
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
99
+ });
100
+
101
+ test("wheel scrolls the rail and is consumed at both boundaries and blank space", (t) => {
102
+ const f = fixture();
103
+ t.after(installSidebar(f.tui, theme));
104
+ const scroll = rail(f);
105
+ scroll.updateLayout(20, 5, () => {});
106
+ for (const [delta, expected] of [[-1, 0], [3, 3], [100, 15], [1, 15], [-100, 0]]) {
107
+ const result = scroll.handleMouse({ type: "wheel", wheelDelta: delta, x: 3, y: 4, screenX: 93, screenY: 6, width: 50, height: 5 } as Parameters<typeof scroll.handleMouse>[0]);
108
+ assert.equal(result?.handled, true);
109
+ assert.equal(scroll.scrollTop, expected);
110
+ assert.equal(result?.target?.component, scroll);
111
+ }
112
+ f.host.terminal.columns = 139;
113
+ assert.equal(f.root[NODE]().type, "vstack");
114
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
115
+ f.host.terminal.columns = 160;
116
+ const restored = rail(f);
117
+ 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 }[] };
119
+ assert.equal(layout.gap, 3);
120
+ assert.deepEqual(layout.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
121
+ { basis: 0, grow: 1, shrink: 1, minSize: 1 },
122
+ { basis: 50, grow: 0, shrink: 0, minSize: 50 },
123
+ ]);
124
+ const restoredLines = restored.render(50);
125
+ // Native ScrollView.render only appends the scrollbar gutter; short rows need not fill the layout allocation.
126
+ for (const line of restoredLines) assert.ok(visibleWidth(line) <= 50);
127
+ assert.ok(restoredLines.some((line) => line.startsWith(" Status ")));
128
+ assert.deepEqual(f.root.render(), ["transcript"]);
129
+ restored.updateLayout(20, 5, () => {});
130
+ const before = restored.scrollTop;
131
+ const result = restored.handleMouse({ type: "wheel", wheelDelta: 2, x: 3, y: 4, screenX: 113, screenY: 6, width: 50, height: 5 } as Parameters<typeof restored.handleMouse>[0]);
132
+ assert.equal(result?.handled, true);
133
+ assert.equal(result?.target?.component, restored);
134
+ assert.equal(restored.scrollTop, Math.min(15, before + 2));
135
+ for (const line of restored.render(50)) assert.ok(visibleWidth(line) <= 50);
136
+ assert.deepEqual(f.root.render(), ["transcript"]);
137
+ scroll.updateLayout(1, 5, () => {});
138
+ assert.equal(scroll.handleMouse({ type: "wheel", wheelDelta: 1 } as Parameters<typeof scroll.handleMouse>[0])?.handled, true);
139
+ assert.equal(scroll.scrollTop, 0);
140
+ });
141
+
142
+ test("rail dispatches a clipped, scroll-translated left click to only the matching sidebar part", (t) => {
143
+ const f = fixture();
144
+ let clicks = 0;
145
+ const received: TuiMouseEvent[] = [];
146
+ const todo = {
147
+ render: () => ["Todo header", "Todo body"],
148
+ invalidate() {},
149
+ handleMouse(event: TuiMouseEvent) {
150
+ received.push(event);
151
+ if (event.type !== "click" || event.button !== "left" || event.y !== 0) return undefined;
152
+ clicks++;
153
+ return { handled: true, render: true };
154
+ },
155
+ };
156
+ sidebarPart(f.tui, "todo", todo);
157
+ const dispose = installSidebar(f.tui, theme);
158
+ t.after(dispose);
159
+ const scroll = rail(f);
160
+ const content = scroll.render(50);
161
+ scroll.updateLayout(content.length, 5, () => {});
162
+ scroll.scrollBy(1);
163
+ const headerY = content.findIndex((line) => line.includes("Todo header")) - scroll.scrollTop;
164
+ assert.ok(headerY >= 0);
165
+ const mouse = (type: TuiMouseEvent["type"], button: TuiMouseEvent["button"], y: number): TuiMouseEvent => ({
166
+ type, button, x: 2, y, screenX: 92, screenY: 30 + y, width: 50, height: 5, shift: false, alt: false, ctrl: false,
167
+ });
168
+
169
+ assert.equal(scroll.handleMouse(mouse("press", "left", headerY)), undefined);
170
+ assert.equal(scroll.handleMouse(mouse("click", "right", headerY)), undefined);
171
+ assert.equal(scroll.handleMouse(mouse("click", "left", headerY + 1)), undefined);
172
+ assert.equal(clicks, 0);
173
+ const hit = scroll.handleMouse(mouse("click", "left", headerY));
174
+ assert.equal(hit?.handled, true);
175
+ 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 });
177
+
178
+ const gapY = headerY - 1;
179
+ 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");
181
+ invalidateSidebar(f.tui);
182
+ assert.equal(scroll.handleMouse(mouse("click", "left", headerY)), undefined, "stale geometry is inert");
183
+ f.host.terminal.columns = 139;
184
+ assert.equal(scroll.handleMouse(mouse("click", "left", headerY)), undefined, "resized-away rails are inert");
185
+ dispose();
186
+ assert.equal(scroll.handleMouse(mouse("click", "left", headerY)), undefined, "disposed rails are inert");
187
+ });
188
+
189
+ test("rail rejects removed or replaced parts before cached geometry is prepared again", (t) => {
190
+ const f = fixture();
191
+ let originalClicks = 0;
192
+ let replacementClicks = 0;
193
+ const original = {
194
+ render: () => ["Todo header"],
195
+ invalidate() {},
196
+ handleMouse(event: TuiMouseEvent) {
197
+ if (event.type !== "click" || event.button !== "left") return undefined;
198
+ originalClicks++;
199
+ return { handled: true };
200
+ },
201
+ };
202
+ const mounted = sidebarPart(f.tui, "todo", { render: () => ["Todo bottom"], invalidate() {} }, original);
203
+ const dispose = installSidebar(f.tui, theme);
204
+ t.after(dispose);
205
+ const scroll = rail(f);
206
+ const content = scroll.render(50);
207
+ scroll.updateLayout(content.length, 5, () => {});
208
+ const headerY = content.findIndex((line) => line.includes("Todo header"));
209
+ const click = (): TuiMouseEvent => ({ type: "click", button: "left", x: 2, y: headerY, screenX: 92, screenY: 30 + headerY, width: 50, height: 5, shift: false, alt: false, ctrl: false });
210
+ assert.equal(scroll.handleMouse(click())?.handled, true, "healthy cached geometry still dispatches");
211
+ assert.equal(originalClicks, 1);
212
+
213
+ mounted.dispose();
214
+ assert.equal(scroll.handleMouse(click()), undefined, "removed parts are inert before the next prepare");
215
+ assert.equal(originalClicks, 1);
216
+
217
+ const replacement = {
218
+ render: () => ["Todo replacement"],
219
+ invalidate() {},
220
+ handleMouse(event: TuiMouseEvent) {
221
+ if (event.type !== "click" || event.button !== "left") return undefined;
222
+ replacementClicks++;
223
+ return { handled: true };
224
+ },
225
+ };
226
+ sidebarPart(f.tui, "todo", { render: () => ["Todo bottom"], invalidate() {} }, replacement);
227
+ assert.equal(scroll.handleMouse(click()), undefined, "a replacement cannot receive stale cached geometry");
228
+ assert.equal(originalClicks, 1);
229
+ assert.equal(replacementClicks, 0);
230
+
231
+ rail(f);
232
+ assert.equal(scroll.handleMouse(click())?.handled, true, "prepared replacement dispatches normally");
233
+ assert.equal(replacementClicks, 1);
234
+ });
235
+
236
+ test("real layout frames reuse unchanged sidebar output and invalidate at state and breakpoint boundaries", (t) => {
237
+ const f = fixture();
238
+ const counts = { footer: 0, changes: 0, agents: 0, todo: 0 };
239
+ let todo = "Todo one";
240
+ for (const key of Object.keys(counts) as Array<keyof typeof counts>) {
241
+ sidebarPart(f.tui, key, {
242
+ render: () => {
243
+ counts[key]++;
244
+ return [`${key === "todo" ? todo : key}`];
245
+ },
246
+ invalidate() {},
247
+ });
248
+ }
249
+ t.after(installSidebar(f.tui, theme));
250
+
251
+ const first = renderLayoutFrame(f.root, 140, 20, () => {});
252
+ assert.deepEqual(counts, { footer: 1, changes: 1, agents: 1, todo: 1 });
253
+ const sidebarRail = first.root.children[1]?.component as ScrollView;
254
+ renderLayoutFrame(f.root, 140, 20, () => {});
255
+ assert.deepEqual(counts, { footer: 1, changes: 1, agents: 1, todo: 1 });
256
+
257
+ todo = "Todo two";
258
+ sidebarRail.invalidate();
259
+ const changed = renderLayoutFrame(f.root, 140, 20, () => {});
260
+ assert.match(changed.lines.join("\n"), /Todo two/);
261
+ assert.deepEqual(counts, { footer: 2, changes: 2, agents: 2, todo: 2 });
262
+
263
+ f.host.terminal.columns = 139;
264
+ renderLayoutFrame(f.root, 139, 20, () => {});
265
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
266
+ f.host.terminal.columns = 140;
267
+ renderLayoutFrame(f.root, 140, 20, () => {});
268
+ assert.deepEqual(counts, { footer: 3, changes: 3, agents: 3, todo: 3 });
269
+
270
+ f.host.mode = "regular";
271
+ renderLayoutFrame(f.root, 140, 20, () => {});
272
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
273
+ f.host.mode = "fullscreen";
274
+ renderLayoutFrame(f.root, 140, 20, () => {});
275
+ assert.deepEqual(counts, { footer: 4, changes: 4, agents: 4, todo: 4 });
276
+
277
+ let replacementRenders = 0;
278
+ sidebarPart(f.tui, "todo", {
279
+ render: () => {
280
+ replacementRenders++;
281
+ return ["Todo replacement"];
282
+ },
283
+ invalidate() {},
284
+ });
285
+ const replaced = renderLayoutFrame(f.root, 140, 20, () => {});
286
+ assert.match(replaced.lines.join("\n"), /Todo replacement/);
287
+ assert.equal(replacementRenders, 1);
288
+ });
289
+
290
+ test("a rail digest refreshes live state that no invalidation announces", (t) => {
291
+ const f = fixture();
292
+ let model = "model-a";
293
+ let renders = 0;
294
+ // The digest is the only signal: no part is re-registered and invalidateSidebar
295
+ // is never called here, which is exactly the /model case in fullscreen.
296
+ sidebarPart(f.tui, "footer", { render: () => ["Status"], invalidate() {} }, {
297
+ digest: () => model,
298
+ render: () => {
299
+ renders++;
300
+ return [`Model ${model}`];
301
+ },
302
+ invalidate() {},
303
+ });
304
+ t.after(installSidebar(f.tui, theme));
305
+
306
+ assert.match(renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n"), /Model model-a/);
307
+ assert.equal(renders, 1);
308
+ renderLayoutFrame(f.root, 140, 20, () => {});
309
+ assert.equal(renders, 1, "an unchanged digest still reuses the prepared rail");
310
+
311
+ model = "model-b";
312
+ assert.match(renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n"), /Model model-b/);
313
+ assert.equal(renders, 2);
314
+
315
+ // Rail digests run inside the layout frame, so a broken one must not disable
316
+ // the sidebar for the parts that still work.
317
+ let todoRenders = 0;
318
+ sidebarPart(f.tui, "todo", { render: () => ["Todo bottom"], invalidate() {} }, {
319
+ digest: () => { throw new Error("broken digest"); },
320
+ render: () => {
321
+ todoRenders++;
322
+ return ["Todo card"];
323
+ },
324
+ invalidate() {},
325
+ });
326
+ model = "model-c";
327
+ assert.match(renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n"), /Model model-c/);
328
+ assert.equal(todoRenders, 1);
329
+ assert.match(renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n"), /Todo card/);
330
+ assert.equal(todoRenders, 1, "a throwing digest degrades to invalidation-only");
331
+ invalidateSidebar(f.tui);
332
+ renderLayoutFrame(f.root, 140, 20, () => {});
333
+ assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest");
334
+ });
335
+
336
+ test("reuses final sidebar presentation until a relevant invalidation", (t) => {
337
+ const f = fixture();
338
+ sidebarPart(f.tui, "todo", { render: () => Array.from({ length: 10 }, (_, index) => `Todo ${index}`), invalidate() {} });
339
+ t.after(installSidebar(f.tui, theme));
340
+
341
+ const first = f.root[NODE]();
342
+ assert.equal(f.root[NODE](), first);
343
+ const scroll = (first as { entries: { component: ScrollView }[] }).entries[1].component;
344
+ scroll.updateLayout(scroll.render(50).length, 1, () => {});
345
+ scroll.scrollBy(1);
346
+ const scrolled = f.root[NODE]();
347
+ assert.notEqual(scrolled, first);
348
+ assert.equal(f.root[NODE](), scrolled);
349
+
350
+ scroll.invalidate();
351
+ const invalidated = f.root[NODE]();
352
+ assert.notEqual(invalidated, scrolled);
353
+ assert.equal(f.root[NODE](), invalidated);
354
+
355
+ f.host.terminal.columns = 141;
356
+ const resized = f.root[NODE]();
357
+ assert.notEqual(resized, invalidated);
358
+ assert.equal(f.root[NODE](), resized);
359
+ f.host.mode = "regular";
360
+ assert.equal(f.root[NODE]().type, "vstack");
361
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
362
+ });
363
+
364
+ test("cleanup restores the native layout and bottom paint without disposing widgets", () => {
365
+ const f = fixture();
366
+ const dispose = installSidebar(f.tui, theme);
367
+ rail(f);
368
+ dispose();
369
+ assert.equal(f.root[NODE], f.original);
370
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
371
+ assert.equal(sidebarState(f.tui).parts.size, 1);
372
+ assert.ok(f.renders() >= 2);
373
+ });
374
+
375
+ test("unsupported roots, empty rails and overflowing parts leave native layout intact", (t) => {
376
+ for (const lines of [[], ["x".repeat(100)]]) {
377
+ const f = fixture();
378
+ sidebarPart(f.tui, "footer", { render: () => lines, invalidate() {} });
379
+ t.after(installSidebar(f.tui, theme));
380
+ assert.equal(f.root[NODE]().type, "vstack");
381
+ assert.equal(sidebarState(f.tui).active, false);
382
+ }
383
+ const f = fixture();
384
+ Reflect.deleteProperty(f.root, NODE);
385
+ t.after(installSidebar(f.tui, theme));
386
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
387
+ });
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { TUI } from "@earendil-works/pi-tui";
4
+ import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
5
+
6
+ const host = (terminal?: object) => ({ terminal }) as TUI;
7
+ const component = () => ({ render: (_width = 80) => ["bottom"], invalidate() {} });
8
+
9
+ test("unsupported hosts retain the original bottom widget and disposal", () => {
10
+ for (const tui of [host(), host(null as unknown as object)]) {
11
+ let disposed = 0;
12
+ const bottom = { ...component(), dispose() { disposed++; } };
13
+ const part = sidebarPart(tui, "todo", bottom);
14
+ assert.equal(part, bottom);
15
+ assert.deepEqual(part.render(), ["bottom"]);
16
+ part.dispose();
17
+ assert.equal(disposed, 1);
18
+ }
19
+ });
20
+
21
+ test("terminal state survives host replacement without leaking across terminals", () => {
22
+ const terminal = {};
23
+ assert.equal(sidebarState(host(terminal)), sidebarState(host(terminal)));
24
+ assert.notEqual(sidebarState(host(terminal)), sidebarState(host({})));
25
+ });
26
+
27
+ test("bottom paint is suppressed only while the sidebar owns the host", () => {
28
+ const tui = host({});
29
+ const state = sidebarState(tui);
30
+ const part = sidebarPart(tui, "todo", component());
31
+ state.active = true;
32
+ assert.deepEqual(part.render(80), ["bottom"]);
33
+ state.ownsHost = () => true;
34
+ assert.deepEqual(part.render(80), []);
35
+ state.active = false;
36
+ assert.deepEqual(part.render(80), ["bottom"]);
37
+ });
38
+
39
+ test("disposing an old part preserves its replacement and releases its bottom", () => {
40
+ const tui = host({});
41
+ let disposed = 0;
42
+ const first = sidebarPart(tui, "todo", { ...component(), dispose() { disposed++; } });
43
+ const replacement = component();
44
+ const second = sidebarPart(tui, "todo", replacement);
45
+ first.dispose?.();
46
+ assert.equal(disposed, 1);
47
+ assert.equal(sidebarState(tui).parts.get("todo"), replacement);
48
+ (second as typeof second & { dispose?(): void }).dispose?.();
49
+ assert.equal(sidebarState(tui).parts.size, 0);
50
+ });