savepoint 1.0.0 → 1.0.1

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 (49) hide show
  1. package/.claude/settings.local.json +5 -1
  2. package/.savepoint/Design.md +8 -4
  3. package/.savepoint/audit/E06-atari-noir-layout/proposals.md +130 -0
  4. package/.savepoint/audit/E06-atari-noir-layout/snapshot.md +84 -0
  5. package/.savepoint/config.yml +3 -3
  6. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/Design.md +24 -6
  7. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T007-detail-card-fixes.md +7 -7
  8. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T008-checkbox-states.md +10 -8
  9. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T009-router-priority-marker.md +16 -9
  10. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +25 -22
  11. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/Design.md +10 -4
  12. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +2 -1
  13. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +38 -0
  14. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +28 -0
  15. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +50 -0
  16. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +44 -0
  17. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +58 -0
  18. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-next-activity-header.md +55 -0
  19. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/Design.md +40 -0
  20. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +34 -0
  21. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +33 -0
  22. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +32 -0
  23. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +38 -0
  24. package/.savepoint/router.md +1 -1
  25. package/.savepoint/visual-identity.md +4 -3
  26. package/AGENTS.md +3 -3
  27. package/README.md +1 -1
  28. package/go.mod +4 -1
  29. package/go.sum +2 -0
  30. package/internal/board/board.go +42 -6
  31. package/internal/board/board_test.go +53 -0
  32. package/internal/board/card.go +9 -3
  33. package/internal/board/card_test.go +28 -14
  34. package/internal/board/column.go +2 -2
  35. package/internal/board/column_test.go +17 -9
  36. package/internal/board/detail.go +21 -11
  37. package/internal/board/detail_test.go +30 -14
  38. package/internal/board/model.go +7 -1
  39. package/internal/board/update.go +24 -1
  40. package/internal/board/view.go +13 -3
  41. package/internal/board/view_test.go +2 -2
  42. package/internal/board/watch.go +82 -0
  43. package/internal/data/parser.go +31 -1
  44. package/internal/data/parser_test.go +8 -2
  45. package/internal/data/task.go +12 -2
  46. package/internal/styles/palette.go +6 -4
  47. package/internal/styles/styles.go +5 -15
  48. package/package.json +5 -4
  49. package/savepoint +0 -0
@@ -9,7 +9,7 @@ import (
9
9
 
10
10
  func TestRenderCard_containsID(t *testing.T) {
11
11
  task := data.Task{ID: "E04/T002", Title: "Build card", Stage: data.StageBuild}
12
- got := RenderCard(task, 30, false)
12
+ got := RenderCard(task, 30, false, "")
13
13
  if !strings.Contains(got, "T002") {
14
14
  t.Error("RenderCard missing short task ID")
15
15
  }
@@ -17,7 +17,7 @@ func TestRenderCard_containsID(t *testing.T) {
17
17
 
18
18
  func TestRenderCard_containsTitle(t *testing.T) {
19
19
  task := data.Task{ID: "T1", Title: "My title", Stage: data.StageBuild}
20
- got := RenderCard(task, 30, false)
20
+ got := RenderCard(task, 30, false, "")
21
21
  if !strings.Contains(got, "My title") {
22
22
  t.Error("RenderCard missing task title")
23
23
  }
@@ -25,7 +25,7 @@ func TestRenderCard_containsTitle(t *testing.T) {
25
25
 
26
26
  func TestRenderCard_containsBuildGlyph(t *testing.T) {
27
27
  task := data.Task{ID: "T1", Stage: data.StageBuild}
28
- got := RenderCard(task, 30, false)
28
+ got := RenderCard(task, 30, false, "")
29
29
  if !strings.Contains(got, glyphBuild) {
30
30
  t.Errorf("RenderCard missing build glyph %q", glyphBuild)
31
31
  }
@@ -33,7 +33,7 @@ func TestRenderCard_containsBuildGlyph(t *testing.T) {
33
33
 
34
34
  func TestRenderCard_containsTestGlyph(t *testing.T) {
35
35
  task := data.Task{ID: "T1", Stage: data.StageTest}
36
- got := RenderCard(task, 30, false)
36
+ got := RenderCard(task, 30, false, "")
37
37
  if !strings.Contains(got, glyphTest) {
38
38
  t.Errorf("RenderCard missing test glyph %q", glyphTest)
39
39
  }
@@ -41,7 +41,7 @@ func TestRenderCard_containsTestGlyph(t *testing.T) {
41
41
 
42
42
  func TestRenderCard_containsAuditGlyph(t *testing.T) {
43
43
  task := data.Task{ID: "T1", Stage: data.StageAudit}
44
- got := RenderCard(task, 30, false)
44
+ got := RenderCard(task, 30, false, "")
45
45
  if !strings.Contains(got, glyphAudit) {
46
46
  t.Errorf("RenderCard missing audit glyph %q", glyphAudit)
47
47
  }
@@ -49,28 +49,30 @@ func TestRenderCard_containsAuditGlyph(t *testing.T) {
49
49
 
50
50
  func TestRenderCard_focusedDoesNotPanic(t *testing.T) {
51
51
  task := data.Task{ID: "T1", Title: "hello", Stage: data.StageBuild}
52
- got := RenderCard(task, 30, true)
52
+ got := RenderCard(task, 30, true, "")
53
53
  if got == "" {
54
54
  t.Error("RenderCard focused returned empty string")
55
55
  }
56
56
  }
57
57
 
58
- func TestRenderCard_titleTruncated(t *testing.T) {
59
- long := "This is a very long title that should be truncated for sure"
58
+ func TestRenderCard_titleWraps(t *testing.T) {
59
+ long := "This is a very long title that should be wrapped for sure"
60
60
  task := data.Task{ID: "T1", Title: long, Stage: data.StageBuild}
61
- got := RenderCard(task, 20, false)
61
+ got := RenderCard(task, 20, false, "")
62
+ // full title as one line does not fit; it must be broken up
62
63
  if strings.Contains(got, long) {
63
- t.Error("RenderCard should truncate long title")
64
+ t.Error("RenderCard should wrap long title, not render it as one line")
64
65
  }
65
- if !strings.Contains(got, "…") {
66
- t.Error("RenderCard should include ellipsis when title truncated")
66
+ // words from the title must still appear somewhere in the output
67
+ if !strings.Contains(got, "This") {
68
+ t.Error("RenderCard wrapped title missing expected content")
67
69
  }
68
70
  }
69
71
 
70
72
  func TestRenderCard_idTruncated(t *testing.T) {
71
73
  long := "E04-board-components/T999-very-long-id"
72
74
  task := data.Task{ID: long, Stage: data.StageBuild}
73
- got := RenderCard(task, 20, false)
75
+ got := RenderCard(task, 20, false, "")
74
76
  if strings.Contains(got, long) {
75
77
  t.Error("RenderCard should truncate long ID")
76
78
  }
@@ -104,8 +106,20 @@ func TestTruncate_maxOne(t *testing.T) {
104
106
 
105
107
  func TestRenderCard_defaultStageUsesBuildGlyph(t *testing.T) {
106
108
  task := data.Task{ID: "T1", Stage: ""}
107
- got := RenderCard(task, 30, false)
109
+ got := RenderCard(task, 30, false, "")
108
110
  if !strings.Contains(got, glyphBuild) {
109
111
  t.Error("RenderCard with empty stage should use build glyph")
110
112
  }
111
113
  }
114
+
115
+ func TestRenderCard_routerPriorityUsesGreenGlyph(t *testing.T) {
116
+ task := data.Task{ID: "E06/T009", Stage: data.StageTest}
117
+ got := RenderCard(task, 30, false, "E06/T009")
118
+ if !strings.Contains(got, glyphBuild) {
119
+ t.Error("router priority card should use build glyph")
120
+ }
121
+ nonPriority := RenderCard(task, 30, false, "")
122
+ if !strings.Contains(nonPriority, glyphTest) {
123
+ t.Error("non-priority test card should use test glyph")
124
+ }
125
+ }
@@ -9,7 +9,7 @@ import (
9
9
  )
10
10
 
11
11
  // RenderColumn renders a board column: header with label+count, task list, bordered container.
12
- func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int, focused bool) string {
12
+ func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int, focused bool, routerTaskID string) string {
13
13
  inner := width - colOverhead
14
14
  if inner < minColWidth {
15
15
  inner = minColWidth
@@ -28,7 +28,7 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int
28
28
  lines = append(lines, styles.TaskItem.Render("(empty)"))
29
29
  } else {
30
30
  for i, t := range tasks {
31
- lines = append(lines, RenderCard(t, inner, focused && i == focusedTask))
31
+ lines = append(lines, RenderCard(t, inner, focused && i == focusedTask, routerTaskID))
32
32
  }
33
33
  }
34
34
 
@@ -8,7 +8,7 @@ import (
8
8
  )
9
9
 
10
10
  func TestRenderColumn_headerContainsLabel(t *testing.T) {
11
- got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false)
11
+ got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false, "")
12
12
  if !strings.Contains(got, "PLANNED") {
13
13
  t.Error("RenderColumn missing PLANNED label")
14
14
  }
@@ -16,14 +16,14 @@ func TestRenderColumn_headerContainsLabel(t *testing.T) {
16
16
 
17
17
  func TestRenderColumn_headerContainsCount(t *testing.T) {
18
18
  tasks := []data.Task{{ID: "T1", Title: "Task one", Column: data.ColumnPlanned}}
19
- got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false)
19
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false, "")
20
20
  if !strings.Contains(got, "(1)") {
21
21
  t.Error("RenderColumn missing task count")
22
22
  }
23
23
  }
24
24
 
25
25
  func TestRenderColumn_emptyShowsPlaceholder(t *testing.T) {
26
- got := RenderColumn(nil, data.ColumnDone, 30, 0, false)
26
+ got := RenderColumn(nil, data.ColumnDone, 30, 0, false, "")
27
27
  if !strings.Contains(got, "(empty)") {
28
28
  t.Error("RenderColumn missing (empty) for empty column")
29
29
  }
@@ -31,7 +31,7 @@ func TestRenderColumn_emptyShowsPlaceholder(t *testing.T) {
31
31
 
32
32
  func TestRenderColumn_focusedDoesNotPanic(t *testing.T) {
33
33
  tasks := []data.Task{{ID: "T1", Column: data.ColumnInProgress}}
34
- got := RenderColumn(tasks, data.ColumnInProgress, 30, 0, true)
34
+ got := RenderColumn(tasks, data.ColumnInProgress, 30, 0, true, "")
35
35
  if got == "" {
36
36
  t.Error("RenderColumn returned empty string for focused column")
37
37
  }
@@ -47,7 +47,7 @@ func TestRenderColumn_allColumnTitles(t *testing.T) {
47
47
  {data.ColumnDone, "DONE"},
48
48
  }
49
49
  for _, tc := range cases {
50
- got := RenderColumn(nil, tc.col, 30, 0, false)
50
+ got := RenderColumn(nil, tc.col, 30, 0, false, "")
51
51
  if !strings.Contains(got, tc.label) {
52
52
  t.Errorf("RenderColumn missing label %q for col %q", tc.label, tc.col)
53
53
  }
@@ -56,7 +56,7 @@ func TestRenderColumn_allColumnTitles(t *testing.T) {
56
56
 
57
57
  func TestRenderColumn_taskTitleRendered(t *testing.T) {
58
58
  tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned}}
59
- got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false)
59
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false, "")
60
60
  if !strings.Contains(got, "Build it") {
61
61
  t.Error("RenderColumn missing task title")
62
62
  }
@@ -64,17 +64,25 @@ func TestRenderColumn_taskTitleRendered(t *testing.T) {
64
64
 
65
65
  func TestRenderColumn_rendersTaskCards(t *testing.T) {
66
66
  tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned, Stage: data.StageAudit}}
67
- got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, true)
67
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, true, "")
68
68
  if !strings.Contains(got, glyphAudit) {
69
69
  t.Error("RenderColumn should render task phase glyph from card")
70
70
  }
71
71
  if !strings.Contains(got, "╭") {
72
- t.Error("RenderColumn should render bordered card")
72
+ t.Error("RenderColumn should render focused card border")
73
+ }
74
+ }
75
+
76
+ func TestRenderColumn_unfocusedUsesSurfaceWithoutBorder(t *testing.T) {
77
+ tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned, Stage: data.StageAudit}}
78
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false, "")
79
+ if strings.Contains(got, "╭") {
80
+ t.Error("unfocused column/card should not render rounded borders")
73
81
  }
74
82
  }
75
83
 
76
84
  func TestRenderColumn_emptyCountIsZero(t *testing.T) {
77
- got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false)
85
+ got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false, "")
78
86
  if !strings.Contains(got, "(0)") {
79
87
  t.Error("RenderColumn missing (0) count for empty column")
80
88
  }
@@ -10,7 +10,8 @@ import (
10
10
  const detailBorderPad = 4 // rounded border (2) + padding (2×1)
11
11
 
12
12
  // RenderDetail renders a task detail overlay panel at the given display width.
13
- func RenderDetail(t data.Task, overlayW int) string {
13
+ // When routerTaskID matches t.ID, a "(router priority)" label is shown.
14
+ func RenderDetail(t data.Task, overlayW int, routerTaskID string) string {
14
15
  inner := overlayW - detailBorderPad
15
16
  if inner < 4 {
16
17
  inner = 4
@@ -34,29 +35,38 @@ func RenderDetail(t data.Task, overlayW int) string {
34
35
  "",
35
36
  styles.ColumnTitle.Render("Description:"),
36
37
  )
37
- for _, line := range wrapText(t.Description, inner) {
38
+ for _, line := range WrapText(t.Description, inner) {
38
39
  lines = append(lines, styles.CardMeta.Render(line))
39
40
  }
40
41
  }
41
42
 
42
43
  if len(t.Acceptance) > 0 {
43
- lines = append(lines, "", styles.ColumnTitle.Render("Acceptance Criteria:"))
44
+ lines = append(lines, "", styles.ColumnTitle.Render("Acceptance Criteria:"), "")
44
45
  for _, a := range t.Acceptance {
45
- for _, line := range wrapText(a, inner-2) {
46
+ for _, line := range WrapText(a, inner-2) {
46
47
  lines = append(lines, styles.CardMeta.Render(" • "+line))
47
48
  }
48
49
  }
49
50
  }
50
51
 
51
52
  if len(t.Checklist) > 0 {
52
- lines = append(lines, "", styles.ColumnTitle.Render("Implementation Plan:"))
53
+ lines = append(lines, "", styles.ColumnTitle.Render("Implementation Plan:"), "")
53
54
  for _, item := range t.Checklist {
54
- for _, line := range wrapText(item, inner-2) {
55
- lines = append(lines, styles.CardMeta.Render(" □ "+line))
55
+ glyph := "□ "
56
+ style := styles.CardMeta
57
+ if item.Done {
58
+ glyph = "☑ "
59
+ style = styles.TagDone
60
+ }
61
+ for _, line := range WrapText(item.Text, inner-2) {
62
+ lines = append(lines, style.Render(" "+glyph+line))
56
63
  }
57
64
  }
58
65
  }
59
66
 
67
+ if routerTaskID != "" && t.ID == routerTaskID {
68
+ lines = append(lines, "", styles.TagDone.Render("(router priority)"))
69
+ }
60
70
  lines = append(lines, "", styles.CardMeta.Render("esc:close"))
61
71
 
62
72
  return styles.DetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
@@ -64,7 +74,7 @@ func RenderDetail(t data.Task, overlayW int) string {
64
74
 
65
75
  func detailRow(label, value string, width int) string {
66
76
  prefix := label + ": "
67
- wrapped := wrapText(value, width-len(prefix))
77
+ wrapped := WrapText(value, width-len(prefix))
68
78
  if len(wrapped) == 0 {
69
79
  wrapped = []string{""}
70
80
  }
@@ -90,7 +100,7 @@ func phaseLabel(s data.ProgressStage) string {
90
100
  }
91
101
  }
92
102
 
93
- func wrapText(s string, width int) []string {
103
+ func WrapText(s string, width int) []string {
94
104
  if width < 4 {
95
105
  width = 4
96
106
  }
@@ -106,7 +116,7 @@ func wrapText(s string, width int) []string {
106
116
  lines = append(lines, current)
107
117
  current = ""
108
118
  }
109
- lines = append(lines, splitLongWord(word, width)...)
119
+ lines = append(lines, SplitLongWord(word, width)...)
110
120
  continue
111
121
  }
112
122
  if current == "" {
@@ -126,7 +136,7 @@ func wrapText(s string, width int) []string {
126
136
  return lines
127
137
  }
128
138
 
129
- func splitLongWord(word string, width int) []string {
139
+ func SplitLongWord(word string, width int) []string {
130
140
  runes := []rune(word)
131
141
  lines := []string{}
132
142
  for len(runes) > width {
@@ -20,49 +20,49 @@ func sampleTask() data.Task {
20
20
  }
21
21
 
22
22
  func TestRenderDetail_containsID(t *testing.T) {
23
- got := RenderDetail(sampleTask(), 60)
23
+ got := RenderDetail(sampleTask(), 60, "")
24
24
  if !strings.Contains(got, "E04/T001") {
25
25
  t.Error("RenderDetail missing task ID")
26
26
  }
27
27
  }
28
28
 
29
29
  func TestRenderDetail_containsTitle(t *testing.T) {
30
- got := RenderDetail(sampleTask(), 60)
30
+ got := RenderDetail(sampleTask(), 60, "")
31
31
  if !strings.Contains(got, "My Task") {
32
32
  t.Error("RenderDetail missing task title")
33
33
  }
34
34
  }
35
35
 
36
36
  func TestRenderDetail_containsEpic(t *testing.T) {
37
- got := RenderDetail(sampleTask(), 60)
37
+ got := RenderDetail(sampleTask(), 60, "")
38
38
  if !strings.Contains(got, "E04-board-components") {
39
39
  t.Error("RenderDetail missing epic")
40
40
  }
41
41
  }
42
42
 
43
43
  func TestRenderDetail_containsRelease(t *testing.T) {
44
- got := RenderDetail(sampleTask(), 60)
44
+ got := RenderDetail(sampleTask(), 60, "")
45
45
  if !strings.Contains(got, "v1") {
46
46
  t.Error("RenderDetail missing release")
47
47
  }
48
48
  }
49
49
 
50
50
  func TestRenderDetail_containsStatus(t *testing.T) {
51
- got := RenderDetail(sampleTask(), 60)
51
+ got := RenderDetail(sampleTask(), 60, "")
52
52
  if !strings.Contains(got, "in_progress") {
53
53
  t.Error("RenderDetail missing status")
54
54
  }
55
55
  }
56
56
 
57
57
  func TestRenderDetail_containsPhase(t *testing.T) {
58
- got := RenderDetail(sampleTask(), 60)
58
+ got := RenderDetail(sampleTask(), 60, "")
59
59
  if !strings.Contains(got, "build") {
60
60
  t.Error("RenderDetail missing phase")
61
61
  }
62
62
  }
63
63
 
64
64
  func TestRenderDetail_containsEscHint(t *testing.T) {
65
- got := RenderDetail(sampleTask(), 60)
65
+ got := RenderDetail(sampleTask(), 60, "")
66
66
  if !strings.Contains(got, "esc") {
67
67
  t.Error("RenderDetail missing esc:close hint")
68
68
  }
@@ -71,14 +71,14 @@ func TestRenderDetail_containsEscHint(t *testing.T) {
71
71
  func TestRenderDetail_containsDescription(t *testing.T) {
72
72
  tk := sampleTask()
73
73
  tk.Description = "some description text"
74
- got := RenderDetail(tk, 60)
74
+ got := RenderDetail(tk, 60, "")
75
75
  if !strings.Contains(got, "some description text") {
76
76
  t.Error("RenderDetail missing description text")
77
77
  }
78
78
  }
79
79
 
80
80
  func TestRenderDetail_noDescriptionSectionWhenEmpty(t *testing.T) {
81
- got := RenderDetail(sampleTask(), 60)
81
+ got := RenderDetail(sampleTask(), 60, "")
82
82
  if strings.Contains(got, "Description:") {
83
83
  t.Error("RenderDetail should not show Description section when empty")
84
84
  }
@@ -87,7 +87,7 @@ func TestRenderDetail_noDescriptionSectionWhenEmpty(t *testing.T) {
87
87
  func TestRenderDetail_containsAcceptanceCriteria(t *testing.T) {
88
88
  tk := sampleTask()
89
89
  tk.Acceptance = []string{"criterion one", "criterion two"}
90
- got := RenderDetail(tk, 60)
90
+ got := RenderDetail(tk, 60, "")
91
91
  if !strings.Contains(got, "criterion one") {
92
92
  t.Error("RenderDetail missing first acceptance criterion")
93
93
  }
@@ -98,8 +98,8 @@ func TestRenderDetail_containsAcceptanceCriteria(t *testing.T) {
98
98
 
99
99
  func TestRenderDetail_containsChecklist(t *testing.T) {
100
100
  tk := sampleTask()
101
- tk.Checklist = []string{"first implementation item", "second implementation item"}
102
- got := RenderDetail(tk, 60)
101
+ tk.Checklist = []data.CheckItem{{Text: "first implementation item"}, {Text: "second implementation item", Done: true}}
102
+ got := RenderDetail(tk, 60, "")
103
103
  if !strings.Contains(got, "Implementation Plan:") {
104
104
  t.Error("RenderDetail missing implementation plan heading")
105
105
  }
@@ -114,7 +114,7 @@ func TestRenderDetail_containsChecklist(t *testing.T) {
114
114
  func TestRenderDetail_wrapsLongDescription(t *testing.T) {
115
115
  tk := sampleTask()
116
116
  tk.Description = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda"
117
- got := RenderDetail(tk, 30)
117
+ got := RenderDetail(tk, 30, "")
118
118
  if strings.Contains(got, tk.Description) {
119
119
  t.Error("RenderDetail should wrap long description text")
120
120
  }
@@ -124,7 +124,7 @@ func TestRenderDetail_wrapsLongDescription(t *testing.T) {
124
124
  }
125
125
 
126
126
  func TestRenderDetail_noAcceptanceSectionWhenEmpty(t *testing.T) {
127
- got := RenderDetail(sampleTask(), 60)
127
+ got := RenderDetail(sampleTask(), 60, "")
128
128
  if strings.Contains(got, "Acceptance Criteria:") {
129
129
  t.Error("RenderDetail should not show Acceptance section when empty")
130
130
  }
@@ -214,6 +214,22 @@ func TestView_detailOverlayRendered(t *testing.T) {
214
214
  }
215
215
  }
216
216
 
217
+ func TestRenderDetail_routerPriorityLabel(t *testing.T) {
218
+ task := sampleTask()
219
+ got := RenderDetail(task, 60, task.ID)
220
+ if !strings.Contains(got, "(router priority)") {
221
+ t.Error("RenderDetail missing router priority label for matching task")
222
+ }
223
+ }
224
+
225
+ func TestRenderDetail_noRouterPriorityLabelWhenNoMatch(t *testing.T) {
226
+ task := sampleTask()
227
+ got := RenderDetail(task, 60, "other-id")
228
+ if strings.Contains(got, "(router priority)") {
229
+ t.Error("RenderDetail should not show router priority label for non-matching task")
230
+ }
231
+ }
232
+
217
233
  func TestOverlayWidth_clampMax(t *testing.T) {
218
234
  if got := overlayWidth(120); got != 80 {
219
235
  t.Errorf("overlayWidth(120) = %d, want 80", got)
@@ -5,6 +5,7 @@ import (
5
5
  "path/filepath"
6
6
 
7
7
  tea "github.com/charmbracelet/bubbletea"
8
+ "github.com/fsnotify/fsnotify"
8
9
  "github.com/opencode/savepoint/internal/data"
9
10
  )
10
11
 
@@ -36,6 +37,8 @@ type Model struct {
36
37
  Height int
37
38
  StatusMessage string
38
39
  Root string
40
+ RouterTask string
41
+ Watcher *fsnotify.Watcher
39
42
  }
40
43
 
41
44
  // NewModel groups tasks by column and returns an initialized Model.
@@ -53,7 +56,10 @@ func NewModel(tasks []data.Task, release, epic string) Model {
53
56
  }
54
57
 
55
58
  func (m Model) Init() tea.Cmd {
56
- return tea.Batch()
59
+ if m.Watcher == nil {
60
+ return nil
61
+ }
62
+ return watchFiles(m.Watcher)
57
63
  }
58
64
 
59
65
  func groupedTasks(tasks []data.Task) map[data.ColumnType][]data.Task {
@@ -13,12 +13,25 @@ var columnOrder = []data.ColumnType{
13
13
 
14
14
  func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
15
15
  switch msg := msg.(type) {
16
+ case fileChangeMsg:
17
+ if m.Root != "" {
18
+ return m, reloadTasks(m.Root)
19
+ }
20
+ case reloadMsg:
21
+ m.AllTasks = msg.tasks
22
+ m.refreshTasks()
23
+ if m.Watcher != nil {
24
+ return m, watchFiles(m.Watcher)
25
+ }
16
26
  case tea.KeyMsg:
17
27
  if m.Overlay != OverlayNone {
18
28
  return m.updateOverlay(msg)
19
29
  }
20
30
  switch msg.String() {
21
31
  case "q", "ctrl+c":
32
+ if m.Watcher != nil {
33
+ m.Watcher.Close()
34
+ }
22
35
  return m, tea.Quit
23
36
  case "left", "h":
24
37
  m.FocusedColumn = prevColumn(m.FocusedColumn)
@@ -55,6 +68,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
55
68
  for i, t := range m.AllTasks {
56
69
  if t.ID == task.ID {
57
70
  Advance(&m.AllTasks[i])
71
+ if m.AllTasks[i].Path != "" {
72
+ if err := data.WriteTaskStatus(m.AllTasks[i].Path, &m.AllTasks[i], task.Mtime); err != nil && err != data.ErrMtimeConflict {
73
+ m.StatusMessage = err.Error()
74
+ }
75
+ }
58
76
  break
59
77
  }
60
78
  }
@@ -65,15 +83,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65
83
  tasks := m.Tasks[m.FocusedColumn]
66
84
  if len(tasks) > 0 && m.FocusedTask < len(tasks) {
67
85
  task := tasks[m.FocusedTask]
86
+ m.StatusMessage = ""
68
87
  for i, t := range m.AllTasks {
69
88
  if t.ID == task.ID {
70
89
  Retreat(&m.AllTasks[i])
90
+ if m.AllTasks[i].Path != "" {
91
+ if err := data.WriteTaskStatus(m.AllTasks[i].Path, &m.AllTasks[i], task.Mtime); err != nil && err != data.ErrMtimeConflict {
92
+ m.StatusMessage = err.Error()
93
+ }
94
+ }
71
95
  break
72
96
  }
73
97
  }
74
98
  m.refreshTasks()
75
99
  }
76
- m.StatusMessage = ""
77
100
  case "e":
78
101
  m.Overlay = OverlayEpic
79
102
  m.EpicCursor = epicIndex(m.Epics, m.SelectedEpic)
@@ -18,14 +18,17 @@ func (m Model) View() string {
18
18
  if w == 0 {
19
19
  w = defaultTermW
20
20
  }
21
+ m.Width = w
21
22
 
22
23
  layout := CalculateLayout(w, m.Height)
23
24
  icon := styles.HeaderIcon.Render("▣")
24
25
  text := styles.HeaderText.Render("S A V E P O I N T")
25
26
  header := styles.HeaderFrame.Width(w).Render(icon + " " + text)
27
+ topDivider := dividerLine(w)
26
28
  board := m.renderBoard(layout)
29
+ bottomDivider := dividerLine(w)
27
30
  footer := m.renderFooter(w)
28
- base := lipgloss.JoinVertical(lipgloss.Left, header, board, footer)
31
+ base := lipgloss.JoinVertical(lipgloss.Left, header, topDivider, board, bottomDivider, footer)
29
32
 
30
33
  h := m.Height
31
34
  if h == 0 {
@@ -53,7 +56,7 @@ func (m Model) View() string {
53
56
  return base
54
57
  }
55
58
  ow := overlayWidth(w)
56
- detail := RenderDetail(task, ow)
59
+ detail := RenderDetail(task, ow, m.RouterTask)
57
60
  return overlayOnBase(dimLines(base), detail, w, h)
58
61
  }
59
62
 
@@ -163,7 +166,7 @@ func (m Model) renderEpicPanel(w int) string {
163
166
 
164
167
  func (m Model) renderColumn(col data.ColumnType, colW int) string {
165
168
  focused := m.FocusedColumn == col
166
- return RenderColumn(m.Tasks[col], col, colW, m.FocusedTask, focused)
169
+ return RenderColumn(m.Tasks[col], col, colW, m.FocusedTask, focused, m.RouterTask)
167
170
  }
168
171
 
169
172
  func (m Model) renderFooter(termW int) string {
@@ -179,6 +182,13 @@ func (m Model) renderFooter(termW int) string {
179
182
  return lipgloss.JoinVertical(lipgloss.Center, phase, spacer, hints)
180
183
  }
181
184
 
185
+ func dividerLine(termW int) string {
186
+ if termW <= 0 {
187
+ termW = defaultTermW
188
+ }
189
+ return styles.Divider.Render(strings.Repeat("─", termW))
190
+ }
191
+
182
192
  func footerLine(termW int, content string) string {
183
193
  if termW <= 0 {
184
194
  termW = defaultTermW
@@ -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
 
@@ -0,0 +1,82 @@
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{ tasks []data.Task }
15
+
16
+ // watchFiles blocks until a file event arrives, debounces for 100ms, emits fileChangeMsg.
17
+ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
18
+ return func() tea.Msg {
19
+ for {
20
+ select {
21
+ case _, ok := <-w.Events:
22
+ if !ok {
23
+ return nil
24
+ }
25
+ timer := time.NewTimer(100 * time.Millisecond)
26
+ drain:
27
+ for {
28
+ select {
29
+ case _, ok := <-w.Events:
30
+ if !ok {
31
+ timer.Stop()
32
+ return nil
33
+ }
34
+ case <-timer.C:
35
+ break drain
36
+ }
37
+ }
38
+ return fileChangeMsg{}
39
+ case _, ok := <-w.Errors:
40
+ if !ok {
41
+ return nil
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ func reloadTasks(root string) tea.Cmd {
49
+ return func() tea.Msg {
50
+ tasks, err := loadAllTasks(root)
51
+ if err != nil {
52
+ return nil
53
+ }
54
+ return reloadMsg{tasks: tasks}
55
+ }
56
+ }
57
+
58
+ // newWatcher watches the releases directory by walking all subdirs (fsnotify v1.10 has no recursive opt).
59
+ func newWatcher(root string) (*fsnotify.Watcher, error) {
60
+ w, err := fsnotify.NewWatcher()
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ releasesPath := filepath.Join(root, ".savepoint", "releases")
65
+ if err := addDirsRecursive(w, releasesPath); err != nil {
66
+ w.Close()
67
+ return nil, err
68
+ }
69
+ return w, nil
70
+ }
71
+
72
+ func addDirsRecursive(w *fsnotify.Watcher, root string) error {
73
+ return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
74
+ if err != nil {
75
+ return nil // skip unreadable dirs
76
+ }
77
+ if d.IsDir() {
78
+ return w.Add(path)
79
+ }
80
+ return nil
81
+ })
82
+ }