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,10 @@
1
1
  package board
2
2
 
3
3
  import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+
4
8
  tea "github.com/charmbracelet/bubbletea"
5
9
  "github.com/opencode/savepoint/internal/data"
6
10
  )
@@ -13,35 +17,111 @@ var columnOrder = []data.ColumnType{
13
17
 
14
18
  func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
15
19
  switch msg := msg.(type) {
20
+ case fileChangeMsg:
21
+ if m.Root != "" {
22
+ return m, reloadTasks(m.Root)
23
+ }
24
+ case reloadMsg:
25
+ m.AllTasks = msg.tasks
26
+ m.Releases = append([]string(nil), msg.releases...)
27
+ m.ReleaseEpics = copyReleaseEpics(msg.releaseEpics)
28
+ m.EpicStatus = msg.epicStatuses
29
+ m.SelectedRelease = firstKnown(m.SelectedRelease, m.Releases)
30
+ m.refreshEpicsForRelease()
31
+ m.refreshTasks()
32
+ m.ensureFocusedTaskVisible()
33
+ if m.Watcher != nil {
34
+ return m, watchFiles(m.Watcher)
35
+ }
16
36
  case tea.KeyMsg:
17
37
  if m.Overlay != OverlayNone {
18
38
  return m.updateOverlay(msg)
19
39
  }
20
40
  switch msg.String() {
21
41
  case "q", "ctrl+c":
42
+ if m.Watcher != nil {
43
+ m.Watcher.Close()
44
+ }
22
45
  return m, tea.Quit
46
+ case "e":
47
+ m.Overlay = OverlayEpic
48
+ m.EpicCursor = epicIndex(m.Epics, m.SelectedEpic)
49
+ return m, nil
50
+ case "r":
51
+ m.Overlay = OverlayRelease
52
+ m.ReleaseCursor = releaseIndex(m.Releases, m.SelectedRelease)
53
+ return m, nil
54
+ case "?":
55
+ m.Overlay = OverlayHelp
56
+ return m, nil
57
+ case "m":
58
+ task, ok := m.focusedTask()
59
+ if !ok {
60
+ return m, nil
61
+ }
62
+ if taskDone(task) {
63
+ m.StatusMessage = "Router not updated: focused task is done"
64
+ return m, nil
65
+ }
66
+ if m.Root == "" {
67
+ m.StatusMessage = "Router not updated: no savepoint root"
68
+ return m, nil
69
+ }
70
+ message, err := m.writeRouterTask(task)
71
+ if err != nil {
72
+ m.StatusMessage = err.Error()
73
+ return m, nil
74
+ }
75
+ m.StatusMessage = message
76
+ return m, nil
77
+ }
78
+ if m.EpicPanelFocus {
79
+ if !m.epicPanelAvailable() {
80
+ m.EpicPanelFocus = false
81
+ } else {
82
+ return m.updateEpicPanel(msg)
83
+ }
84
+ }
85
+ switch msg.String() {
23
86
  case "left", "h":
87
+ if m.FocusedColumn == data.ColumnPlanned && m.epicPanelAvailable() {
88
+ m.EpicPanelFocus = true
89
+ m.EpicPanelCursor = epicIndex(m.Epics, m.SelectedEpic)
90
+ m.StatusMessage = ""
91
+ return m, nil
92
+ }
24
93
  m.FocusedColumn = prevColumn(m.FocusedColumn)
25
94
  m.FocusedTask = 0
95
+ m.ensureFocusedTaskVisible()
26
96
  m.StatusMessage = ""
27
97
  case "right", "l":
28
98
  m.FocusedColumn = nextColumn(m.FocusedColumn)
29
99
  m.FocusedTask = 0
100
+ m.ensureFocusedTaskVisible()
30
101
  m.StatusMessage = ""
31
102
  case "up", "k":
32
103
  if m.FocusedTask > 0 {
33
104
  m.FocusedTask--
34
105
  }
106
+ m.ensureFocusedTaskVisible()
35
107
  m.StatusMessage = ""
36
108
  case "down", "j":
37
109
  if m.FocusedTask < len(m.Tasks[m.FocusedColumn])-1 {
38
110
  m.FocusedTask++
39
111
  }
112
+ m.ensureFocusedTaskVisible()
113
+ m.StatusMessage = ""
114
+ case "pgup":
115
+ m.scrollFocusedColumn(-m.columnPageSize())
116
+ m.StatusMessage = ""
117
+ case "pgdown":
118
+ m.scrollFocusedColumn(m.columnPageSize())
40
119
  m.StatusMessage = ""
41
120
  case "enter":
42
121
  tasks := m.Tasks[m.FocusedColumn]
43
122
  if len(tasks) > 0 && m.FocusedTask < len(tasks) {
44
123
  m.Overlay = OverlayDetail
124
+ m.DetailOffset = 0
45
125
  }
46
126
  m.StatusMessage = ""
47
127
  case " ":
@@ -55,46 +135,153 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
55
135
  for i, t := range m.AllTasks {
56
136
  if t.ID == task.ID {
57
137
  Advance(&m.AllTasks[i])
138
+ if m.AllTasks[i].Path != "" {
139
+ if err := data.WriteTaskStatus(m.AllTasks[i].Path, &m.AllTasks[i], task.Mtime); err != nil && err != data.ErrMtimeConflict {
140
+ m.StatusMessage = err.Error()
141
+ }
142
+ }
58
143
  break
59
144
  }
60
145
  }
61
146
  m.refreshTasks()
147
+ m.ensureFocusedTaskVisible()
62
148
  }
63
149
  }
64
150
  case "backspace":
65
151
  tasks := m.Tasks[m.FocusedColumn]
66
152
  if len(tasks) > 0 && m.FocusedTask < len(tasks) {
67
153
  task := tasks[m.FocusedTask]
154
+ m.StatusMessage = ""
68
155
  for i, t := range m.AllTasks {
69
156
  if t.ID == task.ID {
70
157
  Retreat(&m.AllTasks[i])
158
+ if m.AllTasks[i].Path != "" {
159
+ if err := data.WriteTaskStatus(m.AllTasks[i].Path, &m.AllTasks[i], task.Mtime); err != nil && err != data.ErrMtimeConflict {
160
+ m.StatusMessage = err.Error()
161
+ }
162
+ }
71
163
  break
72
164
  }
73
165
  }
74
166
  m.refreshTasks()
167
+ m.ensureFocusedTaskVisible()
75
168
  }
76
- m.StatusMessage = ""
77
- case "e":
78
- m.Overlay = OverlayEpic
79
- m.EpicCursor = epicIndex(m.Epics, m.SelectedEpic)
80
- case "r":
81
- m.Overlay = OverlayRelease
82
- m.ReleaseCursor = releaseIndex(m.Releases, m.SelectedRelease)
83
- case "?":
84
- m.Overlay = OverlayHelp
85
169
  }
86
170
  case tea.WindowSizeMsg:
87
171
  m.Width = msg.Width
88
172
  m.Height = msg.Height
173
+ if !m.epicPanelAvailable() {
174
+ m.EpicPanelFocus = false
175
+ }
176
+ m.ensureFocusedTaskVisible()
89
177
  }
90
178
  return m, nil
91
179
  }
92
180
 
181
+ func (m Model) updateEpicPanel(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
182
+ if len(m.Epics) == 0 {
183
+ m.EpicPanelFocus = false
184
+ return m, nil
185
+ }
186
+
187
+ m.StatusMessage = ""
188
+ switch msg.String() {
189
+ case "up", "k":
190
+ if m.EpicPanelCursor > 0 {
191
+ m.EpicPanelCursor--
192
+ m.selectEpicPanelEpic()
193
+ }
194
+ case "down", "j":
195
+ if m.EpicPanelCursor < len(m.Epics)-1 {
196
+ m.EpicPanelCursor++
197
+ m.selectEpicPanelEpic()
198
+ }
199
+ case "enter":
200
+ m.openEpicDetailOverlay()
201
+ case "right", "l":
202
+ m.EpicPanelFocus = false
203
+ m.FocusedColumn = data.ColumnPlanned
204
+ m.FocusedTask = 0
205
+ m.ensureFocusedTaskVisible()
206
+ case "left", "h":
207
+ // Stay in the panel; there is no focusable region further left.
208
+ }
209
+ return m, nil
210
+ }
211
+
212
+ func (m *Model) selectEpicPanelEpic() {
213
+ if len(m.Epics) == 0 || m.EpicPanelCursor < 0 || m.EpicPanelCursor >= len(m.Epics) {
214
+ return
215
+ }
216
+ m.SelectedEpic = m.Epics[m.EpicPanelCursor]
217
+ m.FocusedTask = 0
218
+ m.DetailOffset = 0
219
+ m.refreshTasks()
220
+ m.ensureFocusedTaskVisible()
221
+ if m.Root != "" {
222
+ if err := m.writeRouterReleaseEpic(); err != nil {
223
+ m.StatusMessage = err.Error()
224
+ }
225
+ }
226
+ }
227
+
228
+ func (m *Model) openEpicDetailOverlay() {
229
+ if len(m.Epics) == 0 || m.EpicPanelCursor < 0 || m.EpicPanelCursor >= len(m.Epics) {
230
+ return
231
+ }
232
+ epicSlug := m.Epics[m.EpicPanelCursor]
233
+ shortEpicID := epicSlug
234
+ if idx := strings.Index(epicSlug, "-"); idx >= 0 {
235
+ shortEpicID = epicSlug[:idx]
236
+ }
237
+ epicDir := filepath.Join(m.Root, "releases", m.SelectedRelease, "epics", epicSlug)
238
+ m.EpicDetailContent = readEpicDetailFile(epicDir, shortEpicID)
239
+ m.EpicDetailOffset = 0
240
+ m.Overlay = OverlayEpicDetail
241
+ }
242
+
243
+ // readEpicDetailFile finds and reads the canonical detail file for an epic.
244
+ // Tries E##-Detail.md then E##-Design.md; falls back to any E##-*.md in the dir.
245
+ func readEpicDetailFile(epicDir, shortID string) string {
246
+ for _, suffix := range []string{"-Detail.md", "-Design.md"} {
247
+ if raw, err := os.ReadFile(filepath.Join(epicDir, shortID+suffix)); err == nil {
248
+ return string(raw)
249
+ }
250
+ }
251
+ entries, err := os.ReadDir(epicDir)
252
+ if err != nil {
253
+ return "(no detail available)"
254
+ }
255
+ prefix := shortID + "-"
256
+ for _, e := range entries {
257
+ if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ".md") {
258
+ if raw, err := os.ReadFile(filepath.Join(epicDir, e.Name())); err == nil {
259
+ return string(raw)
260
+ }
261
+ }
262
+ }
263
+ return "(no detail available)"
264
+ }
265
+
266
+ func copyReleaseEpics(in map[string][]string) map[string][]string {
267
+ out := make(map[string][]string, len(in))
268
+ for release, epics := range in {
269
+ out[release] = append([]string(nil), epics...)
270
+ }
271
+ return out
272
+ }
273
+
93
274
  func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
94
275
  switch msg.String() {
95
276
  case "esc", "q":
96
277
  m.Overlay = OverlayNone
97
278
  case "up", "k":
279
+ if m.Overlay == OverlayDetail && m.DetailOffset > 0 {
280
+ m.DetailOffset--
281
+ }
282
+ if m.Overlay == OverlayEpicDetail && m.EpicDetailOffset > 0 {
283
+ m.EpicDetailOffset--
284
+ }
98
285
  if m.Overlay == OverlayEpic && m.EpicCursor > 0 {
99
286
  m.EpicCursor--
100
287
  }
@@ -102,6 +289,12 @@ func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
102
289
  m.ReleaseCursor--
103
290
  }
104
291
  case "down", "j":
292
+ if m.Overlay == OverlayDetail {
293
+ m.DetailOffset++
294
+ }
295
+ if m.Overlay == OverlayEpicDetail {
296
+ m.EpicDetailOffset++
297
+ }
105
298
  if m.Overlay == OverlayEpic && len(m.Epics) > 0 && m.EpicCursor < len(m.Epics)-1 {
106
299
  m.EpicCursor++
107
300
  }
@@ -112,7 +305,9 @@ func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
112
305
  if m.Overlay == OverlayEpic && len(m.Epics) > 0 {
113
306
  m.SelectedEpic = m.Epics[m.EpicCursor]
114
307
  m.FocusedTask = 0
308
+ m.DetailOffset = 0
115
309
  m.refreshTasks()
310
+ m.ensureFocusedTaskVisible()
116
311
  m.Overlay = OverlayNone
117
312
  if m.Root != "" {
118
313
  if err := m.writeRouterReleaseEpic(); err != nil {
@@ -124,7 +319,9 @@ func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
124
319
  m.SelectedRelease = m.Releases[m.ReleaseCursor]
125
320
  m.refreshEpicsForRelease()
126
321
  m.FocusedTask = 0
322
+ m.DetailOffset = 0
127
323
  m.refreshTasks()
324
+ m.ensureFocusedTaskVisible()
128
325
  m.Overlay = OverlayNone
129
326
  if m.Root != "" {
130
327
  if err := m.writeRouterReleaseEpic(); err != nil {
@@ -132,10 +329,104 @@ func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
132
329
  }
133
330
  }
134
331
  }
332
+ case "pgup":
333
+ if m.Overlay == OverlayDetail {
334
+ m.DetailOffset -= m.detailPageSize()
335
+ if m.DetailOffset < 0 {
336
+ m.DetailOffset = 0
337
+ }
338
+ }
339
+ if m.Overlay == OverlayEpicDetail {
340
+ m.EpicDetailOffset -= m.detailPageSize()
341
+ if m.EpicDetailOffset < 0 {
342
+ m.EpicDetailOffset = 0
343
+ }
344
+ }
345
+ case "pgdown":
346
+ if m.Overlay == OverlayDetail {
347
+ m.DetailOffset += m.detailPageSize()
348
+ }
349
+ if m.Overlay == OverlayEpicDetail {
350
+ m.EpicDetailOffset += m.detailPageSize()
351
+ }
135
352
  }
136
353
  return m, nil
137
354
  }
138
355
 
356
+ func (m *Model) ensureFocusedTaskVisible() {
357
+ if m.ColumnOffsets == nil {
358
+ m.ColumnOffsets = newColumnOffsets()
359
+ }
360
+ tasks := m.Tasks[m.FocusedColumn]
361
+ if len(tasks) == 0 {
362
+ m.ColumnOffsets[m.FocusedColumn] = 0
363
+ return
364
+ }
365
+ pageSize := m.columnPageSize()
366
+ offset := m.ColumnOffsets[m.FocusedColumn]
367
+ if m.FocusedTask < offset {
368
+ offset = m.FocusedTask
369
+ }
370
+ if m.FocusedTask >= offset+pageSize {
371
+ offset = m.FocusedTask - pageSize + 1
372
+ }
373
+ maxOffset := max(len(tasks)-pageSize, 0)
374
+ if offset > maxOffset {
375
+ offset = maxOffset
376
+ }
377
+ if offset < 0 {
378
+ offset = 0
379
+ }
380
+ m.ColumnOffsets[m.FocusedColumn] = offset
381
+ }
382
+
383
+ func (m *Model) scrollFocusedColumn(delta int) {
384
+ if m.ColumnOffsets == nil {
385
+ m.ColumnOffsets = newColumnOffsets()
386
+ }
387
+ tasks := m.Tasks[m.FocusedColumn]
388
+ if len(tasks) == 0 {
389
+ m.ColumnOffsets[m.FocusedColumn] = 0
390
+ m.FocusedTask = 0
391
+ return
392
+ }
393
+ pageSize := m.columnPageSize()
394
+ maxOffset := max(len(tasks)-pageSize, 0)
395
+ offset := m.ColumnOffsets[m.FocusedColumn] + delta
396
+ if offset < 0 {
397
+ offset = 0
398
+ }
399
+ if offset > maxOffset {
400
+ offset = maxOffset
401
+ }
402
+ m.ColumnOffsets[m.FocusedColumn] = offset
403
+ m.FocusedTask = min(offset, len(tasks)-1)
404
+ }
405
+
406
+ func (m Model) columnPageSize() int {
407
+ h := m.Height
408
+ if h == 0 {
409
+ h = defaultTermH
410
+ }
411
+ return visibleColumnTaskLimit(CalculateLayout(m.Width, h).ContentHeight)
412
+ }
413
+
414
+ func (m Model) detailPageSize() int {
415
+ return max(detailMaxHeight(m.Height)-3, 1)
416
+ }
417
+
418
+ func (m Model) epicPanelPageSize() int {
419
+ h := m.Height
420
+ if h == 0 {
421
+ h = defaultTermH
422
+ }
423
+ return max(h/2, 1)
424
+ }
425
+
426
+ func (m Model) epicPanelAvailable() bool {
427
+ return len(m.Epics) > 0 && CalculateLayout(m.Width, m.Height).EpicPanelVisible
428
+ }
429
+
139
430
  func prevColumn(col data.ColumnType) data.ColumnType {
140
431
  for i, c := range columnOrder {
141
432
  if c == col {
@@ -1,6 +1,9 @@
1
1
  package board
2
2
 
3
3
  import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
4
7
  "testing"
5
8
 
6
9
  tea "github.com/charmbracelet/bubbletea"
@@ -90,6 +93,54 @@ func TestUpdate_downMovesTaskFocus(t *testing.T) {
90
93
  }
91
94
  }
92
95
 
96
+ func TestUpdate_downAutoScrollsFocusedTaskIntoViewport(t *testing.T) {
97
+ tasks := []data.Task{
98
+ {ID: "T1", Column: data.ColumnPlanned},
99
+ {ID: "T2", Column: data.ColumnPlanned},
100
+ {ID: "T3", Column: data.ColumnPlanned},
101
+ {ID: "T4", Column: data.ColumnPlanned},
102
+ {ID: "T5", Column: data.ColumnPlanned},
103
+ }
104
+ m := NewModel(tasks, "v1", "E03")
105
+ m.Width = 100
106
+ m.Height = 24
107
+ m.FocusedTask = 3
108
+
109
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
110
+ updated := requireModel(t, got)
111
+
112
+ if updated.FocusedTask != 4 {
113
+ t.Errorf("FocusedTask = %d, want 4", updated.FocusedTask)
114
+ }
115
+ if updated.ColumnOffsets[data.ColumnPlanned] != 1 {
116
+ t.Errorf("ColumnOffsets[planned] = %d, want 1", updated.ColumnOffsets[data.ColumnPlanned])
117
+ }
118
+ }
119
+
120
+ func TestUpdate_pageDownScrollsFocusedColumnByPage(t *testing.T) {
121
+ tasks := []data.Task{
122
+ {ID: "T1", Column: data.ColumnPlanned},
123
+ {ID: "T2", Column: data.ColumnPlanned},
124
+ {ID: "T3", Column: data.ColumnPlanned},
125
+ {ID: "T4", Column: data.ColumnPlanned},
126
+ {ID: "T5", Column: data.ColumnPlanned},
127
+ {ID: "T6", Column: data.ColumnPlanned},
128
+ }
129
+ m := NewModel(tasks, "v1", "E03")
130
+ m.Width = 100
131
+ m.Height = 24
132
+
133
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgDown})
134
+ updated := requireModel(t, got)
135
+
136
+ if updated.ColumnOffsets[data.ColumnPlanned] != 2 {
137
+ t.Errorf("ColumnOffsets[planned] = %d, want 2", updated.ColumnOffsets[data.ColumnPlanned])
138
+ }
139
+ if updated.FocusedTask != 2 {
140
+ t.Errorf("FocusedTask = %d, want 2", updated.FocusedTask)
141
+ }
142
+ }
143
+
93
144
  func TestUpdate_upMovesTaskFocus(t *testing.T) {
94
145
  tasks := []data.Task{
95
146
  {ID: "T1", Column: data.ColumnPlanned},
@@ -126,3 +177,118 @@ func TestUpdate_unknownMsgNoOp(t *testing.T) {
126
177
  t.Errorf("Width changed unexpectedly: %d", updated.Width)
127
178
  }
128
179
  }
180
+
181
+ func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
182
+ root := writeRouterFixture(t)
183
+ tasks := []data.Task{
184
+ {ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
185
+ {ID: "E05-tasking-permissions/T005-update-help-overlay", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
186
+ }
187
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
188
+ m.Root = root
189
+
190
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
191
+ updated := requireModel(t, got)
192
+
193
+ if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
194
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
195
+ }
196
+ state := readRouterFixture(t, root)
197
+ if state.State != "task-building" {
198
+ t.Errorf("router state = %q, want task-building", state.State)
199
+ }
200
+ if state.Release != "v1.1" {
201
+ t.Errorf("router release = %q, want v1.1", state.Release)
202
+ }
203
+ if state.Epic != "E05-tasking-permissions" {
204
+ t.Errorf("router epic = %q, want E05-tasking-permissions", state.Epic)
205
+ }
206
+ if state.Task != "E05-tasking-permissions/T004-implement-m-hotkey" {
207
+ t.Errorf("router task = %q, want focused task", state.Task)
208
+ }
209
+ }
210
+
211
+ func TestUpdate_mSetsAuditPendingForLastUncompletedTask(t *testing.T) {
212
+ root := writeRouterFixture(t)
213
+ tasks := []data.Task{
214
+ {ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
215
+ {ID: "E05-tasking-permissions/T003-update-design-md", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone},
216
+ }
217
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
218
+ m.Root = root
219
+
220
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
221
+ updated := requireModel(t, got)
222
+
223
+ if updated.StatusMessage != "Audit pending for E05-tasking-permissions" {
224
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
225
+ }
226
+ state := readRouterFixture(t, root)
227
+ if state.State != "audit-pending" {
228
+ t.Errorf("router state = %q, want audit-pending", state.State)
229
+ }
230
+ if state.Task != "" {
231
+ t.Errorf("router task = %q, want empty", state.Task)
232
+ }
233
+ }
234
+
235
+ func TestUpdate_mDoesNothingWhenOverlayOpen(t *testing.T) {
236
+ root := writeRouterFixture(t)
237
+ tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned}}
238
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
239
+ m.Root = root
240
+ m.Overlay = OverlayHelp
241
+
242
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
243
+ updated := requireModel(t, got)
244
+
245
+ if updated.StatusMessage != "" {
246
+ t.Fatalf("StatusMessage = %q, want empty", updated.StatusMessage)
247
+ }
248
+ state := readRouterFixture(t, root)
249
+ if state.Task != "T001" {
250
+ t.Errorf("router task = %q, want unchanged T001", state.Task)
251
+ }
252
+ }
253
+
254
+ func TestUpdate_mDoesNotSetDoneTask(t *testing.T) {
255
+ root := writeRouterFixture(t)
256
+ tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone}}
257
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
258
+ m.Root = root
259
+ m.FocusedColumn = data.ColumnDone
260
+
261
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
262
+ updated := requireModel(t, got)
263
+
264
+ if updated.StatusMessage != "Router not updated: focused task is done" {
265
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
266
+ }
267
+ state := readRouterFixture(t, root)
268
+ if state.Task != "T001" {
269
+ t.Errorf("router task = %q, want unchanged T001", state.Task)
270
+ }
271
+ }
272
+
273
+ func writeRouterFixture(t *testing.T) string {
274
+ t.Helper()
275
+ 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
+ }
280
+ return root
281
+ }
282
+
283
+ func readRouterFixture(t *testing.T, root string) *data.RouterState {
284
+ t.Helper()
285
+ content, err := os.ReadFile(filepath.Join(root, "router.md"))
286
+ if err != nil {
287
+ t.Fatal(err)
288
+ }
289
+ state, err := data.NewRouterReader().ReadState(string(content))
290
+ if err != nil {
291
+ t.Fatal(err)
292
+ }
293
+ return state
294
+ }