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,101 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { VimNormalEngine } from "../lib/vim-normal-engine.ts";
4
+
5
+ function run(text: string, cursor: { line: number; col: number }, keys: string) {
6
+ const engine = new VimNormalEngine();
7
+ let position = cursor;
8
+ let insert: string | undefined;
9
+ for (const key of keys.split(" ")) {
10
+ const result = engine.input(key, text, position);
11
+ position = result.cursor;
12
+ if (result.insert !== undefined) insert = result.insert;
13
+ }
14
+ return { position, insert };
15
+ }
16
+
17
+ test("horizontal motions count graphemes, not UTF-16 units; clamp at line edges", () => {
18
+ assert.deepEqual(run("a👩‍💻é\nz", { line: 0, col: 0 }, "3l").position, { line: 0, col: 7 });
19
+ assert.deepEqual(run("a👩‍💻é\nz", { line: 0, col: 7 }, "2h").position, { line: 0, col: 1 });
20
+ assert.deepEqual(run("a\nb", { line: 0, col: 1 }, "l").position, { line: 0, col: 1 });
21
+ assert.deepEqual(run("a b", { line: 0, col: 0 }, "Space").position, { line: 0, col: 1 });
22
+ });
23
+
24
+ test("vertical motions keep preferred grapheme column across short lines without history", () => {
25
+ assert.deepEqual(run("a👩‍💻z\nx\na👩‍💻z", { line: 0, col: 6 }, "j j").position, { line: 2, col: 6 });
26
+ assert.deepEqual(run("one\ntwo", { line: 0, col: 0 }, "k").position, { line: 0, col: 0 });
27
+ });
28
+
29
+ test("word, line and document motions work across newlines and Unicode", () => {
30
+ assert.deepEqual(run(" 雪 blue\n fin", { line: 0, col: 0 }, "w").position, { line: 0, col: 2 });
31
+ assert.deepEqual(run(" 雪 blue\n fin", { line: 0, col: 2 }, "w").position, { line: 0, col: 4 });
32
+ assert.deepEqual(run("abc de\nfin", { line: 0, col: 0 }, "e").position, { line: 0, col: 2 });
33
+ assert.deepEqual(run("abc de\nfin", { line: 0, col: 4 }, "b").position, { line: 0, col: 0 });
34
+ assert.deepEqual(run(" abc\n fin", { line: 1, col: 2 }, "0").position, { line: 1, col: 0 });
35
+ assert.deepEqual(run(" abc\n fin", { line: 1, col: 0 }, "^").position, { line: 1, col: 1 });
36
+ assert.deepEqual(run(" abc\n fin", { line: 0, col: 0 }, "$ G").position, { line: 1, col: 0 });
37
+ assert.deepEqual(run("a\nb\nc", { line: 2, col: 1 }, "g g").position, { line: 0, col: 0 });
38
+ assert.deepEqual(run("a\nb\nc", { line: 0, col: 0 }, "2G").position, { line: 1, col: 0 });
39
+ assert.deepEqual(run("a\nb\nc", { line: 0, col: 0 }, "2g g").position, { line: 1, col: 0 });
40
+ });
41
+
42
+ test("pending count and gg survive a non-command shortcut probe", () => {
43
+ const engine = new VimNormalEngine();
44
+ const text = "a\nb\nc";
45
+ assert.deepEqual(engine.input("g", text, { line: 2, col: 0 }).cursor, { line: 2, col: 0 });
46
+ assert.deepEqual(engine.input("g", text, { line: 2, col: 0 }).cursor, { line: 0, col: 0 });
47
+ engine.input("2", text, { line: 0, col: 0 });
48
+ assert.deepEqual(engine.input("j", text, { line: 0, col: 0 }).cursor, { line: 2, col: 0 });
49
+ });
50
+
51
+ test("first non-whitespace is a grapheme boundary when whitespace precedes a combining mark", () => {
52
+ assert.deepEqual(run(" \u0301a", { line: 0, col: 0 }, "^").position, { line: 0, col: 2 });
53
+ assert.deepEqual(run(" \u0301a", { line: 0, col: 0 }, "I"), { position: { line: 0, col: 2 }, insert: "" });
54
+ });
55
+
56
+ test("horizontal motions skip registered paste markers atomically", () => {
57
+ const engine = new VimNormalEngine();
58
+ const text = "a[paste #1 1001 chars]b";
59
+ const end = text.indexOf("b");
60
+ const positions = [[0, 1, end, text.length]];
61
+ assert.deepEqual(engine.input("h", text, { line: 0, col: end }, positions).cursor, { line: 0, col: 1 });
62
+ assert.deepEqual(engine.input("l", text, { line: 0, col: 1 }, positions).cursor, { line: 0, col: end });
63
+ });
64
+
65
+ test("counted f/F/t/T find within the current line on grapheme boundaries", () => {
66
+ assert.deepEqual(run("a👩‍💻x👩‍💻x\n👩‍💻x", { line: 0, col: 0 }, "2 f 👩‍💻").position, { line: 0, col: 7 });
67
+ assert.deepEqual(run("a👩‍💻x👩‍💻x", { line: 0, col: 0 }, "2 t 👩‍💻").position, { line: 0, col: 6 });
68
+ assert.deepEqual(run("a👩‍💻x👩‍💻x", { line: 0, col: 13 }, "2 F 👩‍💻").position, { line: 0, col: 1 });
69
+ assert.deepEqual(run("a👩‍💻x👩‍💻x", { line: 0, col: 13 }, "2 T 👩‍💻").position, { line: 0, col: 6 });
70
+ });
71
+
72
+ test(";/, repeat last successful find with counts, reverse direction, and no-match safety", () => {
73
+ assert.deepEqual(run("axaxax", { line: 0, col: 0 }, "t x ;").position, { line: 0, col: 2 });
74
+ assert.deepEqual(run("axaxax", { line: 0, col: 0 }, "f x 2 ; ,").position, { line: 0, col: 3 });
75
+ assert.deepEqual(run("ax\nx", { line: 0, col: 0 }, "f x ;").position, { line: 0, col: 1 });
76
+ assert.deepEqual(run("a👩‍💻b", { line: 0, col: 0 }, "f z ;").position, { line: 0, col: 0 });
77
+ assert.deepEqual(run("ax", { line: 0, col: 0 }, "; ,").position, { line: 0, col: 0 });
78
+ });
79
+
80
+ test("pending find consumes numeric target, resets on cancellation and respects atomic paste", () => {
81
+ const engine = new VimNormalEngine();
82
+ const text = "a1[paste #1 1001 chars]b";
83
+ const end = text.indexOf("b");
84
+ const stops = [[0, 1, 2, end, text.length]];
85
+ assert.deepEqual(engine.input("f", text, { line: 0, col: 0 }, stops).cursor, { line: 0, col: 0 });
86
+ assert.deepEqual(engine.input("1", text, { line: 0, col: 0 }, stops).cursor, { line: 0, col: 1 });
87
+ engine.input("f", text, { line: 0, col: 1 }, stops);
88
+ engine.reset();
89
+ assert.deepEqual(engine.input(";", text, { line: 0, col: 1 }, stops).cursor, { line: 0, col: 1 });
90
+ const markerText = "a[paste #1 1001 chars]b";
91
+ assert.deepEqual(engine.input("f", markerText, { line: 0, col: 0 }, [[0, 1, markerText.length - 1, markerText.length]]).cursor, { line: 0, col: 0 });
92
+ assert.deepEqual(engine.input("#", markerText, { line: 0, col: 0 }, [[0, 1, markerText.length - 1, markerText.length]]).cursor, { line: 0, col: 0 });
93
+ });
94
+
95
+ test("insert/open commands return exact insertion and cursor destination", () => {
96
+ assert.deepEqual(run(" ab", { line: 0, col: 2 }, "I"), { position: { line: 0, col: 2 }, insert: "" });
97
+ assert.deepEqual(run(" ab", { line: 0, col: 2 }, "A"), { position: { line: 0, col: 4 }, insert: "" });
98
+ assert.deepEqual(run("ab", { line: 0, col: 0 }, "a"), { position: { line: 0, col: 1 }, insert: "" });
99
+ assert.deepEqual(run("ab\ncd", { line: 0, col: 0 }, "o"), { position: { line: 0, col: 2 }, insert: "\n" });
100
+ assert.deepEqual(run("ab\ncd", { line: 1, col: 1 }, "O"), { position: { line: 1, col: 0 }, insert: "\n" });
101
+ });
@@ -0,0 +1,215 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { VimOperatorEngine } from "../lib/vim-operator-engine.ts";
4
+
5
+ function run(text: string, col: number, keys: string[], bounds?: number[][]) {
6
+ const engine = new VimOperatorEngine();
7
+ let cursor = { line: 0, col };
8
+ let edit;
9
+ for (const key of keys) {
10
+ const result = engine.input(key, text, cursor, bounds);
11
+ if (result?.edit) {
12
+ edit = result.edit;
13
+ const offset = (p: typeof cursor) => text.split("\n").slice(0, p.line).reduce((n, line) => n + line.length + 1, 0) + p.col;
14
+ text = text.slice(0, offset(edit.start)) + edit.text + text.slice(offset(edit.end));
15
+ cursor = edit.cursor;
16
+ }
17
+ }
18
+ return { text, cursor, edit, engine };
19
+ }
20
+
21
+ test("visual ranges delete or change linewise and shift only selected lines", () => {
22
+ const engine = new VimOperatorEngine();
23
+ const range = { start: { line: 1, col: 0 }, end: { line: 2, col: 5 }, linewise: true };
24
+ assert.deepEqual(engine.visualEdit("d", "one\ntwo\nthree", range).edit, {
25
+ start: { line: 0, col: 3 }, end: { line: 2, col: 5 }, text: "", cursor: { line: 0, col: 0 },
26
+ });
27
+ assert.equal(engine.visualEdit("c", "one\ntwo\nthree", range).edit?.insert, true);
28
+ assert.equal(engine.visualEdit(">", "one\ntwo\nthree", range).edit?.text, " two\n three");
29
+ assert.equal(engine.visualEdit("<", "one\ntwo\nthree", range).edit, undefined);
30
+ });
31
+
32
+ test("visual shifts dedent tabs and empty visual ranges cannot overwrite the register", () => {
33
+ const engine = new VimOperatorEngine();
34
+ const line = { start: { line: 0, col: 0 }, end: { line: 0, col: 4 }, linewise: true };
35
+ assert.equal(engine.visualEdit("<", "\tfoo", line).edit?.text, "foo");
36
+ engine.visualRegister("saved", false);
37
+ engine.visualRegister("", false);
38
+ assert.equal(engine.input("P", "abc", { line: 0, col: 0 })?.edit?.text, "saved");
39
+ });
40
+
41
+ test("operator motions and doubled line operators honor counts and multiline Unicode", () => {
42
+ assert.equal(run("👩‍💻 hi\nnext", 0, ["d", "w"]).text, "hi\nnext");
43
+ assert.equal(run("one\ntwo\nthree", 0, ["2", "d", "d"]).text, "three");
44
+ assert.equal(run("one\ntwo", 0, ["c", "c"]).text, "\ntwo");
45
+ assert.equal(run("one\ntwo", 0, ["y", "y", "p"]).text, "one\none\ntwo");
46
+ assert.equal(run("abcb", 0, ["d", "f", "c"]).text, "b");
47
+ });
48
+
49
+ test("linewise yank records a complete line and pastes at line boundaries", () => {
50
+ for (const paste of ["p", "P"]) {
51
+ const engine = new VimOperatorEngine();
52
+ const cursor = { line: 1, col: 0 };
53
+ assert.deepEqual(engine.input("y", "one\ntwo", cursor), { handled: true });
54
+ assert.deepEqual(engine.input("y", "one\ntwo", cursor), { handled: true });
55
+ const result = engine.input(paste, "one\ntwo", cursor);
56
+ assert.ok(result?.edit);
57
+ const { start, end, text } = result.edit;
58
+ const source = "one\ntwo";
59
+ const offset = (p: typeof cursor) => source.split("\n").slice(0, p.line).reduce((n, line) => n + line.length + 1, 0) + p.col;
60
+ assert.equal(source.slice(0, offset(start)) + text + source.slice(offset(end)), "one\ntwo\ntwo");
61
+ assert.deepEqual(result.edit.cursor, { line: paste === "P" ? 1 : 2, col: 0 });
62
+ }
63
+ });
64
+
65
+ test("change line keeps one empty line and enters insert on the changed line", () => {
66
+ for (const [source, line, expected] of [["one", 0, ""], ["one\ntwo", 1, "one\n"]] as const) {
67
+ const engine = new VimOperatorEngine();
68
+ const cursor = { line, col: 0 };
69
+ engine.input("c", source, cursor);
70
+ const result = engine.input("c", source, cursor);
71
+ assert.ok(result?.edit?.insert);
72
+ const offset = (p: { line: number; col: number }) => source.split("\n").slice(0, p.line).reduce((n, value) => n + value.length + 1, 0) + p.col;
73
+ const edit = result.edit;
74
+ assert.equal(source.slice(0, offset(edit.start)) + edit.text + source.slice(offset(edit.end)), expected);
75
+ assert.deepEqual(edit.cursor, { line, col: 0 });
76
+ }
77
+ });
78
+
79
+ test("linewise change and delete preserve surrounding lines and last-line cursor", () => {
80
+ assert.equal(run("one\ntwo\nthree", 0, ["c", "c"]).text, "\ntwo\nthree");
81
+ const middle = new VimOperatorEngine();
82
+ const at = { line: 1, col: 0 };
83
+ middle.input("c", "one\ntwo\nthree", at);
84
+ const edit = middle.input("c", "one\ntwo\nthree", at)?.edit;
85
+ assert.deepEqual(edit?.cursor, at);
86
+ assert.equal(edit?.text, "\n");
87
+ const last = new VimOperatorEngine();
88
+ last.input("d", "one\ntwo", at);
89
+ assert.deepEqual(last.input("d", "one\ntwo", at)?.edit?.cursor, { line: 0, col: 0 });
90
+ assert.equal(run("one", 0, ["d", "d"]).text, "");
91
+ });
92
+
93
+ test("S on an empty prompt still requests INSERT", () => {
94
+ assert.equal(new VimOperatorEngine().input("S", "", { line: 0, col: 0 })?.edit?.insert, true);
95
+ });
96
+
97
+ test("text objects include punctuation and WORD whitespace with quote/bracket nesting", () => {
98
+ assert.equal(run("alpha beta", 2, ["d", "i", "w"]).text, " beta");
99
+ assert.equal(run("alpha beta", 2, ["d", "a", "w"]).text, "beta");
100
+ assert.equal(run("a.b rest", 2, ["d", "i", "W"]).text, " rest");
101
+ assert.equal(run('x "a👩‍💻b" y', 4, ["d", "i", '"']).text, 'x "" y');
102
+ assert.equal(run("a (b [cd]) z", 7, ["d", "a", "["]).text, "a (b ) z");
103
+ });
104
+
105
+ test("x/s/S and character paste use grapheme boundaries and undo-safe single edit", () => {
106
+ assert.equal(run("👩‍💻é", 0, ["x"]).text, "é");
107
+ assert.equal(run("👩‍💻é", 0, ["s"]).edit?.insert, true);
108
+ assert.equal(run("abc", 0, ["x", "P"]).text, "abc");
109
+ assert.equal(run("abc", 0, ["x", "p"]).text, "bac");
110
+ assert.equal(run("one\ntwo", 0, ["S"]).text, "\ntwo");
111
+ });
112
+
113
+ test("shift operator accepts motion ranges, counts, Unicode and keeps the register", () => {
114
+ const engine = new VimOperatorEngine();
115
+ const at = { line: 0, col: 0 };
116
+ engine.visualRegister("saved", false);
117
+ engine.input(">", "é one\n two\nthree", at);
118
+ const edit = engine.input("w", "é one\n two\nthree", at)?.edit;
119
+ assert.equal(edit?.text, " é one");
120
+ assert.equal(engine.input("P", "abc", at)?.edit?.text, "saved");
121
+ assert.equal(run("a\n b\n c\nd", 0, ["2", ">", "j"]).text, " a\n b\n c\nd");
122
+ assert.equal(run("a\n b\n c", 0, [">", "2", "j"]).text, " a\n b\n c");
123
+ assert.equal(run(" a\n\tb", 0, ["<", "j"]).text, "a\nb");
124
+ assert.equal(run(" a\n b", 0, ["<", "w"]).text, "a\n b");
125
+ assert.equal(run(" a\n b", 0, ["<", "k"]).text, " a\n b");
126
+ assert.equal(run("a\nb\nc", 0, [">", "G"]).text, " a\n b\n c");
127
+ assert.equal(run("a\nb\nc", 0, [">", "2", "G"]).text, " a\n b\nc");
128
+ const repeat = new VimOperatorEngine();
129
+ repeat.input(">", "a\nb", at);
130
+ repeat.remember(repeat.input("j", "a\nb", at)!);
131
+ assert.equal(repeat.repeatAt("é\nz", at)?.edit?.text, " é\n z");
132
+ assert.equal(run("a\nb\nc", 0, [">", "g", "g"]).text, "a\nb\nc");
133
+ assert.equal(run("a\nb\nc", 0, [">", "f", "b"]).text, "a\nb\nc");
134
+ });
135
+
136
+ test("motion shift excludes a next-line column-zero endpoint but keeps inclusive line motions", () => {
137
+ assert.equal(run("a\nb", 0, [">", "w"]).text, " a\nb");
138
+ assert.equal(run(" a\n b", 0, ["<", "w"]).text, "a\n b");
139
+ assert.equal(run("é\nb\nc", 0, [">", "2", "w"]).text, " é\n b\nc");
140
+ assert.equal(run("a\nb", 0, [">", "j"]).text, " a\n b");
141
+ });
142
+
143
+ test("dedent with no indentation leaves undo and repeat history on the previous x", () => {
144
+ const engine = new VimOperatorEngine();
145
+ const cursor = { line: 0, col: 0 };
146
+ const x = engine.input("x", "abc", cursor)!;
147
+ engine.remember(x);
148
+ assert.equal(engine.input("<", "bc", cursor)?.edit, undefined);
149
+ const dedent = engine.input("<", "bc", cursor)!;
150
+ assert.equal(dedent.edit, undefined);
151
+ engine.remember(dedent);
152
+ assert.deepEqual(engine.repeatAt("bc", cursor)?.edit?.end, { line: 0, col: 1 });
153
+ const visual = { start: cursor, end: { line: 0, col: 2 }, linewise: true };
154
+ assert.equal(engine.visualEdit("<", "bc", visual).edit, undefined);
155
+ });
156
+
157
+ test("join and shift linewise commands honor counts and Unicode without clipping", () => {
158
+ assert.equal(run(" 👩‍💻 one \n é two\nthird", 0, ["J"]).text, " 👩‍💻 one é two\nthird");
159
+ assert.equal(run("a\nb\nc", 0, ["3", "J"]).text, "a b c");
160
+ assert.equal(run(" one\n\ttwo", 0, [">", ">", "<", "<"]).text, " one\n\ttwo");
161
+ assert.equal(run("one\ntwo\nthree", 0, ["2", ">", ">"]).text, " one\n two\nthree");
162
+ });
163
+
164
+ test("repeat re-evaluates a completed counted operator at a new cursor, never a motion or yank", () => {
165
+ const engine = new VimOperatorEngine();
166
+ const at = { line: 0, col: 0 };
167
+ for (const key of ["2", "d", "w"]) {
168
+ const result = engine.input(key, "one two three four", at);
169
+ if (result?.edit) engine.remember(result);
170
+ }
171
+ assert.equal(engine.repeatAt("alpha beta gamma delta", at)?.edit?.text, "");
172
+ assert.deepEqual(engine.repeatAt("alpha beta gamma delta", at)?.edit?.end, { line: 0, col: 11 });
173
+ engine.input("y", "alpha beta", at); engine.input("w", "alpha beta", at);
174
+ assert.deepEqual(engine.repeatAt("red blue green", at)?.edit?.end, { line: 0, col: 9 });
175
+ engine.cancel();
176
+ assert.deepEqual(engine.repeatAt("red blue green", at)?.edit?.end, { line: 0, col: 9 });
177
+ });
178
+
179
+ test("repeat of x, p, join and shift uses bounded semantics; empty or failed edits are not recorded", () => {
180
+ const engine = new VimOperatorEngine();
181
+ const at = { line: 0, col: 0 };
182
+ assert.equal(engine.repeatAt("abc", at), undefined);
183
+ const noop = engine.input("x", "", at);
184
+ assert.equal(noop?.edit, undefined);
185
+ engine.remember(noop!);
186
+ assert.equal(engine.repeatAt("abc", at), undefined);
187
+ const x = engine.input("x", "👩‍💻ab", at)!;
188
+ engine.remember(x);
189
+ assert.deepEqual(engine.repeatAt("éz", at)?.edit?.end, { line: 0, col: 1 });
190
+ engine.input("y", "hi\nthere", at); engine.input("y", "hi\nthere", at);
191
+ const paste = engine.input("p", "a\nb", at)!;
192
+ engine.remember(paste);
193
+ assert.equal(engine.repeatAt("é\nz", at)?.edit?.text, "hi\n");
194
+ const join = engine.input("J", "a\nb\nc", at)!;
195
+ engine.remember(join);
196
+ assert.equal(engine.repeatAt("é\nz", at)?.edit?.text, "é z");
197
+ engine.input(">", "a\nb", at);
198
+ engine.remember(engine.input(">", "a\nb", at)!);
199
+ assert.equal(engine.repeatAt("é\nz", at)?.edit?.text, " é");
200
+ });
201
+
202
+ test("cancel and failed motions never overwrite register or mutate paste markers", () => {
203
+ const engine = new VimOperatorEngine();
204
+ const pos = { line: 0, col: 0 };
205
+ assert.deepEqual(engine.input("d", "abc", pos), { handled: true });
206
+ assert.equal(engine.cancel(), true);
207
+ assert.equal(engine.input("w", "abc", pos), null);
208
+ assert.deepEqual(engine.input("p", "abc", pos), { handled: true });
209
+ const text = "a[paste #1 1001 chars]b";
210
+ const end = text.indexOf("b");
211
+ const markerStart = { line: 0, col: 1 };
212
+ assert.deepEqual(engine.input("d", text, markerStart, [[0, 1, end, text.length]]), { handled: true });
213
+ assert.deepEqual(engine.input("w", text, markerStart, [[0, 1, end, text.length]]), { handled: true });
214
+ assert.deepEqual(engine.input("p", text, pos), { handled: true });
215
+ });
@@ -0,0 +1,19 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { resolveVimPolicy, writeVimPolicy } from "../lib/vim-policy.ts";
7
+
8
+ test("vim preference defaults off, persists strictly, and fails closed on malformed input", () => {
9
+ const home = mkdtempSync(join(tmpdir(), "gentle-vim-"));
10
+ const options = { gentlePiConfigHome: home };
11
+ assert.equal(resolveVimPolicy(options).policy, "off");
12
+ assert.equal(resolveVimPolicy(options).source, "default");
13
+ writeVimPolicy("on", options);
14
+ assert.equal(resolveVimPolicy(options).policy, "on");
15
+ assert.equal(JSON.parse(readFileSync(join(home, "vim.json"), "utf8")).schema, "gentle-pi.vim/v1");
16
+ writeFileSync(join(home, "vim.json"), '{"schema":"gentle-pi.vim/v1","policy":"on","extra":true}');
17
+ assert.equal(resolveVimPolicy(options).policy, "off");
18
+ assert.equal(resolveVimPolicy(options).malformed, true);
19
+ });
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { VimVisualEngine } from "../lib/vim-visual-engine.ts";
4
+
5
+ const bounds = (text: string) => text.split("\n").map((line) => [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(line)].map((unit) => unit.index).concat(line.length));
6
+
7
+ test("character selection includes the moving grapheme, crosses newlines, and swaps its anchor", () => {
8
+ const engine = new VimVisualEngine();
9
+ const text = "a👩‍💻\néx";
10
+ const stops = bounds(text);
11
+ engine.start("char", { line: 0, col: 1 });
12
+ assert.deepEqual(engine.range(text, { line: 0, col: 1 }, stops), { start: { line: 0, col: 1 }, end: { line: 0, col: 6 }, linewise: false });
13
+ assert.deepEqual(engine.range(text, { line: 1, col: 0 }, stops), { start: { line: 0, col: 1 }, end: { line: 1, col: 1 }, linewise: false });
14
+ assert.deepEqual(engine.swap({ line: 1, col: 0 }), { line: 0, col: 1 });
15
+ assert.deepEqual(engine.range(text, { line: 0, col: 1 }, stops), { start: { line: 0, col: 1 }, end: { line: 1, col: 1 }, linewise: false });
16
+ engine.cancel();
17
+ assert.equal(engine.range(text, { line: 0, col: 0 }, stops), undefined);
18
+ });
19
+
20
+ test("visual objects resolve word, WORD, quotes and nested brackets across graphemes and lines", () => {
21
+ const engine = new VimVisualEngine();
22
+ const text = 'a👩‍💻b rest\n(é [two]) "hi"';
23
+ const stops = bounds(text);
24
+ engine.start("char", { line: 0, col: 1 });
25
+ assert.deepEqual(engine.object(text, { line: 0, col: 1 }, stops, "i", "w"), {
26
+ start: { line: 0, col: 1 }, end: { line: 0, col: 6 }, linewise: false,
27
+ });
28
+ assert.deepEqual(engine.object(text, { line: 0, col: 1 }, stops, "a", "W")?.end, { line: 0, col: 8 });
29
+ assert.deepEqual(engine.object(text, { line: 1, col: 5 }, stops, "i", "("), {
30
+ start: { line: 1, col: 1 }, end: { line: 1, col: 8 }, linewise: false,
31
+ });
32
+ assert.deepEqual(engine.object(text, { line: 1, col: 11 }, stops, "a", '"'), {
33
+ start: { line: 1, col: 10 }, end: { line: 1, col: 14 }, linewise: false,
34
+ });
35
+ assert.equal(engine.object(text, { line: 1, col: 0 }, stops, "i", '"'), undefined);
36
+ assert.equal(engine.object(text, { line: 0, col: 0 }, stops, "i", "["), undefined);
37
+ assert.equal(engine.object("(one\ntwo)", { line: 1, col: 1 }, bounds("(one\ntwo)"), "i", "(")?.end.line, 1);
38
+ const across = "(one\nnext\n)";
39
+ assert.deepEqual(engine.object(across, { line: 0, col: 1 }, bounds(across), "i", "("), {
40
+ start: { line: 0, col: 1 }, end: { line: 2, col: 0 }, linewise: false,
41
+ });
42
+ engine.start("char", { line: 0, col: 1 });
43
+ assert.deepEqual(engine.range(across, { line: 1, col: 4 }, bounds(across))?.end, { line: 2, col: 0 });
44
+ });
45
+
46
+ test("line selection includes complete lines even at the final line without a newline", () => {
47
+ const engine = new VimVisualEngine();
48
+ engine.start("line", { line: 1, col: 1 });
49
+ assert.deepEqual(engine.range("one\ntwo\nthree", { line: 2, col: 2 }, bounds("one\ntwo\nthree")), {
50
+ start: { line: 1, col: 0 }, end: { line: 2, col: 5 }, linewise: true,
51
+ });
52
+ });
@@ -0,0 +1,110 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { DEFAULT_VISUAL_SETTINGS, DENSITY, HEADER_PLACEMENT, parseVisualSettingsFile, resolveVisualSettings, STATUS_PLACEMENT, VISUAL_SECTION_KEYS, writeVisualSettings, VISUAL_SCHEMA } from "../lib/visual-customization-policy.ts";
7
+
8
+ test("missing settings use responsive defaults and return independent copies", () => {
9
+ const home = mkdtempSync(join(tmpdir(), "visual-policy-"));
10
+ try {
11
+ const resolved = resolveVisualSettings({ gentlePiConfigHome: home });
12
+ assert.deepEqual(resolved.settings, DEFAULT_VISUAL_SETTINGS);
13
+ assert.equal(DEFAULT_VISUAL_SETTINGS.statusPlacement, "auto");
14
+ assert.equal(DEFAULT_VISUAL_SETTINGS.headerPlacement, "top");
15
+ assert.equal(DEFAULT_VISUAL_SETTINGS.density, "comfortable");
16
+ assert.deepEqual(DEFAULT_VISUAL_SETTINGS.visibility, { changes: true, rdd: true, agents: true, todo: true, usageCost: true, modelDetails: true });
17
+ resolved.settings.visibility.agents = false;
18
+ assert.equal(DEFAULT_VISUAL_SETTINGS.visibility.agents, true);
19
+ assert.equal(resolveVisualSettings({ gentlePiConfigHome: home }).settings.visibility.agents, true);
20
+ } finally { rmSync(home, { recursive: true, force: true }); }
21
+ });
22
+
23
+ test("strict versioned parsing rejects malformed and partial settings", () => {
24
+ const valid = { schema: VISUAL_SCHEMA, ...DEFAULT_VISUAL_SETTINGS };
25
+ assert.deepEqual(parseVisualSettingsFile(JSON.stringify(valid)), DEFAULT_VISUAL_SETTINGS);
26
+ for (const value of ["{", "null", "[]", JSON.stringify({ ...valid, schema: "future" }), JSON.stringify({ ...valid, density: "dense" }), JSON.stringify({ ...valid, visibility: { ...valid.visibility, todo: "yes" } }), JSON.stringify({ ...valid, extra: 1 }), JSON.stringify({ ...valid, visibility: { changes: true } })]) {
27
+ assert.equal(parseVisualSettingsFile(value), undefined);
28
+ }
29
+ });
30
+
31
+ test("Sections rows follow one ordered key list that covers every visibility key", () => {
32
+ assert.deepEqual(VISUAL_SECTION_KEYS, ["changes", "rdd", "agents", "todo", "usageCost", "modelDetails"]);
33
+ assert.deepEqual([...VISUAL_SECTION_KEYS].sort(), Object.keys(DEFAULT_VISUAL_SETTINGS.visibility).sort());
34
+ });
35
+
36
+ test("settings saved before the rdd section existed load as valid with rdd shown", () => {
37
+ const legacy = { schema: VISUAL_SCHEMA, ...DEFAULT_VISUAL_SETTINGS, visibility: { changes: false, agents: true, todo: false, usageCost: true, modelDetails: false } };
38
+ const expected = { ...DEFAULT_VISUAL_SETTINGS, visibility: { changes: false, rdd: true, agents: true, todo: false, usageCost: true, modelDetails: false } };
39
+ assert.deepEqual(parseVisualSettingsFile(JSON.stringify(legacy)), expected);
40
+ assert.deepEqual(parseVisualSettingsFile(JSON.stringify({ ...legacy, visibility: { ...expected.visibility, rdd: false } })), { ...expected, visibility: { ...expected.visibility, rdd: false } });
41
+ for (const visibility of [{ ...legacy.visibility, extra: true }, { ...legacy.visibility, rdd: "no" }, { ...legacy.visibility, todo: "yes" }, { agents: true, todo: true, usageCost: true, modelDetails: true, rdd: true }]) {
42
+ assert.equal(parseVisualSettingsFile(JSON.stringify({ ...legacy, visibility })), undefined);
43
+ }
44
+ const home = mkdtempSync(join(tmpdir(), "visual-policy-"));
45
+ try {
46
+ writeFileSync(join(home, "visual-customization.json"), JSON.stringify(legacy));
47
+ const resolved = resolveVisualSettings({ gentlePiConfigHome: home });
48
+ assert.equal(resolved.malformed, false);
49
+ assert.deepEqual(resolved.settings, expected);
50
+ const { rdd: _rdd, ...legacyVisibility } = expected.visibility;
51
+ assert.throws(() => writeVisualSettings({ ...expected, visibility: legacyVisibility } as typeof expected), /Invalid visual settings/);
52
+ } finally { rmSync(home, { recursive: true, force: true }); }
53
+ });
54
+
55
+ test("an unreadable settings path is a read error, not malformed content", () => {
56
+ const home = mkdtempSync(join(tmpdir(), "visual-policy-"));
57
+ try {
58
+ mkdirSync(join(home, "visual-customization.json"));
59
+ const result = resolveVisualSettings({ gentlePiConfigHome: home });
60
+ assert.equal(result.malformed, false);
61
+ assert.equal(result.readError, true);
62
+ assert.equal(result.source, "global_file");
63
+ assert.deepEqual(result.settings, DEFAULT_VISUAL_SETTINGS);
64
+ } finally { rmSync(home, { recursive: true, force: true }); }
65
+ });
66
+
67
+ test("failed rename preserves the destination and removes the temporary file", () => {
68
+ const home = mkdtempSync(join(tmpdir(), "visual-policy-"));
69
+ try {
70
+ const destination = join(home, "visual-customization.json");
71
+ mkdirSync(destination);
72
+ assert.throws(() => writeVisualSettings(DEFAULT_VISUAL_SETTINGS, { gentlePiConfigHome: home }));
73
+ assert.deepEqual(readdirSync(home), ["visual-customization.json"]);
74
+ assert.equal(statSync(destination).isDirectory(), true);
75
+ } finally { rmSync(home, { recursive: true, force: true }); }
76
+ });
77
+
78
+ test("each placement and density value survives a versioned write and read", () => {
79
+ const home = mkdtempSync(join(tmpdir(), "visual-policy-"));
80
+ try {
81
+ for (const statusPlacement of Object.values(STATUS_PLACEMENT)) {
82
+ for (const headerPlacement of Object.values(HEADER_PLACEMENT)) {
83
+ for (const density of Object.values(DENSITY)) {
84
+ const settings = { ...DEFAULT_VISUAL_SETTINGS, statusPlacement, headerPlacement, density };
85
+ writeVisualSettings(settings, { gentlePiConfigHome: home });
86
+ assert.deepEqual(resolveVisualSettings({ gentlePiConfigHome: home }).settings, settings);
87
+ }
88
+ }
89
+ }
90
+ } finally { rmSync(home, { recursive: true, force: true }); }
91
+ });
92
+
93
+ test("atomic write round trips and invalid existing files fall back without overwriting", () => {
94
+ const home = mkdtempSync(join(tmpdir(), "visual-policy-"));
95
+ try {
96
+ const settings = { ...DEFAULT_VISUAL_SETTINGS, statusPlacement: "hidden" as const, headerPlacement: "below-input" as const, density: "minimal" as const, visibility: { ...DEFAULT_VISUAL_SETTINGS.visibility, agents: false } };
97
+ const path = writeVisualSettings(settings, { gentlePiConfigHome: home });
98
+ assert.equal(path, join(home, "visual-customization.json"));
99
+ if (process.platform !== "win32") assert.equal(statSync(path).mode & 0o777, 0o600);
100
+ assert.deepEqual(resolveVisualSettings({ gentlePiConfigHome: home }).settings, settings);
101
+ assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { schema: VISUAL_SCHEMA, ...settings });
102
+ const validFile = readFileSync(path, "utf8");
103
+ assert.throws(() => writeVisualSettings({ ...settings, density: "bad" as typeof settings.density }, { gentlePiConfigHome: home }));
104
+ assert.equal(readFileSync(path, "utf8"), validFile);
105
+ writeFileSync(path, "broken");
106
+ assert.deepEqual(resolveVisualSettings({ gentlePiConfigHome: home }).settings, DEFAULT_VISUAL_SETTINGS);
107
+ assert.equal(resolveVisualSettings({ gentlePiConfigHome: home }).malformed, true);
108
+ assert.equal(readFileSync(path, "utf8"), "broken");
109
+ } finally { rmSync(home, { recursive: true, force: true }); }
110
+ });