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
@@ -0,0 +1,491 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ createCodemodeExtension,
5
+ initTheme,
6
+ keyHint,
7
+ type AgentToolResult,
8
+ type ExtensionAPI,
9
+ type ExtensionFactory,
10
+ type ToolDefinition,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { getCapabilities, imageFallback, setCapabilities, visibleWidth } from "@earendil-works/pi-tui";
13
+ import quietTools from "../extensions/quiet-tools.ts";
14
+ import { decorateCodemodeTool, registerCompactCodemode } from "../lib/codemode-renderer.ts";
15
+ import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
16
+ import { stripAnsi } from "../lib/terminal-theme.ts";
17
+
18
+ type ToolRenderContext = Parameters<NonNullable<ToolDefinition["renderCall"]>>[2];
19
+
20
+ initTheme("dark");
21
+ const theme = { bold: (text: string) => text, fg: (_color: string, text: string) => text };
22
+
23
+ function registry() {
24
+ const tools: ToolDefinition[] = [];
25
+ const hooks: unknown[][] = [];
26
+ const pi = {
27
+ registerTool(tool: ToolDefinition) { tools.push(tool); },
28
+ on(...args: unknown[]) { hooks.push(args); },
29
+ getSettings() { return { codemode: { mode: "on" } }; },
30
+ getAllTools() { return []; },
31
+ appendEntry() {},
32
+ } as unknown as ExtensionAPI;
33
+ return { tools, hooks, pi };
34
+ }
35
+
36
+ function registeredCodemode() {
37
+ const { tools, pi } = registry();
38
+ quietTools(pi);
39
+ const tool = tools.find((tool) => tool.name === "codemode");
40
+ assert.ok(tool, "quiet-tools must decorate the public codemode registration");
41
+ return tool;
42
+ }
43
+
44
+ function context(overrides: Partial<ToolRenderContext> = {}): ToolRenderContext {
45
+ return {
46
+ args: { code: "await tools.read({path: '/private/argument'});" },
47
+ toolCallId: "code-1", invalidate() {}, lastComponent: undefined, state: {}, cwd: "/fixture",
48
+ executionStarted: true, argsComplete: true, isPartial: false, expanded: false, showImages: false,
49
+ isError: false, ...overrides,
50
+ };
51
+ }
52
+
53
+ function result(calls: unknown = [], text = "Script completed\nWall time 0.1 seconds\nOutput:\n"): AgentToolResult<unknown> {
54
+ return { content: [{ type: "text", text }], details: { calls } };
55
+ }
56
+
57
+ function render(tool: ToolDefinition, value: AgentToolResult<unknown>, ctx = context(), width = 100) {
58
+ const call = tool.renderCall!(ctx.args, theme as never, ctx).render(width);
59
+ const body = tool.renderResult!(value, { expanded: ctx.expanded, isPartial: ctx.isPartial }, theme as never, ctx).render(width);
60
+ return [...call, ...body].map(stripAnsi).join("\n");
61
+ }
62
+
63
+ test("quiet tools registers an inactive upstream codemode with compact renderers", () => {
64
+ const tool = registeredCodemode();
65
+ assert.equal(tool.defaultActive, false);
66
+ assert.equal(tool.renderShell, "self");
67
+ assert.equal(tool.name, "codemode");
68
+ });
69
+
70
+ test("public factory runs once and every non-presentation field retains its exact reference", async () => {
71
+ const { pi, tools, hooks } = registry();
72
+ let original: ToolDefinition | undefined;
73
+ let factoryRuns = 0;
74
+ const factory: ExtensionFactory = (api) => {
75
+ factoryRuns++;
76
+ api.on("session_start", () => {});
77
+ return createCodemodeExtension()(new Proxy(api, {
78
+ get(target, property, receiver) {
79
+ if (property === "registerTool") return (tool: ToolDefinition) => { original = tool; api.registerTool(tool); };
80
+ return Reflect.get(target, property, receiver);
81
+ },
82
+ }));
83
+ };
84
+ await registerCompactCodemode(pi, factory);
85
+ assert.equal(factoryRuns, 1);
86
+ assert.equal(tools.length, 1);
87
+ assert.equal(hooks.length, 1);
88
+ assert.ok(original);
89
+ for (const key of Object.keys(original)) {
90
+ if (["renderCall", "renderResult", "renderShell"].includes(key)) continue;
91
+ assert.equal(Reflect.get(tools[0]!, key), Reflect.get(original, key), `${key} must remain upstream-owned`);
92
+ }
93
+ assert.equal(tools[0]!.execute, original.execute);
94
+ assert.equal(tools[0]!.parameters, original.parameters);
95
+ assert.equal(tools[0]!.prepareLoadout, original.prepareLoadout);
96
+ assert.equal(tools[0]!.exposure, original.exposure);
97
+ });
98
+
99
+ test("public loadout modes preserve hidden and deferred exposure policy", async () => {
100
+ for (const mode of ["on", "only"] as const) {
101
+ const { pi, tools } = registry();
102
+ await registerCompactCodemode(pi, createCodemodeExtension({ mode }));
103
+ const tool = tools[0]!;
104
+ const direct = { ...tool, name: "direct_fixture", description: "direct" };
105
+ const deferred = { ...tool, name: "deferred_fixture", description: "deferred" };
106
+ const hidden = { ...tool, name: "hidden_fixture", description: "hidden" };
107
+ const loadout = {
108
+ declared: [tool, direct], callable: [direct, deferred], registered: [tool, direct, deferred, hidden],
109
+ getExposure: (name: string) => name === "deferred_fixture" ? "deferred" : name === "hidden_fixture" ? "hidden" : "direct",
110
+ getNamespace: () => undefined,
111
+ } as unknown as Parameters<NonNullable<ToolDefinition["prepareLoadout"]>>[0];
112
+ const changes = tool.prepareLoadout!(loadout)!;
113
+ assert.deepEqual(changes.hiddenDeclarations, mode === "only" ? ["direct_fixture"] : []);
114
+ assert.doesNotMatch(changes.descriptions!.codemode!, /hidden_fixture/);
115
+ assert.doesNotMatch(changes.descriptions!.codemode!, /### `deferred_fixture`/);
116
+ assert.equal(tool.defaultActive, false);
117
+ assert.equal(tool.exposure, "model-only");
118
+ }
119
+ });
120
+
121
+ test("factory return and lifecycle API registrations pass through unchanged", async () => {
122
+ const { pi, hooks } = registry();
123
+ let completed = false;
124
+ const factory: ExtensionFactory = async (api) => {
125
+ api.on("session_shutdown", () => {});
126
+ await Promise.resolve();
127
+ completed = true;
128
+ };
129
+ const promise = registerCompactCodemode(pi, factory);
130
+ assert.ok(promise instanceof Promise);
131
+ await promise;
132
+ assert.equal(completed, true);
133
+ assert.equal(hooks[0]![0], "session_shutdown");
134
+ });
135
+
136
+ test("decoration preserves execute arguments, updates, return identity and thrown errors", async () => {
137
+ const upstream = registeredCodemode();
138
+ const args = { code: "not executed by this fixture" };
139
+ const signal = new AbortController().signal;
140
+ const ctx = {} as Parameters<ToolDefinition["execute"]>[4];
141
+ const value = result();
142
+ const update = () => {};
143
+ let received: unknown[] = [];
144
+ const original = { ...upstream, execute: async (...values: Parameters<ToolDefinition["execute"]>) => { received = values; return value; } };
145
+ const decorated = decorateCodemodeTool(original);
146
+ assert.equal(decorated.execute, original.execute);
147
+ assert.equal(await decorated.execute("call", args, signal, update, ctx), value);
148
+ assert.deepEqual(received, ["call", args, signal, update, ctx]);
149
+ const failure = new Error("original rejection");
150
+ const rejecting = { ...upstream, execute: async () => { throw failure; } };
151
+ await assert.rejects(decorateCodemodeTool(rejecting).execute("call", args, signal, update, ctx), (error) => error === failure);
152
+ });
153
+
154
+ test("actual upstream execution publishes observed calls and keeps failed-script partial output", async () => {
155
+ const tool = registeredCodemode();
156
+ const updates: AgentToolResult<unknown>[] = [];
157
+ const child = { ...tool, name: "observed", description: "fixture", execute: async () => result() };
158
+ const ctx = {
159
+ tools: [child], sessionManager: { getBranch: () => [] }, modelRegistry: {},
160
+ executeTool: async (name: string, args: unknown) => {
161
+ assert.equal(name, "observed");
162
+ assert.deepEqual(args, { code: "private argument" });
163
+ return { toolCall: { id: "live/1" }, isError: true, result: { content: [{ type: "text", text: "child failure" }], details: undefined } };
164
+ },
165
+ } as unknown as Parameters<ToolDefinition["execute"]>[4];
166
+ const args = { code: "text('partial output'); await tools.observed({code: 'private argument'});" };
167
+ const value = await tool.execute("live", args, undefined, (update) => updates.push(update), ctx);
168
+ const details = value.details as { calls: Array<{ name: string; status: string; durationMs: number }> };
169
+ assert.equal(details.calls[0]!.status, "error");
170
+ assert.equal(details.calls[0]!.name, "observed");
171
+ assert.ok(details.calls[0]!.durationMs >= 0);
172
+ assert.ok(updates.some((update) => (update.details as typeof details).calls[0]?.status === "running"));
173
+ assert.equal(Reflect.get(value, "isError"), true);
174
+ const expanded = render(tool, value, context({ args, expanded: true, isError: true }));
175
+ assert.match(expanded, /partial output/);
176
+ assert.match(expanded, /child failure/);
177
+ assert.match(expanded, /Script failed/);
178
+ assert.match(expanded, /Wall time [\d.]+ seconds/);
179
+ });
180
+
181
+ test("Code retains its distinct lambda icon in the top rule at every host outcome", () => {
182
+ const tool = registeredCodemode();
183
+ for (const overrides of [{}, { isPartial: true }, { isError: true }, { expanded: true }]) {
184
+ const ctx = context(overrides);
185
+ const rows = tool.renderCall!(ctx.args, theme as never, ctx).render(100).map(stripAnsi);
186
+ assert.match(rows[0], /^╭─ λ Code /);
187
+ assert.match(rows[0], /to (?:expand|collapse) ╮$/);
188
+ for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8]) {
189
+ assert.ok(tool.renderCall!(ctx.args, theme as never, ctx).render(width).every((row) => visibleWidth(row) <= width));
190
+ }
191
+ }
192
+ });
193
+
194
+ test("collapsed card shows flat observed order, repeats, durations and useful output without arguments", () => {
195
+ const calls = [
196
+ { name: "read", status: "ok", durationMs: 0, args: "private child argument" },
197
+ { name: "bash", status: "running", durationMs: 1234 },
198
+ { name: "read", status: "cancelled", durationMs: 5, error: "/private/error payload" },
199
+ ];
200
+ const text = render(registeredCodemode(), result(calls, "private output"));
201
+ assert.match(text, /Code/);
202
+ assert.match(text, /ok · read · 0ms[\s\S]*running · bash · 1.2s[\s\S]*cancelled · read · 5ms/);
203
+ assert.match(text, /private output/);
204
+ assert.match(text, /\/private\/error payload/);
205
+ assert.doesNotMatch(text, /private child argument|await|queued|done|finished/);
206
+ assert.equal(text.split("╭").length - 1, 1);
207
+ assert.equal(text.split("╰").length - 1, 1);
208
+ });
209
+
210
+ test("partial metadata never claims overall success and final flags expose script failure", () => {
211
+ const tool = registeredCodemode();
212
+ const partial = render(tool, result([{ name: "read", status: "ok", durationMs: 1 }], "partial output"), context({ isPartial: true }));
213
+ assert.match(partial, /Code/);
214
+ assert.doesNotMatch(partial, /finished|completed|success/);
215
+ const failed = render(tool, result([{ name: "read", status: "ok" }]), context({ isError: true }));
216
+ assert.match(failed, /Code/);
217
+ assert.doesNotMatch(failed, /Code · failed/);
218
+ assert.match(failed, /Script failed/);
219
+ const completedWithChildFailure = render(tool, result([{ name: "read", status: "error", error: "caught" }]));
220
+ assert.match(completedWithChildFailure, /error · read/);
221
+ assert.doesNotMatch(completedWithChildFailure, /Code · success/);
222
+ });
223
+
224
+ test("expanded call and replay use per-call public args, not last-call mutable state", () => {
225
+ const tool = registeredCodemode();
226
+ const first = context({ args: { code: "return 'first JS';" }, expanded: true, state: {} });
227
+ const second = context({ args: { code: "throw Error('second JS');" }, expanded: true, state: {}, isError: true });
228
+ tool.renderCall!(first.args, theme as never, first);
229
+ const secondText = render(tool, result([], "second output"), second);
230
+ const firstText = render(tool, result([], "first output\nWall time 0.3 seconds"), first);
231
+ assert.match(firstText, /first JS/);
232
+ assert.match(firstText, /first output/);
233
+ assert.doesNotMatch(firstText, /second JS|second output/);
234
+ assert.match(secondText, /second JS/);
235
+ assert.match(secondText, /second output/);
236
+ assert.doesNotMatch(secondText, /first/);
237
+ assert.match(render(tool, result([], "replayed"), context({ ...first, state: {} })), /first JS/);
238
+ });
239
+
240
+ test("missing and malformed metadata stays honest, finite and does not infer source calls", () => {
241
+ const tool = registeredCodemode();
242
+ for (const details of [undefined, null, {}, { calls: null }, { calls: "invalid" }]) {
243
+ const value = { ...result(), details };
244
+ const text = render(tool, value);
245
+ assert.match(text, /No observed child calls/);
246
+ assert.doesNotMatch(text, /read|0ms|success/);
247
+ }
248
+ const text = render(tool, result([null, { name: {}, status: "done", durationMs: NaN, error: {} },
249
+ { name: "bad-time", status: "ok", durationMs: -1 }, { name: "infinity", status: "ok", durationMs: Infinity }]));
250
+ assert.match(text, /status unavailable/);
251
+ assert.match(text, /name unavailable/);
252
+ assert.doesNotMatch(text, /done|NaN|Infinity|-1ms|undefined|\[object Object\]/);
253
+ });
254
+
255
+ test("bounded collapsed children preserve order and disclose failures outside the preview", () => {
256
+ const calls = Array.from({ length: 20 }, (_, index) => ({ name: `child-${index}`, status: index === 19 ? "error" : "ok", error: index === 19 ? "late error" : undefined }));
257
+ const tool = registeredCodemode();
258
+ const text = render(tool, result(calls), context(), 120);
259
+ assert.match(text, /child-0[\s\S]*child-7/);
260
+ assert.match(text, /1 errors\/cancellations reported · 12 more calls/);
261
+ assert.match(text, /child-19[\s\S]*late error/);
262
+ assert.ok(text.split("\n").length <= 17);
263
+ const expanded = render(tool, result(calls), context({ expanded: true }));
264
+ assert.match(expanded, /child-19/);
265
+ assert.match(expanded, /late error/);
266
+ });
267
+
268
+ test("expanded output retains image fallback and full-output locator without mutating the result", () => {
269
+ const tool = registeredCodemode();
270
+ const value: AgentToolResult<unknown> = {
271
+ content: [{ type: "text", text: "first\nlast" }, { type: "image", mimeType: "image/png", data: "fixture" }],
272
+ details: { calls: [], fullOutputPath: "/fixture/full-output.txt" },
273
+ };
274
+ const before = structuredClone(value);
275
+ const collapsed = render(tool, value);
276
+ assert.match(collapsed, /first[\s\S]*last/);
277
+ assert.match(collapsed, /image\/png/);
278
+ assert.match(collapsed, /full-output/);
279
+ const expanded = render(tool, value, context({ expanded: true, showImages: false }));
280
+ assert.match(expanded, /first[\s\S]*last/);
281
+ assert.match(expanded, /image\/png/);
282
+ assert.match(expanded, /\/fixture\/full-output.txt/);
283
+ assert.deepEqual(value, before);
284
+ });
285
+
286
+ test("expanded images use text fallback only when host image painting is unavailable", async (t) => {
287
+ const tool = registeredCodemode();
288
+ const originalCapabilities = getCapabilities();
289
+ const value: AgentToolResult<unknown> = {
290
+ content: [{ type: "text", text: "text preserved" }, { type: "image", mimeType: "image/png", data: "fixture" }],
291
+ details: { calls: [] },
292
+ };
293
+ const originalResult = structuredClone(value);
294
+ for (const images of [null, "kitty", "iterm2"] as const) {
295
+ for (const showImages of [true, false]) {
296
+ await t.test(`${images ?? "unsupported"} · showImages=${showImages}`, () => {
297
+ try {
298
+ setCapabilities({ ...originalCapabilities, images });
299
+ const expanded = render(tool, value, context({ expanded: true, showImages }));
300
+ assert.match(expanded, /text preserved/);
301
+ assert.equal(expanded.includes(imageFallback("image/png")), !images || !showImages,
302
+ "fallback must remain visible without host image painting, and must not duplicate a supported image");
303
+ const collapsed = render(tool, value, context({ showImages }));
304
+ assert.match(collapsed, /text preserved/);
305
+ assert.equal(collapsed.includes(imageFallback("image/png")), !images || !showImages);
306
+ assert.deepEqual(value, originalResult, "host must retain the original image content");
307
+ } finally {
308
+ setCapabilities(originalCapabilities);
309
+ assert.equal(getCapabilities(), originalCapabilities, "restore the exact owned capability state");
310
+ }
311
+ });
312
+ }
313
+ }
314
+ });
315
+
316
+ test("collapsed Code skips upstream bookkeeping to preview the actual final output", () => {
317
+ const text = render(registeredCodemode(), result([], "Script completed\nWall time 0.1 seconds\nOutput:\nactual answer\nsecond detail\nthird detail\nhidden detail"));
318
+ assert.match(text, /actual answer[\s\S]*second detail[\s\S]*third detail/);
319
+ assert.doesNotMatch(text, /Script completed|Wall time|Output:|hidden detail/);
320
+ });
321
+
322
+ test("Code components repaint semantic colors after theme invalidation without background fill", (t) => {
323
+ const found = cardStyle();
324
+ t.after(() => setCardStyle(found));
325
+ setCardStyle(CARD_STYLE.NEON);
326
+ const tool = registeredCodemode();
327
+ let paint = "\x1b[31m";
328
+ const themed = { fg: (_role: string, text: string) => `${paint}${text}\x1b[0m`, bg: (_role: string, text: string) => `\x1b[44m${text}\x1b[49m` };
329
+ const ctx = context({ isError: true });
330
+ const components = [tool.renderCall!(ctx.args, themed as never, ctx), tool.renderResult!(result([{ name: "read", status: "error", error: "denied" }], "failure detail"), { expanded: false, isPartial: false }, themed as never, ctx)];
331
+ const first = components.flatMap((component) => component.render(100)).join("\n");
332
+ assert.match(first, /\x1b\[31m/);
333
+ paint = "\x1b[35m";
334
+ for (const component of components) component.invalidate();
335
+ const changed = components.flatMap((component) => component.render(100)).join("\n");
336
+ assert.doesNotMatch(changed, /\x1b\[31m|\x1b\[44m/);
337
+ assert.match(changed, /\x1b\[35m/);
338
+ assert.equal(stripAnsi(first), stripAnsi(changed));
339
+ });
340
+
341
+ test("collapsed Code output has a fixed physical row budget and semantic tones", () => {
342
+ const tool = registeredCodemode();
343
+ for (const [isError, isPartial, expected] of [[false, false, "border"], [true, false, "error"], [false, true, "warning"]] as const) {
344
+ const roles: string[] = [];
345
+ const painted = { fg: (role: string, text: string) => { roles.push(role); return text; } };
346
+ const ctx = context({ isError, isPartial });
347
+ const value = result([], "useful 界🌹 e\u0301 " + "x".repeat(800) + "\nlast");
348
+ for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8, 24, 120]) {
349
+ const rows = tool.renderResult!(value, { expanded: false, isPartial }, painted as never, ctx).render(width);
350
+ assert.ok(rows.length <= 6);
351
+ assert.ok(rows.every((row) => visibleWidth(row) <= width));
352
+ assert.equal(rows.filter((row) => row.startsWith("╰")).length, width ? 1 : 0);
353
+ }
354
+ assert.ok(roles.includes(expected));
355
+ }
356
+ });
357
+
358
+ test("terminal controls cannot spoof child, code or output rows at narrow and wide widths", () => {
359
+ const tool = registeredCodemode();
360
+ const malicious = "\u001b[2J\u001b]8;;https://invalid\u0007危险🌹\u202e\u2066\u0000\t\r\nforged";
361
+ const value = result([{ name: malicious, status: "error", error: malicious, durationMs: 0 }], malicious);
362
+ for (const width of [0, 1, 2, 3, 4, 5, 8, 12, 24, 80, 180]) {
363
+ for (const expanded of [false, true]) {
364
+ const ctx = context({ args: { code: malicious }, expanded, isError: true });
365
+ const components = [tool.renderCall!(ctx.args, theme as never, ctx), tool.renderResult!(value, { expanded, isPartial: false }, theme as never, ctx)];
366
+ for (const line of components.flatMap((component) => component.render(width))) {
367
+ assert.ok(visibleWidth(line) <= width, `width ${width}: ${line}`);
368
+ assert.doesNotMatch(stripAnsi(line), /[\u0000\u0007\u001b\u202e\u2066\r]/);
369
+ assert.doesNotMatch(line, /\u001b\[2J|https:\/\/invalid/);
370
+ }
371
+ }
372
+ }
373
+ });
374
+
375
+ // Float style: one borderless panel across the call and result components,
376
+ // with exactly one separator between the heading and whatever body comes first.
377
+ const floatTheme = {
378
+ bold: (text: string) => text,
379
+ fg: (_color: string, text: string) => `\x1b[38;5;203m${text}\x1b[39m`,
380
+ bg: (color: string, text: string) => `\x1b[48;5;${color === "toolErrorBg" ? 52 : 22}m${text}\x1b[49m`,
381
+ };
382
+
383
+ function renderFloat(tool: ToolDefinition, value: AgentToolResult<unknown>, ctx: ToolRenderContext, width = 70) {
384
+ setCardStyle(CARD_STYLE.FLOAT);
385
+ try {
386
+ const call = tool.renderCall!(ctx.args, floatTheme as never, ctx).render(width);
387
+ const body = tool.renderResult!(value, { expanded: ctx.expanded, isPartial: ctx.isPartial }, floatTheme as never, ctx).render(width);
388
+ return [...call, ...body];
389
+ } finally {
390
+ setCardStyle(CARD_STYLE.NEON);
391
+ }
392
+ }
393
+
394
+ test("float Code cards separate the heading from the first body rows exactly once", () => {
395
+ const tool = registeredCodemode();
396
+ const value = result([{ name: "read", status: "ok" }], "Script completed\nWall time 0.1 seconds\nOutput:\nhello");
397
+ const collapsed = renderFloat(tool, value, context());
398
+ const plain = collapsed.map(stripAnsi);
399
+ for (const line of collapsed) {
400
+ assert.equal(visibleWidth(line), 70);
401
+ assert.ok(line.startsWith(" \x1b[48;5;22m") && line.endsWith("\x1b[49m "), JSON.stringify(line));
402
+ }
403
+ assert.doesNotMatch(plain.join("\n"), /[╭╮╰╯│─]/);
404
+ assert.match(plain[0]!, /^ ▎ +$/);
405
+ assert.match(plain[1]!, /^ ▎ λ Code /);
406
+ assert.match(plain[2]!, /^ ▎ +$/);
407
+ assert.match(plain[3]!, /^ ▎ ok · read/);
408
+ assert.match(plain.at(-1)!, /^ ▎ +$/);
409
+ assert.equal(plain.filter((line) => /^ ▎ +$/.test(line)).length, 3);
410
+
411
+ // Expanded, the script is the first body: the separator moves above it.
412
+ const expanded = renderFloat(tool, value, context({ expanded: true })).map(stripAnsi);
413
+ assert.match(expanded[2]!, /^ ▎ +$/);
414
+ assert.match(expanded[3]!, /^ ▎ await tools\.read/);
415
+ assert.equal(expanded.filter((line) => /^ ▎ +$/.test(line)).length, 3, "no second separator above the result");
416
+
417
+ const failed = renderFloat(tool, result([], "Script failed"), context({ isError: true }));
418
+ for (const line of failed) assert.ok(line.startsWith(" \x1b[48;5;52m"), JSON.stringify(line));
419
+ });
420
+
421
+ // Pi builds the call component, then the result component once a result
422
+ // exists, and only then renders them: the call learns about the result at
423
+ // render time, through the row state both renderers share.
424
+ function piRow(tool: ToolDefinition, value: AgentToolResult<unknown> | undefined, ctx: ToolRenderContext, width: number, paint: object = theme): string[] {
425
+ const call = tool.renderCall!(ctx.args, paint as never, ctx);
426
+ const body = value ? tool.renderResult!(value, { expanded: ctx.expanded, isPartial: ctx.isPartial }, paint as never, ctx) : undefined;
427
+ return [...call.render(width), ...(body?.render(width) ?? [])];
428
+ }
429
+
430
+ const pending = () => context({ isPartial: true, executionStarted: true, state: {} });
431
+
432
+ test("a running Code card closes its own frame with one running row until a result exists", () => {
433
+ const tool = registeredCodemode();
434
+ const roles: string[] = [];
435
+ const painted = { bold: (text: string) => text, fg: (role: string, text: string) => { roles.push(`${role}:${text}`); return text; } };
436
+ const running = piRow(tool, undefined, pending(), 70, painted);
437
+ assert.equal(running.length, 3, running.join("\n"));
438
+ assert.match(running[0]!, /^╭─ λ Code .*╮$/);
439
+ assert.match(running[1]!, /^│ running… +│$/);
440
+ assert.match(running[2]!, /^╰─+╯$/);
441
+ for (const row of running) assert.equal(visibleWidth(row), 70);
442
+ assert.ok(roles.includes("muted:running…"), "the running row is muted");
443
+ assert.ok(roles.includes("warning:╰"), "the running frame keeps the pending tone");
444
+ for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8]) {
445
+ const rows = piRow(tool, undefined, pending(), width);
446
+ assert.ok(rows.every((row) => visibleWidth(row) <= width), `width ${width}`);
447
+ assert.equal(rows.length, width === 0 ? 0 : 3);
448
+ }
449
+
450
+ // A partial result below the call closes the frame instead: one bottom rule, no running row.
451
+ const partial = piRow(tool, result([{ name: "read", status: "running" }], "partial output"), pending(), 70);
452
+ assert.equal(partial.filter((row) => row.startsWith("╭")).length, 1);
453
+ assert.equal(partial.filter((row) => row.startsWith("╰")).length, 1);
454
+ assert.match(partial.at(-1)!, /^╰─+╯$/);
455
+ assert.doesNotMatch(partial.join("\n"), /running…/);
456
+ });
457
+
458
+ test("a finished Code card renders exactly as before the running frame existed", () => {
459
+ const tool = registeredCodemode();
460
+ const hint = stripAnsi(keyHint("app.tools.expand", "to expand"));
461
+ const value = result([{ name: "read", status: "ok", durationMs: 3 }], "Script completed\nWall time 0.1 seconds\nOutput:\nhello");
462
+ assert.deepEqual(piRow(tool, value, context({ state: {} }), 70).map(stripAnsi), [
463
+ `╭─ λ Code ${"─".repeat(57 - hint.length)} ${hint} ╮`,
464
+ `│ ok · read · 3ms${" ".repeat(51)} │`,
465
+ `│ hello${" ".repeat(61)} │`,
466
+ `╰${"─".repeat(68)}╯`,
467
+ ]);
468
+ });
469
+
470
+ test("a running float Code card is one closed panel with a single separator", () => {
471
+ const tool = registeredCodemode();
472
+ setCardStyle(CARD_STYLE.FLOAT);
473
+ try {
474
+ const running = piRow(tool, undefined, pending(), 70, floatTheme);
475
+ const plain = running.map(stripAnsi);
476
+ for (const line of running) assert.equal(visibleWidth(line), 70);
477
+ assert.doesNotMatch(plain.join("\n"), /[╭╮╰╯│─]/);
478
+ assert.equal(plain.length, 5, plain.join("\n"));
479
+ assert.match(plain[0]!, /^ ▎ +$/);
480
+ assert.match(plain[1]!, /^ ▎ λ Code /);
481
+ assert.match(plain[2]!, /^ ▎ +$/);
482
+ assert.match(plain[3]!, /^ ▎ running… +$/);
483
+ assert.match(plain[4]!, /^ ▎ +$/);
484
+
485
+ const partial = piRow(tool, result([{ name: "read", status: "running" }], "partial output"), pending(), 70, floatTheme).map(stripAnsi);
486
+ assert.equal(partial.filter((line) => /^ ▎ +$/.test(line)).length, 3, "one blank above, one separator, one closing row");
487
+ assert.doesNotMatch(partial.join("\n"), /running…/);
488
+ } finally {
489
+ setCardStyle(CARD_STYLE.NEON);
490
+ }
491
+ });
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { existsSync, readFileSync } from "node:fs";
3
4
  import { visibleWidth } from "@earendil-works/pi-tui";
4
5
  import { CommandPalette, commandsKey, rankPaletteGroups, type CommandPaletteGroup, type CommandPaletteItem, type CommandPaletteResult, type CommandPaletteTheme } from "../lib/command-palette.ts";
5
6
  import { buildCommandPaletteGroups, COMMAND_PALETTE_CATALOG } from "../lib/command-palette-catalog.ts";
@@ -32,6 +33,26 @@ function createPalette(groups: readonly CommandPaletteGroup[], theme?: CommandPa
32
33
  return { palette, results };
33
34
  }
34
35
 
36
+ test("Vim palette help names the opt-in editor and Pi slash handoff without promising full parity", () => {
37
+ const groups = buildCommandPaletteGroups([{ name: "gentle:vim", description: "Show or set global Vim prompt editing (status|enable|disable); no argument opens a menu." }], {});
38
+ assert.equal(groups[0]?.title, "Configuration");
39
+ const vim = groups[0]?.items[0];
40
+ assert.equal(vim?.command, "gentle:vim");
41
+ assert.match(vim?.label ?? "", /Vim.*opt-in/i);
42
+ assert.match(vim?.label ?? "", /Pi.*slash/i);
43
+ assert.equal(vim?.description, "Show or set global Vim prompt editing (status|enable|disable); no argument opens a menu.");
44
+ assert.equal(rankPaletteGroups(groups, "vim")[0]?.items[0]?.command, "gentle:vim");
45
+ });
46
+
47
+ test("Vim reference distinguishes supported commands, scope and slash divergence", () => {
48
+ const reference = readFileSync(new URL("../docs/readme-reference.md", import.meta.url), "utf8");
49
+ const section = reference.split("### Vim prompt editing\n")[1]?.split("\n### ")[0] ?? "";
50
+ for (const term of ["`/gentle:vim enable`", "`/gentle:vim disable`", "`status`", "VISUAL", "`Ctrl+[`", "`gg/G`", "`f/F/t/T`", "`d/c/y`", "`u`", "`.`", "Pi", "first line", "reverse prompt-history search", "0.99.1", "paste marker"]) {
51
+ assert.ok(section.includes(term), `Vim reference missing ${term}`);
52
+ }
53
+ assert.doesNotMatch(section, /full Claude (?:Code )?parity/i);
54
+ });
55
+
35
56
  test("animations is discoverable under Configuration with its live description", () => {
36
57
  const groups = buildCommandPaletteGroups([{ name: "gentle:animations", description: "status|quality|performance|potato" }], {});
37
58
  assert.equal(groups[0]?.title, "Configuration");
@@ -335,7 +356,7 @@ test("commandsKey is disabled by an empty value or off (case-insensitive)", () =
335
356
  test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () => {
336
357
  assert.deepEqual(
337
358
  COMMAND_PALETTE_CATALOG.map((group) => group.title),
338
- ["Configuration", "Session", "Diagnostics", "SDD", "Skills"],
359
+ ["Configuration", "Session", "Diagnostics", "Skills"],
339
360
  );
340
361
  const byTitle = (title: string) => COMMAND_PALETTE_CATALOG.find((group) => group.title === title)?.items.map((item) => item.command);
341
362
  assert.deepEqual(byTitle("Configuration"), [
@@ -345,7 +366,9 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
345
366
  "gentle:review-mode",
346
367
  "gentle:background-subagents",
347
368
  "gentle:double-esc-cancel",
369
+ "gentle:customize",
348
370
  "gentle:animations",
371
+ "gentle:vim",
349
372
  "gentle:telemetry",
350
373
  "gentle:banner",
351
374
  "gentle:banner-color",
@@ -353,12 +376,25 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
353
376
  "gentle:toggle-text-logo",
354
377
  "gentle:dev-binary",
355
378
  ]);
356
- assert.deepEqual(byTitle("Session"), ["gentle:changes", "gentle:agents", "gentle:usage", "gentle:review-session-permission"]);
379
+ assert.deepEqual(byTitle("Session"), ["gentle:yolo", "gentle:changes", "gentle:agents", "gentle:usage", "gentle:review-session-permission"]);
357
380
  assert.deepEqual(byTitle("Diagnostics"), ["gentle:status", "gentle:doctor"]);
358
- assert.deepEqual(byTitle("SDD"), ["gentle:sdd-preflight", "gentle-sdd-status", "gentle-sdd-continue", "gentle-sdd-init"]);
381
+ assert.equal(byTitle("SDD"), undefined);
359
382
  assert.deepEqual(byTitle("Skills"), ["skill-registry:refresh"]);
360
383
  });
361
384
 
385
+ test("retired SDD commands are absent from the palette and extension registrations", () => {
386
+ const retired = ["gentle:sdd-preflight", "gentle-sdd-status", "gentle-sdd-continue", "gentle-sdd-init", "gentle:install-sdd"];
387
+ const catalogCommands = COMMAND_PALETTE_CATALOG.flatMap((group) => group.items.map((item) => item.command));
388
+ assert.equal(existsSync(new URL("../extensions/sdd-init.ts", import.meta.url)), false, "retired init extension must stay absent");
389
+ const source = readFileSync(new URL("../extensions/gentle-ai.ts", import.meta.url), "utf8");
390
+ for (const command of retired) {
391
+ assert.ok(!catalogCommands.includes(command), `${command} must not appear in the palette`);
392
+ assert.ok(!source.includes(`registerCommand("${command}"`), `${command} must not be registered`);
393
+ }
394
+ assert.match(source, /for \(const owner of \["delegation", "review"\] as const\) \{\s*const label = owner;\s*pi\.registerCommand\(`gentle:install-\$\{owner\}`/);
395
+ assert.ok(catalogCommands.includes("gentle:review-mode"));
396
+ });
397
+
362
398
  test("buildCommandPaletteGroups keeps only registered commands, attaches descriptions and shortcuts, drops empty groups, preserves catalog order", () => {
363
399
  const registered = [
364
400
  { name: "gentle:models", description: "Configure models" },