gentle-pi 2.3.0 → 2.5.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,176 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ formatCost,
6
+ formatTokens,
7
+ gaugeTone,
8
+ renderGauge,
9
+ renderShellBar,
10
+ shellEnabled,
11
+ type ShellBarModel,
12
+ type ShellBarTheme,
13
+ } from "../lib/shell-bar.ts";
14
+
15
+ // The Gentle Shell bar replaces pi's three-line footer with one line of
16
+ // segments. Rendering is pure so it can be verified without a TUI.
17
+
18
+ const taggedTheme: ShellBarTheme = {
19
+ fg(color: string, value: string) {
20
+ return `<${color}>${value}</${color}>`;
21
+ },
22
+ bold(value: string) {
23
+ return value;
24
+ },
25
+ };
26
+
27
+ const plainTheme: ShellBarTheme = {
28
+ fg(_color: string, value: string) {
29
+ return value;
30
+ },
31
+ bold(value: string) {
32
+ return value;
33
+ },
34
+ };
35
+
36
+ function model(overrides: Partial<ShellBarModel> = {}): ShellBarModel {
37
+ return {
38
+ cwd: "~/work/gentle-pi",
39
+ branch: "main",
40
+ dirty: undefined,
41
+ sessionName: undefined,
42
+ modelId: "gpt-5.5",
43
+ effort: "medium",
44
+ contextPercent: 45,
45
+ contextWindow: 272_000,
46
+ costTotal: 9.49,
47
+ subscription: true,
48
+ usage: undefined,
49
+ statuses: [],
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ test("renderGauge fills cells proportionally to the percentage", () => {
55
+ assert.equal(renderGauge(45, 8), "▰▰▰▰▱▱▱▱");
56
+ assert.equal(renderGauge(0, 8), "▱▱▱▱▱▱▱▱");
57
+ assert.equal(renderGauge(100, 8), "▰▰▰▰▰▰▰▰");
58
+ assert.equal(renderGauge(null, 8), "▱▱▱▱▱▱▱▱");
59
+ });
60
+
61
+ test("gaugeTone turns to warning at 80% and error at 95%", () => {
62
+ assert.equal(gaugeTone(45), "accent");
63
+ assert.equal(gaugeTone(79.9), "accent");
64
+ assert.equal(gaugeTone(80), "warning");
65
+ assert.equal(gaugeTone(95), "error");
66
+ assert.equal(gaugeTone(null), "dim");
67
+ });
68
+
69
+ test("formatTokens and formatCost keep the bar compact", () => {
70
+ assert.equal(formatTokens(950), "950");
71
+ assert.equal(formatTokens(4_200), "4.2k");
72
+ assert.equal(formatTokens(272_000), "272k");
73
+ assert.equal(formatTokens(13_000_000), "13M");
74
+ assert.equal(formatCost(9.49, true), "$9.49 sub");
75
+ assert.equal(formatCost(0.004, false), "$0.004");
76
+ });
77
+
78
+ test("renderShellBar renders one line with the segments in order", () => {
79
+ const [line, ...rest] = renderShellBar(model(), plainTheme, 160);
80
+ assert.equal(rest.length, 0);
81
+ assert.equal(
82
+ line,
83
+ "✿ gentle-pi ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium ⟡ ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub",
84
+ );
85
+ });
86
+
87
+ test("renderShellBar colors the brand, model, effort, and gauge by role", () => {
88
+ const [line] = renderShellBar(model(), taggedTheme, 400);
89
+ assert.match(line, /<accent>✿ gentle-pi<\/accent>/);
90
+ assert.match(line, /<text>gpt-5\.5<\/text>/);
91
+ assert.match(line, /<syntaxFunction>medium<\/syntaxFunction>/);
92
+ assert.match(line, /<accent>▰▰▰▰<\/accent><border>▱▱▱▱<\/border>/);
93
+ assert.match(line, /<dim>⟡<\/dim>/);
94
+ });
95
+
96
+ test("renderShellBar shows the branch as dirty-neutral and omits it outside git", () => {
97
+ const [line] = renderShellBar(model({ branch: null }), plainTheme, 160);
98
+ assert.match(line, /⟡ ~\/work\/gentle-pi ⟡/);
99
+ });
100
+
101
+ test("renderShellBar shows the session dirty count next to the branch", () => {
102
+ const [line] = renderShellBar(model({ dirty: 3 }), taggedTheme, 400);
103
+ assert.match(line, /<text>main<\/text> <warning>±3<\/warning>/);
104
+ const [clean] = renderShellBar(model({ dirty: 0 }), plainTheme, 160);
105
+ assert.doesNotMatch(clean, /±/);
106
+ });
107
+
108
+ test("renderShellBar adds the subscription windows after the cost when usage is known", () => {
109
+ const usage = {
110
+ provider: "openai-codex",
111
+ plan: "pro",
112
+ fetchedAt: 0,
113
+ limits: [{ name: "codex", limitReached: false, windows: [
114
+ { label: "5h", usedPercent: 62, windowSeconds: 18_000, resetAt: null },
115
+ { label: "week", usedPercent: 31, windowSeconds: 604_800, resetAt: null },
116
+ ] }],
117
+ };
118
+ const [line] = renderShellBar(model({ usage }), plainTheme, 200);
119
+ assert.match(line, /\$9\.49 sub ⟡ codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31%$/);
120
+ });
121
+
122
+ test("renderShellBar shows an unknown context as a question mark after compaction", () => {
123
+ const [line] = renderShellBar(model({ contextPercent: null }), plainTheme, 160);
124
+ assert.match(line, /ctx ▱▱▱▱▱▱▱▱ \?%/);
125
+ });
126
+
127
+ test("renderShellBar right-aligns the session name when it fits", () => {
128
+ const [line] = renderShellBar(model({ sessionName: "Release notes" }), plainTheme, 120);
129
+ assert.equal(visibleWidth(line), 120);
130
+ assert.match(line, /Release notes$/);
131
+ });
132
+
133
+ test("renderShellBar appends extension statuses as trailing segments", () => {
134
+ const [line] = renderShellBar(model({ statuses: ["🔌 MCP: 3 servers\tenabled"] }), plainTheme, 160);
135
+ assert.match(line, /⟡ 🔌 MCP: 3 servers enabled$/);
136
+ });
137
+
138
+ test("renderShellBar repaints extension statuses in the bar role, discarding colors the extension embedded", () => {
139
+ const tagged = { fg: (color: string, text: string) => `<${color}>${text}</${color}>`, bold: (text: string) => text };
140
+ const [line] = renderShellBar(model({ statuses: ["\x1b[38;2;255;0;0mMCP: 3/3 servers\x1b[0m"] }), tagged, 400);
141
+ assert.match(line, /<muted>MCP: 3\/3 servers<\/muted>$/);
142
+ assert.doesNotMatch(line, /\x1b\[/);
143
+ });
144
+
145
+ test("renderShellBar compacts the path and branch before it sacrifices an extension status", () => {
146
+ const long = model({ branch: "fix/shell-bar-status-ansi", dirty: 2, statuses: ["MCP: 3/3 servers"] });
147
+ const [full] = renderShellBar(long, plainTheme, 160);
148
+ assert.match(full, /~\/work\/gentle-pi fix\/shell-bar-status-ansi ±2 .* MCP: 3\/3 servers$/);
149
+ const [compact] = renderShellBar(long, plainTheme, 118);
150
+ assert.ok(visibleWidth(compact) <= 118, `line overflowed: ${visibleWidth(compact)}`);
151
+ assert.match(compact, /⟡ gentle-pi fix\/shell-bar-… ±2 ⟡/);
152
+ assert.match(compact, /MCP: 3\/3 servers$/);
153
+ });
154
+
155
+ test("renderShellBar drops the session name, then trailing segments, before truncating", () => {
156
+ const wide = model({ sessionName: "Release notes", statuses: ["MCP: 3 servers enabled"] });
157
+ const [atNinety] = renderShellBar(wide, plainTheme, 90);
158
+ assert.ok(visibleWidth(atNinety) <= 90, `line overflowed: ${visibleWidth(atNinety)}`);
159
+ assert.doesNotMatch(atNinety, /Release notes/);
160
+ assert.match(atNinety, /gpt-5\.5/);
161
+
162
+ const [atFifty] = renderShellBar(wide, plainTheme, 50);
163
+ assert.ok(visibleWidth(atFifty) <= 50, `line overflowed: ${visibleWidth(atFifty)}`);
164
+ assert.match(atFifty, /^✿ gentle-pi/);
165
+ });
166
+
167
+ test("shellEnabled stays off inside a Gentle Agents child", () => {
168
+ assert.equal(shellEnabled({ GENTLE_PI_AGENTS_CHILD: "1" }), false);
169
+ });
170
+
171
+ test("shellEnabled honors GENTLE_PI_SHELL=0", () => {
172
+ assert.equal(shellEnabled({}), true);
173
+ assert.equal(shellEnabled({ GENTLE_PI_SHELL: "1" }), true);
174
+ assert.equal(shellEnabled({ GENTLE_PI_SHELL: "0" }), false);
175
+ assert.equal(shellEnabled({ GENTLE_PI_SHELL: "false" }), false);
176
+ });
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { CARD_TONE, renderCard, type Card } from "../lib/shell-card.ts";
5
+ import { stripAnsi } from "../lib/terminal-theme.ts";
6
+
7
+ // Cards are how Gentle notices look: the same rounded frame as the prompt,
8
+ // with the title in the notice tone. They collapse to one body line when pi
9
+ // asks for it.
10
+
11
+ const plainTheme = {
12
+ fg(_color: string, text: string) {
13
+ return text;
14
+ },
15
+ };
16
+
17
+ const taggedTheme = {
18
+ fg(color: string, text: string) {
19
+ return `<${color}>${text}</${color}>`;
20
+ },
21
+ };
22
+
23
+ const ansiTheme = {
24
+ fg(_color: string, text: string) {
25
+ return `\x1b[35m${text}\x1b[0m`;
26
+ },
27
+ };
28
+
29
+ function card(overrides: Partial<Card> = {}): Card {
30
+ return {
31
+ title: "Gentle AI",
32
+ subtitle: "review preflight",
33
+ body: ["Receipt-driven development is enabled, and this worktree holds an unreviewed candidate.", "", "Call the gentle_review tool with inspect and follow the transition it returns."],
34
+ tone: CARD_TONE.INFO,
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ test("renderCard draws the rounded frame with the title in the top rule and wraps the body inside", () => {
40
+ const lines = renderCard(card(), plainTheme, 48, { expanded: true }).map(stripAnsi);
41
+ assert.match(lines[0], /^╭─ ✿ Gentle AI · review preflight ─+╮$/);
42
+ assert.match(lines[1], /^│ Receipt-driven development is enabled, and +│$/);
43
+ for (const line of lines) assert.equal(visibleWidth(line), 48, `"${line}" is not 48 wide`);
44
+ assert.ok(lines.some((line) => /^│ +│$/.test(line)), "blank body lines keep the frame");
45
+ assert.ok(lines.some((line) => line.includes("gentle_review")), "every paragraph is rendered when expanded");
46
+ assert.match(lines[lines.length - 1], /^╰─+╯$/);
47
+ });
48
+
49
+ test("renderCard collapses to the frame and the first body line with an expand hint", () => {
50
+ const lines = renderCard(card(), plainTheme, 60, { expanded: false }).map(stripAnsi);
51
+ assert.equal(lines.length, 3);
52
+ assert.match(lines[1], /^│ Receipt-driven development is enabled.*… +│$/);
53
+ assert.equal(visibleWidth(lines[1]), 60);
54
+ });
55
+
56
+ test("renderCard keeps the rail and its corners in the tone and the rest of the frame in the border color", () => {
57
+ const info = renderCard(card(), taggedTheme, 80, { expanded: true });
58
+ assert.match(info[0], /^<customMessageLabel>╭<\/customMessageLabel><border>─ <\/border><customMessageLabel>✿ Gentle AI<\/customMessageLabel> <muted>·<\/muted> <muted>review preflight<\/muted><border> ─+<\/border><border>╮<\/border>$/);
59
+ assert.match(info[1], /^<customMessageLabel>│<\/customMessageLabel> <text>.*<border>│<\/border>$/);
60
+ assert.match(info[info.length - 1], /^<customMessageLabel>╰<\/customMessageLabel><border>─+╯<\/border>$/);
61
+
62
+ const warning = renderCard(card({ tone: CARD_TONE.WARNING, subtitle: undefined }), taggedTheme, 80, { expanded: true });
63
+ assert.match(warning[0], /^<warning>╭<\/warning>/);
64
+ assert.match(warning[0], /<warning>✿ Gentle AI<\/warning>/);
65
+ assert.match(warning[1], /^<warning>│<\/warning> /);
66
+ assert.match(warning[warning.length - 1], /^<warning>╰<\/warning>/);
67
+
68
+ const error = renderCard(card({ tone: CARD_TONE.ERROR }), taggedTheme, 80, { expanded: true });
69
+ assert.match(error[0], /<error>✿ Gentle AI<\/error>/);
70
+ assert.equal(CARD_TONE.SUCCESS, "success");
71
+ });
72
+
73
+ test("renderCard places a hint at the right end of the top rule and can paint every line", () => {
74
+ const lines = renderCard(card(), plainTheme, 60, { expanded: false, hint: "ctrl+o expand" });
75
+ assert.match(stripAnsi(lines[0]), /^╭─ ✿ Gentle AI · review preflight ─+ ctrl\+o expand ╮$/);
76
+ assert.equal(visibleWidth(lines[0]), 60);
77
+
78
+ const painted = renderCard(card(), plainTheme, 60, { expanded: false, paint: (line) => `[bg]${line}[/bg]` });
79
+ assert.ok(painted.every((line) => line.startsWith("[bg]") && line.endsWith("[/bg]")));
80
+ });
81
+
82
+ test("renderCard accepts a custom glyph and an empty body", () => {
83
+ const lines = renderCard(card({ glyph: "✎", body: [] }), plainTheme, 40, { expanded: true }).map(stripAnsi);
84
+ assert.equal(lines.length, 2);
85
+ assert.match(lines[0], /^╭─ ✎ Gentle AI · review preflight ─+╮$/);
86
+ assert.match(lines[1], /^╰─+╯$/);
87
+ });
88
+
89
+ test("renderCard keeps the top rule at width with a two-cell glyph", () => {
90
+ const lines = renderCard(card({ glyph: "\u{1F339}\uFE0E" }), plainTheme, 60, { expanded: false, hint: "ctrl+o expand" });
91
+ for (const line of lines) assert.equal(visibleWidth(line), 60, `"${stripAnsi(line)}" is not 60 wide`);
92
+ });
93
+
94
+ test("renderCard drops the hint before truncating title content", () => {
95
+ const lines = renderCard(card(), plainTheme, 40, { expanded: false, hint: "ctrl+o expand" }).map(stripAnsi);
96
+ assert.equal(lines[0], "╭─ ✿ Gentle AI · review preflight ─────╮");
97
+ assert.ok(!lines[0].includes("ctrl+o"));
98
+ assert.equal(visibleWidth(lines[0]), 40);
99
+ });
100
+
101
+ test("renderCard truncates ANSI-styled title content at display width", () => {
102
+ const lines = renderCard(
103
+ card({ glyph: "\u{1F339}\uFE0E", title: "Gentle AI review", subtitle: "completed · review acknowledge approved" }),
104
+ ansiTheme,
105
+ 30,
106
+ { expanded: false, hint: "ctrl+o to expand" },
107
+ );
108
+ assert.match(stripAnsi(lines[0]), /^╭─ 🌹︎ Gentle AI review.*╮$/);
109
+ assert.ok(!stripAnsi(lines[0]).includes("ctrl+o"));
110
+ for (const line of lines) assert.equal(visibleWidth(line), 30, `"${stripAnsi(line)}" is not 30 wide`);
111
+ });
112
+
113
+ test("renderCard never exceeds extremely narrow supplied widths", () => {
114
+ for (const width of [0, 1, 2, 3, 4, 5, 8, 16]) {
115
+ const lines = renderCard(card({ glyph: "\u{1F339}\uFE0E" }), ansiTheme, width, { expanded: true, hint: "ctrl+o to expand" });
116
+ for (const line of lines) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
117
+ }
118
+ });
@@ -0,0 +1,146 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { CHANGE_STATUS, changesModel, type ChangedFile } from "../lib/shell-changes.ts";
5
+ import { ChangesView, colorDiff, type ChangesViewDeps } from "../lib/shell-changes-view.ts";
6
+ import { stripAnsi } from "../lib/terminal-theme.ts";
7
+
8
+ // The changes overlay: files on the left, the selected file's diff on the
9
+ // right, keys at the bottom. Rendering is pure; git access is injected.
10
+
11
+ const plainTheme = {
12
+ fg(_color: string, text: string) {
13
+ return text;
14
+ },
15
+ };
16
+
17
+ const taggedTheme = {
18
+ fg(color: string, text: string) {
19
+ return `<${color}>${text}</${color}>`;
20
+ },
21
+ };
22
+
23
+ function file(path: string, added: number, deleted: number, status: ChangedFile["status"] = CHANGE_STATUS.MODIFIED): ChangedFile {
24
+ return { path, added, deleted, status };
25
+ }
26
+
27
+ const DIFF_A = ["diff --git a/lib/a.ts b/lib/a.ts", "index 1..2 100644", "--- a/lib/a.ts", "+++ b/lib/a.ts", "@@ -1,2 +1,3 @@", " const a = 1;", "-const b = 2;", "+const b = 3;", "+const c = 4;"].join("\n");
28
+
29
+ function view(overrides: Partial<ChangesViewDeps> = {}, files = [file("lib/a.ts", 2, 1), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)]) {
30
+ const calls: string[] = [];
31
+ const events: string[] = [];
32
+ const deps: ChangesViewDeps = {
33
+ theme: plainTheme,
34
+ rows: 12,
35
+ async loadDiff(target) {
36
+ calls.push(target.path);
37
+ return target.path === "lib/a.ts" ? DIFF_A : "";
38
+ },
39
+ onOpen(target) {
40
+ events.push(`open:${target.path}`);
41
+ },
42
+ onClose() {
43
+ events.push("close");
44
+ },
45
+ requestRender() {
46
+ events.push("render");
47
+ },
48
+ ...overrides,
49
+ };
50
+ return { view: new ChangesView(changesModel(files), deps), calls, events };
51
+ }
52
+
53
+ async function settle(): Promise<void> {
54
+ await new Promise((resolve) => setTimeout(resolve, 0));
55
+ }
56
+
57
+ test("colorDiff drops git headers and colors hunks, additions, and removals by role", () => {
58
+ const lines = colorDiff(DIFF_A, taggedTheme);
59
+ assert.deepEqual(lines, [
60
+ "<customMessageLabel>@@ -1,2 +1,3 @@</customMessageLabel>",
61
+ "<toolDiffContext> const a = 1;</toolDiffContext>",
62
+ "<toolDiffRemoved>-const b = 2;</toolDiffRemoved>",
63
+ "<toolDiffAdded>+const b = 3;</toolDiffAdded>",
64
+ "<toolDiffAdded>+const c = 4;</toolDiffAdded>",
65
+ ]);
66
+ });
67
+
68
+ test("ChangesView renders a framed two-pane layout at the requested size", async () => {
69
+ const { view: component } = view();
70
+ await settle();
71
+ const lines = component.render(80);
72
+ assert.equal(lines.length, 12);
73
+ for (const line of lines) assert.equal(visibleWidth(line), 80, `"${stripAnsi(line)}" is not 80 wide`);
74
+ const plain = lines.map(stripAnsi);
75
+ assert.match(plain[0], /^╭─ ✎ Changes · 2 files · \+12 −1 ─+╮$/);
76
+ assert.match(plain[1], /^│ ▸ lib\/a\.ts +\+2 −1 +│ @@ -1,2 \+1,3 @@ +│$/);
77
+ assert.match(plain[2], /^│ lib\/b\.ts +\+10 new +│ const a = 1; +│$/);
78
+ assert.match(plain[11], /^╰─+╯$/);
79
+ assert.match(plain[10], /j\/k file .* o open in editor .* esc close/);
80
+ });
81
+
82
+ test("ChangesView loads the selected diff lazily and moves with j/k and arrows", async () => {
83
+ const { view: component, calls, events } = view();
84
+ await settle();
85
+ assert.deepEqual(calls, ["lib/a.ts"]);
86
+ component.handleInput("j");
87
+ await settle();
88
+ assert.deepEqual(calls, ["lib/a.ts", "lib/b.ts"]);
89
+ assert.match(stripAnsi(component.render(80)[2]), /^│ ▸ lib\/b\.ts/);
90
+ component.handleInput("\x1b[A");
91
+ assert.match(stripAnsi(component.render(80)[1]), /^│ ▸ lib\/a\.ts/);
92
+ component.handleInput("k");
93
+ assert.match(stripAnsi(component.render(80)[1]), /^│ ▸ lib\/a\.ts/);
94
+ assert.ok(events.filter((event) => event === "render").length >= 2);
95
+ });
96
+
97
+ test("ChangesView scrolls the diff pane and shows an empty state for files without a diff", async () => {
98
+ const long = Array.from({ length: 40 }, (_, index) => `+line ${index}`).join("\n");
99
+ const { view: component } = view({ async loadDiff() { return `@@ -0,0 +1,40 @@\n${long}`; } });
100
+ await settle();
101
+ component.handleInput("\x1b[6~");
102
+ const plain = component.render(80).map(stripAnsi);
103
+ assert.doesNotMatch(plain[1], /@@/);
104
+ assert.match(plain[1], /\+line \d+/);
105
+ component.handleInput("\x0b");
106
+ assert.match(stripAnsi(component.render(80)[1]), /@@/, "ctrl+k scrolls back to the top");
107
+ component.handleInput("\x0a");
108
+ assert.doesNotMatch(stripAnsi(component.render(80)[1]), /@@/, "ctrl+j scrolls a page down");
109
+
110
+ const empty = view({ async loadDiff() { return ""; } });
111
+ await settle();
112
+ assert.match(stripAnsi(empty.view.render(80)[1]), /no diff for this file/);
113
+ });
114
+
115
+ test("ChangesView opens the selected file and closes on escape or q", async () => {
116
+ const { view: component, events } = view();
117
+ await settle();
118
+ component.handleInput("o");
119
+ assert.ok(events.includes("open:lib/a.ts"));
120
+ component.handleInput("\x1b");
121
+ component.handleInput("q");
122
+ assert.equal(events.filter((event) => event === "close").length, 2);
123
+ });
124
+
125
+ test("ChangesView.update keeps the selected file, reloads moved diffs, and survives an empty tree", async () => {
126
+ const { view: component, calls } = view();
127
+ await settle();
128
+ component.handleInput("j");
129
+ await settle();
130
+ assert.deepEqual(calls, ["lib/a.ts", "lib/b.ts"]);
131
+
132
+ component.update(changesModel([file("lib/a.ts", 5, 1), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED), file("lib/c.ts", 1, 0)]));
133
+ await settle();
134
+ assert.match(stripAnsi(component.render(80)[2]), /^│ ▸ lib\/b\.ts/);
135
+ assert.deepEqual(calls, ["lib/a.ts", "lib/b.ts"], "unchanged selected file must not reload");
136
+ component.handleInput("k");
137
+ await settle();
138
+ assert.deepEqual(calls, ["lib/a.ts", "lib/b.ts", "lib/a.ts"], "moved counts must reload the diff");
139
+
140
+ component.update(changesModel([]));
141
+ const plain = component.render(80).map(stripAnsi);
142
+ assert.match(plain[0], /0 files · \+0 −0/);
143
+ assert.match(plain[1], /working tree is clean/);
144
+ component.handleInput("j");
145
+ component.handleInput("o");
146
+ });
@@ -0,0 +1,182 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ CHANGE_STATUS,
6
+ ChangesTracker,
7
+ changesSummary,
8
+ emptyChanges,
9
+ parseNumstat,
10
+ parsePorcelain,
11
+ renderChangesWidget,
12
+ changesModel,
13
+ snapshotChanges,
14
+ type ChangedFile,
15
+ } from "../lib/shell-changes.ts";
16
+
17
+ // The changes view shows the working tree against HEAD, new files included,
18
+ // so a resumed session sees the same picture as a fresh one.
19
+
20
+ const plainTheme = {
21
+ fg(_color: string, text: string) {
22
+ return text;
23
+ },
24
+ bold(text: string) {
25
+ return text;
26
+ },
27
+ };
28
+
29
+ const taggedTheme = {
30
+ fg(color: string, text: string) {
31
+ return `<${color}>${text}</${color}>`;
32
+ },
33
+ bold(text: string) {
34
+ return text;
35
+ },
36
+ };
37
+
38
+ function file(path: string, added: number, deleted: number, status: ChangedFile["status"] = CHANGE_STATUS.MODIFIED): ChangedFile {
39
+ return { path, added, deleted, status };
40
+ }
41
+
42
+ test("parseNumstat reads counts, treats binaries as zero, and resolves rename braces", () => {
43
+ const parsed = parseNumstat("12\t3\tlib/a.ts\n-\t-\tassets/logo.png\n0\t0\tsrc/{old => new}/file.ts\n5\t1\told.ts => new.ts\n");
44
+ assert.deepEqual(parsed, [
45
+ { path: "lib/a.ts", added: 12, deleted: 3 },
46
+ { path: "assets/logo.png", added: 0, deleted: 0 },
47
+ { path: "src/new/file.ts", added: 0, deleted: 0 },
48
+ { path: "new.ts", added: 5, deleted: 1 },
49
+ ]);
50
+ });
51
+
52
+ test("parsePorcelain reads NUL-separated status entries including renames and untracked files", () => {
53
+ const raw = [" M lib/a.ts", "A lib/b.ts", " D lib/c.ts", "?? notes.md", "R new.ts", "old.ts", "MM lib/d.ts"].join("\0") + "\0";
54
+ const parsed = parsePorcelain(raw);
55
+ assert.deepEqual(
56
+ [...parsed.entries()],
57
+ [
58
+ ["lib/a.ts", CHANGE_STATUS.MODIFIED],
59
+ ["lib/b.ts", CHANGE_STATUS.ADDED],
60
+ ["lib/c.ts", CHANGE_STATUS.DELETED],
61
+ ["notes.md", CHANGE_STATUS.UNTRACKED],
62
+ ["new.ts", CHANGE_STATUS.RENAMED],
63
+ ["lib/d.ts", CHANGE_STATUS.MODIFIED],
64
+ ],
65
+ );
66
+ });
67
+
68
+ test("snapshotChanges merges status with counts and keeps untracked files without counts", () => {
69
+ const files = snapshotChanges({
70
+ numstat: "4\t2\tlib/a.ts\n",
71
+ porcelain: " M lib/a.ts\0?? notes.md\0",
72
+ });
73
+ assert.deepEqual(files, [file("lib/a.ts", 4, 2), file("notes.md", 0, 0, CHANGE_STATUS.UNTRACKED)]);
74
+ });
75
+
76
+ test("changesModel sorts files by path and totals the counts", () => {
77
+ const model = changesModel([file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED), file("lib/a.ts", 4, 2)]);
78
+ assert.deepEqual(model.files, [file("lib/a.ts", 4, 2), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)]);
79
+ assert.equal(model.added, 14);
80
+ assert.equal(model.deleted, 2);
81
+ });
82
+
83
+ test("changesSummary and the widget describe the session at a glance", () => {
84
+ const model = changesModel([file("extensions/gentle-shell.ts", 31, 0, CHANGE_STATUS.ADDED), file("lib/shell-bar.ts", 9, 7), file("tests/x.test.ts", 2, 0)]);
85
+ assert.equal(changesSummary(model), "3 files · +42 −7");
86
+ assert.equal(changesSummary(changesModel([file("a.ts", 1, 0)])), "1 file · +1 −0");
87
+
88
+ const [line, ...rest] = renderChangesWidget(model, plainTheme, 120);
89
+ assert.equal(rest.length, 0);
90
+ assert.match(line, /^✎ 3 files · \+42 −7 · extensions\/gentle-shell\.ts · lib\/shell-bar\.ts · tests\/x\.test\.ts {2,}\/gentle:changes$/);
91
+ assert.equal(visibleWidth(line), 120, "the command sits on the right edge");
92
+ });
93
+
94
+ test("renderChangesWidget colors counts by direction and yields nothing when clean", () => {
95
+ const model = changesModel([file("a.ts", 1, 2)]);
96
+ const [line] = renderChangesWidget(model, taggedTheme, 400);
97
+ assert.match(line, /<accent>✎<\/accent>/);
98
+ assert.match(line, /<success>\+1<\/success> <error>−2<\/error>/);
99
+ assert.match(line, /<dim>\/gentle:changes<\/dim>$/);
100
+ assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 120), []);
101
+ });
102
+
103
+ test("renderChangesWidget drops the file list before truncating on narrow terminals", () => {
104
+ const model = changesModel([file("a/very/long/path/one.ts", 1, 0), file("a/very/long/path/two.ts", 1, 0)]);
105
+ const [line] = renderChangesWidget(model, plainTheme, 40);
106
+ assert.equal(visibleWidth(line), 40);
107
+ assert.match(line, /^✎ 2 files · \+2 −0 +\/gentle:changes$/);
108
+ assert.doesNotMatch(line, /one\.ts/);
109
+ const [tiny] = renderChangesWidget(model, plainTheme, 20);
110
+ assert.ok(visibleWidth(tiny) <= 20);
111
+ assert.match(tiny, /^✎ 2 files/);
112
+ });
113
+
114
+ function fakeGit(numstats: string[], porcelains: string[], code = 0) {
115
+ const calls: string[][] = [];
116
+ let round = 0;
117
+ const git = async (args: string[]) => {
118
+ calls.push(args);
119
+ const isNumstat = args[0] === "diff";
120
+ const index = Math.min(isNumstat ? round : round++, numstats.length - 1);
121
+ return { stdout: isNumstat ? numstats[index] : porcelains[index], code };
122
+ };
123
+ return { git, calls };
124
+ }
125
+
126
+ test("ChangesTracker reads the working tree on start and again on refresh", async () => {
127
+ const { git, calls } = fakeGit(
128
+ ["4\t2\tlib/a.ts\n", "4\t2\tlib/a.ts\n10\t0\tlib/b.ts\n"],
129
+ [" M lib/a.ts\0", " M lib/a.ts\0A lib/b.ts\0"],
130
+ );
131
+ const tracker = new ChangesTracker(git);
132
+ await tracker.start();
133
+ assert.deepEqual(tracker.model.files, [file("lib/a.ts", 4, 2)]);
134
+ const model = await tracker.refresh();
135
+ assert.deepEqual(model.files, [file("lib/a.ts", 4, 2), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)]);
136
+ assert.equal(calls.length, 4);
137
+ assert.deepEqual(calls[0], ["diff", "--numstat", "HEAD"]);
138
+ assert.deepEqual(calls[1], ["status", "--porcelain=v1", "--untracked-files=all", "-z"]);
139
+ });
140
+
141
+ test("ChangesTracker stays quiet outside a git repository", async () => {
142
+ const { git, calls } = fakeGit([""], [""], 128);
143
+ const tracker = new ChangesTracker(git);
144
+ await tracker.start();
145
+ assert.deepEqual(await tracker.refresh(), emptyChanges());
146
+ assert.equal(calls.length, 2);
147
+ });
148
+
149
+ test("ChangesTracker coalesces overlapping refreshes into at most one extra round", async () => {
150
+ let resolveGate: (() => void) | undefined;
151
+ let rounds = 0;
152
+ const git = async (args: string[]) => {
153
+ if (args[0] === "diff") {
154
+ rounds += 1;
155
+ if (rounds === 2) await new Promise<void>((resolve) => { resolveGate = resolve; });
156
+ }
157
+ return { stdout: "", code: 0 };
158
+ };
159
+ const tracker = new ChangesTracker(git);
160
+ await tracker.start();
161
+ const first = tracker.refresh();
162
+ const second = tracker.refresh();
163
+ const third = tracker.refresh();
164
+ await new Promise((resolve) => setTimeout(resolve, 0));
165
+ resolveGate?.();
166
+ await Promise.all([first, second, third]);
167
+ assert.equal(rounds, 3);
168
+ });
169
+
170
+ test("ChangesTracker counts the lines of untracked files, which git numstat leaves out", async () => {
171
+ const { git } = fakeGit(["", ""], ["", "?? notes.md\0?? empty.md\0"]);
172
+ const counted: string[] = [];
173
+ const tracker = new ChangesTracker(git, async (path) => {
174
+ counted.push(path);
175
+ return path === "notes.md" ? 3 : 0;
176
+ });
177
+ await tracker.start();
178
+ const model = await tracker.refresh();
179
+ assert.deepEqual(model.files, [file("empty.md", 0, 0, CHANGE_STATUS.UNTRACKED), file("notes.md", 3, 0, CHANGE_STATUS.UNTRACKED)]);
180
+ assert.equal(model.added, 3);
181
+ assert.deepEqual(counted, ["notes.md", "empty.md"]);
182
+ });