savepoint 1.0.0 → 1.0.2

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 (136) hide show
  1. package/.claude/settings.local.json +8 -1
  2. package/.savepoint/Design.md +26 -17
  3. package/.savepoint/audit/v1/E01/proposals.md +168 -0
  4. package/.savepoint/audit/v1/E01/snapshot.md +78 -0
  5. package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/proposals.md +7 -7
  6. package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/snapshot.md +2 -2
  7. package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/AGENTS.md +5 -5
  8. package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/proposals.md +20 -20
  9. package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/snapshot.md +1 -1
  10. package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/proposals.md +11 -11
  11. package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/snapshot.md +1 -1
  12. package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/proposals.md +14 -14
  13. package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/snapshot.md +1 -1
  14. package/.savepoint/audit/{E05-init-command → v1/E05-init-command}/snapshot.md +1 -1
  15. package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/proposals.md +4 -4
  16. package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/snapshot.md +1 -1
  17. package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +130 -0
  18. package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +84 -0
  19. package/.savepoint/audit/{E07-audit-pipeline → v1/E07-audit-pipeline}/snapshot.md +6 -6
  20. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/proposals.md +114 -0
  21. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +41 -0
  22. package/.savepoint/audit/v1.1/E04-epic-navigation/proposals.md +156 -0
  23. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +48 -0
  24. package/.savepoint/config.yml +3 -3
  25. package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
  26. package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
  27. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
  28. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
  29. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/E06-Detail.md +62 -0
  30. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
  31. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
  32. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
  33. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T007-detail-card-fixes.md +7 -7
  34. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T008-checkbox-states.md +10 -8
  35. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T009-router-priority-marker.md +16 -9
  36. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +27 -22
  37. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/E01-Detail.md +40 -0
  38. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-next-activity-header.md +56 -0
  39. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +38 -0
  40. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +28 -0
  41. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +51 -0
  42. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +45 -0
  43. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +68 -0
  44. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
  45. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/E02-Detail.md +49 -0
  46. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +37 -0
  47. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +38 -0
  48. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +36 -0
  49. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +59 -0
  50. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +32 -0
  51. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
  52. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
  53. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
  54. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
  55. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
  56. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
  57. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
  58. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
  59. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
  60. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +45 -0
  61. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +34 -0
  62. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +30 -0
  63. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +33 -0
  64. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +88 -0
  65. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +30 -0
  66. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +46 -0
  67. package/.savepoint/releases/v1.1/v1.1-PRD.md +79 -0
  68. package/.savepoint/router.md +33 -105
  69. package/.savepoint/visual-identity.md +4 -3
  70. package/AGENTS.md +56 -113
  71. package/Makefile +19 -3
  72. package/README.md +7 -6
  73. package/agent-skills/savepoint-audit/SKILL.md +6 -6
  74. package/agent-skills/savepoint-build-task/SKILL.md +2 -2
  75. package/agent-skills/savepoint-create-plan/SKILL.md +3 -3
  76. package/agent-skills/savepoint-create-task/SKILL.md +2 -2
  77. package/agent-skills/savepoint-draft-prd/SKILL.md +1 -1
  78. package/agent-skills/savepoint-system-design/SKILL.md +1 -1
  79. package/go.mod +4 -1
  80. package/go.sum +2 -0
  81. package/internal/board/board.go +66 -14
  82. package/internal/board/board_test.go +124 -0
  83. package/internal/board/card.go +40 -3
  84. package/internal/board/card_test.go +121 -14
  85. package/internal/board/column.go +40 -5
  86. package/internal/board/column_test.go +65 -10
  87. package/internal/board/detail.go +115 -23
  88. package/internal/board/detail_test.go +132 -25
  89. package/internal/board/epic_panel.go +105 -8
  90. package/internal/board/epic_panel_test.go +343 -5
  91. package/internal/board/layout.go +12 -2
  92. package/internal/board/layout_test.go +17 -0
  93. package/internal/board/model.go +146 -23
  94. package/internal/board/render_policy_test.go +77 -0
  95. package/internal/board/status.go +23 -0
  96. package/internal/board/update.go +300 -9
  97. package/internal/board/update_test.go +166 -0
  98. package/internal/board/view.go +141 -17
  99. package/internal/board/view_test.go +161 -3
  100. package/internal/board/watch.go +100 -0
  101. package/internal/buildtool/main.go +219 -0
  102. package/internal/data/parser.go +39 -1
  103. package/internal/data/parser_test.go +43 -2
  104. package/internal/data/task.go +22 -2
  105. package/internal/styles/palette.go +9 -7
  106. package/internal/styles/styles.go +42 -25
  107. package/main.go +9 -0
  108. package/package.json +5 -4
  109. package/savepoint +0 -0
  110. package/savepoint.exe +0 -0
  111. package/templates/project/.savepoint/router.md +6 -5
  112. package/templates/project/AGENTS.md +47 -101
  113. package/templates/prompts/audit-reconciliation.prompt.md +6 -6
  114. package/templates/prompts/epic-design.prompt.md +3 -3
  115. package/templates/prompts/task-breakdown.prompt.md +1 -1
  116. package/templates/prompts/task-building.prompt.md +1 -1
  117. package/templates/prompts/task-planning.prompt.md +1 -1
  118. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/Design.md +0 -42
  119. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/Design.md +0 -26
  120. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -35
  121. package/main.exe +0 -0
  122. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/Design.md +0 -0
  123. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/epic-Design.md +0 -0
  124. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/quality-review.md +0 -0
  125. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/snapshot.md +0 -0
  126. /package/.savepoint/audit/{E02-data-model → v1/E02-data-model}/snapshot.md +0 -0
  127. /package/.savepoint/audit/{E03-cli-foundation → v1/E03-cli-foundation}/snapshot.md +0 -0
  128. /package/.savepoint/audit/{E04-templates-and-prompts → v1/E04-templates-and-prompts}/snapshot.md +0 -0
  129. /package/.savepoint/audit/{E06-tui-board → v1/E06-tui-board}/snapshot.md +0 -0
  130. /package/.savepoint/audit/{E08-board-workflow-cleanup → v1/E08-board-workflow-cleanup}/snapshot.md +0 -0
  131. /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
  132. /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
  133. /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
  134. /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
  135. /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
  136. /package/.savepoint/releases/v1/{PRD.md → v1-PRD.md} +0 -0
@@ -1,6 +1,7 @@
1
1
  package board
2
2
 
3
3
  import (
4
+ "fmt"
4
5
  "strings"
5
6
 
6
7
  "github.com/charmbracelet/lipgloss"
@@ -18,19 +19,26 @@ func (m Model) View() string {
18
19
  if w == 0 {
19
20
  w = defaultTermW
20
21
  }
21
-
22
- layout := CalculateLayout(w, m.Height)
23
- icon := styles.HeaderIcon.Render("▣")
24
- text := styles.HeaderText.Render("S A V E P O I N T")
25
- header := styles.HeaderFrame.Width(w).Render(icon + " " + text)
26
- board := m.renderBoard(layout)
27
- footer := m.renderFooter(w)
28
- base := lipgloss.JoinVertical(lipgloss.Left, header, board, footer)
29
-
22
+ m.Width = w
30
23
  h := m.Height
31
24
  if h == 0 {
32
25
  h = defaultTermH
33
26
  }
27
+ m.Height = h
28
+
29
+ header := m.renderHeader(w)
30
+ nextActivity := m.renderNextActivityLine(w)
31
+ layout := CalculateLayoutWithChrome(w, h, extraHeaderLines(nextActivity))
32
+ topDivider := dividerLine(w)
33
+ board := m.renderBoard(layout)
34
+ bottomDivider := dividerLine(w)
35
+ footer := m.renderFooter(w)
36
+ sections := []string{header}
37
+ if nextActivity != "" {
38
+ sections = append(sections, nextActivity)
39
+ }
40
+ sections = append(sections, topDivider, board, bottomDivider, footer)
41
+ base := lipgloss.JoinVertical(lipgloss.Left, sections...)
34
42
 
35
43
  if m.Overlay == OverlayEpic {
36
44
  overlay := RenderEpicDropdown(m.Epics, m.EpicCursor, min(40, w))
@@ -53,13 +61,111 @@ func (m Model) View() string {
53
61
  return base
54
62
  }
55
63
  ow := overlayWidth(w)
56
- detail := RenderDetail(task, ow)
64
+ detail := RenderDetail(task, ow, m.RouterState, detailMaxHeight(h), m.DetailOffset)
65
+ return overlayOnBase(dimLines(base), detail, w, h)
66
+ }
67
+
68
+ if m.Overlay == OverlayEpicDetail {
69
+ ow := overlayWidth(w)
70
+ epicSlug := ""
71
+ if m.EpicPanelCursor >= 0 && m.EpicPanelCursor < len(m.Epics) {
72
+ epicSlug = m.Epics[m.EpicPanelCursor]
73
+ }
74
+ detail := RenderEpicDetail(epicSlug, m.EpicDetailContent, ow, detailMaxHeight(h), m.EpicDetailOffset)
57
75
  return overlayOnBase(dimLines(base), detail, w, h)
58
76
  }
59
77
 
60
78
  return base
61
79
  }
62
80
 
81
+ func (m Model) renderHeader(w int) string {
82
+ icon := styles.HeaderIcon.Render("▣")
83
+ text := styles.HeaderText.Render("S A V E P O I N T")
84
+ left := icon + " " + text
85
+ return styles.HeaderFrame.Width(w).Render(left)
86
+ }
87
+
88
+ func extraHeaderLines(line string) int {
89
+ if line == "" {
90
+ return 0
91
+ }
92
+ return 1
93
+ }
94
+
95
+ func (m Model) renderNextActivityLine(w int) string {
96
+ if w <= 0 {
97
+ w = defaultTermW
98
+ }
99
+ return renderNextActivityLine(m.RouterState, w)
100
+ }
101
+
102
+ func renderNextActivityLine(state *data.RouterState, w int) string {
103
+ tag, style, ok := nextActivityPhase(state)
104
+ if !ok || strings.TrimSpace(state.NextAction) == "" {
105
+ return ""
106
+ }
107
+
108
+ content := style.Render(tag+":") + " " + state.NextAction
109
+ if lipgloss.Width(content) > w {
110
+ content = xansi.Truncate(content, w, "…")
111
+ }
112
+ return styles.RootLine.Width(w).Render(content)
113
+ }
114
+
115
+ func nextActivityPhase(state *data.RouterState) (string, lipgloss.Style, bool) {
116
+ if state == nil {
117
+ return "", lipgloss.Style{}, false
118
+ }
119
+ switch state.State {
120
+ case "task-building":
121
+ return "BUILD", styles.FooterPhaseBuild, true
122
+ case "audit-pending":
123
+ return "AUDIT", styles.FooterPhaseAudit, true
124
+ case "pre-implementation", "epic-design", "epic-task-breakdown":
125
+ return "PLAN", styles.FooterPhasePlan, true
126
+ default:
127
+ return "", lipgloss.Style{}, false
128
+ }
129
+ }
130
+
131
+ // FormatNextActivity formats a compact activity string from router state.
132
+ // Returns empty string when state is nil. Result is capped at 20 visible chars.
133
+ func FormatNextActivity(state *data.RouterState) string {
134
+ if state == nil {
135
+ return ""
136
+ }
137
+ var s string
138
+ switch state.State {
139
+ case "task-building":
140
+ s = fmt.Sprintf("Build %s %s/%s", state.Release, shortRouterID(state.Epic), shortRouterID(state.Task))
141
+ case "audit-pending":
142
+ s = fmt.Sprintf("Audit %s", shortRouterID(state.Epic))
143
+ case "epic-design":
144
+ s = fmt.Sprintf("Design %s", shortRouterID(state.Epic))
145
+ case "epic-task-breakdown":
146
+ s = fmt.Sprintf("Plan %s", shortRouterID(state.Epic))
147
+ case "pre-implementation":
148
+ s = fmt.Sprintf("Planning %s", state.Release)
149
+ default:
150
+ s = state.State
151
+ }
152
+ return xansi.Truncate(s, 20, "…")
153
+ }
154
+
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
+ }
168
+
63
169
  func (m Model) focusedTask() (data.Task, bool) {
64
170
  tasks := m.Tasks[m.FocusedColumn]
65
171
  if len(tasks) == 0 || m.FocusedTask >= len(tasks) {
@@ -147,23 +253,34 @@ func (m Model) renderBoard(layout Layout) string {
147
253
 
148
254
  func (m Model) renderColumns(layout Layout) string {
149
255
  if layout.ColCount == 1 {
150
- return m.renderColumn(m.FocusedColumn, layout.ColWidths[0])
256
+ return m.renderColumn(m.FocusedColumn, layout.ColWidths[0], layout.ContentHeight)
151
257
  }
152
258
  allCols := []data.ColumnType{data.ColumnPlanned, data.ColumnInProgress, data.ColumnDone}
153
259
  rendered := make([]string, len(allCols))
154
260
  for i, col := range allCols {
155
- rendered[i] = m.renderColumn(col, layout.ColWidths[i])
261
+ rendered[i] = m.renderColumn(col, layout.ColWidths[i], layout.ContentHeight)
156
262
  }
157
263
  return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
158
264
  }
159
265
 
160
266
  func (m Model) renderEpicPanel(w int) string {
161
- return RenderEpicSidebar(m.Epics, m.SelectedEpic, w)
267
+ return RenderEpicSidebar(m.Epics, m.SelectedEpic, w, m.EpicPanelFocus, m.EpicPanelCursor, m.EpicStatus)
162
268
  }
163
269
 
164
- func (m Model) renderColumn(col data.ColumnType, colW int) string {
165
- focused := m.FocusedColumn == col
166
- return RenderColumn(m.Tasks[col], col, colW, m.FocusedTask, focused)
270
+ func (m Model) renderColumn(col data.ColumnType, colW, maxHeight int) string {
271
+ focused := !m.EpicPanelFocus && m.FocusedColumn == col
272
+ return RenderColumn(m.Tasks[col], col, colW, maxHeight, m.ColumnOffsets[col], m.FocusedTask, focused, m.RouterState)
273
+ }
274
+
275
+ func detailMaxHeight(termH int) int {
276
+ if termH <= 0 {
277
+ termH = defaultTermH
278
+ }
279
+ h := termH * 7 / 10
280
+ if h < 6 {
281
+ h = 6
282
+ }
283
+ return h
167
284
  }
168
285
 
169
286
  func (m Model) renderFooter(termW int) string {
@@ -179,6 +296,13 @@ func (m Model) renderFooter(termW int) string {
179
296
  return lipgloss.JoinVertical(lipgloss.Center, phase, spacer, hints)
180
297
  }
181
298
 
299
+ func dividerLine(termW int) string {
300
+ if termW <= 0 {
301
+ termW = defaultTermW
302
+ }
303
+ return styles.Divider.Render(strings.Repeat("─", termW))
304
+ }
305
+
182
306
  func footerLine(termW int, content string) string {
183
307
  if termW <= 0 {
184
308
  termW = defaultTermW
@@ -186,5 +310,5 @@ func footerLine(termW int, content string) string {
186
310
  if lipgloss.Width(content) > termW {
187
311
  content = xansi.Truncate(content, termW, "")
188
312
  }
189
- return lipgloss.NewStyle().Width(termW).Align(lipgloss.Center).Render(content)
313
+ return styles.RootLine.Width(termW).Align(lipgloss.Center).Render(content)
190
314
  }
@@ -46,8 +46,8 @@ func TestView_containsDivider(t *testing.T) {
46
46
  m := NewModel(nil, "v1", "E03")
47
47
  m.Width = 120
48
48
  got := m.View()
49
- if !strings.Contains(got, "─") {
50
- t.Error("View() missing horizontal divider")
49
+ if !strings.Contains(got, strings.Repeat("─", 120)) {
50
+ t.Error("View() missing full-width horizontal divider")
51
51
  }
52
52
  }
53
53
 
@@ -74,7 +74,7 @@ func TestView_containsFooterHints(t *testing.T) {
74
74
  if len(lines) != 3 {
75
75
  t.Fatalf("renderFooter() returned %d lines, want 3", len(lines))
76
76
  }
77
- if strings.TrimSpace(lines[1]) != "" {
77
+ if strings.TrimSpace(plainTerminal(lines[1])) != "" {
78
78
  t.Fatalf("renderFooter() spacer line = %q, want blank", lines[1])
79
79
  }
80
80
  for i, line := range lines {
@@ -133,6 +133,164 @@ func TestView_wideShowsEpicPanel(t *testing.T) {
133
133
  }
134
134
  }
135
135
 
136
+ func TestFormatNextActivity_nil(t *testing.T) {
137
+ if got := FormatNextActivity(nil); got != "" {
138
+ t.Errorf("FormatNextActivity(nil) = %q, want empty", got)
139
+ }
140
+ }
141
+
142
+ func TestFormatNextActivity_states(t *testing.T) {
143
+ cases := []struct {
144
+ state *data.RouterState
145
+ want string
146
+ }{
147
+ {&data.RouterState{State: "task-building", Task: "T010", Epic: "E06", Release: "v1"}, "Build v1 E06/T010"},
148
+ {&data.RouterState{State: "audit-pending", Epic: "E06"}, "Audit E06"},
149
+ {&data.RouterState{State: "epic-design", Epic: "E06"}, "Design E06"},
150
+ {&data.RouterState{State: "epic-task-breakdown", Epic: "E06"}, "Plan E06"},
151
+ {&data.RouterState{State: "pre-implementation", Release: "v1"}, "Planning v1"},
152
+ }
153
+ for _, c := range cases {
154
+ got := FormatNextActivity(c.state)
155
+ if got != c.want {
156
+ t.Errorf("FormatNextActivity(%q) = %q, want %q", c.state.State, got, c.want)
157
+ }
158
+ }
159
+ }
160
+
161
+ func TestFormatNextActivity_truncation(t *testing.T) {
162
+ state := &data.RouterState{State: "task-building", Task: "T001", Epic: "E01-very-long-epic-name", Release: "v1.1"}
163
+ got := FormatNextActivity(state)
164
+ if lipgloss.Width(got) > 20 {
165
+ t.Errorf("FormatNextActivity truncation: width %d > 20, got %q", lipgloss.Width(got), got)
166
+ }
167
+ }
168
+
169
+ func TestFormatNextActivity_taskBuildingKeepsMinorReleaseVisible(t *testing.T) {
170
+ state := &data.RouterState{State: "task-building", Task: "T001", Epic: "E03", Release: "v1.1"}
171
+ got := FormatNextActivity(state)
172
+ if got != "Build v1.1 E03/T001" {
173
+ t.Errorf("FormatNextActivity() = %q, want Build v1.1 E03/T001", got)
174
+ }
175
+ }
176
+
177
+ func TestView_headerShowsNextActivity(t *testing.T) {
178
+ m := NewModel(nil, "v1", "E03")
179
+ m.Width = 120
180
+ m.RouterState = &data.RouterState{State: "audit-pending", NextAction: "Audit E06"}
181
+ got := m.View()
182
+ if !strings.Contains(got, "AUDIT:") {
183
+ t.Error("View() missing Next Activity phase tag")
184
+ }
185
+ if !strings.Contains(got, "Audit E06") {
186
+ t.Error("View() missing activity text below header")
187
+ }
188
+ if strings.Contains(got, "Next Activity:") {
189
+ t.Error("View() should not render Next Activity inside the header")
190
+ }
191
+ }
192
+
193
+ func TestView_nextActivityLineImmediatelyBelowHeader(t *testing.T) {
194
+ m := NewModel(nil, "v1", "E03")
195
+ m.Width = 120
196
+ m.RouterState = &data.RouterState{State: "task-building", NextAction: "Build T010 (E06) v1"}
197
+ got := m.View()
198
+
199
+ lines := strings.Split(got, "\n")
200
+ headerIndex := -1
201
+ activityIndex := -1
202
+ dividerIndex := -1
203
+ for i, line := range lines {
204
+ if strings.Contains(line, "S A V E P O I N T") {
205
+ headerIndex = i
206
+ }
207
+ if strings.Contains(line, "BUILD:") && strings.Contains(line, "Build T010 (E06) v1") {
208
+ activityIndex = i
209
+ }
210
+ if dividerIndex == -1 && strings.Contains(line, strings.Repeat("─", 120)) {
211
+ dividerIndex = i
212
+ }
213
+ }
214
+ if headerIndex == -1 || activityIndex == -1 || dividerIndex == -1 {
215
+ t.Fatalf("View() missing expected header/activity/divider lines: header=%d activity=%d divider=%d", headerIndex, activityIndex, dividerIndex)
216
+ }
217
+ if !(headerIndex < activityIndex && activityIndex < dividerIndex) {
218
+ t.Fatalf("Next Activity line order invalid: header=%d activity=%d divider=%d", headerIndex, activityIndex, dividerIndex)
219
+ }
220
+ }
221
+
222
+ func TestView_headerNoActivityWhenNilState(t *testing.T) {
223
+ m := NewModel(nil, "v1", "E03")
224
+ m.Width = 120
225
+ m.RouterState = nil
226
+ got := m.View()
227
+ if strings.Contains(got, "Next Activity:") || strings.Contains(got, "BUILD:") || strings.Contains(got, "PLAN:") || strings.Contains(got, "AUDIT:") {
228
+ t.Error("View() should not show Next Activity line when RouterState is nil")
229
+ }
230
+ }
231
+
232
+ func TestView_headerNarrowWidth(t *testing.T) {
233
+ m := NewModel(nil, "v1", "E03")
234
+ m.Width = 40
235
+ m.RouterState = &data.RouterState{State: "audit-pending", NextAction: "Audit E06"}
236
+ got := m.View()
237
+ // Should not panic and header text should still be present
238
+ if !strings.Contains(got, "S A V E P O I N T") {
239
+ t.Error("View() at narrow width missing header text")
240
+ }
241
+ }
242
+
243
+ func TestRenderNextActivityLine_phaseMapping(t *testing.T) {
244
+ cases := []struct {
245
+ name string
246
+ state *data.RouterState
247
+ tag string
248
+ }{
249
+ {"build", &data.RouterState{State: "task-building", NextAction: "Build T010 (E06) v1"}, "BUILD:"},
250
+ {"audit", &data.RouterState{State: "audit-pending", NextAction: "Audit E03"}, "AUDIT:"},
251
+ {"pre implementation", &data.RouterState{State: "pre-implementation", NextAction: "Plan v1.1"}, "PLAN:"},
252
+ {"epic design", &data.RouterState{State: "epic-design", NextAction: "Design E03"}, "PLAN:"},
253
+ {"task breakdown", &data.RouterState{State: "epic-task-breakdown", NextAction: "Break down E03"}, "PLAN:"},
254
+ }
255
+ for _, tc := range cases {
256
+ t.Run(tc.name, func(t *testing.T) {
257
+ got := renderNextActivityLine(tc.state, 80)
258
+ if !strings.Contains(got, tc.tag) {
259
+ t.Fatalf("renderNextActivityLine() missing phase tag %q in %q", tc.tag, got)
260
+ }
261
+ if !strings.Contains(got, tc.state.NextAction) {
262
+ t.Fatalf("renderNextActivityLine() missing next_action %q in %q", tc.state.NextAction, got)
263
+ }
264
+ })
265
+ }
266
+ }
267
+
268
+ func TestRenderNextActivityLine_hiddenStates(t *testing.T) {
269
+ cases := []*data.RouterState{
270
+ nil,
271
+ {State: "idle", NextAction: "Wait"},
272
+ {State: "task-building", NextAction: ""},
273
+ }
274
+ for _, state := range cases {
275
+ if got := renderNextActivityLine(state, 80); got != "" {
276
+ t.Fatalf("renderNextActivityLine(%v) = %q, want empty", state, got)
277
+ }
278
+ }
279
+ }
280
+
281
+ func TestRenderNextActivityLine_truncatesAtNarrowWidth(t *testing.T) {
282
+ got := renderNextActivityLine(&data.RouterState{
283
+ State: "pre-implementation",
284
+ NextAction: "Build T010 (E06) v1 with a very long follow-up activity",
285
+ }, 18)
286
+ if lipgloss.Width(got) > 18 {
287
+ t.Fatalf("renderNextActivityLine() width = %d, want <= 18; got %q", lipgloss.Width(got), got)
288
+ }
289
+ if !strings.Contains(got, "PLAN:") || !strings.Contains(got, "…") {
290
+ t.Fatalf("renderNextActivityLine() = %q, want PLAN tag and ellipsis", got)
291
+ }
292
+ }
293
+
136
294
  func TestView_narrowShowsSingleColumn(t *testing.T) {
137
295
  m := NewModel(nil, "v1", "E03")
138
296
  m.Width = 60
@@ -0,0 +1,100 @@
1
+ package board
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "time"
7
+
8
+ tea "github.com/charmbracelet/bubbletea"
9
+ "github.com/fsnotify/fsnotify"
10
+ "github.com/opencode/savepoint/internal/data"
11
+ )
12
+
13
+ type fileChangeMsg struct{}
14
+ type reloadMsg struct {
15
+ tasks []data.Task
16
+ releases []string
17
+ releaseEpics map[string][]string
18
+ epicStatuses map[string]string
19
+ }
20
+
21
+ // watchFiles blocks until a file event arrives, debounces for 100ms, emits fileChangeMsg.
22
+ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
23
+ return func() tea.Msg {
24
+ for {
25
+ select {
26
+ case event, ok := <-w.Events:
27
+ if !ok {
28
+ return nil
29
+ }
30
+ watchCreatedDir(w, event)
31
+ timer := time.NewTimer(100 * time.Millisecond)
32
+ drain:
33
+ for {
34
+ select {
35
+ case event, ok := <-w.Events:
36
+ if !ok {
37
+ timer.Stop()
38
+ return nil
39
+ }
40
+ watchCreatedDir(w, event)
41
+ case <-timer.C:
42
+ break drain
43
+ }
44
+ }
45
+ return fileChangeMsg{}
46
+ case _, ok := <-w.Errors:
47
+ if !ok {
48
+ return nil
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ func reloadTasks(root string) tea.Cmd {
56
+ return func() tea.Msg {
57
+ tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root)
58
+ if err != nil {
59
+ return nil
60
+ }
61
+ return reloadMsg{tasks: tasks, releases: releases, releaseEpics: releaseEpics, epicStatuses: epicStatuses}
62
+ }
63
+ }
64
+
65
+ // newWatcher watches the releases directory by walking all subdirs (fsnotify v1.10 has no recursive opt).
66
+ func newWatcher(root string) (*fsnotify.Watcher, error) {
67
+ w, err := fsnotify.NewWatcher()
68
+ if err != nil {
69
+ return nil, err
70
+ }
71
+ releasesPath := filepath.Join(root, "releases")
72
+ if err := addDirsRecursive(w, releasesPath); err != nil {
73
+ w.Close()
74
+ return nil, err
75
+ }
76
+ return w, nil
77
+ }
78
+
79
+ func watchCreatedDir(w *fsnotify.Watcher, event fsnotify.Event) {
80
+ if !event.Has(fsnotify.Create) {
81
+ return
82
+ }
83
+ info, err := os.Stat(event.Name)
84
+ if err != nil || !info.IsDir() {
85
+ return
86
+ }
87
+ _ = addDirsRecursive(w, event.Name)
88
+ }
89
+
90
+ func addDirsRecursive(w *fsnotify.Watcher, root string) error {
91
+ return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
92
+ if err != nil {
93
+ return nil // skip unreadable dirs
94
+ }
95
+ if d.IsDir() {
96
+ return w.Add(path)
97
+ }
98
+ return nil
99
+ })
100
+ }