savepoint 1.0.1 → 1.0.3

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 (278) hide show
  1. package/.claude/settings.local.json +15 -1
  2. package/.golangci.yml +11 -0
  3. package/.savepoint/Design.md +52 -46
  4. package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
  5. package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
  6. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
  7. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
  8. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/{Design.md → E06-Detail.md} +5 -3
  9. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
  10. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
  11. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
  12. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +2 -0
  13. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/{Design.md → E01-Detail.md} +9 -1
  14. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/{T007-next-activity-header.md → T001-next-activity-header.md} +13 -12
  15. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +9 -9
  16. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +2 -2
  17. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +13 -12
  18. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +14 -13
  19. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +25 -15
  20. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
  21. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md +124 -0
  22. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/{Design.md → E02-Detail.md} +12 -3
  23. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +11 -8
  24. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +12 -7
  25. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +9 -5
  26. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +30 -9
  27. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
  28. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +45 -0
  29. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
  30. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
  31. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
  32. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
  33. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
  34. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Audit.md +167 -0
  35. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
  36. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
  37. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
  38. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
  39. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
  40. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +54 -0
  41. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +45 -0
  42. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +40 -0
  43. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +47 -0
  44. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +98 -0
  45. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +33 -0
  46. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +62 -0
  47. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
  48. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
  49. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
  50. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
  51. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
  52. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
  53. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
  54. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
  55. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
  56. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
  57. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
  58. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
  59. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
  60. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
  61. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
  62. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
  63. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
  64. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
  65. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
  66. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
  67. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
  68. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
  69. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
  70. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
  71. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
  72. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
  73. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
  74. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
  75. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
  76. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
  77. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
  78. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
  79. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
  80. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
  81. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
  82. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
  83. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
  84. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
  85. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
  86. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
  87. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
  88. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
  89. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
  90. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
  91. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
  92. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
  93. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
  94. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
  95. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
  96. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
  97. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
  98. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
  99. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
  100. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
  101. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
  102. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
  103. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
  104. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
  105. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
  106. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
  107. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
  108. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
  109. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
  110. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
  111. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
  112. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
  113. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
  114. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +43 -0
  115. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
  116. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +28 -0
  117. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
  118. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
  119. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
  120. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
  121. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +28 -0
  122. package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
  123. package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
  124. package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
  125. package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
  126. package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
  127. package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
  128. package/.savepoint/releases/v1.1/v1.1-PRD.md +139 -0
  129. package/.savepoint/router.md +29 -108
  130. package/AGENTS.md +69 -111
  131. package/Makefile +19 -3
  132. package/README.md +6 -6
  133. package/agent-skills/savepoint-audit/SKILL.md +87 -35
  134. package/agent-skills/savepoint-build-task/SKILL.md +9 -4
  135. package/agent-skills/savepoint-create-plan/SKILL.md +10 -5
  136. package/agent-skills/savepoint-create-task/SKILL.md +44 -31
  137. package/agent-skills/savepoint-draft-prd/SKILL.md +8 -3
  138. package/agent-skills/savepoint-system-design/SKILL.md +8 -3
  139. package/agent_skills_test.go +91 -0
  140. package/cmd/board.go +59 -0
  141. package/cmd/board_test.go +137 -0
  142. package/cmd/doctor.go +53 -0
  143. package/cmd/doctor_test.go +146 -0
  144. package/cmd/init.go +63 -0
  145. package/cmd/init_test.go +104 -0
  146. package/internal/board/board.go +69 -49
  147. package/internal/board/board_test.go +83 -67
  148. package/internal/board/card.go +71 -20
  149. package/internal/board/card_test.go +141 -12
  150. package/internal/board/column.go +77 -11
  151. package/internal/board/column_test.go +63 -13
  152. package/internal/board/detail.go +107 -72
  153. package/internal/board/detail_test.go +117 -26
  154. package/internal/board/epic_panel.go +211 -18
  155. package/internal/board/epic_panel_test.go +637 -14
  156. package/internal/board/help.go +1 -0
  157. package/internal/board/help_test.go +1 -0
  158. package/internal/board/integration_test.go +266 -0
  159. package/internal/board/interfaces.go +65 -0
  160. package/internal/board/interfaces_test.go +114 -0
  161. package/internal/board/io.go +93 -0
  162. package/internal/board/layout.go +12 -2
  163. package/internal/board/layout_test.go +17 -0
  164. package/internal/board/model.go +130 -52
  165. package/internal/board/plain.go +88 -0
  166. package/internal/board/plain_test.go +117 -0
  167. package/internal/board/release.go +1 -9
  168. package/internal/board/release_test.go +6 -6
  169. package/internal/board/render_policy_test.go +77 -0
  170. package/internal/board/status.go +23 -0
  171. package/internal/board/theme.go +24 -0
  172. package/internal/board/theme_test.go +31 -0
  173. package/internal/board/transitions.go +113 -88
  174. package/internal/board/transitions_test.go +164 -141
  175. package/internal/board/tui.go +32 -0
  176. package/internal/board/update.go +472 -94
  177. package/internal/board/update_test.go +447 -0
  178. package/internal/board/util.go +76 -0
  179. package/internal/board/view.go +139 -22
  180. package/internal/board/view_test.go +171 -3
  181. package/internal/board/watch.go +57 -9
  182. package/internal/buildtool/main.go +211 -0
  183. package/internal/buildtool/main_test.go +46 -0
  184. package/internal/data/config.go +17 -3
  185. package/internal/data/config_test.go +49 -0
  186. package/internal/data/discover.go +26 -0
  187. package/internal/data/discover_test.go +34 -10
  188. package/internal/data/errors.go +4 -0
  189. package/internal/data/lifecycle.go +13 -6
  190. package/internal/data/lifecycle_test.go +14 -11
  191. package/internal/data/parser.go +29 -6
  192. package/internal/data/parser_test.go +66 -7
  193. package/internal/data/task.go +1 -0
  194. package/internal/data/write.go +85 -11
  195. package/internal/data/write_test.go +167 -0
  196. package/internal/doctor/checks.go +567 -0
  197. package/internal/doctor/checks_test.go +716 -0
  198. package/internal/doctor/gates.go +193 -0
  199. package/internal/doctor/gates_test.go +166 -0
  200. package/internal/doctor/interfaces.go +64 -0
  201. package/internal/doctor/interfaces_test.go +104 -0
  202. package/internal/doctor/repairs.go +80 -0
  203. package/internal/doctor/repairs_test.go +81 -0
  204. package/internal/doctor/report.go +157 -0
  205. package/internal/doctor/report_test.go +89 -0
  206. package/internal/init/clipboard.go +146 -0
  207. package/internal/init/clipboard_test.go +74 -0
  208. package/internal/init/install.go +16 -0
  209. package/internal/init/integration_test.go +197 -0
  210. package/internal/init/prompt.go +14 -0
  211. package/internal/init/prompt_test.go +77 -0
  212. package/internal/init/scaffold.go +59 -0
  213. package/internal/init/scaffold_test.go +179 -0
  214. package/internal/init/template_freshness_test.go +56 -0
  215. package/internal/init/validate.go +85 -0
  216. package/internal/init/validate_test.go +141 -0
  217. package/internal/init/write.go +73 -0
  218. package/internal/init/write_test.go +91 -0
  219. package/internal/styles/palette.go +3 -3
  220. package/internal/styles/styles.go +39 -12
  221. package/internal/styles/styles_test.go +133 -0
  222. package/internal/testutil/fixture.go +113 -0
  223. package/internal/testutil/fs.go +26 -0
  224. package/main.go +107 -1
  225. package/package.json +2 -2
  226. package/project-audit/audit_report_glm_5.1.md +411 -0
  227. package/project-audit/audit_report_opus_4.6 +406 -0
  228. package/project-audit/consolidated-audit-report.md +456 -0
  229. package/savepoint +0 -0
  230. package/templates/project/.savepoint/Design.md +2 -2
  231. package/templates/project/.savepoint/router.md +15 -14
  232. package/templates/project/AGENTS.md +56 -98
  233. package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
  234. package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
  235. package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
  236. package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
  237. package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
  238. package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
  239. package/templates/prompts/audit-reconciliation.prompt.md +35 -30
  240. package/templates/prompts/design.prompt.md +3 -1
  241. package/templates/prompts/epic-design.prompt.md +3 -3
  242. package/templates/prompts/task-breakdown.prompt.md +1 -1
  243. package/templates/prompts/task-building.prompt.md +1 -1
  244. package/templates/prompts/task-planning.prompt.md +1 -1
  245. package/.savepoint/audit/E01-go-setup/proposals.md +0 -166
  246. package/.savepoint/audit/E01-go-setup/snapshot.md +0 -71
  247. package/.savepoint/audit/E01-scaffolding/proposals/AGENTS.md +0 -66
  248. package/.savepoint/audit/E01-scaffolding/proposals/Design.md +0 -210
  249. package/.savepoint/audit/E01-scaffolding/proposals/epic-Design.md +0 -117
  250. package/.savepoint/audit/E01-scaffolding/proposals/quality-review.md +0 -101
  251. package/.savepoint/audit/E01-scaffolding/snapshot.md +0 -54
  252. package/.savepoint/audit/E02-data-model/snapshot.md +0 -128
  253. package/.savepoint/audit/E02-data-readers/proposals.md +0 -123
  254. package/.savepoint/audit/E02-data-readers/snapshot.md +0 -54
  255. package/.savepoint/audit/E03-board-tui-core/proposals.md +0 -146
  256. package/.savepoint/audit/E03-board-tui-core/snapshot.md +0 -57
  257. package/.savepoint/audit/E03-cli-foundation/snapshot.md +0 -106
  258. package/.savepoint/audit/E04-board-components/proposals.md +0 -118
  259. package/.savepoint/audit/E04-board-components/snapshot.md +0 -77
  260. package/.savepoint/audit/E04-templates-and-prompts/snapshot.md +0 -115
  261. package/.savepoint/audit/E05-init-command/snapshot.md +0 -125
  262. package/.savepoint/audit/E05-phase-transitions/proposals.md +0 -83
  263. package/.savepoint/audit/E05-phase-transitions/snapshot.md +0 -36
  264. package/.savepoint/audit/E06-atari-noir-layout/proposals.md +0 -130
  265. package/.savepoint/audit/E06-atari-noir-layout/snapshot.md +0 -84
  266. package/.savepoint/audit/E06-tui-board/snapshot.md +0 -64
  267. package/.savepoint/audit/E07-audit-pipeline/snapshot.md +0 -165
  268. package/.savepoint/audit/E08-board-workflow-cleanup/snapshot.md +0 -65
  269. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -36
  270. package/ink-cli-ui-design.zip +0 -0
  271. package/main.exe +0 -0
  272. package/savepoint.exe +0 -0
  273. /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
  274. /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
  275. /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
  276. /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
  277. /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
  278. /package/.savepoint/releases/v1/{PRD.md → v1-PRD.md} +0 -0
@@ -1,10 +1,15 @@
1
1
  package board
2
2
 
3
3
  import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
4
7
  "testing"
8
+ "time"
5
9
 
6
10
  tea "github.com/charmbracelet/bubbletea"
7
11
  "github.com/opencode/savepoint/internal/data"
12
+ "github.com/opencode/savepoint/internal/testutil"
8
13
  )
9
14
 
10
15
  func requireModel(t *testing.T, got tea.Model) Model {
@@ -90,6 +95,54 @@ func TestUpdate_downMovesTaskFocus(t *testing.T) {
90
95
  }
91
96
  }
92
97
 
98
+ func TestUpdate_downAutoScrollsFocusedTaskIntoViewport(t *testing.T) {
99
+ tasks := []data.Task{
100
+ {ID: "T1", Column: data.ColumnPlanned},
101
+ {ID: "T2", Column: data.ColumnPlanned},
102
+ {ID: "T3", Column: data.ColumnPlanned},
103
+ {ID: "T4", Column: data.ColumnPlanned},
104
+ {ID: "T5", Column: data.ColumnPlanned},
105
+ }
106
+ m := NewModel(tasks, "v1", "E03")
107
+ m.Width = 100
108
+ m.Height = 24
109
+ m.FocusedTask = 3
110
+
111
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
112
+ updated := requireModel(t, got)
113
+
114
+ if updated.FocusedTask != 4 {
115
+ t.Errorf("FocusedTask = %d, want 4", updated.FocusedTask)
116
+ }
117
+ if updated.ColumnOffsets[data.ColumnPlanned] != 1 {
118
+ t.Errorf("ColumnOffsets[planned] = %d, want 1", updated.ColumnOffsets[data.ColumnPlanned])
119
+ }
120
+ }
121
+
122
+ func TestUpdate_pageDownScrollsFocusedColumnByPage(t *testing.T) {
123
+ tasks := []data.Task{
124
+ {ID: "T1", Column: data.ColumnPlanned},
125
+ {ID: "T2", Column: data.ColumnPlanned},
126
+ {ID: "T3", Column: data.ColumnPlanned},
127
+ {ID: "T4", Column: data.ColumnPlanned},
128
+ {ID: "T5", Column: data.ColumnPlanned},
129
+ {ID: "T6", Column: data.ColumnPlanned},
130
+ }
131
+ m := NewModel(tasks, "v1", "E03")
132
+ m.Width = 100
133
+ m.Height = 24
134
+
135
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgDown})
136
+ updated := requireModel(t, got)
137
+
138
+ if updated.ColumnOffsets[data.ColumnPlanned] != 2 {
139
+ t.Errorf("ColumnOffsets[planned] = %d, want 2", updated.ColumnOffsets[data.ColumnPlanned])
140
+ }
141
+ if updated.FocusedTask != 2 {
142
+ t.Errorf("FocusedTask = %d, want 2", updated.FocusedTask)
143
+ }
144
+ }
145
+
93
146
  func TestUpdate_upMovesTaskFocus(t *testing.T) {
94
147
  tasks := []data.Task{
95
148
  {ID: "T1", Column: data.ColumnPlanned},
@@ -126,3 +179,397 @@ func TestUpdate_unknownMsgNoOp(t *testing.T) {
126
179
  t.Errorf("Width changed unexpectedly: %d", updated.Width)
127
180
  }
128
181
  }
182
+
183
+ func processCmd(t *testing.T, m Model, cmd tea.Cmd) Model {
184
+ t.Helper()
185
+ msg := cmd()
186
+ got, _ := m.Update(msg)
187
+ return requireModel(t, got)
188
+ }
189
+
190
+ func TestUpdate_pSetsRouterToFocusedTask(t *testing.T) {
191
+ root := writeRouterFixture(t)
192
+ tasks := []data.Task{
193
+ {ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
194
+ {ID: "E05-tasking-permissions/T005-update-help-overlay", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
195
+ }
196
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
197
+ m.Root = root
198
+
199
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
200
+ first := requireModel(t, got)
201
+ updated := processCmd(t, first, cmd)
202
+
203
+ if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
204
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
205
+ }
206
+ state := readRouterFixture(t, root)
207
+ if state.State != "task-building" {
208
+ t.Errorf("router state = %q, want task-building", state.State)
209
+ }
210
+ if state.Release != "v1.1" {
211
+ t.Errorf("router release = %q, want v1.1", state.Release)
212
+ }
213
+ if state.Epic != "E05-tasking-permissions" {
214
+ t.Errorf("router epic = %q, want E05-tasking-permissions", state.Epic)
215
+ }
216
+ if state.Task != "E05-tasking-permissions/T004-implement-m-hotkey" {
217
+ t.Errorf("router task = %q, want focused task", state.Task)
218
+ }
219
+ }
220
+
221
+ func TestUpdate_pSetsRouterToFocusedTaskWhenItIsLastUncompleted(t *testing.T) {
222
+ root := writeRouterFixture(t)
223
+ tasks := []data.Task{
224
+ {ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
225
+ {ID: "E05-tasking-permissions/T003-update-design-md", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone},
226
+ }
227
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
228
+ m.Root = root
229
+
230
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
231
+ first := requireModel(t, got)
232
+ updated := processCmd(t, first, cmd)
233
+
234
+ if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
235
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
236
+ }
237
+ state := readRouterFixture(t, root)
238
+ if state.State != "task-building" {
239
+ t.Errorf("router state = %q, want task-building", state.State)
240
+ }
241
+ if state.Task != "E05-tasking-permissions/T004-implement-m-hotkey" {
242
+ t.Errorf("router task = %q, want focused task", state.Task)
243
+ }
244
+ }
245
+
246
+ func TestUpdate_pDoesNothingWhenOverlayOpen(t *testing.T) {
247
+ root := writeRouterFixture(t)
248
+ tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned}}
249
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
250
+ m.Root = root
251
+ m.Overlay = OverlayHelp
252
+
253
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
254
+ updated := requireModel(t, got)
255
+
256
+ if updated.StatusMessage != "" {
257
+ t.Fatalf("StatusMessage = %q, want empty", updated.StatusMessage)
258
+ }
259
+ state := readRouterFixture(t, root)
260
+ if state.Task != "T001" {
261
+ t.Errorf("router task = %q, want unchanged T001", state.Task)
262
+ }
263
+ }
264
+
265
+ func TestUpdate_mDoesNotSetRouterTask(t *testing.T) {
266
+ root := writeRouterFixture(t)
267
+ tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned}}
268
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
269
+ m.Root = root
270
+
271
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
272
+ updated := requireModel(t, got)
273
+
274
+ if updated.StatusMessage != "" {
275
+ t.Fatalf("StatusMessage = %q, want empty", updated.StatusMessage)
276
+ }
277
+ state := readRouterFixture(t, root)
278
+ if state.Task != "T001" {
279
+ t.Errorf("router task = %q, want unchanged T001", state.Task)
280
+ }
281
+ }
282
+
283
+ func TestUpdate_pDoesNotSetDoneTask(t *testing.T) {
284
+ root := writeRouterFixture(t)
285
+ tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone}}
286
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
287
+ m.Root = root
288
+ m.FocusedColumn = data.ColumnDone
289
+
290
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
291
+ updated := requireModel(t, got)
292
+
293
+ if updated.StatusMessage != "Router not updated: focused task is done" {
294
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
295
+ }
296
+ state := readRouterFixture(t, root)
297
+ if state.Task != "T001" {
298
+ t.Errorf("router task = %q, want unchanged T001", state.Task)
299
+ }
300
+ }
301
+
302
+ func TestUpdate_spaceShowsPhaseTransitionMessage(t *testing.T) {
303
+ tasks := []data.Task{{ID: "E05/T004", Column: data.ColumnInProgress, Stage: data.StageBuild}}
304
+ m := NewModel(tasks, "v1.1", "E05")
305
+ m.FocusedColumn = data.ColumnInProgress
306
+
307
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
308
+ updated := requireModel(t, got)
309
+
310
+ if updated.StatusMessage != "Moved T004 to test" {
311
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
312
+ }
313
+ if updated.AllTasks[0].Stage != data.StageTest {
314
+ t.Errorf("Stage = %q, want test", updated.AllTasks[0].Stage)
315
+ }
316
+ }
317
+
318
+ func TestUpdate_spaceWarnsAfterStaleMtime(t *testing.T) {
319
+ path := filepath.Join(t.TempDir(), "T004-task.md")
320
+ content := "---\nid: E05/T004\nstatus: in_progress\nstage: build\nphase: build\n---\n\n# Task\n"
321
+ testutil.WriteFile(t, path, content)
322
+ fi, err := os.Stat(path)
323
+ if err != nil {
324
+ t.Fatal(err)
325
+ }
326
+ tasks := []data.Task{{
327
+ ID: "E05/T004",
328
+ Column: data.ColumnInProgress,
329
+ Stage: data.StageBuild,
330
+ Path: path,
331
+ Mtime: fi.ModTime().Add(-time.Hour),
332
+ }}
333
+ m := NewModel(tasks, "v1.1", "E05")
334
+ m.FocusedColumn = data.ColumnInProgress
335
+
336
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
337
+ // cmd should be errorMsg since mtime is stale
338
+ msg := cmd()
339
+ if _, ok := msg.(errorMsg); !ok {
340
+ t.Fatalf("expected errorMsg, got %T", msg)
341
+ }
342
+ updated := requireModel(t, got)
343
+ got2, _ := updated.Update(msg)
344
+ updated2 := requireModel(t, got2)
345
+
346
+ if updated2.StatusMessage != "mtime conflict: refresh before retrying" {
347
+ t.Fatalf("StatusMessage = %q", updated2.StatusMessage)
348
+ }
349
+ raw, err := os.ReadFile(path)
350
+ if err != nil {
351
+ t.Fatal(err)
352
+ }
353
+ parsed, err := data.NewParser().ParseTaskFile(path, string(raw))
354
+ if err != nil {
355
+ t.Fatal(err)
356
+ }
357
+ if parsed.Stage != data.StageBuild {
358
+ t.Errorf("persisted Stage = %q, want build", parsed.Stage)
359
+ }
360
+ if !strings.Contains(string(raw), "stage:") {
361
+ t.Error("legacy stage field should remain when write is rejected")
362
+ }
363
+ if updated2.AllTasks[0].Stage != data.StageBuild {
364
+ t.Errorf("model Stage = %q after rejected write, want build", updated2.AllTasks[0].Stage)
365
+ }
366
+ }
367
+
368
+ func TestUpdate_backspaceShowsRetreatMessageAndSyncsStatus(t *testing.T) {
369
+ tasks := []data.Task{{ID: "E05/T004", Status: string(data.ColumnDone), Column: data.ColumnDone}}
370
+ m := NewModel(tasks, "v1.1", "E05")
371
+ m.FocusedColumn = data.ColumnDone
372
+
373
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
374
+ updated := requireModel(t, got)
375
+
376
+ if updated.StatusMessage != "Moved back T004 to audit" {
377
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
378
+ }
379
+ if updated.AllTasks[0].Column != data.ColumnInProgress {
380
+ t.Errorf("Column = %q, want in_progress", updated.AllTasks[0].Column)
381
+ }
382
+ if updated.AllTasks[0].Stage != data.StageAudit {
383
+ t.Errorf("Stage = %q, want audit", updated.AllTasks[0].Stage)
384
+ }
385
+ if updated.AllTasks[0].Status != string(data.ColumnInProgress) {
386
+ t.Errorf("Status = %q, want in_progress", updated.AllTasks[0].Status)
387
+ }
388
+ }
389
+
390
+ func TestUpdate_key1SwitchesToDetailTab(t *testing.T) {
391
+ m := NewModel(nil, "v1.1", "E06-audit-command")
392
+ m.Epics = []string{"E06-audit-command"}
393
+ m.Overlay = OverlayEpicDetail
394
+ m.EpicDetailTab = 1
395
+ m.EpicDetailOffset = 5
396
+
397
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")})
398
+ updated := requireModel(t, got)
399
+
400
+ if updated.EpicDetailTab != 0 {
401
+ t.Errorf("EpicDetailTab = %d, want 0", updated.EpicDetailTab)
402
+ }
403
+ if updated.EpicDetailOffset != 0 {
404
+ t.Errorf("EpicDetailOffset = %d, want 0 (reset on tab switch)", updated.EpicDetailOffset)
405
+ }
406
+ }
407
+
408
+ func TestUpdate_key2SwitchesToAuditTabAndLoadsContent(t *testing.T) {
409
+ root := t.TempDir()
410
+ auditDir := filepath.Join(root, "releases", "v1.1", "epics", "E06-audit-command")
411
+ testutil.MkdirAll(t, auditDir)
412
+ auditContent := "# E06 Audit\n\n## Findings\n\n- [x] All good\n"
413
+ testutil.WriteFile(t, filepath.Join(auditDir, "E06-Audit.md"), auditContent)
414
+
415
+ m := NewModel(nil, "v1.1", "E06-audit-command")
416
+ m.Root = root
417
+ m.Epics = []string{"E06-audit-command"}
418
+ m.EpicPanelCursor = 0
419
+ m.Overlay = OverlayEpicDetail
420
+ m.EpicDetailTab = 0
421
+ m.EpicDetailOffset = 3
422
+
423
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
424
+ updated := requireModel(t, got)
425
+
426
+ if updated.EpicDetailTab != 1 {
427
+ t.Errorf("EpicDetailTab = %d, want 1", updated.EpicDetailTab)
428
+ }
429
+ if updated.EpicDetailOffset != 0 {
430
+ t.Errorf("EpicDetailOffset = %d, want 0 (reset on tab switch)", updated.EpicDetailOffset)
431
+ }
432
+
433
+ msg := cmd()
434
+ got2, _ := updated.Update(msg)
435
+ updated2 := requireModel(t, got2)
436
+ if updated2.EpicAuditContent != auditContent {
437
+ t.Errorf("EpicAuditContent = %q, want %q", updated2.EpicAuditContent, auditContent)
438
+ }
439
+ }
440
+
441
+ func TestUpdate_key2LoadsAuditForOpenedEpicWhenPanelCursorStale(t *testing.T) {
442
+ root := t.TempDir()
443
+ epicA := filepath.Join(root, "releases", "v1.1", "epics", "E02-cross-platform-compatibility")
444
+ epicB := filepath.Join(root, "releases", "v1.1", "epics", "E06-audit-command")
445
+ testutil.MkdirAll(t, epicA)
446
+ testutil.MkdirAll(t, epicB)
447
+ auditContent := "# E06 Audit\n\n## Main Findings\nE06 content\n"
448
+ testutil.WriteFile(t, filepath.Join(epicB, "E06-Audit.md"), auditContent)
449
+
450
+ m := NewModel(nil, "v1.1", "E06-audit-command")
451
+ m.Root = root
452
+ m.Epics = []string{"E02-cross-platform-compatibility", "E06-audit-command"}
453
+ m.SelectedEpic = "E06-audit-command"
454
+ m.EpicDetailEpic = "E06-audit-command"
455
+ m.EpicPanelCursor = 0
456
+ m.Overlay = OverlayEpicDetail
457
+
458
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
459
+ updated := requireModel(t, got)
460
+
461
+ msg := cmd()
462
+ got2, _ := updated.Update(msg)
463
+ updated2 := requireModel(t, got2)
464
+ if updated2.EpicAuditContent != auditContent {
465
+ t.Errorf("EpicAuditContent = %q, want opened epic audit content", updated2.EpicAuditContent)
466
+ }
467
+ }
468
+
469
+ func TestUpdate_key2FallsBackWhenNoAuditFile(t *testing.T) {
470
+ m := NewModel(nil, "v1.1", "E06-audit-command")
471
+ m.Epics = []string{"E06-audit-command"}
472
+ m.EpicPanelCursor = 0
473
+ m.Overlay = OverlayEpicDetail
474
+
475
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
476
+ updated := requireModel(t, got)
477
+
478
+ if updated.EpicDetailTab != 1 {
479
+ t.Errorf("EpicDetailTab = %d, want 1", updated.EpicDetailTab)
480
+ }
481
+
482
+ msg := cmd()
483
+ got2, _ := updated.Update(msg)
484
+ updated2 := requireModel(t, got2)
485
+ if updated2.EpicAuditContent != "(no audit available)" {
486
+ t.Errorf("EpicAuditContent = %q, want \"(no audit available)\"", updated2.EpicAuditContent)
487
+ }
488
+ }
489
+
490
+ func TestUpdate_key2CachesAuditContent(t *testing.T) {
491
+ m := NewModel(nil, "v1.1", "E06-audit-command")
492
+ m.Epics = []string{"E06-audit-command"}
493
+ m.EpicPanelCursor = 0
494
+ m.Overlay = OverlayEpicDetail
495
+ m.EpicAuditContent = "already cached"
496
+
497
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
498
+ updated := requireModel(t, got)
499
+
500
+ if updated.EpicAuditContent != "already cached" {
501
+ t.Errorf("EpicAuditContent = %q, want cached value preserved", updated.EpicAuditContent)
502
+ }
503
+ }
504
+
505
+ func TestUpdate_openEpicDetailOverlayResetsTabState(t *testing.T) {
506
+ m := NewModel(nil, "v1.1", "E06-audit-command")
507
+ m.Epics = []string{"E06-audit-command"}
508
+ m.EpicPanelCursor = 0
509
+ m.EpicDetailTab = 1
510
+ m.EpicAuditContent = "stale content"
511
+
512
+ m.openEpicDetailOverlay()
513
+
514
+ if m.EpicDetailTab != 0 {
515
+ t.Errorf("EpicDetailTab = %d, want 0 after overlay open", m.EpicDetailTab)
516
+ }
517
+ if m.EpicAuditContent != "" {
518
+ t.Errorf("EpicAuditContent = %q, want empty after overlay open", m.EpicAuditContent)
519
+ }
520
+ }
521
+
522
+ func TestUpdate_tabKeysNoopOutsideEpicDetailOverlay(t *testing.T) {
523
+ m := NewModel(nil, "v1.1", "E06-audit-command")
524
+ m.Overlay = OverlayHelp
525
+ m.EpicDetailTab = 0
526
+
527
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
528
+ updated := requireModel(t, got)
529
+
530
+ if updated.EpicDetailTab != 0 {
531
+ t.Errorf("EpicDetailTab changed outside EpicDetail overlay: got %d", updated.EpicDetailTab)
532
+ }
533
+ }
534
+
535
+ func TestReloadMsgUpdatesRouterState(t *testing.T) {
536
+ m := NewModel(nil, "v1", "E01")
537
+ m.RouterState = &data.RouterState{State: "task-building", Task: "E01/T001", NextAction: "Build E01/T001."}
538
+ m.RouterTask = "E01/T001"
539
+
540
+ newState := &data.RouterState{State: "task-building", Task: "E01/T002", NextAction: "Build E01/T002."}
541
+ got, _ := m.Update(reloadMsg{
542
+ tasks: nil,
543
+ releases: []string{"v1"},
544
+ releaseEpics: map[string][]string{"v1": {"E01"}},
545
+ routerState: newState,
546
+ })
547
+ updated := requireModel(t, got)
548
+
549
+ if updated.RouterTask != "E01/T002" {
550
+ t.Errorf("RouterTask = %q, want E01/T002", updated.RouterTask)
551
+ }
552
+ if updated.RouterState == nil || updated.RouterState.NextAction != "Build E01/T002." {
553
+ t.Errorf("RouterState.NextAction = %q, want Build E01/T002.", updated.RouterState.NextAction)
554
+ }
555
+ }
556
+
557
+ func writeRouterFixture(t *testing.T) string {
558
+ t.Helper()
559
+ root := t.TempDir()
560
+ testutil.WriteRouter(t, root, "task-building", "v1.1", "E05", "T001", "Build T001.")
561
+ return root
562
+ }
563
+
564
+ func readRouterFixture(t *testing.T, root string) *data.RouterState {
565
+ t.Helper()
566
+ content, err := os.ReadFile(filepath.Join(root, "router.md"))
567
+ if err != nil {
568
+ t.Fatal(err)
569
+ }
570
+ state, err := data.NewRouterReader().ReadState(string(content))
571
+ if err != nil {
572
+ t.Fatal(err)
573
+ }
574
+ return state
575
+ }
@@ -0,0 +1,76 @@
1
+ package board
2
+
3
+ import "strings"
4
+
5
+ // sliceIndex returns the index of target in items, or 0 if not found.
6
+ func sliceIndex(items []string, target string) int {
7
+ for i, e := range items {
8
+ if e == target {
9
+ return i
10
+ }
11
+ }
12
+ return 0
13
+ }
14
+
15
+ // WrapText wraps s to fit within width, splitting on word boundaries.
16
+ func WrapText(s string, width int) []string {
17
+ if width < 4 {
18
+ width = 4
19
+ }
20
+ words := strings.Fields(s)
21
+ if len(words) == 0 {
22
+ return nil
23
+ }
24
+ lines := []string{}
25
+ current := ""
26
+ for _, word := range words {
27
+ if len([]rune(word)) > width {
28
+ if current != "" {
29
+ lines = append(lines, current)
30
+ current = ""
31
+ }
32
+ lines = append(lines, SplitLongWord(word, width)...)
33
+ continue
34
+ }
35
+ if current == "" {
36
+ current = word
37
+ continue
38
+ }
39
+ if len([]rune(current))+1+len([]rune(word)) <= width {
40
+ current += " " + word
41
+ continue
42
+ }
43
+ lines = append(lines, current)
44
+ current = word
45
+ }
46
+ if current != "" {
47
+ lines = append(lines, current)
48
+ }
49
+ return lines
50
+ }
51
+
52
+ // SplitLongWord splits a long word into chunks of at most width runes.
53
+ func SplitLongWord(word string, width int) []string {
54
+ runes := []rune(word)
55
+ lines := []string{}
56
+ for len(runes) > width {
57
+ lines = append(lines, string(runes[:width]))
58
+ runes = runes[width:]
59
+ }
60
+ if len(runes) > 0 {
61
+ lines = append(lines, string(runes))
62
+ }
63
+ return lines
64
+ }
65
+
66
+ // truncate clips s to max runes, appending "…" if clipped.
67
+ func truncate(s string, max int) string {
68
+ runes := []rune(s)
69
+ if len(runes) <= max {
70
+ return s
71
+ }
72
+ if max <= 1 {
73
+ return "…"
74
+ }
75
+ return string(runes[:max-1]) + "…"
76
+ }