gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -1,11 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { initTheme } from "@earendil-works/pi-coding-agent";
3
4
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
4
5
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
5
6
  import { Box, visibleWidth } from "@earendil-works/pi-tui";
6
- import { renderGentleAiResult, GentleAiCallCard } from "../lib/gentle-ai-renderer.ts";
7
+ import { renderGentleAiLifecycleCall, renderGentleAiResult, GentleAiCallCard } from "../lib/gentle-ai-renderer.ts";
8
+ import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
7
9
  import { stripAnsi } from "../lib/terminal-theme.ts";
8
10
 
11
+ initTheme("dark");
12
+
9
13
  // Rose cards: exactly one component closes the frame in every state. While
10
14
  // a call runs, the call card draws the bottom rule (a partial result never
11
15
  // does); once the final result is in, the result card closes the frame.
@@ -17,7 +21,7 @@ test("a running call card closes its own frame and a completed one leaves that t
17
21
  card.update("running", "review capture · reliability", plainTheme);
18
22
  const running = card.render(60).map(stripAnsi);
19
23
  assert.equal(running.length, 2);
20
- assert.match(running[0], /^╭─ 🌹︎ Gentle AI · running · review capture · reliability ─*╮$/);
24
+ assert.match(running[0], /^╭─ 🌹 rdd running · capture · reliability ─*╮$/);
21
25
  assert.match(running[1], /^╰─+╯$/);
22
26
  card.update("preparing", "review status", plainTheme, "$ gentle-ai review status");
23
27
  assert.match(card.render(60).map(stripAnsi)[2], /^╰─+╯$/);
@@ -37,7 +41,7 @@ test("completed review cards fit Pi's default Box at terminal width 57", () => {
37
41
  const lines = box.render(57).map(stripAnsi);
38
42
  for (const line of lines) assert.equal(visibleWidth(line), 57, `${operationPath}: ${JSON.stringify(line)}`);
39
43
  if (operationPath === "review inspect") {
40
- assert.equal(lines[1], " ╭─ 🌹︎ Gentle AI · completed · review inspect ─────────╮ ");
44
+ assert.equal(lines[1], " ╭─ 🌹 rdd inspect ────────────────── ctrl+o to expand ╮ ");
41
45
  }
42
46
  }
43
47
  });
@@ -52,7 +56,10 @@ test("review registrations own their shell", () => {
52
56
  for (const tool of review) assert.equal(tool.renderShell, "self", tool.name);
53
57
  });
54
58
 
55
- test("review call and result cards have no passive background fill", () => {
59
+ test("review call and result cards have no passive background fill", (t) => {
60
+ const found = cardStyle();
61
+ t.after(() => setCardStyle(found));
62
+ setCardStyle(CARD_STYLE.NEON);
56
63
  const theme = { ...plainTheme, bg: (_role: string, text: string) => `\x1b[44m${text}\x1b[49m` };
57
64
  for (const options of [
58
65
  { expanded: true }, { expanded: false },
@@ -80,11 +87,12 @@ test("review call and result cards have no passive background fill", () => {
80
87
 
81
88
  test("a partial result draws no bottom rule and a final one draws exactly one", () => {
82
89
  const partial = renderGentleAiResult({ content: [{ type: "text", text: "half" }] }, { expanded: false, isPartial: true }, plainTheme).render(60).map(stripAnsi);
83
- assert.deepEqual(partial.map((line) => line.slice(0, 1)), ["│"], "only the count row, no closing rule");
90
+ assert.deepEqual(partial.map((line) => line.slice(0, 1)), ["│"], "only the preview row, no closing rule");
84
91
  const final = renderGentleAiResult({ content: [{ type: "text", text: "one\ntwo" }] }, { expanded: false }, plainTheme).render(60).map(stripAnsi);
85
- assert.equal(final.length, 2);
86
- assert.match(final[0], /^│ 2 lines +│$/);
87
- assert.match(final[1], /^╰─+╯$/);
92
+ assert.equal(final.length, 3);
93
+ assert.match(final[0], /^│ one +│$/);
94
+ assert.match(final[1], /^│ two +│$/);
95
+ assert.match(final[2], /^╰─+╯$/);
88
96
  const empty = renderGentleAiResult({ content: [] }, { expanded: false }, plainTheme).render(60).map(stripAnsi);
89
97
  assert.deepEqual(empty.map((line) => line.slice(0, 1)), ["╰"]);
90
98
  });
@@ -101,3 +109,474 @@ test("promoting the shared state to finished invalidates after the render return
101
109
  await new Promise((resolve) => queueMicrotask(() => resolve(undefined)));
102
110
  assert.equal(invalidations, 1, "an unchanged state does not invalidate again");
103
111
  });
112
+
113
+ function stateStartedAt(rowState: Record<string, unknown>): number | undefined {
114
+ return (rowState.gentleAiRender as Record<string, unknown> | undefined)?.startedAt as number | undefined;
115
+ }
116
+
117
+ function stateEndedAt(rowState: Record<string, unknown>): number | undefined {
118
+ return (rowState.gentleAiRender as Record<string, unknown> | undefined)?.endedAt as number | undefined;
119
+ }
120
+
121
+ function statePendingTimer(rowState: Record<string, unknown>): unknown {
122
+ return (rowState.gentleAiRender as Record<string, unknown> | undefined)?.pendingTimer;
123
+ }
124
+
125
+ test("a call card stamps its duration from first sight to terminal freeze", () => {
126
+ const rowState: Record<string, unknown> = {};
127
+ const running = renderGentleAiLifecycleCall("review capture · risk", plainTheme, { state: rowState, argsComplete: true, executionStarted: false } as never, undefined, 1_000);
128
+ const runningLines = running.render(100).map(stripAnsi);
129
+ assert.match(runningLines[0], /^╭─ 🌹 rdd running · capture · risk ─*╮$/);
130
+ assert.match(runningLines[runningLines.length - 1], / 0s ╯$/, "the live duration ticks on the bottom rule");
131
+ assert.equal(stateStartedAt(rowState), 1_000);
132
+ const done = renderGentleAiLifecycleCall("review capture · risk", plainTheme, { state: rowState, executionStarted: true, isPartial: false } as never, undefined, 31_000);
133
+ const doneLine = done.render(120).map(stripAnsi)[0];
134
+ assert.match(doneLine, /^╭─ 🌹 rdd capture · risk ─+\s+to expand ╮$/);
135
+ const doneResult = renderGentleAiResult({ content: [{ type: "text", text: "x" }] } as never, { expanded: false }, plainTheme, { state: rowState } as never).render(90).map(stripAnsi);
136
+ assert.match(doneResult[doneResult.length - 1], /─* 30s ╯$/, "the frozen duration closes the frame, right-aligned");
137
+ assert.equal(stateEndedAt(rowState), 31_000);
138
+ const frozen = renderGentleAiLifecycleCall("review capture · risk", plainTheme, { state: rowState, executionStarted: true, isPartial: false } as never, undefined, 99_000);
139
+ const frozenResult = renderGentleAiResult({ content: [{ type: "text", text: "x" }] } as never, { expanded: false }, plainTheme, { state: rowState } as never).render(90).map(stripAnsi);
140
+ assert.match(frozenResult[frozenResult.length - 1], / 30s ╯$/, "a late re-render never grows the duration");
141
+ });
142
+
143
+ test("a replayed call shows its persisted duration; one without a start stays honest", () => {
144
+ const persistedRow: Record<string, unknown> = { gentleAiRender: { startedAt: 1_000, endedAt: 31_000, finished: true } };
145
+ const persisted = renderGentleAiLifecycleCall("review status", plainTheme, { state: persistedRow, executionStarted: false } as never, undefined, 90_000);
146
+ const persistedLines = persisted.render(90).map(stripAnsi);
147
+ assert.match(persistedLines[0], /^╭─ 🌹 rdd status ─+\s+to expand ╮$/);
148
+ const persistedResult = renderGentleAiResult({ content: [{ type: "text", text: "x" }] } as never, { expanded: false }, plainTheme, { state: persistedRow } as never).render(90).map(stripAnsi);
149
+ assert.match(persistedResult[persistedResult.length - 1], /─* 30s ╯$/, "a replay with persisted stamps shows its frozen duration on the closing rule");
150
+ const promotedRow: Record<string, unknown> = { gentleAiRender: { finished: true } };
151
+ const promoted = renderGentleAiLifecycleCall("review status", plainTheme, { state: promotedRow, executionStarted: false } as never, undefined, 90_000);
152
+ const promotedLine = promoted.render(90).map(stripAnsi)[0];
153
+ assert.match(promotedLine, /^╭─ 🌹 rdd status ─*\s*to expand ╮$/, "a result-promoted replay shows only the expand key");
154
+ assert.doesNotMatch(promotedLine, /\d+s/);
155
+ });
156
+
157
+ test("a card with no render state stays honest about unknown duration", () => {
158
+ const card = renderGentleAiLifecycleCall("review capture", plainTheme, { executionStarted: true, isPartial: false } as never, undefined, 5_000);
159
+ const line = card.render(80).map(stripAnsi)[0];
160
+ assert.match(line, / rdd capture /);
161
+ assert.doesNotMatch(line, /completed/);
162
+ assert.doesNotMatch(line, /\d+s/);
163
+ });
164
+
165
+ test("a historical replay never invents a duration: fresh state, preparing render, stored result, invalidation", async () => {
166
+ const rowState: Record<string, unknown> = {};
167
+ const initial = renderGentleAiLifecycleCall("review status", plainTheme, { state: rowState, executionStarted: false, argsComplete: false } as never, undefined, 90_000);
168
+ const initialLines = initial.render(90).map(stripAnsi);
169
+ assert.match(initialLines[0], / rdd preparing · status /);
170
+ assert.doesNotMatch(initialLines.join("\n"), /\d+s/);
171
+ assert.equal(stateStartedAt(rowState), undefined, "the preparing render of a replayed row must not invent a start");
172
+ const resultCard = renderGentleAiResult({ content: [{ type: "text", text: "x" }] } as never, { expanded: false }, plainTheme, { state: rowState, invalidate: () => {} } as never);
173
+ const resultLines = resultCard.render(90).map(stripAnsi);
174
+ assert.doesNotMatch(resultLines.join("\n"), /\d+s/, "no persisted stamps means no duration on the closing rule");
175
+ await new Promise((resolve) => queueMicrotask(() => resolve(undefined)));
176
+ const replayed = renderGentleAiLifecycleCall("review status", plainTheme, { state: rowState, executionStarted: false, argsComplete: false } as never, undefined, 90_040);
177
+ const replayedLine = replayed.render(90).map(stripAnsi)[0];
178
+ assert.match(replayedLine, / rdd status /);
179
+ assert.doesNotMatch(replayedLine, /\d+s/, "a replayed row with no persisted timestamps omits the unknown duration");
180
+ assert.equal(stateStartedAt(rowState), undefined);
181
+ assert.equal(stateEndedAt(rowState), undefined);
182
+ });
183
+
184
+ test("a replayed row restores its true frozen duration from durable session timing", async () => {
185
+ const rowState: Record<string, unknown> = {};
186
+ const durable = new Map([["call-1", { toolCallId: "call-1", startedAt: 1_000, endedAt: 31_000 }]]);
187
+ const context = {
188
+ state: rowState,
189
+ toolCallId: "call-1",
190
+ elapsedTiming: { lookup: (id: string) => durable.get(id) },
191
+ executionStarted: false,
192
+ argsComplete: false,
193
+ invalidate: () => {},
194
+ };
195
+ const initial = renderGentleAiLifecycleCall("review status", plainTheme, context as never, undefined, 90_000);
196
+ assert.match(initial.render(90).map(stripAnsi).join("\n"), / 30s ╯$/, "the seeded preparing card shows the durable duration");
197
+ assert.equal(stateStartedAt(rowState), 1_000);
198
+ assert.equal(stateEndedAt(rowState), 31_000);
199
+ const resultCard = renderGentleAiResult({ content: [{ type: "text", text: "x" }] } as never, { expanded: false }, plainTheme, { state: rowState, invalidate: context.invalidate } as never);
200
+ assert.match(resultCard.render(90).map(stripAnsi).join("\n"), /─* 30s ╯$/, "the stored result closes the frame with the frozen duration");
201
+ await new Promise((resolve) => queueMicrotask(() => resolve(undefined)));
202
+ const replayed = renderGentleAiLifecycleCall("review status", plainTheme, { ...context } as never, undefined, 90_040);
203
+ const replayedLines = replayed.render(90).map(stripAnsi);
204
+ assert.match(replayedLines[0], / rdd status /);
205
+ assert.doesNotMatch(replayedLines[0], /90s|89s/, "the terminal re-render never grows the duration to the replay clock");
206
+ assert.equal(stateStartedAt(rowState), 1_000, "the durable start survives the replay");
207
+ assert.equal(stateEndedAt(rowState), 31_000, "the replayed row must not invent a new end");
208
+ assert.equal(statePendingTimer(rowState), undefined, "the transient replay timer is cleared by the terminal render");
209
+ });
210
+
211
+ test("a replayed row with a start-only durable record never invents an end", async () => {
212
+ const rowState: Record<string, unknown> = {};
213
+ const durable = new Map([["call-2", { toolCallId: "call-2", startedAt: 1_000 }]]);
214
+ const context = {
215
+ state: rowState,
216
+ toolCallId: "call-2",
217
+ elapsedTiming: { lookup: (id: string) => durable.get(id) },
218
+ executionStarted: false,
219
+ argsComplete: false,
220
+ invalidate: () => {},
221
+ };
222
+ const initial = renderGentleAiLifecycleCall("review status", plainTheme, context as never, undefined, 90_000);
223
+ assert.doesNotMatch(initial.render(90).map(stripAnsi).join("\n"), /\d+s/, "a start without a durable end shows no duration");
224
+ renderGentleAiResult({ content: [{ type: "text", text: "x" }] } as never, { expanded: false }, plainTheme, { state: rowState, invalidate: context.invalidate } as never);
225
+ await new Promise((resolve) => queueMicrotask(() => resolve(undefined)));
226
+ const replayed = renderGentleAiLifecycleCall("review status", plainTheme, { ...context } as never, undefined, 90_040);
227
+ assert.match(replayed.render(90).map(stripAnsi)[0], / rdd status /);
228
+ assert.equal(stateEndedAt(rowState), undefined, "a live-only end freeze must not fire on a replayed row");
229
+ const final = renderGentleAiResult({ content: [{ type: "text", text: "x" }] } as never, { expanded: false }, plainTheme, { state: rowState } as never);
230
+ assert.doesNotMatch(final.render(90).map(stripAnsi).join("\n"), /\d+s/);
231
+ });
232
+
233
+ test("running renders keep a single pending duration timer and the terminal render clears it", (t) => {
234
+ t.mock.timers.enable({ apis: ["setTimeout"] });
235
+ const rowState: Record<string, unknown> = {};
236
+ let invalidations = 0;
237
+ const context = { state: rowState, argsComplete: true, executionStarted: false, invalidate: () => { invalidations += 1; } };
238
+ renderGentleAiLifecycleCall("review capture", plainTheme, context as never, undefined, 1_000);
239
+ const firstTimer = statePendingTimer(rowState);
240
+ assert.ok(firstTimer, "a live running row keeps one pending duration timer");
241
+ renderGentleAiLifecycleCall("review capture", plainTheme, context as never, undefined, 1_500);
242
+ assert.notEqual(statePendingTimer(rowState), firstTimer, "a new render replaces the pending timer instead of stacking one");
243
+ t.mock.timers.tick(1_000);
244
+ assert.equal(invalidations, 1, "only the latest timer fires; the replaced one was cleared");
245
+ renderGentleAiLifecycleCall("review capture", plainTheme, { ...context, executionStarted: true, isPartial: false } as never, undefined, 31_000);
246
+ assert.equal(statePendingTimer(rowState), undefined, "a terminal render clears the pending timer");
247
+ t.mock.timers.tick(60_000);
248
+ assert.equal(invalidations, 1, "no timer outlives the terminal render");
249
+ });
250
+
251
+ test("native review registration passes host failure context into the rose result", () => {
252
+ const tools: ToolDefinition[] = [];
253
+ createGentleAiExtension({ nativeReviewCli: null } as never)({
254
+ on() {}, registerCommand() {}, registerTool(tool: ToolDefinition) { tools.push(tool); },
255
+ } as unknown as ExtensionAPI);
256
+ const tool = tools.find((tool) => tool.name === "gentle_review");
257
+ assert.ok(tool?.renderResult);
258
+ const roles: string[] = [];
259
+ const theme = { fg: (role: string, text: string) => { roles.push(role); return text; } };
260
+ const result = { content: [{ type: "text" as const, text: "candidate unavailable" }], details: undefined };
261
+ const component = tool.renderResult(result, { expanded: false, isPartial: false }, theme as never, { isError: true, state: {} } as never);
262
+ assert.match(component.render(80).join("\n"), /candidate unavailable/);
263
+ assert.ok(roles.includes("error"));
264
+ assert.ok(!roles.includes("success"));
265
+ });
266
+
267
+ test("native context failures keep red useful previews and promote replay state", async () => {
268
+ const state = {};
269
+ const roles: string[] = [];
270
+ const theme = { fg: (role: string, text: string) => { roles.push(role); return text; } };
271
+ const text = "authority unavailable\nretry with the bound candidate\nthird detail\nfourth detail";
272
+ const ctx = { state, isError: true, executionStarted: false };
273
+ const result = { content: [{ type: "text" as const, text }], details: undefined };
274
+ const collapsed = renderGentleAiResult(result, { expanded: false }, theme, ctx);
275
+ assert.match(collapsed.render(80).join("\n"), /authority unavailable/);
276
+ assert.ok(roles.includes("error"));
277
+ assert.ok(!roles.includes("success"));
278
+ await Promise.resolve();
279
+ const call = renderGentleAiLifecycleCall("review status", theme, { state });
280
+ assert.match(call.render(100)[0], /🌹 rdd failed · status /);
281
+ for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8, 24, 100]) {
282
+ const rows = collapsed.render(width);
283
+ assert.ok(rows.length <= 4);
284
+ assert.ok(rows.every((row) => visibleWidth(row) <= width));
285
+ assert.equal(rows.filter((row) => row.startsWith("╰")).length, width === 0 ? 0 : 1);
286
+ }
287
+ assert.match(renderGentleAiResult(result, { expanded: true }, theme, ctx).render(80).join("\n"), /fourth detail/);
288
+ roles.length = 0;
289
+ const partial = renderGentleAiResult(result, { expanded: false, isPartial: true }, theme, { state: {} });
290
+ assert.match(partial.render(80).join("\n"), /authority unavailable/);
291
+ assert.ok(roles.includes("warning"));
292
+ assert.ok(!roles.includes("success"));
293
+ roles.length = 0;
294
+ const success = renderGentleAiResult(result, { expanded: false }, theme, { state: {} });
295
+ assert.match(success.render(80).join("\n"), /authority unavailable/);
296
+ assert.ok(roles.includes("success"), "status comes from the host, not text classification");
297
+ });
298
+
299
+ // R4-replay-running-start-fabrication regressions. Real replays carry NO
300
+ // argsComplete (pi omits it on historical rows), unlike the older replay
301
+ // fixtures above that pass argsComplete: false (the PREPARING path).
302
+
303
+ test("a replayed row without argsComplete renders running but never fabricates a start", () => {
304
+ const rowState: Record<string, unknown> = {};
305
+ let invalidations = 0;
306
+ const context = { state: rowState, executionStarted: false, invalidate: () => { invalidations += 1; } };
307
+ const replayed = renderGentleAiLifecycleCall("review status", plainTheme, context as never, undefined, 90_000);
308
+ const line = replayed.render(90).map(stripAnsi)[0];
309
+ assert.match(line, / rdd running · status /, "a replayed unfinished row computes to running (absent argsComplete)");
310
+ assert.doesNotMatch(line, /\d+s/, "a replayed running row without durable stamps shows no duration");
311
+ assert.equal(stateStartedAt(rowState), undefined, "an unexplained RUNNING on a replayed row must not stamp a start");
312
+ assert.equal(statePendingTimer(rowState), undefined, "no start means no ticking timer for a dead row");
313
+ assert.equal(invalidations, 0, "no invalidation fires during the render itself");
314
+ });
315
+
316
+ test("a start-only durable record under an args-less replay stays honest", () => {
317
+ const rowState: Record<string, unknown> = {};
318
+ const durable = new Map([["call-3", { toolCallId: "call-3", startedAt: 1_000 }]]);
319
+ const context = {
320
+ state: rowState,
321
+ toolCallId: "call-3",
322
+ elapsedTiming: { lookup: (id: string) => durable.get(id) },
323
+ executionStarted: false,
324
+ invalidate: () => {},
325
+ };
326
+ const initial = renderGentleAiLifecycleCall("review status", plainTheme, context as never, undefined, 90_000);
327
+ assert.doesNotMatch(initial.render(90).map(stripAnsi).join("\n"), /\d+s/, "a start-only record shows no duration");
328
+ assert.equal(stateStartedAt(rowState), undefined, "the start-only record is not seeded onto the replay");
329
+ assert.equal(statePendingTimer(rowState), undefined, "no fabricated start means the 1 Hz invalidate loop never arms");
330
+ });
331
+
332
+ test("a live running row still stamps its start and keeps one timer", () => {
333
+ const rowState: Record<string, unknown> = {};
334
+ const context = { state: rowState, argsComplete: true, executionStarted: false, invalidate: () => {} };
335
+ renderGentleAiLifecycleCall("review capture", plainTheme, context as never, undefined, 1_000);
336
+ assert.equal(stateStartedAt(rowState), 1_000, "live evidence (argsComplete true) still stamps the start");
337
+ assert.match(renderGentleAiLifecycleCall("review capture", plainTheme, { ...context } as never, undefined, 1_500).render(90).map(stripAnsi).join("\n"), /\d+s/);
338
+ assert.ok(statePendingTimer(rowState), "the live row keeps its duration timer");
339
+ });
340
+
341
+ test("a seeded replay with a frozen end arms no ticking timer", () => {
342
+ const rowState: Record<string, unknown> = {};
343
+ const durable = new Map([["call-4", { toolCallId: "call-4", startedAt: 1_000, endedAt: 31_000 }]]);
344
+ const context = {
345
+ state: rowState,
346
+ toolCallId: "call-4",
347
+ elapsedTiming: { lookup: (id: string) => durable.get(id) },
348
+ executionStarted: false,
349
+ invalidate: () => {},
350
+ };
351
+ renderGentleAiLifecycleCall("review status", plainTheme, context as never, undefined, 90_000);
352
+ assert.equal(stateStartedAt(rowState), 1_000, "the durable start is seeded");
353
+ assert.equal(stateEndedAt(rowState), 31_000, "the durable end is seeded");
354
+ assert.equal(statePendingTimer(rowState), undefined, "a frozen duration needs no wake-up timer");
355
+ });
356
+
357
+ // JSON envelopes (review inspect/assess and friends) collapse to one human
358
+ // summary line instead of raw JSON rows; expanding shows the pretty envelope.
359
+
360
+ const digest = `sha256:${"a".repeat(64)}`;
361
+ const inspectEnvelope = {
362
+ operation: "inspect",
363
+ status: "blocked",
364
+ result: {
365
+ schema: "gentle-ai.review-integration.status/v9",
366
+ target_identity: digest,
367
+ action: "start",
368
+ next_transition: {
369
+ kind: "execute",
370
+ reason_code: "fresh_target_ready",
371
+ execute: { operation: "review.start", arguments: [{ name: "lineage", token: "--lineage=review-fixture" }], binding: { target_identity: digest } },
372
+ },
373
+ },
374
+ };
375
+ const assessEnvelope = {
376
+ operation: "assess",
377
+ schema: "gentle-pi.review-assessment-plan/v1",
378
+ risk: "unassessable",
379
+ reasons: [{ code: "non-zero", detail: "native review assess failed: Error: untracked files require an explicit declaration; pass intendedUntracked with the exact paths" }],
380
+ changedPaths: 0,
381
+ changedLines: 0,
382
+ candidate: null,
383
+ rddLine: "on",
384
+ nativeReviewOutcome: "unknown",
385
+ plan: { verifier: "independent", commands: ["gentle-ai review assess --cwd . --json"] },
386
+ };
387
+
388
+ function jsonResult(value: unknown, compact = true) {
389
+ return { content: [{ type: "text" as const, text: compact ? JSON.stringify(value) : JSON.stringify(value, null, 2) }], details: undefined };
390
+ }
391
+
392
+ function bodyRows(lines: string[]): string[] {
393
+ return lines.map(stripAnsi).filter((line) => line.startsWith("│")).map((line) => line.slice(1, -1).trim());
394
+ }
395
+
396
+ test("a collapsed inspect envelope shows one readable summary instead of raw JSON", () => {
397
+ const lines = renderGentleAiResult(jsonResult(inspectEnvelope), { expanded: false }, plainTheme).render(120);
398
+ assert.deepEqual(bodyRows(lines), ["blocked · start · fresh_target_ready"]);
399
+ const text = lines.join("\n");
400
+ assert.doesNotMatch(text, /\{"|schema|sha256|review-integration|--lineage|review\.start/);
401
+ assert.match(stripAnsi(lines[lines.length - 1]), /^╰─+╯$/);
402
+ });
403
+
404
+ test("a collapsed assess envelope shows its risk and the diagnostic message", () => {
405
+ for (const compact of [true, false]) {
406
+ const lines = renderGentleAiResult(jsonResult(assessEnvelope, compact), { expanded: false }, plainTheme).render(120);
407
+ assert.deepEqual(bodyRows(lines), ["unassessable · native review assess failed: untracked files require an explicit declaration"]);
408
+ assert.doesNotMatch(lines.join("\n"), /\{"|schema|assessment-plan|gentle-ai review|Error:/);
409
+ }
410
+ });
411
+
412
+ test("an expanded JSON envelope is pretty-printed in full", () => {
413
+ const lines = renderGentleAiResult(jsonResult(inspectEnvelope), { expanded: true }, plainTheme).render(160);
414
+ const raw = lines.map(stripAnsi).filter((line) => line.startsWith("│")).map((line) => line.slice(2).replace(/ *│$/, ""));
415
+ assert.equal(raw.join("\n"), JSON.stringify(inspectEnvelope, null, 2));
416
+ assert.equal(lines.filter((line) => stripAnsi(line).startsWith("╰")).length, 1);
417
+ });
418
+
419
+ test("an error envelope keeps the red frame and shows the error message", () => {
420
+ const roles: string[] = [];
421
+ const theme = { fg: (role: string, text: string) => { roles.push(role); return text; } };
422
+ const envelope = {
423
+ operation: "start",
424
+ status: "blocked",
425
+ outcome: "native-status-package-binary-missing",
426
+ diagnostics: { operation: "status", error_code: "package-binary-missing", timed_out: false, output_limit_exceeded: false, stderr: "gentle-ai binary not found\nsecond line" },
427
+ recovery_command: "npm install --global gentle-ai",
428
+ };
429
+ const lines = renderGentleAiResult(jsonResult(envelope), { expanded: false }, theme, { isError: true, state: {} }).render(120);
430
+ assert.deepEqual(bodyRows(lines), ["blocked · native-status-package-binary-missing · package-binary-missing · gentle-ai binary not found"]);
431
+ assert.ok(roles.includes("error"));
432
+ assert.ok(!roles.includes("success"));
433
+ assert.doesNotMatch(lines.join("\n"), /npm install|second line/);
434
+ });
435
+
436
+ test("a JSON object without known fields collapses to a field count", () => {
437
+ const lines = renderGentleAiResult(jsonResult({ version: 1, sha256: digest, cursor: 0, totalPaths: 1, entries: [] }), { expanded: false }, plainTheme).render(80);
438
+ assert.deepEqual(bodyRows(lines), ["5 fields"]);
439
+ const shorthand = renderGentleAiResult(jsonResult({ next_transition: "stop" }), { expanded: false }, plainTheme).render(80);
440
+ assert.deepEqual(bodyRows(shorthand), ["stop"], "a string transition is its own reason");
441
+ });
442
+
443
+ test("decoded escape sequences inside JSON values never reach the terminal", () => {
444
+ const lines = renderGentleAiResult(jsonResult({ status: "blocked\u001b[31m", reason: "bad\u0007 input" }), { expanded: false }, plainTheme).render(80);
445
+ assert.deepEqual(bodyRows(lines), ["blocked · bad input"]);
446
+ assert.doesNotMatch(lines.join(""), /[\u0000-\u0008\u000b-\u001f\u007f]/);
447
+ const expanded = renderGentleAiResult(jsonResult({ status: "blocked\u001b[31m" }), { expanded: true }, plainTheme).render(80).join("\n");
448
+ assert.doesNotMatch(expanded, /\u001b/);
449
+ assert.match(expanded, /\\u001b\[31m/);
450
+ });
451
+
452
+ test("non-JSON results keep the text preview", () => {
453
+ for (const text of ["{not json", "[1, 2, 3]", "plain one\nplain two"]) {
454
+ const lines = renderGentleAiResult({ content: [{ type: "text", text }], details: undefined }, { expanded: false }, plainTheme).render(80);
455
+ assert.deepEqual(bodyRows(lines), text.split("\n"));
456
+ }
457
+ });
458
+
459
+ test("JSON summaries stay bounded at every width", () => {
460
+ for (const envelope of [inspectEnvelope, assessEnvelope]) {
461
+ for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8, 24, 80]) {
462
+ const rows = renderGentleAiResult(jsonResult(envelope), { expanded: false }, plainTheme).render(width);
463
+ if (width === 0) { assert.deepEqual(rows, []); continue; }
464
+ assert.ok(rows.length <= 3, `width ${width}: ${rows.length} rows`);
465
+ assert.ok(rows.every((row) => visibleWidth(row) <= width), `width ${width}`);
466
+ assert.equal(rows.filter((row) => stripAnsi(row).startsWith("╰")).length, 1);
467
+ }
468
+ }
469
+ const narrow = bodyRows(renderGentleAiResult(jsonResult(assessEnvelope), { expanded: false }, plainTheme).render(40));
470
+ assert.equal(narrow.length, 1);
471
+ assert.match(narrow[0], /^unassessable · native.*…$/);
472
+ });
473
+
474
+ // The rose call card reads like the quiet read card: glyph, a short name and
475
+ // the operation as its argument, in one title span without a muted subtitle.
476
+
477
+ test("rose call cards title the operation as rdd <op>, or gentle-ai <op> outside review", () => {
478
+ const title = (status: "preparing" | "running" | "completed" | "failed", operationPath: string) => {
479
+ const card = new GentleAiCallCard();
480
+ card.update(status, operationPath, plainTheme);
481
+ return stripAnsi(card.render(120)[0]).replace(/^╭─ /, "").replace(/ ─+╮$/, "");
482
+ };
483
+ assert.equal(title("completed", "review inspect"), "\u{1F339} rdd inspect");
484
+ assert.equal(title("completed", "review assess"), "\u{1F339} rdd assess");
485
+ assert.equal(title("running", "review start"), "\u{1F339} rdd running · start");
486
+ assert.equal(title("failed", "review capture · risk"), "\u{1F339} rdd failed · capture · risk");
487
+ assert.equal(title("preparing", "review mode enable"), "\u{1F339} rdd preparing · mode enable");
488
+ assert.equal(title("running", "review"), "\u{1F339} rdd running");
489
+ assert.equal(title("completed", "review"), "\u{1F339} rdd");
490
+ assert.equal(title("completed", "version"), "\u{1F339} gentle-ai version");
491
+ assert.equal(title("running", "command"), "\u{1F339} gentle-ai running · command");
492
+ });
493
+
494
+ test("the rose is a colored emoji and the title is one span in the outcome tone", () => {
495
+ const theme = { fg: (role: string, text: string) => `<${role}>${text}</${role}>` };
496
+ const card = new GentleAiCallCard();
497
+ card.update("completed", "review inspect", theme, undefined, "ctrl+o to expand");
498
+ const top = card.render(80)[0];
499
+ assert.match(top, /<success>\u{1F339} rdd inspect<\/success>/u);
500
+ assert.doesNotMatch(top, /\uFE0E|Gentle AI|<muted>/);
501
+ assert.equal(visibleWidth("\u{1F339}"), 2);
502
+ });
503
+
504
+ test("rose call cards stay exactly as wide as the terminal with the 2-cell emoji", () => {
505
+ for (const [status, operationPath, hint] of [
506
+ ["completed", "review inspect", "ctrl+o to expand"],
507
+ ["running", "review capture group · risk · resilience · readability · reliability", undefined],
508
+ ["failed", "version", "ctrl+o to expand"],
509
+ ] as const) {
510
+ const card = new GentleAiCallCard();
511
+ card.update(status, operationPath, plainTheme, undefined, hint);
512
+ for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8, 24, 40, 80, 120]) {
513
+ const lines = card.render(width);
514
+ if (width === 0) { assert.deepEqual(lines, []); continue; }
515
+ for (const line of lines) assert.equal(visibleWidth(line), width, `${operationPath} at ${width}: ${JSON.stringify(line)}`);
516
+ }
517
+ }
518
+ });
519
+
520
+ // Float style: the rose call card and its result read as one borderless panel.
521
+ const floatTheme = {
522
+ fg: (_color: string, text: string) => `\x1b[38;5;114m${text}\x1b[39m`,
523
+ bg: (color: string, text: string) => `\x1b[48;5;${color === "toolErrorBg" ? 52 : color === "toolPendingBg" ? 58 : 22}m${text}\x1b[49m`,
524
+ };
525
+
526
+ function withFloatCards<T>(run: () => T): T {
527
+ setCardStyle(CARD_STYLE.FLOAT);
528
+ try {
529
+ return run();
530
+ } finally {
531
+ setCardStyle(CARD_STYLE.NEON);
532
+ }
533
+ }
534
+
535
+ const BLANK = /^ ▎ +$/;
536
+
537
+ test("float rose cards join the call and the JSON summary into one panel", () => {
538
+ const rows = withFloatCards(() => {
539
+ const call = new GentleAiCallCard();
540
+ call.update("completed", "review inspect", floatTheme, "$ gentle-ai review inspect", "ctrl+o to expand");
541
+ const envelope = JSON.stringify({ schema: "x/v1", status: "ready", risk: "medium" });
542
+ const result = renderGentleAiResult({ content: [{ type: "text", text: envelope }] } as never, { expanded: false }, floatTheme);
543
+ return [...call.render(70), ...result.render(70)];
544
+ });
545
+ const plain = rows.map(stripAnsi);
546
+ for (const line of rows) {
547
+ assert.equal(visibleWidth(line), 70);
548
+ assert.ok(line.startsWith(" \x1b[48;5;22m") && line.endsWith("\x1b[49m "), JSON.stringify(line));
549
+ }
550
+ assert.doesNotMatch(plain.join("\n"), /[╭╮╰╯│─]/);
551
+ assert.match(plain[0]!, BLANK);
552
+ assert.match(plain[1]!, /^ ▎ 🌹 rdd inspect +ctrl\+o to expand {4}$/);
553
+ assert.match(plain[2]!, /^ ▎ \$ gentle-ai review inspect +$/, "the command stays with the heading");
554
+ assert.match(plain[3]!, BLANK);
555
+ assert.match(plain[4]!, /^ ▎ ready · medium +$/);
556
+ assert.match(plain[5]!, BLANK);
557
+ assert.equal(plain.length, 6);
558
+ assert.equal(plain[1]!.indexOf("🌹"), plain[4]!.indexOf("ready"));
559
+ });
560
+
561
+ test("float rose cards keep the elapsed time on the blank closing row and add no separator under a running call", () => {
562
+ const rows = withFloatCards(() => {
563
+ const call = new GentleAiCallCard();
564
+ call.update("running", "review status", floatTheme, undefined, undefined, "3s");
565
+ const partial = renderGentleAiResult({ content: [{ type: "text", text: "working" }] } as never, { isPartial: true }, floatTheme);
566
+ return [...call.render(40), ...partial.render(40)];
567
+ });
568
+ const plain = rows.map(stripAnsi);
569
+ assert.match(plain[0]!, BLANK);
570
+ assert.match(plain[1]!, /^ ▎ 🌹 rdd running · status +$/);
571
+ assert.match(plain[2]!, /^ ▎ +3s {3}$/);
572
+ assert.match(plain[3]!, /^ ▎ working +$/);
573
+ assert.equal(plain.length, 4);
574
+ for (const line of rows) assert.ok(line.startsWith(" \x1b[48;5;58m"), JSON.stringify(line));
575
+ });
576
+
577
+ test("float rose error results paint the error background on every row", () => {
578
+ const rows = withFloatCards(() => renderGentleAiResult({ content: [{ type: "text", text: "boom\nsecond" }] } as never, { isError: true }, floatTheme).render(40));
579
+ const plain = rows.map(stripAnsi);
580
+ assert.deepEqual(plain.map((line) => BLANK.test(line)), [true, false, false, true]);
581
+ for (const line of rows) assert.ok(line.startsWith(" \x1b[48;5;52m"), JSON.stringify(line));
582
+ });