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,7 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { visibleWidth } from "@earendil-works/pi-tui";
4
- import { CARD_TONE, renderCard, type Card } from "../lib/shell-card.ts";
4
+ import {
5
+ CARD_STYLE, CARD_TONE, cardAwaitingResult, cardRunningLine, cardStyle, cardTopRows, markCardResult, panelExtraRows, panelHeaderRow, renderCard, setCardStyle,
6
+ type Card, type CardStyle, type CardTone,
7
+ } from "../lib/shell-card.ts";
5
8
  import { stripAnsi } from "../lib/terminal-theme.ts";
6
9
 
7
10
  // Cards are how Gentle notices look: the same rounded frame as the prompt,
@@ -135,9 +138,358 @@ test("renderCard truncates ANSI-styled title content at display width", () => {
135
138
  for (const line of lines) assert.equal(visibleWidth(line), 30, `"${stripAnsi(line)}" is not 30 wide`);
136
139
  });
137
140
 
141
+ test("tool heading continuation reserves configured hint columns before ANSI and Unicode wrapping", () => {
142
+ const title = "bash $ printf 界e\u0301 alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa";
143
+ const hint = "\x1b[36mconfigured key to expand\x1b[0m";
144
+ const value = card({ title: `\x1b[1m${title}\x1b[0m`, subtitle: undefined, glyph: "🌹︎", body: [] });
145
+ for (const width of [60, 80, 120]) {
146
+ const lines = cardTopRows(value, ansiTheme, width, hint);
147
+ assert.match(stripAnsi(lines[0]), /configured key to expand ╮$/);
148
+ assert.match(stripAnsi(lines[0]), /^╭─ 🌹︎ bash \$ printf/);
149
+ assert.ok(lines.length > 1, "long heading continues inside the same frame");
150
+ assert.ok(lines.every((line) => visibleWidth(line) === width));
151
+ const content = lines.map(stripAnsi).map((line, index) => index === 0
152
+ ? line.replace(/^╭─ 🌹︎ /, "").replace(/ ─+ configured key to expand ╮$/, "")
153
+ : line.replace(/^│ /, "").replace(/ +│$/, "")).join(" ").replace(/\s+/g, " ").trim();
154
+ assert.equal(content, title, "hint reservation must not lose any command text");
155
+ }
156
+ for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8]) {
157
+ const lines = cardTopRows(value, ansiTheme, width, hint);
158
+ assert.ok(lines.every((line) => visibleWidth(line) <= width));
159
+ assert.ok(!lines.some((line) => stripAnsi(line).includes("configured key")));
160
+ }
161
+ });
162
+
163
+ test("tool previews share the frame with a configurable physical-row budget", () => {
164
+ const value = card({ body: ["first useful row", "界🌹e\u0301".repeat(80), "last"], tone: CARD_TONE.ERROR });
165
+ for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8, 24, 100]) {
166
+ const lines = renderCard(value, ansiTheme, width, { expanded: false, previewRows: 3 });
167
+ assert.ok(lines.length <= 5);
168
+ assert.ok(lines.every((line) => visibleWidth(line) <= width));
169
+ if (width === 0) assert.deepEqual(lines, []);
170
+ if (width === 100) assert.match(stripAnsi(lines[1]), /first useful row/);
171
+ }
172
+ assert.equal(renderCard(card({ body: ["one", "two", "three", "four"] }), plainTheme, 60, { expanded: false, previewRows: 3 }).length, 5);
173
+ const multiline = renderCard(card({ body: ["one\ntwo\nthree\nfour"] }), plainTheme, 60, { expanded: false, previewRows: 3 });
174
+ assert.equal(multiline.length, 5);
175
+ assert.ok(multiline.every((line) => !line.includes("\n")), "each returned row is physically one terminal row");
176
+ assert.doesNotMatch(multiline.join("\n"), /four/);
177
+ });
178
+
179
+ test("legacy renderCard keeps its empty-row shape at nonpositive widths while opt-in previews stay empty", () => {
180
+ // Agents, Todos and Status call renderCard without their own width guard, so
181
+ // their row count at width zero must remain the pre-preview shape: every
182
+ // row is present and empty. Only the opt-in previewRows path returns nothing.
183
+ const paragraphs = card({ subtitle: "2 running", body: ["ab", "", "c d"] });
184
+ const empty = card({ glyph: "✎", subtitle: undefined, body: [], tone: CARD_TONE.WARNING });
185
+ for (const width of [0, -1, -7]) {
186
+ for (const theme of [plainTheme, taggedTheme, ansiTheme]) {
187
+ assert.deepEqual(renderCard(paragraphs, theme, width, { expanded: true }), ["", "", "", "", "", "", ""], `expanded at ${width}`);
188
+ assert.deepEqual(renderCard(paragraphs, theme, width, { expanded: true, hint: "ctrl+o expand" }), ["", "", "", "", "", "", ""], `hinted at ${width}`);
189
+ assert.deepEqual(renderCard(paragraphs, theme, width, { expanded: false }), ["", "", ""], `collapsed at ${width}`);
190
+ assert.deepEqual(renderCard(empty, theme, width, { expanded: true }), ["", ""], `empty body at ${width}`);
191
+ assert.deepEqual(renderCard(paragraphs, theme, width, { expanded: false, previewRows: 3 }), [], `preview at ${width}`);
192
+ assert.deepEqual(renderCard(paragraphs, theme, width, { expanded: true, previewRows: 3 }), [], `expanded preview at ${width}`);
193
+ }
194
+ }
195
+ });
196
+
138
197
  test("renderCard never exceeds extremely narrow supplied widths", () => {
139
198
  for (const width of [0, 1, 2, 3, 4, 5, 8, 16]) {
140
199
  const lines = renderCard(card({ glyph: "\u{1F339}\uFE0E" }), ansiTheme, width, { expanded: true, hint: "ctrl+o to expand" });
141
200
  for (const line of lines) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
142
201
  }
143
202
  });
203
+
204
+ // Float style: the same rows become a borderless panel. Content cells keep a
205
+ // tone-matched background, the frame turns into a tone accent bar and spaces,
206
+ // and a one-column margin stays outside the panel on both sides.
207
+ const FG_CODE: Record<string, number> = { border: 240, accent: 211, success: 114, warning: 221, error: 203, dim: 245, muted: 244, text: 252 };
208
+ const BG_CODE: Record<string, number> = { toolSuccessBg: 22, toolPendingBg: 58, toolErrorBg: 52 };
209
+ const floatTheme = {
210
+ fg(color: string, text: string) {
211
+ return `\x1b[38;5;${FG_CODE[color] ?? 250}m${text}\x1b[39m`;
212
+ },
213
+ bg(color: string, text: string) {
214
+ return `\x1b[48;5;${BG_CODE[color] ?? 17}m${text}\x1b[49m`;
215
+ },
216
+ };
217
+
218
+ function withCardStyle<T>(style: CardStyle, run: () => T): T {
219
+ setCardStyle(style);
220
+ try {
221
+ return run();
222
+ } finally {
223
+ setCardStyle(CARD_STYLE.NEON);
224
+ }
225
+ }
226
+
227
+ /** Background code of every visible cell, following resets exactly as a terminal does. */
228
+ function cellBackgrounds(line: string): (number | undefined)[] {
229
+ const cells: (number | undefined)[] = [];
230
+ let bg: number | undefined;
231
+ for (const token of line.match(/\x1b\[[\d;]*m|[^\x1b]/gu) ?? []) {
232
+ if (!token.startsWith("\x1b")) {
233
+ for (let cell = 0; cell < visibleWidth(token); cell++) cells.push(bg);
234
+ continue;
235
+ }
236
+ const codes = token.slice(2, -1).split(";").map((code) => (code === "" ? 0 : Number(code)));
237
+ for (let index = 0; index < codes.length; index++) {
238
+ if (codes[index] === 0 || codes[index] === 49) bg = undefined;
239
+ if (codes[index] === 48 && codes[index + 1] === 5) bg = codes[index + 2];
240
+ }
241
+ }
242
+ return cells;
243
+ }
244
+
245
+ const floatCard = (overrides: Partial<Card> = {}) => card({ title: "read", subtitle: undefined, glyph: "⌖", body: ["alpha", "beta"], tone: CARD_TONE.SUCCESS, ...overrides });
246
+
247
+ test("card style defaults to the float style and lives in a process-wide slot", () => {
248
+ const slot = Symbol.for("gentle-pi.card-style");
249
+ const state = globalThis as typeof globalThis & { [slot]?: string };
250
+ // Own the slot for this test only, whatever earlier tests left behind.
251
+ const found = state[slot];
252
+ try {
253
+ delete state[slot];
254
+ assert.equal(cardStyle(), CARD_STYLE.FLOAT, "an unset slot reads as the float style");
255
+ withCardStyle(CARD_STYLE.FLOAT, () => assert.equal(state[slot], CARD_STYLE.FLOAT));
256
+ state[slot] = "neon";
257
+ assert.equal(cardStyle(), CARD_STYLE.NEON, "another loader's copy sees the same slot");
258
+ state[slot] = "bogus";
259
+ assert.equal(cardStyle(), CARD_STYLE.FLOAT, "unknown values read as the float style");
260
+ } finally {
261
+ if (found === undefined) delete state[slot];
262
+ else state[slot] = found;
263
+ }
264
+ });
265
+
266
+ test("float cards are borderless panels with a margin, an accent bar and balanced blank rows", () => {
267
+ const width = 40;
268
+ const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard(), floatTheme, width, { expanded: false, previewRows: 3, hint: "ctrl+o" }));
269
+ const plain = lines.map(stripAnsi);
270
+ assert.deepEqual(plain, [
271
+ ` ▎${" ".repeat(width - 3)} `,
272
+ ` ▎ ⌖ read${" ".repeat(width - 19)}ctrl+o `,
273
+ ` ▎${" ".repeat(width - 3)} `,
274
+ ` ▎ alpha${" ".repeat(width - 9)} `,
275
+ ` ▎ beta${" ".repeat(width - 8)} `,
276
+ ` ▎${" ".repeat(width - 3)} `,
277
+ ]);
278
+ for (const line of lines) assert.equal(visibleWidth(line), width);
279
+ assert.doesNotMatch(plain.join("\n"), /[╭╮╰╯│─]/);
280
+ // The glyph starts in the same column as the body text.
281
+ assert.equal(plain[1]!.indexOf("⌖"), plain[3]!.indexOf("alpha"));
282
+ for (const line of lines) {
283
+ const cells = cellBackgrounds(line);
284
+ assert.equal(cells[0], undefined, "left margin stays transparent");
285
+ assert.equal(cells[width - 1], undefined, "right margin stays transparent");
286
+ assert.deepEqual(cells.slice(1, -1), Array(width - 2).fill(BG_CODE.toolSuccessBg));
287
+ assert.ok(line.startsWith(` \x1b[48;5;${BG_CODE.toolSuccessBg}m\x1b[38;5;${FG_CODE.success}m▎`), "the bar carries the tone colour");
288
+ }
289
+ });
290
+
291
+ test("float panels pick the background from the tone", () => {
292
+ const expected = { [CARD_TONE.INFO]: BG_CODE.toolSuccessBg, [CARD_TONE.SUCCESS]: BG_CODE.toolSuccessBg, [CARD_TONE.WARNING]: BG_CODE.toolPendingBg, [CARD_TONE.ERROR]: BG_CODE.toolErrorBg };
293
+ for (const [tone, code] of Object.entries(expected)) {
294
+ const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard({ tone: tone as CardTone }), floatTheme, 30, { expanded: true, previewRows: 3 }));
295
+ for (const line of lines) assert.deepEqual(cellBackgrounds(line).slice(1, -1), Array(28).fill(code), `${tone}: ${JSON.stringify(line)}`);
296
+ for (const panel of [false, true]) {
297
+ const rows = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard({ tone: tone as CardTone }), floatTheme, 30, panel ? { expanded: true, panel: true } : { expanded: true, previewRows: 3 }));
298
+ for (const [index, row] of rows.entries()) {
299
+ assert.match(stripAnsi(row), /^ ▎/, `${tone}, panel=${panel}, row ${index}: continuous accent including padding`);
300
+ assert.ok(row.includes(`\x1b[38;5;${FG_CODE[tone === CARD_TONE.INFO ? "border" : tone]}m▎`), "every accent uses its tone foreground");
301
+ assert.deepEqual(cellBackgrounds(row).slice(1, -1), Array(28).fill(code), "every padding cell is fully painted");
302
+ }
303
+ }
304
+ }
305
+ });
306
+
307
+ test("float panels keep body text with box-drawing characters intact", () => {
308
+ const body = "│ keep ╭─╮ and ╰─╯ │";
309
+ const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard({ body: [body], title: "a ─ title │ ╮" }), floatTheme, 50, { expanded: true, previewRows: 3 })).map(stripAnsi);
310
+ assert.equal(lines[3], ` ▎ ${body}${" ".repeat(50 - 4 - visibleWidth(body))} `);
311
+ assert.ok(lines[1]!.startsWith(" ▎ ⌖ a ─ title │ ╮ "), JSON.stringify(lines[1]));
312
+ });
313
+
314
+ test("float panels re-arm their background after content and truncation resets", () => {
315
+ // A long heading is truncated, and pi-tui's truncation inserts its own reset.
316
+ const long = `red\x1b[0m blue\x1b[49m tail`;
317
+ const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard({ title: "x".repeat(80), body: [long] }), floatTheme, 30, { expanded: false, previewRows: 1 }));
318
+ assert.match(lines[1]!, /\x1b\[0m/, "the truncated heading carries a reset");
319
+ for (const line of lines) {
320
+ assert.equal(visibleWidth(line), 30);
321
+ assert.deepEqual(cellBackgrounds(line).slice(1, -1), Array(28).fill(BG_CODE.toolSuccessBg), JSON.stringify(line));
322
+ }
323
+ });
324
+
325
+ test("float cards without a body skip the separator and keep the blank rows around the heading", () => {
326
+ const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard({ body: [] }), floatTheme, 30, { expanded: true, previewRows: 3 })).map(stripAnsi);
327
+ assert.deepEqual(lines, [` ▎${" ".repeat(27)} `, ` ▎ ⌖ read${" ".repeat(20)} `, ` ▎${" ".repeat(27)} `]);
328
+ });
329
+
330
+ test("float falls back to the outlined card without a theme background or below ten columns", () => {
331
+ const plainOptions = { expanded: false, previewRows: 3, hint: "ctrl+o" };
332
+ for (const width of [0, 1, 5, 9, 40]) {
333
+ const outlined = renderCard(floatCard(), ansiTheme, width, plainOptions);
334
+ assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard(), ansiTheme, width, plainOptions)), outlined, `no bg at ${width}`);
335
+ }
336
+ const throwing = { ...floatTheme, bg(color: string, text: string): string { throw new Error(`unknown background ${color}${text}`); } };
337
+ assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard(), throwing, 40, plainOptions)), renderCard(floatCard(), throwing, 40, plainOptions), "a missing background token");
338
+ for (const width of [0, 1, 5, 9]) {
339
+ assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard(), floatTheme, width, plainOptions)), renderCard(floatCard(), floatTheme, width, plainOptions), `narrow ${width}`);
340
+ }
341
+ assert.match(stripAnsi(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard(), floatTheme, 10, plainOptions))[1]!), /^ ▎ ⌖ r {4}$/);
342
+ });
343
+
344
+ test("the neon style paints no background even when the theme can", () => {
345
+ for (const width of [8, 40]) {
346
+ for (const line of renderCard(floatCard(), floatTheme, width, { expanded: true, previewRows: 3 })) {
347
+ assert.ok(cellBackgrounds(line).every((cell) => cell === undefined), JSON.stringify(line));
348
+ }
349
+ }
350
+ });
351
+
352
+ // Panels (Agents, Todos, Status rail) opt in with `panel`. Neon keeps the
353
+ // outlined frame; float paints them like float cards, centered between two
354
+ // accent-barred padding rows with a blank row between header and body: two
355
+ // rows taller when a body exists, with the header on row 1.
356
+ const panelCard = (overrides: Partial<Card> = {}) => card({ title: "Agents", subtitle: "2 running", glyph: "◐", body: ["alpha", "beta"], ...overrides });
357
+
358
+ test("legacy renderCard without the panel opt-in stays byte-identical in both styles", () => {
359
+ const paragraphs = card({ subtitle: "2 running", body: ["ab", "", "c d", "│ ╭─╮"] });
360
+ for (const width of [-3, 0, 1, 4, 9, 10, 40, 80]) {
361
+ for (const options of [{ expanded: true }, { expanded: false }, { expanded: true, hint: "ctrl+o" }]) {
362
+ for (const theme of [plainTheme, taggedTheme, floatTheme]) {
363
+ const outlined = withCardStyle(CARD_STYLE.NEON, () => renderCard(paragraphs, theme, width, options));
364
+ assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(paragraphs, theme, width, options)), outlined, `width ${width}`);
365
+ }
366
+ }
367
+ }
368
+ });
369
+
370
+ test("a panel in the neon style is byte-identical to the legacy outlined card", () => {
371
+ const paragraphs = card({ subtitle: "2 running", body: ["ab", "", "c d", "│ ╭─╮"] });
372
+ for (const width of [-3, 0, 1, 4, 9, 10, 40, 80]) {
373
+ for (const options of [{ expanded: true }, { expanded: false }, { expanded: true, hint: "ctrl+o" }]) {
374
+ for (const theme of [plainTheme, taggedTheme, floatTheme]) {
375
+ const outlined = withCardStyle(CARD_STYLE.NEON, () => renderCard(paragraphs, theme, width, options));
376
+ assert.deepEqual(withCardStyle(CARD_STYLE.NEON, () => renderCard(paragraphs, theme, width, { ...options, panel: true })), outlined, `width ${width}`);
377
+ }
378
+ }
379
+ }
380
+ });
381
+
382
+ test("a panel in the float style is a float card centered between padding rows, with a blank row after the header, two rows taller than neon", () => {
383
+ const options = { expanded: true, hint: "ctrl+a", panel: true };
384
+ const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard(), floatTheme, 40, options));
385
+ assert.deepEqual(lines.map(stripAnsi), [
386
+ ` ▎${" ".repeat(37)} `,
387
+ ` ▎ ◐ Agents 2 running${" ".repeat(9)}ctrl+a `,
388
+ ` ▎${" ".repeat(37)} `,
389
+ ` ▎ alpha${" ".repeat(32)}`,
390
+ ` ▎ beta${" ".repeat(33)}`,
391
+ ` ▎${" ".repeat(37)} `,
392
+ ]);
393
+ assert.equal(lines.length, withCardStyle(CARD_STYLE.NEON, () => renderCard(panelCard(), floatTheme, 40, options)).length + 2);
394
+ for (const line of lines) {
395
+ const cells = cellBackgrounds(line);
396
+ assert.equal(cells.length, 40);
397
+ assert.equal(cells[0], undefined, "the left margin stays unpainted");
398
+ assert.equal(cells[39], undefined, "the right margin stays unpainted");
399
+ assert.ok(cells.slice(1, 39).every((cell) => cell === BG_CODE.toolSuccessBg), "the neutral tone paints the float card background");
400
+ }
401
+ assert.ok(lines.every((line) => line.includes(`\x1b[38;5;${FG_CODE.border}m▎`)), "the accent bar uses the frame role on every row, padding and separator rows included");
402
+ assert.ok(lines[1]!.includes(`\x1b[38;5;${FG_CODE.muted}m2 running`), "the count is muted");
403
+ assert.ok(lines[1]!.includes(`\x1b[38;5;${FG_CODE.muted}mctrl+a`), "the hint is muted");
404
+
405
+ const warning = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard({ tone: CARD_TONE.WARNING }), floatTheme, 40, options));
406
+ assert.ok(warning.every((line) => cellBackgrounds(line).slice(1, 39).every((cell) => cell === BG_CODE.toolPendingBg)), "a warning panel paints the warning card background");
407
+ assert.ok(warning[0]!.includes(`\x1b[38;5;${FG_CODE.warning}m▎`));
408
+ });
409
+
410
+ test("a float panel keeps embedded styled title segments, re-arms its background, drops a hint that does not fit, and is two rows taller than neon", () => {
411
+ const control = "\x1b[38;5;211m▾ Collapse\x1b[0m";
412
+ const [, header] = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard({ title: `Todos ${control}`, subtitle: "1 of 3", glyph: "☰" }), floatTheme, 40, { expanded: true, panel: true }));
413
+ assert.match(stripAnsi(header!), /^ ▎ ☰ Todos ▾ Collapse 1 of 3 +$/, "the title keeps its case");
414
+ assert.ok(cellBackgrounds(header!).slice(1, 39).every((cell) => cell === BG_CODE.toolSuccessBg), "a reset inside the title re-arms the background");
415
+ assert.equal(stripAnsi(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard(), floatTheme, 26, { expanded: true, hint: "ctrl+a expand", panel: true }))[1]!), ` ▎ ◐ Agents 2 running${" ".repeat(3)} `, "the hint drops when it does not fit");
416
+ for (let width = 10; width <= 80; width++) {
417
+ // Chrome adds two rows. Content wider than the float body (two columns
418
+ // narrower than neon) still wraps, so this body fits at every width.
419
+ const short = panelCard({ body: ["ab", "", "cd"] });
420
+ for (const expanded of [true, false]) {
421
+ const options = { expanded, hint: "ctrl+o", panel: true };
422
+ const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(short, floatTheme, width, options));
423
+ assert.equal(lines.length, withCardStyle(CARD_STYLE.NEON, () => renderCard(short, floatTheme, width, options)).length + 2, `rows at ${width}`);
424
+ const headerRow = withCardStyle(CARD_STYLE.FLOAT, () => panelHeaderRow(floatTheme, width));
425
+ assert.equal(headerRow, 1);
426
+ assert.match(stripAnsi(lines[headerRow]!), /^ ▎ ◐/, `header on row ${headerRow} at ${width}`);
427
+ assert.equal(stripAnsi(lines[headerRow + 1]!), ` ▎${" ".repeat(width - 3)} `, `a blank separator row follows the header at ${width}`);
428
+ for (const line of lines) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
429
+ }
430
+ const long = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard({ subtitle: "3 active · 2 done · 1 failed", body: ["a".repeat(120)] }), floatTheme, width, { expanded: true, hint: "ctrl+o", panel: true }));
431
+ for (const line of long) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
432
+ }
433
+ });
434
+
435
+ test("a float panel without a body skips the separator row: padding, header, padding", () => {
436
+ const options = { expanded: true, hint: "ctrl+a", panel: true };
437
+ const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard({ body: [] }), floatTheme, 40, options)).map(stripAnsi);
438
+ assert.deepEqual(lines, [
439
+ ` ▎${" ".repeat(37)} `,
440
+ ` ▎ ◐ Agents 2 running${" ".repeat(9)}ctrl+a `,
441
+ ` ▎${" ".repeat(37)} `,
442
+ ]);
443
+ assert.equal(lines.length, withCardStyle(CARD_STYLE.NEON, () => renderCard(panelCard({ body: [] }), floatTheme, 40, options)).length + 1);
444
+ });
445
+
446
+ test("panelExtraRows is 2 only where the float panel applies, so a capped panel body can stay as tall as neon", () => {
447
+ for (const width of [10, 40, 80]) {
448
+ assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelExtraRows(floatTheme, width)), 2, `float at ${width}`);
449
+ assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelExtraRows(floatTheme, width, CARD_TONE.ERROR)), 2, `error tone at ${width}`);
450
+ assert.equal(withCardStyle(CARD_STYLE.NEON, () => panelExtraRows(floatTheme, width)), 0, `neon at ${width}`);
451
+ assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelExtraRows(taggedTheme, width)), 0, `no bg at ${width}`);
452
+ }
453
+ for (const width of [-3, 0, 9]) assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelExtraRows(floatTheme, width)), 0, `narrow ${width}`);
454
+ });
455
+
456
+ test("panelHeaderRow is 1 only where the float panel applies, so callers can hit-test the header", () => {
457
+ for (const width of [10, 40, 80]) {
458
+ assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelHeaderRow(floatTheme, width)), 1, `float at ${width}`);
459
+ assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelHeaderRow(floatTheme, width, CARD_TONE.ERROR)), 1, `error tone at ${width}`);
460
+ assert.equal(withCardStyle(CARD_STYLE.NEON, () => panelHeaderRow(floatTheme, width)), 0, `neon at ${width}`);
461
+ assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelHeaderRow(taggedTheme, width)), 0, `no bg at ${width}`);
462
+ }
463
+ for (const width of [-3, 0, 9]) assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelHeaderRow(floatTheme, width)), 0, `narrow ${width}`);
464
+ });
465
+
466
+ test("a float panel falls back to the outlined card below ten columns or without a theme background", () => {
467
+ const options = { expanded: true, hint: "ctrl+a", panel: true };
468
+ for (const width of [-3, 0, 1, 4, 9]) {
469
+ for (const theme of [taggedTheme, floatTheme]) {
470
+ const outlined = withCardStyle(CARD_STYLE.NEON, () => renderCard(panelCard(), theme, width, options));
471
+ assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard(), theme, width, options)), outlined, `width ${width}`);
472
+ }
473
+ }
474
+ for (const width of [10, 40, 80]) {
475
+ const outlined = withCardStyle(CARD_STYLE.NEON, () => renderCard(panelCard(), taggedTheme, width, options));
476
+ assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard(), taggedTheme, width, options)), outlined, `no bg at ${width}`);
477
+ }
478
+ });
479
+
480
+ test("a call card awaits its result until pi finishes the row or a result renderer marks it", () => {
481
+ const state = {};
482
+ assert.equal(cardAwaitingResult({ isPartial: true, state }), true);
483
+ assert.equal(cardAwaitingResult({ state }), true, "an unknown lifecycle is still pending");
484
+ assert.equal(cardAwaitingResult({ isPartial: true }), true, "no shared state: only the final flag closes it");
485
+ assert.equal(cardAwaitingResult({ isPartial: false, state }), false, "a final result always exists");
486
+ markCardResult(state);
487
+ assert.equal(cardAwaitingResult({ isPartial: true, state }), false, "a partial result component closes the frame");
488
+ assert.deepEqual(Object.keys(state), [], "the mark never shows up as ordinary row state");
489
+ for (const value of [undefined, null, 1, "state"]) {
490
+ assert.doesNotThrow(() => markCardResult(value));
491
+ assert.equal(cardAwaitingResult({ isPartial: true, state: value }), true);
492
+ }
493
+ assert.equal(cardRunningLine(CARD_TONE.WARNING, plainTheme, 20), `│ running…${" ".repeat(8)} │`);
494
+ for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8]) assert.ok(visibleWidth(cardRunningLine(CARD_TONE.WARNING, plainTheme, width)) <= width);
495
+ });
@@ -470,6 +470,30 @@ test("colorDiff drops git headers and colors hunks, additions, and removals by r
470
470
  ]);
471
471
  });
472
472
 
473
+ test("colorDiff sanitizes control characters, strips carriage returns, and expands tabs", () => {
474
+ const raw = [
475
+ "diff --git a/file.ts b/file.ts\r",
476
+ "index 1..2 100644\r",
477
+ "--- a/file.ts\r",
478
+ "+++ b/file.ts\r",
479
+ "@@ -1,3 +1,3 @@\r",
480
+ " \tconst before = 1;\r",
481
+ "-\tconst removed = 2;\x1b[31minjected\x1b[0m\r",
482
+ "+\t\tconst added = 3;\x07\r",
483
+ ].join("\n");
484
+ const lines = colorDiff(raw, taggedTheme);
485
+ assert.deepEqual(lines, [
486
+ "<customMessageLabel>@@ -1,3 +1,3 @@</customMessageLabel>",
487
+ "<toolDiffContext> const before = 1;</toolDiffContext>",
488
+ "<toolDiffRemoved>- const removed = 2;injected</toolDiffRemoved>",
489
+ "<toolDiffAdded>+ const added = 3;</toolDiffAdded>",
490
+ ]);
491
+ for (const line of lines) {
492
+ assert.ok(!line.includes("\t"), "tabs must be expanded to spaces");
493
+ assert.ok(!line.includes("\r"), "carriage returns must be stripped");
494
+ }
495
+ });
496
+
473
497
  test("ChangesView renders a framed two-pane layout at the requested size", async () => {
474
498
  const { view: component } = view();
475
499
  await settle();
@@ -484,6 +508,34 @@ test("ChangesView renders a framed two-pane layout at the requested size", async
484
508
  assert.match(plain[10], /j\/k file .* o open in editor .* esc close/);
485
509
  });
486
510
 
511
+ test("ChangesView renders tabbed CRLF modified diffs with aligned frame columns", async () => {
512
+ const tabbedDiff = [
513
+ "diff --git a/lib/a.ts b/lib/a.ts\r",
514
+ "index 1..2 100644\r",
515
+ "--- a/lib/a.ts\r",
516
+ "+++ b/lib/a.ts\r",
517
+ "@@ -1,2 +1,3 @@\r",
518
+ " \tconst a = 1;\r",
519
+ "-\tconst b = 2;\r",
520
+ "+\tconst b = 3;\r",
521
+ "+\t\tconst c = 4;\r",
522
+ ].join("\n");
523
+ const { view: component } = view({
524
+ loadDiff: async () => tabbedDiff,
525
+ });
526
+ await settle();
527
+ const lines = component.render(80);
528
+ assert.equal(lines.length, 12);
529
+ for (const line of lines) {
530
+ assert.equal(visibleWidth(line), 80, `"${stripAnsi(line)}" is not 80 wide`);
531
+ assert.ok(!line.includes("\r"), "carriage return must not leak into rendered line");
532
+ assert.ok(!line.includes("\t"), "raw tab must not leak into rendered line");
533
+ }
534
+ const plain = lines.map(stripAnsi);
535
+ assert.match(plain[1], /^│ ▸ M lib\/a\.ts +\+2 -1 +│ @@ -1,2 \+1,3 @@ +│$/);
536
+ assert.match(plain[2], /^│ A lib\/b\.ts +\+10 -0 +│ +const a = 1; +│$/);
537
+ });
538
+
487
539
  test("ChangesView loads the selected diff lazily and moves with j/k and arrows", async () => {
488
540
  const { view: component, calls, events } = view();
489
541
  await settle();
@@ -52,6 +52,7 @@ const CURSOR = "\x1b[7m \x1b[0m";
52
52
 
53
53
  function options(overrides: Partial<PromptFrameOptions> = {}): PromptFrameOptions {
54
54
  return {
55
+ style: "neon",
55
56
  state: PROMPT_STATE.IDLE,
56
57
  tick: 0,
57
58
  borderColor: (text) => text,
@@ -87,6 +88,40 @@ test("framePromptLines paints the frame with the editor border color and the pet
87
88
  assert.match(lines[2], /^\[b\]╰─+╯\[\/b\]$/);
88
89
  });
89
90
 
91
+ test("framePromptLines renders an explicit ODD phase workingLabel instead of the generic working label", () => {
92
+ const plain = (_color: string, text: string) => text;
93
+ const phased = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 3, fg: plain, workingLabel: "exploring…" }));
94
+ assert.match(stripAnsi(phased[0]), /^╭─ ✾ exploring… ─+╮$/);
95
+ assert.equal(visibleWidth(phased[0]), 40);
96
+ });
97
+
98
+ test("framePromptLines falls back to the generic working label when no phase was reported", () => {
99
+ const plain = (_color: string, text: string) => text;
100
+ const fallback = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 3, fg: plain, workingLabel: undefined }));
101
+ assert.match(stripAnsi(fallback[0]), /^╭─ ✾ working… ─+╮$/);
102
+ });
103
+
104
+ test("framePromptLines ignores workingLabel outside the working state", () => {
105
+ const plain = (_color: string, text: string) => text;
106
+ const idle = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.IDLE, fg: plain, workingLabel: "exploring…" }));
107
+ assert.equal(stripAnsi(idle[0]).includes("exploring"), false);
108
+ const queued = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.QUEUED, fg: plain, workingLabel: "exploring…" }));
109
+ assert.match(stripAnsi(queued[0]), /^╭─ ✿ queued ─+╮$/);
110
+ });
111
+
112
+ test("framePromptLines stays width-safe at narrow widths with the longest ODD phase label", () => {
113
+ const plain = (_color: string, text: string) => text;
114
+ // "implementing…" is the longest ODD_PHASES label (lib/odd-phase.ts).
115
+ const longestLabel = "implementing…";
116
+ for (const width of [1, 4, 8, 10, 14, 20, 40]) {
117
+ const lines = framePromptLines(editorLines(Math.max(4, width)), width, options({ state: PROMPT_STATE.WORKING, tick: 3, fg: plain, workingLabel: longestLabel }));
118
+ for (const line of lines) assert.ok(visibleWidth(line) <= width, `width ${width}: "${line}" exceeds the frame width`);
119
+ }
120
+ // At a comfortable width the full label still renders, unclipped.
121
+ const comfortable = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 3, fg: plain, workingLabel: longestLabel }));
122
+ assert.match(stripAnsi(comfortable[0]), /implementing…/);
123
+ });
124
+
90
125
  test("petalTone rests bright, walks the rose ramp while working, and turns to warning when queued", () => {
91
126
  assert.equal(petalTone(PROMPT_STATE.IDLE, 2), "borderAccent");
92
127
  assert.deepEqual([0, 1, 2, 3, 4].map((tick) => petalTone(PROMPT_STATE.WORKING, tick)), ["mdQuoteBorder", "thinkingHigh", "accent", "borderAccent", "accent"]);
@@ -163,7 +198,7 @@ test("withPromptHint leaves the line alone when the hint does not fit", () => {
163
198
  assert.equal(hinted, line);
164
199
  });
165
200
 
166
- test("working prompt stays transparent at narrow and normal widths", () => {
201
+ test("neon/fallback working prompt stays transparent at narrow and normal widths", () => {
167
202
  for (const width of [0, 1, 2, 3, 8, 40]) {
168
203
  const lines = framePromptLines(["──", ` ${CURSOR}界`, "──"], width, options({
169
204
  state: PROMPT_STATE.WORKING, fg: (_c, t) => t,
@@ -187,8 +222,65 @@ test("working prompt stays transparent at narrow and normal widths", () => {
187
222
  }
188
223
  });
189
224
 
190
- test("transparent prompt preserves pi's cursor reset", () => {
225
+ test("neon/fallback transparent prompt preserves pi's cursor reset", () => {
191
226
  const lines = framePromptLines(editorLines(40), 40, options({ fg: (_c, t) => t }));
192
227
  assert.ok(lines[1].includes(CURSOR));
193
228
  assert.doesNotMatch(lines.join("\n"), /\x1b\[44m/);
194
229
  });
230
+
231
+ const floatBg = (_role: string, text: string) => `\x1b[48;2;20;30;40m${text}\x1b[49m`;
232
+
233
+ test("T2 float prompt keeps status and content inside the background with inset and bottom padding", () => {
234
+ const rows = framePromptLines(["── ↑ 2 more", `ab${CURSOR}cd`, "── ↓ 3 more"], 80, options({
235
+ style: "float", bg: floatBg, fg: (_c, t) => t, state: PROMPT_STATE.WORKING, workingLabel: "exploring…", escHint: "esc again to cancel",
236
+ }));
237
+ assert.equal(rows.length, 3);
238
+ assert.match(stripAnsi(rows[0]), /^ ▎ ✿ exploring… · ↑ 2 more · esc again to cancel/);
239
+ assert.match(stripAnsi(rows[1]), /^ ▎ ab cd + $/);
240
+ assert.ok(rows[1].includes(`${CURSOR}\x1b[48;2;20;30;40m`));
241
+ assert.equal(stripAnsi(rows[2]), ` ▎${" ".repeat(77)} `);
242
+ assert.ok(rows.every((row) => row.startsWith(" \x1b[48;2;20;30;40m") && row.endsWith("\x1b[49m ")));
243
+ assert.ok(rows.every((row) => visibleWidth(row) === 80));
244
+ });
245
+
246
+ test("T2 float prompt falls back byte-exactly before narrow or unusable backgrounds", () => {
247
+ for (const width of [0, 1, 2, 9, 10, 30, 80]) {
248
+ const native = ["──", `x${CURSOR}`, "──"];
249
+ const plain = options({ fg: (_c, t) => t });
250
+ const neon = framePromptLines(native, width, { ...plain, style: "neon", bg: floatBg });
251
+ assert.deepEqual(neon, framePromptLines(native, width, plain));
252
+ for (const bg of [undefined, (_r: string, t: string) => t,
253
+ (_r: string, t: string) => `\x1b[39m${t}\x1b[49m`,
254
+ (_r: string, t: string) => `\x1b[44m\x1b[49m${t}\x1b[49m`,
255
+ (_r: string, _t: string): string => { throw new Error("missing"); }]) {
256
+ assert.deepEqual(framePromptLines(native, width, { ...plain, style: "float", bg }), neon);
257
+ }
258
+ const float = framePromptLines(native, width, { ...plain, style: "float", bg: floatBg });
259
+ if (width < 10) assert.deepEqual(float, neon);
260
+ else assert.match(stripAnsi(float[1]), /^ ▎/);
261
+ assert.ok(float.every((row) => visibleWidth(row) <= width));
262
+ }
263
+ });
264
+
265
+ test("T2 float prompt idle label uses the quiet card background without changing other states or neon", () => {
266
+ const bg = (role: string, text: string) => {
267
+ assert.equal(role, "toolSuccessBg");
268
+ return floatBg(role, text);
269
+ };
270
+ for (const state of [PROMPT_STATE.IDLE, PROMPT_STATE.WORKING, PROMPT_STATE.QUEUED]) {
271
+ const rows = framePromptLines(["──", "draft", "──"], 80, options({ style: "float", bg, state, fg: (_c, t) => t }));
272
+ assert.match(stripAnsi(rows[0]), state === PROMPT_STATE.IDLE ? /✿ waiting for input/ : state === PROMPT_STATE.WORKING ? /working…/ : /queued/);
273
+ if (state !== PROMPT_STATE.IDLE) assert.doesNotMatch(stripAnsi(rows[0]), /waiting for input/);
274
+ }
275
+ const neon = framePromptLines(["──", "draft", "──"], 80, options({ style: "neon", state: PROMPT_STATE.IDLE, fg: (_c, t) => t }));
276
+ assert.doesNotMatch(stripAnsi(neon[0]), /waiting for input/);
277
+ });
278
+
279
+ test("T2 float prompt rearms real SGR resets without altering inversion or marker bytes", () => {
280
+ for (const open of ["\x1b[44m", "\x1b[48;5;24m", "\x1b[48;2;0;30;0m"]) {
281
+ const marker = "\x1b_pi:c\x07";
282
+ const native = `${marker}\x1b[7mx\x1b[0mY\x1b[0;7mZ\x1b[27m`;
283
+ const rows = framePromptLines(["──", native, "──"], 30, options({ style: "float", bg: (_r, t) => `${open}${t}\x1b[49m`, fg: (_c, t) => t }));
284
+ assert.ok(rows[1].includes(`${marker}\x1b[7mx\x1b[0m${open}Y\x1b[0;7m${open}Z\x1b[27m`));
285
+ }
286
+ });