savepoint 1.0.1 → 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 (127) hide show
  1. package/.claude/settings.local.json +4 -1
  2. package/.savepoint/Design.md +22 -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/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/proposals.md +2 -2
  18. package/.savepoint/audit/{E07-audit-pipeline → v1/E07-audit-pipeline}/snapshot.md +6 -6
  19. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/proposals.md +114 -0
  20. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +41 -0
  21. package/.savepoint/audit/v1.1/E04-epic-navigation/proposals.md +156 -0
  22. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +48 -0
  23. package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
  24. package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
  25. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
  26. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
  27. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/{Design.md → E06-Detail.md} +5 -3
  28. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
  29. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
  30. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
  31. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +2 -0
  32. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/{Design.md → E01-Detail.md} +9 -1
  33. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/{T007-next-activity-header.md → T001-next-activity-header.md} +13 -12
  34. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +9 -9
  35. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +2 -2
  36. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +13 -12
  37. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +14 -13
  38. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +25 -15
  39. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
  40. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/{Design.md → E02-Detail.md} +12 -3
  41. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +11 -8
  42. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +12 -7
  43. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +9 -5
  44. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +30 -9
  45. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +32 -0
  46. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
  47. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
  48. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
  49. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
  50. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
  51. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
  52. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
  53. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
  54. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
  55. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +45 -0
  56. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +34 -0
  57. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +30 -0
  58. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +33 -0
  59. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +88 -0
  60. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +30 -0
  61. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +46 -0
  62. package/.savepoint/releases/v1.1/v1.1-PRD.md +79 -0
  63. package/.savepoint/router.md +33 -105
  64. package/AGENTS.md +56 -113
  65. package/Makefile +19 -3
  66. package/README.md +6 -5
  67. package/agent-skills/savepoint-audit/SKILL.md +6 -6
  68. package/agent-skills/savepoint-build-task/SKILL.md +2 -2
  69. package/agent-skills/savepoint-create-plan/SKILL.md +3 -3
  70. package/agent-skills/savepoint-create-task/SKILL.md +2 -2
  71. package/agent-skills/savepoint-draft-prd/SKILL.md +1 -1
  72. package/agent-skills/savepoint-system-design/SKILL.md +1 -1
  73. package/internal/board/board.go +43 -27
  74. package/internal/board/board_test.go +71 -0
  75. package/internal/board/card.go +34 -3
  76. package/internal/board/card_test.go +105 -12
  77. package/internal/board/column.go +40 -5
  78. package/internal/board/column_test.go +60 -13
  79. package/internal/board/detail.go +107 -25
  80. package/internal/board/detail_test.go +117 -26
  81. package/internal/board/epic_panel.go +105 -8
  82. package/internal/board/epic_panel_test.go +343 -5
  83. package/internal/board/layout.go +12 -2
  84. package/internal/board/layout_test.go +17 -0
  85. package/internal/board/model.go +141 -24
  86. package/internal/board/render_policy_test.go +77 -0
  87. package/internal/board/status.go +23 -0
  88. package/internal/board/update.go +276 -8
  89. package/internal/board/update_test.go +166 -0
  90. package/internal/board/view.go +131 -17
  91. package/internal/board/view_test.go +159 -1
  92. package/internal/board/watch.go +24 -6
  93. package/internal/buildtool/main.go +219 -0
  94. package/internal/data/parser.go +8 -0
  95. package/internal/data/parser_test.go +35 -0
  96. package/internal/data/task.go +10 -0
  97. package/internal/styles/palette.go +3 -3
  98. package/internal/styles/styles.go +39 -12
  99. package/main.go +9 -0
  100. package/package.json +1 -1
  101. package/savepoint +0 -0
  102. package/savepoint.exe +0 -0
  103. package/templates/project/.savepoint/router.md +6 -5
  104. package/templates/project/AGENTS.md +47 -101
  105. package/templates/prompts/audit-reconciliation.prompt.md +6 -6
  106. package/templates/prompts/epic-design.prompt.md +3 -3
  107. package/templates/prompts/task-breakdown.prompt.md +1 -1
  108. package/templates/prompts/task-building.prompt.md +1 -1
  109. package/templates/prompts/task-planning.prompt.md +1 -1
  110. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -36
  111. package/main.exe +0 -0
  112. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/Design.md +0 -0
  113. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/epic-Design.md +0 -0
  114. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/quality-review.md +0 -0
  115. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/snapshot.md +0 -0
  116. /package/.savepoint/audit/{E02-data-model → v1/E02-data-model}/snapshot.md +0 -0
  117. /package/.savepoint/audit/{E03-cli-foundation → v1/E03-cli-foundation}/snapshot.md +0 -0
  118. /package/.savepoint/audit/{E04-templates-and-prompts → v1/E04-templates-and-prompts}/snapshot.md +0 -0
  119. /package/.savepoint/audit/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/snapshot.md +0 -0
  120. /package/.savepoint/audit/{E06-tui-board → v1/E06-tui-board}/snapshot.md +0 -0
  121. /package/.savepoint/audit/{E08-board-workflow-cleanup → v1/E08-board-workflow-cleanup}/snapshot.md +0 -0
  122. /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
  123. /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
  124. /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
  125. /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
  126. /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
  127. /package/.savepoint/releases/v1/{PRD.md → v1-PRD.md} +0 -0
@@ -12,33 +12,42 @@ import (
12
12
  type OverlayType string
13
13
 
14
14
  const (
15
- OverlayNone OverlayType = ""
16
- OverlayHelp OverlayType = "help"
17
- OverlayEpic OverlayType = "epic"
18
- OverlayRelease OverlayType = "release"
19
- OverlayDetail OverlayType = "detail"
15
+ OverlayNone OverlayType = ""
16
+ OverlayHelp OverlayType = "help"
17
+ OverlayEpic OverlayType = "epic"
18
+ OverlayRelease OverlayType = "release"
19
+ OverlayDetail OverlayType = "detail"
20
+ OverlayEpicDetail OverlayType = "detail-epic"
20
21
  )
21
22
 
22
23
  // Model holds all board state. Tasks are grouped by column for O(1) column access.
23
24
  type Model struct {
24
- AllTasks []data.Task
25
- Tasks map[data.ColumnType][]data.Task
26
- FocusedColumn data.ColumnType
27
- FocusedTask int
28
- SelectedEpic string
29
- SelectedRelease string
30
- Epics []string
31
- EpicCursor int
32
- Releases []string
33
- ReleaseEpics map[string][]string
34
- ReleaseCursor int
35
- Overlay OverlayType
36
- Width int
37
- Height int
38
- StatusMessage string
39
- Root string
40
- RouterTask string
41
- Watcher *fsnotify.Watcher
25
+ AllTasks []data.Task
26
+ Tasks map[data.ColumnType][]data.Task
27
+ FocusedColumn data.ColumnType
28
+ FocusedTask int
29
+ ColumnOffsets map[data.ColumnType]int
30
+ DetailOffset int
31
+ SelectedEpic string
32
+ SelectedRelease string
33
+ Epics []string
34
+ EpicCursor int
35
+ EpicPanelFocus bool
36
+ EpicPanelCursor int
37
+ EpicDetailOffset int
38
+ EpicDetailContent string
39
+ Releases []string
40
+ ReleaseEpics map[string][]string
41
+ ReleaseCursor int
42
+ Overlay OverlayType
43
+ Width int
44
+ Height int
45
+ StatusMessage string
46
+ Root string
47
+ EpicStatus map[string]string
48
+ RouterTask string
49
+ RouterState *data.RouterState
50
+ Watcher *fsnotify.Watcher
42
51
  }
43
52
 
44
53
  // NewModel groups tasks by column and returns an initialized Model.
@@ -47,6 +56,7 @@ func NewModel(tasks []data.Task, release, epic string) Model {
47
56
  AllTasks: append([]data.Task(nil), tasks...),
48
57
  FocusedColumn: data.ColumnPlanned,
49
58
  FocusedTask: 0,
59
+ ColumnOffsets: newColumnOffsets(),
50
60
  SelectedEpic: epic,
51
61
  SelectedRelease: release,
52
62
  Overlay: OverlayNone,
@@ -91,6 +101,15 @@ func (m *Model) refreshTasks() {
91
101
  }
92
102
  m.Tasks = groupedTasks(visible)
93
103
  m.clampFocusedTask()
104
+ m.clampColumnOffsets()
105
+ }
106
+
107
+ func newColumnOffsets() map[data.ColumnType]int {
108
+ return map[data.ColumnType]int{
109
+ data.ColumnPlanned: 0,
110
+ data.ColumnInProgress: 0,
111
+ data.ColumnDone: 0,
112
+ }
94
113
  }
95
114
 
96
115
  func (m *Model) refreshEpicsForRelease() {
@@ -103,18 +122,36 @@ func (m *Model) refreshEpicsForRelease() {
103
122
  if len(m.Epics) == 0 {
104
123
  m.SelectedEpic = ""
105
124
  m.EpicCursor = 0
125
+ m.EpicPanelCursor = 0
126
+ m.EpicPanelFocus = false
106
127
  return
107
128
  }
108
129
 
109
130
  for _, epic := range m.Epics {
110
131
  if epic == m.SelectedEpic {
111
132
  m.EpicCursor = epicIndex(m.Epics, m.SelectedEpic)
133
+ m.clampEpicPanelCursor()
112
134
  return
113
135
  }
114
136
  }
115
137
 
116
138
  m.SelectedEpic = m.Epics[0]
117
139
  m.EpicCursor = 0
140
+ m.clampEpicPanelCursor()
141
+ }
142
+
143
+ func (m *Model) clampEpicPanelCursor() {
144
+ if len(m.Epics) == 0 {
145
+ m.EpicPanelCursor = 0
146
+ m.EpicPanelFocus = false
147
+ return
148
+ }
149
+ if m.EpicPanelCursor >= len(m.Epics) {
150
+ m.EpicPanelCursor = len(m.Epics) - 1
151
+ }
152
+ if m.EpicPanelCursor < 0 {
153
+ m.EpicPanelCursor = 0
154
+ }
118
155
  }
119
156
 
120
157
  func (m *Model) clampFocusedTask() {
@@ -131,6 +168,23 @@ func (m *Model) clampFocusedTask() {
131
168
  }
132
169
  }
133
170
 
171
+ func (m *Model) clampColumnOffsets() {
172
+ if m.ColumnOffsets == nil {
173
+ m.ColumnOffsets = newColumnOffsets()
174
+ }
175
+ for _, col := range columnOrder {
176
+ tasks := m.Tasks[col]
177
+ offset := m.ColumnOffsets[col]
178
+ if offset < 0 || len(tasks) == 0 {
179
+ m.ColumnOffsets[col] = 0
180
+ continue
181
+ }
182
+ if offset >= len(tasks) {
183
+ m.ColumnOffsets[col] = len(tasks) - 1
184
+ }
185
+ }
186
+ }
187
+
134
188
  func (m *Model) writeRouterReleaseEpic() error {
135
189
  routerPath := filepath.Join(m.Root, "router.md")
136
190
 
@@ -150,8 +204,71 @@ func (m *Model) writeRouterReleaseEpic() error {
150
204
  return err
151
205
  }
152
206
 
153
- state.Epic = m.SelectedEpic
207
+ state.Epic = shortID(m.SelectedEpic)
154
208
  state.Release = m.SelectedRelease
155
209
 
156
210
  return data.WriteRouterState(m.Root, state, fi.ModTime())
157
211
  }
212
+
213
+ func (m *Model) writeRouterTask(task data.Task) (string, error) {
214
+ routerPath := filepath.Join(m.Root, "router.md")
215
+
216
+ fi, err := os.Stat(routerPath)
217
+ if err != nil {
218
+ return "", err
219
+ }
220
+
221
+ content, err := os.ReadFile(routerPath)
222
+ if err != nil {
223
+ return "", err
224
+ }
225
+
226
+ r := data.NewRouterReader()
227
+ state, err := r.ReadState(string(content))
228
+ if err != nil {
229
+ return "", err
230
+ }
231
+
232
+ state.Release = task.Release
233
+ state.Epic = task.Epic
234
+ if m.isLastUncompletedTask(task) {
235
+ state.State = "audit-pending"
236
+ state.Task = ""
237
+ state.NextAction = "Audit " + task.Epic + "."
238
+ if err := data.WriteRouterState(m.Root, state, fi.ModTime()); err != nil {
239
+ return "", err
240
+ }
241
+ m.RouterState = state
242
+ m.RouterTask = ""
243
+ return "Audit pending for " + task.Epic, nil
244
+ }
245
+
246
+ state.State = "task-building"
247
+ state.Task = task.ID
248
+ state.NextAction = "Build " + task.ID + "."
249
+ if err := data.WriteRouterState(m.Root, state, fi.ModTime()); err != nil {
250
+ return "", err
251
+ }
252
+ m.RouterState = state
253
+ m.RouterTask = task.ID
254
+ return "Router set to " + task.Release + " " + task.Epic + "/" + shortID(task.ID), nil
255
+ }
256
+
257
+ func (m Model) isLastUncompletedTask(task data.Task) bool {
258
+ for _, candidate := range m.AllTasks {
259
+ if candidate.ID == task.ID {
260
+ continue
261
+ }
262
+ if candidate.Release != task.Release || candidate.Epic != task.Epic {
263
+ continue
264
+ }
265
+ if !taskDone(candidate) {
266
+ return false
267
+ }
268
+ }
269
+ return true
270
+ }
271
+
272
+ func taskDone(task data.Task) bool {
273
+ return task.Column == data.ColumnDone || task.Status == string(data.StatusDone)
274
+ }
@@ -0,0 +1,77 @@
1
+ package board
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/charmbracelet/lipgloss"
8
+ "github.com/muesli/termenv"
9
+ "github.com/opencode/savepoint/internal/data"
10
+ )
11
+
12
+ func TestRenderPolicy_noBackgroundEscapes(t *testing.T) {
13
+ lipgloss.SetColorProfile(termenv.ANSI256)
14
+
15
+ task := data.Task{
16
+ ID: "E05-tasking-permissions/T004-implement-m-hotkey",
17
+ Title: "Implement router priority",
18
+ Release: "v1.1",
19
+ Epic: "E05-tasking-permissions",
20
+ Column: data.ColumnInProgress,
21
+ Stage: data.StageBuild,
22
+ }
23
+ m := NewModel([]data.Task{
24
+ {
25
+ ID: task.ID,
26
+ Title: task.Title,
27
+ Release: task.Release,
28
+ Epic: task.Epic,
29
+ Column: task.Column,
30
+ Stage: task.Stage,
31
+ },
32
+ }, "v1.1", "E05-tasking-permissions")
33
+ m.Width = 120
34
+ m.Height = 30
35
+ m.FocusedColumn = data.ColumnInProgress
36
+ m.RouterState = &data.RouterState{
37
+ State: "task-building",
38
+ Release: "v1.1",
39
+ Epic: "E05-tasking-permissions",
40
+ Task: "E05-tasking-permissions/T004-implement-m-hotkey",
41
+ NextAction: "Build E05-tasking-permissions/T004-implement-m-hotkey.",
42
+ }
43
+
44
+ cases := map[string]string{
45
+ "board": m.View(),
46
+ "card": RenderCard(task, 30, true, m.RouterState),
47
+ "detail": RenderDetail(task, 60, m.RouterState, 0, 0),
48
+ "epic dropdown": RenderEpicDropdown([]string{"E05-tasking-permissions"}, 0, 40),
49
+ "release dropdown": RenderReleaseDropdown([]string{"v1.1"}, 0, 40),
50
+ "help": RenderHelp(60),
51
+ }
52
+ for name, got := range cases {
53
+ assertNoBackgroundEscapes(t, name, got)
54
+ }
55
+ }
56
+
57
+ func assertNoBackgroundEscapes(t *testing.T, name, got string) {
58
+ t.Helper()
59
+ for _, escape := range []string{"\x1b[48;", "\x1b[40m"} {
60
+ if strings.Contains(got, escape) {
61
+ t.Fatalf("%s emitted background escape prefix %q in %q", name, escape, got)
62
+ }
63
+ }
64
+ }
65
+
66
+ func TestRenderPolicy_usesSingleLineBorders(t *testing.T) {
67
+ m := NewModel([]data.Task{{ID: "T001", Title: "Task", Column: data.ColumnPlanned}}, "v1", "E01")
68
+ m.Width = 120
69
+ got := m.View()
70
+
71
+ if strings.Contains(got, "╭") || strings.Contains(got, "╮") || strings.Contains(got, "╰") || strings.Contains(got, "╯") {
72
+ t.Fatalf("View should use single-line borders, got rounded border glyphs in %q", got)
73
+ }
74
+ if !strings.Contains(got, "┌") || !strings.Contains(got, "┐") || !strings.Contains(got, "└") || !strings.Contains(got, "┘") {
75
+ t.Fatalf("View missing expected single-line border glyphs in %q", got)
76
+ }
77
+ }
@@ -0,0 +1,23 @@
1
+ package board
2
+
3
+ import (
4
+ "github.com/opencode/savepoint/internal/data"
5
+ "github.com/opencode/savepoint/internal/styles"
6
+ )
7
+
8
+ const statusGlyphDefault = " "
9
+
10
+ func statusGlyph(status string) string {
11
+ switch status {
12
+ case string(data.StatusPlanned):
13
+ return styles.CardMeta.Render("○")
14
+ case string(data.StatusInProgress):
15
+ return styles.GlyphBuild.Render("▶")
16
+ case string(data.StatusDone):
17
+ return styles.TagDone.Render("◉")
18
+ case string(data.StatusAudited):
19
+ return styles.TagDone.Render("✓")
20
+ default:
21
+ return statusGlyphDefault
22
+ }
23
+ }
@@ -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
  )
@@ -19,7 +23,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
19
23
  }
20
24
  case reloadMsg:
21
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()
22
31
  m.refreshTasks()
32
+ m.ensureFocusedTaskVisible()
23
33
  if m.Watcher != nil {
24
34
  return m, watchFiles(m.Watcher)
25
35
  }
@@ -33,28 +43,85 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33
43
  m.Watcher.Close()
34
44
  }
35
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() {
36
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
+ }
37
93
  m.FocusedColumn = prevColumn(m.FocusedColumn)
38
94
  m.FocusedTask = 0
95
+ m.ensureFocusedTaskVisible()
39
96
  m.StatusMessage = ""
40
97
  case "right", "l":
41
98
  m.FocusedColumn = nextColumn(m.FocusedColumn)
42
99
  m.FocusedTask = 0
100
+ m.ensureFocusedTaskVisible()
43
101
  m.StatusMessage = ""
44
102
  case "up", "k":
45
103
  if m.FocusedTask > 0 {
46
104
  m.FocusedTask--
47
105
  }
106
+ m.ensureFocusedTaskVisible()
48
107
  m.StatusMessage = ""
49
108
  case "down", "j":
50
109
  if m.FocusedTask < len(m.Tasks[m.FocusedColumn])-1 {
51
110
  m.FocusedTask++
52
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())
53
119
  m.StatusMessage = ""
54
120
  case "enter":
55
121
  tasks := m.Tasks[m.FocusedColumn]
56
122
  if len(tasks) > 0 && m.FocusedTask < len(tasks) {
57
123
  m.Overlay = OverlayDetail
124
+ m.DetailOffset = 0
58
125
  }
59
126
  m.StatusMessage = ""
60
127
  case " ":
@@ -77,6 +144,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77
144
  }
78
145
  }
79
146
  m.refreshTasks()
147
+ m.ensureFocusedTaskVisible()
80
148
  }
81
149
  }
82
150
  case "backspace":
@@ -96,28 +164,124 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
96
164
  }
97
165
  }
98
166
  m.refreshTasks()
167
+ m.ensureFocusedTaskVisible()
99
168
  }
100
- case "e":
101
- m.Overlay = OverlayEpic
102
- m.EpicCursor = epicIndex(m.Epics, m.SelectedEpic)
103
- case "r":
104
- m.Overlay = OverlayRelease
105
- m.ReleaseCursor = releaseIndex(m.Releases, m.SelectedRelease)
106
- case "?":
107
- m.Overlay = OverlayHelp
108
169
  }
109
170
  case tea.WindowSizeMsg:
110
171
  m.Width = msg.Width
111
172
  m.Height = msg.Height
173
+ if !m.epicPanelAvailable() {
174
+ m.EpicPanelFocus = false
175
+ }
176
+ m.ensureFocusedTaskVisible()
177
+ }
178
+ return m, nil
179
+ }
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.
112
208
  }
113
209
  return m, nil
114
210
  }
115
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
+
116
274
  func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
117
275
  switch msg.String() {
118
276
  case "esc", "q":
119
277
  m.Overlay = OverlayNone
120
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
+ }
121
285
  if m.Overlay == OverlayEpic && m.EpicCursor > 0 {
122
286
  m.EpicCursor--
123
287
  }
@@ -125,6 +289,12 @@ func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
125
289
  m.ReleaseCursor--
126
290
  }
127
291
  case "down", "j":
292
+ if m.Overlay == OverlayDetail {
293
+ m.DetailOffset++
294
+ }
295
+ if m.Overlay == OverlayEpicDetail {
296
+ m.EpicDetailOffset++
297
+ }
128
298
  if m.Overlay == OverlayEpic && len(m.Epics) > 0 && m.EpicCursor < len(m.Epics)-1 {
129
299
  m.EpicCursor++
130
300
  }
@@ -135,7 +305,9 @@ func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
135
305
  if m.Overlay == OverlayEpic && len(m.Epics) > 0 {
136
306
  m.SelectedEpic = m.Epics[m.EpicCursor]
137
307
  m.FocusedTask = 0
308
+ m.DetailOffset = 0
138
309
  m.refreshTasks()
310
+ m.ensureFocusedTaskVisible()
139
311
  m.Overlay = OverlayNone
140
312
  if m.Root != "" {
141
313
  if err := m.writeRouterReleaseEpic(); err != nil {
@@ -147,7 +319,9 @@ func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
147
319
  m.SelectedRelease = m.Releases[m.ReleaseCursor]
148
320
  m.refreshEpicsForRelease()
149
321
  m.FocusedTask = 0
322
+ m.DetailOffset = 0
150
323
  m.refreshTasks()
324
+ m.ensureFocusedTaskVisible()
151
325
  m.Overlay = OverlayNone
152
326
  if m.Root != "" {
153
327
  if err := m.writeRouterReleaseEpic(); err != nil {
@@ -155,10 +329,104 @@ func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
155
329
  }
156
330
  }
157
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
+ }
158
352
  }
159
353
  return m, nil
160
354
  }
161
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
+
162
430
  func prevColumn(col data.ColumnType) data.ColumnType {
163
431
  for i, c := range columnOrder {
164
432
  if c == col {