savepoint 1.0.2 → 1.0.4

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 (242) hide show
  1. package/.claude/settings.local.json +12 -1
  2. package/.github/workflows/ci.yml +20 -0
  3. package/.golangci.yml +11 -0
  4. package/.savepoint/Design.md +40 -38
  5. package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
  6. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
  7. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
  8. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
  9. package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
  10. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
  11. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
  12. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
  13. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
  14. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
  15. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
  16. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
  17. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
  18. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
  19. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
  20. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
  21. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
  22. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
  23. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
  24. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
  25. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
  26. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
  27. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
  28. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
  29. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
  30. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
  31. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
  32. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
  33. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
  34. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
  35. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
  36. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
  37. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
  38. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
  39. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
  40. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
  41. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
  42. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
  43. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
  44. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
  45. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
  46. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
  47. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
  48. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
  49. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
  50. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
  51. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
  52. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
  53. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
  54. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
  55. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
  56. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
  57. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
  58. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
  59. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
  60. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
  61. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
  62. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
  63. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
  64. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
  65. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
  66. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
  67. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
  68. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
  69. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
  70. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
  71. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
  72. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
  73. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
  74. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
  75. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
  76. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
  77. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
  78. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
  79. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
  80. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
  81. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
  82. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
  83. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
  84. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
  85. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Audit.md +272 -0
  86. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +60 -0
  87. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
  88. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +34 -0
  89. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
  90. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
  91. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
  92. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
  93. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +33 -0
  94. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T008-ci-and-release-automation.md +46 -0
  95. package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
  96. package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
  97. package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
  98. package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
  99. package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
  100. package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
  101. package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
  102. package/.savepoint/router.md +10 -17
  103. package/AGENTS.md +39 -24
  104. package/Makefile +3 -1
  105. package/README.md +0 -1
  106. package/agent-skills/savepoint-audit/SKILL.md +86 -34
  107. package/agent-skills/savepoint-build-task/SKILL.md +7 -2
  108. package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
  109. package/agent-skills/savepoint-create-task/SKILL.md +44 -31
  110. package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
  111. package/agent-skills/savepoint-system-design/SKILL.md +7 -2
  112. package/agent_skills_test.go +91 -0
  113. package/cmd/board.go +59 -0
  114. package/cmd/board_test.go +137 -0
  115. package/cmd/doctor.go +53 -0
  116. package/cmd/doctor_test.go +146 -0
  117. package/cmd/init.go +63 -0
  118. package/cmd/init_test.go +104 -0
  119. package/internal/board/board.go +44 -36
  120. package/internal/board/board_test.go +27 -82
  121. package/internal/board/card.go +43 -23
  122. package/internal/board/card_test.go +74 -5
  123. package/internal/board/column.go +75 -15
  124. package/internal/board/column_test.go +76 -2
  125. package/internal/board/debug.go +26 -0
  126. package/internal/board/debug_test.go +108 -0
  127. package/internal/board/detail.go +33 -47
  128. package/internal/board/detail_test.go +48 -0
  129. package/internal/board/epic_panel.go +120 -22
  130. package/internal/board/epic_panel_test.go +302 -17
  131. package/internal/board/help.go +1 -0
  132. package/internal/board/help_test.go +1 -0
  133. package/internal/board/integration_test.go +266 -0
  134. package/internal/board/interfaces.go +65 -0
  135. package/internal/board/interfaces_test.go +114 -0
  136. package/internal/board/io.go +93 -0
  137. package/internal/board/model.go +79 -118
  138. package/internal/board/plain.go +88 -0
  139. package/internal/board/plain_test.go +117 -0
  140. package/internal/board/release.go +1 -9
  141. package/internal/board/release_test.go +6 -6
  142. package/internal/board/status.go +4 -4
  143. package/internal/board/theme.go +24 -0
  144. package/internal/board/theme_test.go +31 -0
  145. package/internal/board/transitions.go +113 -88
  146. package/internal/board/transitions_test.go +164 -141
  147. package/internal/board/tui.go +32 -0
  148. package/internal/board/update.go +344 -215
  149. package/internal/board/update_test.go +326 -18
  150. package/internal/board/util.go +76 -0
  151. package/internal/board/view.go +31 -28
  152. package/internal/board/view_test.go +74 -2
  153. package/internal/board/watch.go +41 -5
  154. package/internal/buildtool/main.go +45 -15
  155. package/internal/buildtool/main_test.go +224 -0
  156. package/internal/data/config.go +17 -3
  157. package/internal/data/config_test.go +49 -0
  158. package/internal/data/discover.go +26 -0
  159. package/internal/data/discover_test.go +34 -10
  160. package/internal/data/errors.go +4 -0
  161. package/internal/data/fuzz_test.go +75 -0
  162. package/internal/data/lifecycle.go +13 -6
  163. package/internal/data/lifecycle_test.go +14 -11
  164. package/internal/data/parser.go +22 -6
  165. package/internal/data/parser_test.go +31 -7
  166. package/internal/data/task.go +0 -9
  167. package/internal/data/testdata/fuzz/FuzzSplitFrontmatterBody/68eb66b0fe91e7e3 +2 -0
  168. package/internal/data/write.go +88 -11
  169. package/internal/data/write_test.go +167 -0
  170. package/internal/doctor/checks.go +567 -0
  171. package/internal/doctor/checks_test.go +716 -0
  172. package/internal/doctor/gates.go +193 -0
  173. package/internal/doctor/gates_test.go +166 -0
  174. package/internal/doctor/interfaces.go +64 -0
  175. package/internal/doctor/interfaces_test.go +104 -0
  176. package/internal/doctor/repairs.go +80 -0
  177. package/internal/doctor/repairs_test.go +81 -0
  178. package/internal/doctor/report.go +157 -0
  179. package/internal/doctor/report_test.go +89 -0
  180. package/internal/init/clipboard.go +146 -0
  181. package/internal/init/clipboard_test.go +74 -0
  182. package/internal/init/install.go +16 -0
  183. package/internal/init/integration_test.go +197 -0
  184. package/internal/init/prompt.go +14 -0
  185. package/internal/init/prompt_test.go +77 -0
  186. package/internal/init/scaffold.go +59 -0
  187. package/internal/init/scaffold_test.go +179 -0
  188. package/internal/init/template_freshness_test.go +56 -0
  189. package/internal/init/validate.go +85 -0
  190. package/internal/init/validate_test.go +141 -0
  191. package/internal/init/write.go +73 -0
  192. package/internal/init/write_test.go +91 -0
  193. package/internal/styles/styles_test.go +133 -0
  194. package/internal/testutil/fixture.go +113 -0
  195. package/internal/testutil/fs.go +26 -0
  196. package/main.go +120 -4
  197. package/package.json +2 -2
  198. package/project-audit/audit_report_glm_5.1.md +411 -0
  199. package/project-audit/audit_report_opus_4.6.md +406 -0
  200. package/project-audit/consolidated-audit-report.md +456 -0
  201. package/templates/project/.savepoint/Design.md +2 -2
  202. package/templates/project/.savepoint/router.md +10 -10
  203. package/templates/project/AGENTS.md +33 -21
  204. package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
  205. package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
  206. package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
  207. package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
  208. package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
  209. package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
  210. package/templates/prompts/audit-reconciliation.prompt.md +33 -28
  211. package/templates/prompts/design.prompt.md +3 -1
  212. package/.savepoint/audit/v1/E01/proposals.md +0 -168
  213. package/.savepoint/audit/v1/E01/snapshot.md +0 -78
  214. package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
  215. package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
  216. package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
  217. package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
  218. package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
  219. package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
  220. package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
  221. package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
  222. package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
  223. package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
  224. package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
  225. package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
  226. package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
  227. package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
  228. package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
  229. package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
  230. package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
  231. package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
  232. package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
  233. package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
  234. package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
  235. package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
  236. package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
  237. package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
  238. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
  239. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
  240. package/ink-cli-ui-design.zip +0 -0
  241. package/savepoint +0 -0
  242. package/savepoint.exe +0 -0
@@ -5,9 +5,11 @@ import (
5
5
  "path/filepath"
6
6
  "strings"
7
7
  "testing"
8
+ "time"
8
9
 
9
10
  tea "github.com/charmbracelet/bubbletea"
10
11
  "github.com/opencode/savepoint/internal/data"
12
+ "github.com/opencode/savepoint/internal/testutil"
11
13
  )
12
14
 
13
15
  func requireModel(t *testing.T, got tea.Model) Model {
@@ -178,7 +180,14 @@ func TestUpdate_unknownMsgNoOp(t *testing.T) {
178
180
  }
179
181
  }
180
182
 
181
- func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
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) {
182
191
  root := writeRouterFixture(t)
183
192
  tasks := []data.Task{
184
193
  {ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
@@ -187,8 +196,9 @@ func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
187
196
  m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
188
197
  m.Root = root
189
198
 
190
- got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
191
- updated := requireModel(t, got)
199
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
200
+ first := requireModel(t, got)
201
+ updated := processCmd(t, first, cmd)
192
202
 
193
203
  if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
194
204
  t.Fatalf("StatusMessage = %q", updated.StatusMessage)
@@ -208,7 +218,7 @@ func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
208
218
  }
209
219
  }
210
220
 
211
- func TestUpdate_mSetsAuditPendingForLastUncompletedTask(t *testing.T) {
221
+ func TestUpdate_pSetsRouterToFocusedTaskWhenItIsLastUncompleted(t *testing.T) {
212
222
  root := writeRouterFixture(t)
213
223
  tasks := []data.Task{
214
224
  {ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
@@ -217,28 +227,47 @@ func TestUpdate_mSetsAuditPendingForLastUncompletedTask(t *testing.T) {
217
227
  m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
218
228
  m.Root = root
219
229
 
220
- got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
221
- updated := requireModel(t, got)
230
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
231
+ first := requireModel(t, got)
232
+ updated := processCmd(t, first, cmd)
222
233
 
223
- if updated.StatusMessage != "Audit pending for E05-tasking-permissions" {
234
+ if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
224
235
  t.Fatalf("StatusMessage = %q", updated.StatusMessage)
225
236
  }
226
237
  state := readRouterFixture(t, root)
227
- if state.State != "audit-pending" {
228
- t.Errorf("router state = %q, want audit-pending", state.State)
238
+ if state.State != "task-building" {
239
+ t.Errorf("router state = %q, want task-building", state.State)
229
240
  }
230
- if state.Task != "" {
231
- t.Errorf("router task = %q, want empty", state.Task)
241
+ if state.Task != "E05-tasking-permissions/T004-implement-m-hotkey" {
242
+ t.Errorf("router task = %q, want focused task", state.Task)
232
243
  }
233
244
  }
234
245
 
235
- func TestUpdate_mDoesNothingWhenOverlayOpen(t *testing.T) {
246
+ func TestUpdate_pDoesNothingWhenOverlayOpen(t *testing.T) {
236
247
  root := writeRouterFixture(t)
237
248
  tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned}}
238
249
  m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
239
250
  m.Root = root
240
251
  m.Overlay = OverlayHelp
241
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
+
242
271
  got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
243
272
  updated := requireModel(t, got)
244
273
 
@@ -251,14 +280,14 @@ func TestUpdate_mDoesNothingWhenOverlayOpen(t *testing.T) {
251
280
  }
252
281
  }
253
282
 
254
- func TestUpdate_mDoesNotSetDoneTask(t *testing.T) {
283
+ func TestUpdate_pDoesNotSetDoneTask(t *testing.T) {
255
284
  root := writeRouterFixture(t)
256
285
  tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone}}
257
286
  m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
258
287
  m.Root = root
259
288
  m.FocusedColumn = data.ColumnDone
260
289
 
261
- got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
290
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
262
291
  updated := requireModel(t, got)
263
292
 
264
293
  if updated.StatusMessage != "Router not updated: focused task is done" {
@@ -270,13 +299,292 @@ func TestUpdate_mDoesNotSetDoneTask(t *testing.T) {
270
299
  }
271
300
  }
272
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 TestEnsureFocusedTaskVisible_lastTaskAlwaysVisible(t *testing.T) {
558
+ // 5 tasks, pageSize=4 at standard height. Pressing down past the page
559
+ // boundary must keep the focused task in the rendered window.
560
+ tasks := make([]data.Task, 5)
561
+ for i := range tasks {
562
+ tasks[i] = data.Task{ID: "T00" + string(rune('1'+i)), Column: data.ColumnPlanned}
563
+ }
564
+ m := NewModel(tasks, "v1", "E01")
565
+ m.Width = 100
566
+ m.Height = 24
567
+ m.FocusedColumn = data.ColumnPlanned
568
+ m.FocusedTask = 4
569
+
570
+ m.ensureFocusedTaskVisible()
571
+
572
+ offset := m.ColumnOffsets[data.ColumnPlanned]
573
+ pageSize := m.columnPageSize() // 4
574
+ if offset+pageSize-1 < 4 {
575
+ t.Errorf("offset=%d pageSize=%d: focused task 4 not in [offset, offset+pageSize-1]", offset, pageSize)
576
+ }
577
+ // Render must actually show the focused task (not cut off by scroll indicator).
578
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, CalculateLayout(100, 24).ContentHeight, offset, 4, true, nil)
579
+ if !strings.Contains(got, tasks[4].ID) {
580
+ t.Errorf("focused last task %q not visible in rendered column (offset=%d):\n%s", tasks[4].ID, offset, got)
581
+ }
582
+ }
583
+
273
584
  func writeRouterFixture(t *testing.T) string {
274
585
  t.Helper()
275
586
  root := t.TempDir()
276
- content := "# Agent State Machine\n\n## Current state\n\n```yaml\nstate: task-building\nrelease: v1.1\nepic: E05\ntask: T001\nnext_action: Build T001.\n```\n"
277
- if err := os.WriteFile(filepath.Join(root, "router.md"), []byte(content), 0644); err != nil {
278
- t.Fatal(err)
279
- }
587
+ testutil.WriteRouter(t, root, "task-building", "v1.1", "E05", "T001", "Build T001.")
280
588
  return root
281
589
  }
282
590
 
@@ -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
+ }
@@ -28,9 +28,18 @@ func (m Model) View() string {
28
28
 
29
29
  header := m.renderHeader(w)
30
30
  nextActivity := m.renderNextActivityLine(w)
31
- layout := CalculateLayoutWithChrome(w, h, extraHeaderLines(nextActivity))
31
+ extra := extraHeaderLines(nextActivity)
32
+ layout := CalculateLayoutWithChrome(w, h, extra)
32
33
  topDivider := dividerLine(w)
33
34
  board := m.renderBoard(layout)
35
+ boardBudget := h - 8 - extra
36
+ if boardBudget < 0 {
37
+ boardBudget = 0
38
+ }
39
+ boardLines := strings.Split(board, "\n")
40
+ if len(boardLines) > boardBudget {
41
+ board = strings.Join(boardLines[:boardBudget], "\n")
42
+ }
34
43
  bottomDivider := dividerLine(w)
35
44
  footer := m.renderFooter(w)
36
45
  sections := []string{header}
@@ -67,11 +76,13 @@ func (m Model) View() string {
67
76
 
68
77
  if m.Overlay == OverlayEpicDetail {
69
78
  ow := overlayWidth(w)
70
- epicSlug := ""
71
- if m.EpicPanelCursor >= 0 && m.EpicPanelCursor < len(m.Epics) {
72
- epicSlug = m.Epics[m.EpicPanelCursor]
79
+ epicSlug := m.epicDetailEpic()
80
+ var detail string
81
+ if m.EpicDetailTab == 1 {
82
+ detail = RenderEpicAuditTab(epicSlug, m.EpicAuditContent, ow, detailMaxHeight(h), m.EpicDetailOffset, m.EpicDetailTab)
83
+ } else {
84
+ detail = RenderEpicDetail(epicSlug, m.EpicDetailContent, ow, detailMaxHeight(h), m.EpicDetailOffset, m.EpicDetailTab)
73
85
  }
74
- detail := RenderEpicDetail(epicSlug, m.EpicDetailContent, ow, detailMaxHeight(h), m.EpicDetailOffset)
75
86
  return overlayOnBase(dimLines(base), detail, w, h)
76
87
  }
77
88
 
@@ -137,13 +148,13 @@ func FormatNextActivity(state *data.RouterState) string {
137
148
  var s string
138
149
  switch state.State {
139
150
  case "task-building":
140
- s = fmt.Sprintf("Build %s %s/%s", state.Release, shortRouterID(state.Epic), shortRouterID(state.Task))
151
+ s = fmt.Sprintf("Build %s %s/%s", state.Release, shortID(state.Epic), shortID(state.Task))
141
152
  case "audit-pending":
142
- s = fmt.Sprintf("Audit %s", shortRouterID(state.Epic))
153
+ s = fmt.Sprintf("Audit %s", shortID(state.Epic))
143
154
  case "epic-design":
144
- s = fmt.Sprintf("Design %s", shortRouterID(state.Epic))
155
+ s = fmt.Sprintf("Design %s", shortID(state.Epic))
145
156
  case "epic-task-breakdown":
146
- s = fmt.Sprintf("Plan %s", shortRouterID(state.Epic))
157
+ s = fmt.Sprintf("Plan %s", shortID(state.Epic))
147
158
  case "pre-implementation":
148
159
  s = fmt.Sprintf("Planning %s", state.Release)
149
160
  default:
@@ -152,19 +163,7 @@ func FormatNextActivity(state *data.RouterState) string {
152
163
  return xansi.Truncate(s, 20, "…")
153
164
  }
154
165
 
155
- // shortRouterID extracts the compact prefix from a full router slug.
156
- // "E01-tui-optimisation/T001-border-resize-fix" → "T001"
157
- // "E01-tui-optimisation" → "E01"
158
- func shortRouterID(full string) string {
159
- part := full
160
- if i := strings.LastIndex(full, "/"); i >= 0 {
161
- part = full[i+1:]
162
- }
163
- if i := strings.Index(part, "-"); i >= 0 {
164
- return part[:i]
165
- }
166
- return part
167
- }
166
+
168
167
 
169
168
  func (m Model) focusedTask() (data.Task, bool) {
170
169
  tasks := m.Tasks[m.FocusedColumn]
@@ -243,7 +242,7 @@ func (m Model) renderBoard(layout Layout) string {
243
242
  cols := m.renderColumns(layout)
244
243
  var content string
245
244
  if layout.EpicPanelVisible {
246
- epic := m.renderEpicPanel(layout.EpicPanelWidth)
245
+ epic := m.renderEpicPanel(layout.EpicPanelWidth, layout.ContentHeight)
247
246
  content = lipgloss.JoinHorizontal(lipgloss.Top, epic, cols)
248
247
  } else {
249
248
  content = cols
@@ -263,8 +262,8 @@ func (m Model) renderColumns(layout Layout) string {
263
262
  return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
264
263
  }
265
264
 
266
- func (m Model) renderEpicPanel(w int) string {
267
- return RenderEpicSidebar(m.Epics, m.SelectedEpic, w, m.EpicPanelFocus, m.EpicPanelCursor, m.EpicStatus)
265
+ func (m Model) renderEpicPanel(w int, maxHeight int) string {
266
+ return RenderEpicSidebar(m.Epics, m.SelectedEpic, w, m.EpicPanelFocus, m.EpicPanelCursor, m.EpicStatus, maxHeight)
268
267
  }
269
268
 
270
269
  func (m Model) renderColumn(col data.ColumnType, colW, maxHeight int) string {
@@ -291,9 +290,13 @@ func (m Model) renderFooter(termW int) string {
291
290
  styles.FooterDivider.Render(" │ ")+
292
291
  styles.FooterPhaseAudit.Render("AUDIT"),
293
292
  )
294
- hints := footerLine(termW, styles.FooterHints.Render("←/→:nav E:epic R:release ?:help q:quit"))
295
- spacer := footerLine(termW, "")
296
- return lipgloss.JoinVertical(lipgloss.Center, phase, spacer, hints)
293
+ hints := footerLine(termW, styles.FooterHints.Render("←/→:nav p: Priority R:release ?:help q:quit"))
294
+ status := ""
295
+ if m.StatusMessage != "" {
296
+ status = styles.StatusBar.Render(m.StatusMessage)
297
+ }
298
+ statusLine := footerLine(termW, status)
299
+ return lipgloss.JoinVertical(lipgloss.Center, phase, statusLine, hints)
297
300
  }
298
301
 
299
302
  func dividerLine(termW int) string {