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.
- package/.claude/settings.local.json +4 -1
- package/.savepoint/Design.md +22 -17
- package/.savepoint/audit/v1/E01/proposals.md +168 -0
- package/.savepoint/audit/v1/E01/snapshot.md +78 -0
- package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/proposals.md +7 -7
- package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/snapshot.md +2 -2
- package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/AGENTS.md +5 -5
- package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/proposals.md +20 -20
- package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/snapshot.md +1 -1
- package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/proposals.md +11 -11
- package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/snapshot.md +1 -1
- package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/proposals.md +14 -14
- package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/snapshot.md +1 -1
- package/.savepoint/audit/{E05-init-command → v1/E05-init-command}/snapshot.md +1 -1
- package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/proposals.md +4 -4
- package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/snapshot.md +1 -1
- package/.savepoint/audit/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/proposals.md +2 -2
- package/.savepoint/audit/{E07-audit-pipeline → v1/E07-audit-pipeline}/snapshot.md +6 -6
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/proposals.md +114 -0
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +41 -0
- package/.savepoint/audit/v1.1/E04-epic-navigation/proposals.md +156 -0
- package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +48 -0
- package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
- package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
- package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
- package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/{Design.md → E06-Detail.md} +5 -3
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +2 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/{Design.md → E01-Detail.md} +9 -1
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/{T007-next-activity-header.md → T001-next-activity-header.md} +13 -12
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +9 -9
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +2 -2
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +13 -12
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +14 -13
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +25 -15
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/{Design.md → E02-Detail.md} +12 -3
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +11 -8
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +12 -7
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +9 -5
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +30 -9
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +32 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +45 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +34 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +30 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +33 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +88 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +30 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +46 -0
- package/.savepoint/releases/v1.1/v1.1-PRD.md +79 -0
- package/.savepoint/router.md +33 -105
- package/AGENTS.md +56 -113
- package/Makefile +19 -3
- package/README.md +6 -5
- package/agent-skills/savepoint-audit/SKILL.md +6 -6
- package/agent-skills/savepoint-build-task/SKILL.md +2 -2
- package/agent-skills/savepoint-create-plan/SKILL.md +3 -3
- package/agent-skills/savepoint-create-task/SKILL.md +2 -2
- package/agent-skills/savepoint-draft-prd/SKILL.md +1 -1
- package/agent-skills/savepoint-system-design/SKILL.md +1 -1
- package/internal/board/board.go +43 -27
- package/internal/board/board_test.go +71 -0
- package/internal/board/card.go +34 -3
- package/internal/board/card_test.go +105 -12
- package/internal/board/column.go +40 -5
- package/internal/board/column_test.go +60 -13
- package/internal/board/detail.go +107 -25
- package/internal/board/detail_test.go +117 -26
- package/internal/board/epic_panel.go +105 -8
- package/internal/board/epic_panel_test.go +343 -5
- package/internal/board/layout.go +12 -2
- package/internal/board/layout_test.go +17 -0
- package/internal/board/model.go +141 -24
- package/internal/board/render_policy_test.go +77 -0
- package/internal/board/status.go +23 -0
- package/internal/board/update.go +276 -8
- package/internal/board/update_test.go +166 -0
- package/internal/board/view.go +131 -17
- package/internal/board/view_test.go +159 -1
- package/internal/board/watch.go +24 -6
- package/internal/buildtool/main.go +219 -0
- package/internal/data/parser.go +8 -0
- package/internal/data/parser_test.go +35 -0
- package/internal/data/task.go +10 -0
- package/internal/styles/palette.go +3 -3
- package/internal/styles/styles.go +39 -12
- package/main.go +9 -0
- package/package.json +1 -1
- package/savepoint +0 -0
- package/savepoint.exe +0 -0
- package/templates/project/.savepoint/router.md +6 -5
- package/templates/project/AGENTS.md +47 -101
- package/templates/prompts/audit-reconciliation.prompt.md +6 -6
- package/templates/prompts/epic-design.prompt.md +3 -3
- package/templates/prompts/task-breakdown.prompt.md +1 -1
- package/templates/prompts/task-building.prompt.md +1 -1
- package/templates/prompts/task-planning.prompt.md +1 -1
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -36
- package/main.exe +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/Design.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/epic-Design.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/quality-review.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/snapshot.md +0 -0
- /package/.savepoint/audit/{E02-data-model → v1/E02-data-model}/snapshot.md +0 -0
- /package/.savepoint/audit/{E03-cli-foundation → v1/E03-cli-foundation}/snapshot.md +0 -0
- /package/.savepoint/audit/{E04-templates-and-prompts → v1/E04-templates-and-prompts}/snapshot.md +0 -0
- /package/.savepoint/audit/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/snapshot.md +0 -0
- /package/.savepoint/audit/{E06-tui-board → v1/E06-tui-board}/snapshot.md +0 -0
- /package/.savepoint/audit/{E08-board-workflow-cleanup → v1/E08-board-workflow-cleanup}/snapshot.md +0 -0
- /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
- /package/.savepoint/releases/v1/{PRD.md → v1-PRD.md} +0 -0
|
@@ -4,11 +4,12 @@ import (
|
|
|
4
4
|
"strings"
|
|
5
5
|
"testing"
|
|
6
6
|
|
|
7
|
+
"github.com/charmbracelet/lipgloss"
|
|
7
8
|
"github.com/opencode/savepoint/internal/data"
|
|
8
9
|
)
|
|
9
10
|
|
|
10
11
|
func TestRenderColumn_headerContainsLabel(t *testing.T) {
|
|
11
|
-
got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false,
|
|
12
|
+
got := RenderColumn(nil, data.ColumnPlanned, 30, 0, 0, 0, false, nil)
|
|
12
13
|
if !strings.Contains(got, "PLANNED") {
|
|
13
14
|
t.Error("RenderColumn missing PLANNED label")
|
|
14
15
|
}
|
|
@@ -16,14 +17,14 @@ func TestRenderColumn_headerContainsLabel(t *testing.T) {
|
|
|
16
17
|
|
|
17
18
|
func TestRenderColumn_headerContainsCount(t *testing.T) {
|
|
18
19
|
tasks := []data.Task{{ID: "T1", Title: "Task one", Column: data.ColumnPlanned}}
|
|
19
|
-
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false,
|
|
20
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, 0, 0, false, nil)
|
|
20
21
|
if !strings.Contains(got, "(1)") {
|
|
21
22
|
t.Error("RenderColumn missing task count")
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
func TestRenderColumn_emptyShowsPlaceholder(t *testing.T) {
|
|
26
|
-
got := RenderColumn(nil, data.ColumnDone, 30, 0, false,
|
|
27
|
+
got := RenderColumn(nil, data.ColumnDone, 30, 0, 0, 0, false, nil)
|
|
27
28
|
if !strings.Contains(got, "(empty)") {
|
|
28
29
|
t.Error("RenderColumn missing (empty) for empty column")
|
|
29
30
|
}
|
|
@@ -31,7 +32,7 @@ func TestRenderColumn_emptyShowsPlaceholder(t *testing.T) {
|
|
|
31
32
|
|
|
32
33
|
func TestRenderColumn_focusedDoesNotPanic(t *testing.T) {
|
|
33
34
|
tasks := []data.Task{{ID: "T1", Column: data.ColumnInProgress}}
|
|
34
|
-
got := RenderColumn(tasks, data.ColumnInProgress, 30, 0, true,
|
|
35
|
+
got := RenderColumn(tasks, data.ColumnInProgress, 30, 0, 0, 0, true, nil)
|
|
35
36
|
if got == "" {
|
|
36
37
|
t.Error("RenderColumn returned empty string for focused column")
|
|
37
38
|
}
|
|
@@ -47,7 +48,7 @@ func TestRenderColumn_allColumnTitles(t *testing.T) {
|
|
|
47
48
|
{data.ColumnDone, "DONE"},
|
|
48
49
|
}
|
|
49
50
|
for _, tc := range cases {
|
|
50
|
-
got := RenderColumn(nil, tc.col, 30, 0, false,
|
|
51
|
+
got := RenderColumn(nil, tc.col, 30, 0, 0, 0, false, nil)
|
|
51
52
|
if !strings.Contains(got, tc.label) {
|
|
52
53
|
t.Errorf("RenderColumn missing label %q for col %q", tc.label, tc.col)
|
|
53
54
|
}
|
|
@@ -56,7 +57,7 @@ func TestRenderColumn_allColumnTitles(t *testing.T) {
|
|
|
56
57
|
|
|
57
58
|
func TestRenderColumn_taskTitleRendered(t *testing.T) {
|
|
58
59
|
tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned}}
|
|
59
|
-
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false,
|
|
60
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, 0, 0, false, nil)
|
|
60
61
|
if !strings.Contains(got, "Build it") {
|
|
61
62
|
t.Error("RenderColumn missing task title")
|
|
62
63
|
}
|
|
@@ -64,26 +65,72 @@ func TestRenderColumn_taskTitleRendered(t *testing.T) {
|
|
|
64
65
|
|
|
65
66
|
func TestRenderColumn_rendersTaskCards(t *testing.T) {
|
|
66
67
|
tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned, Stage: data.StageAudit}}
|
|
67
|
-
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, true,
|
|
68
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, 0, 0, true, nil)
|
|
68
69
|
if !strings.Contains(got, glyphAudit) {
|
|
69
70
|
t.Error("RenderColumn should render task phase glyph from card")
|
|
70
71
|
}
|
|
71
|
-
if !strings.Contains(got, "
|
|
72
|
+
if !strings.Contains(got, "┌") {
|
|
72
73
|
t.Error("RenderColumn should render focused card border")
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
func
|
|
77
|
+
func TestRenderColumn_focusStatesUseStableBorderDimensions(t *testing.T) {
|
|
77
78
|
tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned, Stage: data.StageAudit}}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
unfocused := RenderColumn(tasks, data.ColumnPlanned, 30, 0, 0, -1, false, nil)
|
|
80
|
+
focused := RenderColumn(tasks, data.ColumnPlanned, 30, 0, 0, -1, true, nil)
|
|
81
|
+
|
|
82
|
+
if !strings.Contains(unfocused, "┌") {
|
|
83
|
+
t.Error("unfocused column should render a single-line border")
|
|
84
|
+
}
|
|
85
|
+
if !strings.Contains(focused, "┌") {
|
|
86
|
+
t.Error("focused column should render a single-line border")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
unfocusedLines := strings.Split(unfocused, "\n")
|
|
90
|
+
focusedLines := strings.Split(focused, "\n")
|
|
91
|
+
if len(unfocusedLines) != len(focusedLines) {
|
|
92
|
+
t.Fatalf("line count changed between focus states: unfocused=%d focused=%d", len(unfocusedLines), len(focusedLines))
|
|
93
|
+
}
|
|
94
|
+
for i := range unfocusedLines {
|
|
95
|
+
if lipgloss.Width(unfocusedLines[i]) != lipgloss.Width(focusedLines[i]) {
|
|
96
|
+
t.Fatalf("line %d width changed between focus states: unfocused=%d focused=%d", i, lipgloss.Width(unfocusedLines[i]), lipgloss.Width(focusedLines[i]))
|
|
97
|
+
}
|
|
81
98
|
}
|
|
82
99
|
}
|
|
83
100
|
|
|
84
101
|
func TestRenderColumn_emptyCountIsZero(t *testing.T) {
|
|
85
|
-
got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false,
|
|
102
|
+
got := RenderColumn(nil, data.ColumnPlanned, 30, 0, 0, 0, false, nil)
|
|
86
103
|
if !strings.Contains(got, "(0)") {
|
|
87
104
|
t.Error("RenderColumn missing (0) count for empty column")
|
|
88
105
|
}
|
|
89
106
|
}
|
|
107
|
+
|
|
108
|
+
func TestRenderColumn_viewportShowsScrollIndicators(t *testing.T) {
|
|
109
|
+
tasks := []data.Task{
|
|
110
|
+
{ID: "T1", Title: "Task one", Column: data.ColumnPlanned},
|
|
111
|
+
{ID: "T2", Title: "Task two", Column: data.ColumnPlanned},
|
|
112
|
+
{ID: "T3", Title: "Task three", Column: data.ColumnPlanned},
|
|
113
|
+
{ID: "T4", Title: "Task four", Column: data.ColumnPlanned},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, 8, 1, 1, true, nil)
|
|
117
|
+
|
|
118
|
+
if !strings.Contains(got, "↑ 1 above") {
|
|
119
|
+
t.Error("RenderColumn missing above indicator")
|
|
120
|
+
}
|
|
121
|
+
if !strings.Contains(got, "↓ 1 more") {
|
|
122
|
+
t.Error("RenderColumn missing more indicator")
|
|
123
|
+
}
|
|
124
|
+
if strings.Contains(got, "Task one") {
|
|
125
|
+
t.Error("RenderColumn should not render tasks above viewport")
|
|
126
|
+
}
|
|
127
|
+
if strings.Contains(got, "Task four") {
|
|
128
|
+
t.Error("RenderColumn should not render tasks below viewport")
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func TestVisibleColumnTaskLimitDefaultsToFourAtStandardHeight(t *testing.T) {
|
|
133
|
+
if got := visibleColumnTaskLimit(CalculateLayout(100, 24).ContentHeight); got != 4 {
|
|
134
|
+
t.Errorf("visibleColumnTaskLimit(standard height) = %d, want 4", got)
|
|
135
|
+
}
|
|
136
|
+
}
|
package/internal/board/detail.go
CHANGED
|
@@ -3,15 +3,17 @@ package board
|
|
|
3
3
|
import (
|
|
4
4
|
"strings"
|
|
5
5
|
|
|
6
|
+
"github.com/charmbracelet/lipgloss"
|
|
6
7
|
"github.com/opencode/savepoint/internal/data"
|
|
7
8
|
"github.com/opencode/savepoint/internal/styles"
|
|
8
9
|
)
|
|
9
10
|
|
|
10
|
-
const detailBorderPad = 4
|
|
11
|
+
const detailBorderPad = 4 // rounded border (2) + padding (2×1)
|
|
12
|
+
const detailVerticalOverhead = 4 // overlay border (2) + fixed title/header rows (2)
|
|
11
13
|
|
|
12
14
|
// RenderDetail renders a task detail overlay panel at the given display width.
|
|
13
|
-
// When
|
|
14
|
-
func RenderDetail(t data.Task, overlayW int,
|
|
15
|
+
// When router state matches t's release/epic/task, a "(router priority)" label is shown.
|
|
16
|
+
func RenderDetail(t data.Task, overlayW int, routerState *data.RouterState, maxHeight, offset int) string {
|
|
15
17
|
inner := overlayW - detailBorderPad
|
|
16
18
|
if inner < 4 {
|
|
17
19
|
inner = 4
|
|
@@ -21,57 +23,137 @@ func RenderDetail(t data.Task, overlayW int, routerTaskID string) string {
|
|
|
21
23
|
styles.ColumnTitleFocused.Render("TASK DETAIL"),
|
|
22
24
|
strings.Repeat("─", inner),
|
|
23
25
|
}
|
|
24
|
-
|
|
26
|
+
body := []string{
|
|
25
27
|
detailRow("ID", t.ID, inner),
|
|
26
28
|
detailRow("Title", t.Title, inner),
|
|
27
29
|
detailRow("Epic", t.Epic, inner),
|
|
28
30
|
detailRow("Release", t.Release, inner),
|
|
29
31
|
detailRow("Status", string(t.Column), inner),
|
|
30
32
|
detailRow("Phase", phaseLabel(t.Stage), inner),
|
|
31
|
-
|
|
33
|
+
}
|
|
32
34
|
|
|
33
35
|
if t.Description != "" {
|
|
34
|
-
|
|
36
|
+
body = append(body,
|
|
35
37
|
"",
|
|
36
38
|
styles.ColumnTitle.Render("Description:"),
|
|
37
39
|
)
|
|
38
40
|
for _, line := range WrapText(t.Description, inner) {
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if len(t.Acceptance) > 0 {
|
|
44
|
-
lines = append(lines, "", styles.ColumnTitle.Render("Acceptance Criteria:"), "")
|
|
45
|
-
for _, a := range t.Acceptance {
|
|
46
|
-
for _, line := range WrapText(a, inner-2) {
|
|
47
|
-
lines = append(lines, styles.CardMeta.Render(" • "+line))
|
|
48
|
-
}
|
|
41
|
+
body = append(body, styles.CardMeta.Render(line))
|
|
49
42
|
}
|
|
50
43
|
}
|
|
51
44
|
|
|
52
45
|
if len(t.Checklist) > 0 {
|
|
53
|
-
|
|
46
|
+
body = append(body, "", styles.ColumnTitle.Render("Implementation Plan:"), "")
|
|
54
47
|
for _, item := range t.Checklist {
|
|
55
|
-
glyph := "
|
|
48
|
+
glyph := "[ ] "
|
|
56
49
|
style := styles.CardMeta
|
|
57
50
|
if item.Done {
|
|
58
|
-
glyph = "
|
|
51
|
+
glyph = "[x] "
|
|
59
52
|
style = styles.TagDone
|
|
60
53
|
}
|
|
61
|
-
|
|
62
|
-
lines = append(lines, style.Render(" "+glyph+line))
|
|
63
|
-
}
|
|
54
|
+
body = append(body, renderChecklistSentences(item.Text, glyph, inner, style)...)
|
|
64
55
|
}
|
|
65
56
|
}
|
|
66
57
|
|
|
67
|
-
if
|
|
68
|
-
|
|
58
|
+
if t.Column != data.ColumnDone && isRouterPriority(t, routerState) {
|
|
59
|
+
body = append(body, "", styles.TagDone.Render("(router priority)"))
|
|
69
60
|
}
|
|
70
|
-
|
|
61
|
+
body = append(body, "", styles.CardMeta.Render("esc:close"))
|
|
62
|
+
lines = append(lines, visibleDetailLines(body, maxHeight-detailVerticalOverhead, offset)...)
|
|
71
63
|
|
|
72
64
|
return styles.DetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
|
|
73
65
|
}
|
|
74
66
|
|
|
67
|
+
func renderChecklistSentences(text, glyph string, width int, style lipgloss.Style) []string {
|
|
68
|
+
textWidth := width - len(glyph)
|
|
69
|
+
if textWidth < 4 {
|
|
70
|
+
textWidth = 4
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
lines := []string{}
|
|
74
|
+
continuationIndent := strings.Repeat(" ", len(glyph))
|
|
75
|
+
for _, sentence := range splitChecklistSentences(text) {
|
|
76
|
+
wrapped := WrapText(sentence, textWidth)
|
|
77
|
+
for i, line := range wrapped {
|
|
78
|
+
if i == 0 {
|
|
79
|
+
lines = append(lines, style.Render(glyph+line))
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
lines = append(lines, style.Render(continuationIndent+line))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return lines
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func splitChecklistSentences(text string) []string {
|
|
89
|
+
fields := strings.Fields(text)
|
|
90
|
+
if len(fields) == 0 {
|
|
91
|
+
return nil
|
|
92
|
+
}
|
|
93
|
+
normalized := strings.Join(fields, " ")
|
|
94
|
+
|
|
95
|
+
sentences := []string{}
|
|
96
|
+
start := 0
|
|
97
|
+
for i, r := range normalized {
|
|
98
|
+
if r != '.' && r != '!' && r != '?' {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
end := i + len(string(r))
|
|
102
|
+
if end < len(normalized) && normalized[end] != ' ' {
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
sentence := strings.TrimSpace(normalized[start:end])
|
|
106
|
+
if sentence != "" {
|
|
107
|
+
sentences = append(sentences, sentence)
|
|
108
|
+
}
|
|
109
|
+
start = end
|
|
110
|
+
}
|
|
111
|
+
if tail := strings.TrimSpace(normalized[start:]); tail != "" {
|
|
112
|
+
sentences = append(sentences, tail)
|
|
113
|
+
}
|
|
114
|
+
return sentences
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func visibleDetailLines(lines []string, maxBodyHeight, offset int) []string {
|
|
118
|
+
total := len(lines)
|
|
119
|
+
if maxBodyHeight <= 0 || total <= maxBodyHeight {
|
|
120
|
+
return lines
|
|
121
|
+
}
|
|
122
|
+
offset = clampDetailOffset(offset, total)
|
|
123
|
+
available := maxBodyHeight
|
|
124
|
+
if offset > 0 {
|
|
125
|
+
available--
|
|
126
|
+
}
|
|
127
|
+
if available < 1 {
|
|
128
|
+
available = 1
|
|
129
|
+
}
|
|
130
|
+
end := min(offset+available, total)
|
|
131
|
+
if end < total && available > 1 {
|
|
132
|
+
available--
|
|
133
|
+
end = min(offset+available, total)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
visible := make([]string, 0, available+2)
|
|
137
|
+
if offset > 0 {
|
|
138
|
+
visible = append(visible, renderScrollIndicator("↑", offset, "above"))
|
|
139
|
+
}
|
|
140
|
+
visible = append(visible, lines[offset:end]...)
|
|
141
|
+
if end < total {
|
|
142
|
+
visible = append(visible, renderScrollIndicator("↓", total-end, "more"))
|
|
143
|
+
}
|
|
144
|
+
return visible
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func clampDetailOffset(offset, total int) int {
|
|
148
|
+
if offset < 0 || total <= 0 {
|
|
149
|
+
return 0
|
|
150
|
+
}
|
|
151
|
+
if offset >= total {
|
|
152
|
+
return total - 1
|
|
153
|
+
}
|
|
154
|
+
return offset
|
|
155
|
+
}
|
|
156
|
+
|
|
75
157
|
func detailRow(label, value string, width int) string {
|
|
76
158
|
prefix := label + ": "
|
|
77
159
|
wrapped := WrapText(value, width-len(prefix))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package board
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"regexp"
|
|
4
5
|
"strings"
|
|
5
6
|
"testing"
|
|
6
7
|
|
|
@@ -8,6 +9,12 @@ import (
|
|
|
8
9
|
"github.com/opencode/savepoint/internal/data"
|
|
9
10
|
)
|
|
10
11
|
|
|
12
|
+
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
|
|
13
|
+
|
|
14
|
+
func plainTerminal(s string) string {
|
|
15
|
+
return ansiPattern.ReplaceAllString(s, "")
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
func sampleTask() data.Task {
|
|
12
19
|
return data.Task{
|
|
13
20
|
ID: "E04/T001",
|
|
@@ -20,49 +27,49 @@ func sampleTask() data.Task {
|
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
func TestRenderDetail_containsID(t *testing.T) {
|
|
23
|
-
got := RenderDetail(sampleTask(), 60,
|
|
30
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
24
31
|
if !strings.Contains(got, "E04/T001") {
|
|
25
32
|
t.Error("RenderDetail missing task ID")
|
|
26
33
|
}
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
func TestRenderDetail_containsTitle(t *testing.T) {
|
|
30
|
-
got := RenderDetail(sampleTask(), 60,
|
|
37
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
31
38
|
if !strings.Contains(got, "My Task") {
|
|
32
39
|
t.Error("RenderDetail missing task title")
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
func TestRenderDetail_containsEpic(t *testing.T) {
|
|
37
|
-
got := RenderDetail(sampleTask(), 60,
|
|
44
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
38
45
|
if !strings.Contains(got, "E04-board-components") {
|
|
39
46
|
t.Error("RenderDetail missing epic")
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
func TestRenderDetail_containsRelease(t *testing.T) {
|
|
44
|
-
got := RenderDetail(sampleTask(), 60,
|
|
51
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
45
52
|
if !strings.Contains(got, "v1") {
|
|
46
53
|
t.Error("RenderDetail missing release")
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
func TestRenderDetail_containsStatus(t *testing.T) {
|
|
51
|
-
got := RenderDetail(sampleTask(), 60,
|
|
58
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
52
59
|
if !strings.Contains(got, "in_progress") {
|
|
53
60
|
t.Error("RenderDetail missing status")
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
func TestRenderDetail_containsPhase(t *testing.T) {
|
|
58
|
-
got := RenderDetail(sampleTask(), 60,
|
|
65
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
59
66
|
if !strings.Contains(got, "build") {
|
|
60
67
|
t.Error("RenderDetail missing phase")
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
func TestRenderDetail_containsEscHint(t *testing.T) {
|
|
65
|
-
got := RenderDetail(sampleTask(), 60,
|
|
72
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
66
73
|
if !strings.Contains(got, "esc") {
|
|
67
74
|
t.Error("RenderDetail missing esc:close hint")
|
|
68
75
|
}
|
|
@@ -71,35 +78,23 @@ func TestRenderDetail_containsEscHint(t *testing.T) {
|
|
|
71
78
|
func TestRenderDetail_containsDescription(t *testing.T) {
|
|
72
79
|
tk := sampleTask()
|
|
73
80
|
tk.Description = "some description text"
|
|
74
|
-
got := RenderDetail(tk, 60,
|
|
81
|
+
got := RenderDetail(tk, 60, nil, 0, 0)
|
|
75
82
|
if !strings.Contains(got, "some description text") {
|
|
76
83
|
t.Error("RenderDetail missing description text")
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
func TestRenderDetail_noDescriptionSectionWhenEmpty(t *testing.T) {
|
|
81
|
-
got := RenderDetail(sampleTask(), 60,
|
|
88
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
82
89
|
if strings.Contains(got, "Description:") {
|
|
83
90
|
t.Error("RenderDetail should not show Description section when empty")
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
func TestRenderDetail_containsAcceptanceCriteria(t *testing.T) {
|
|
88
|
-
tk := sampleTask()
|
|
89
|
-
tk.Acceptance = []string{"criterion one", "criterion two"}
|
|
90
|
-
got := RenderDetail(tk, 60, "")
|
|
91
|
-
if !strings.Contains(got, "criterion one") {
|
|
92
|
-
t.Error("RenderDetail missing first acceptance criterion")
|
|
93
|
-
}
|
|
94
|
-
if !strings.Contains(got, "criterion two") {
|
|
95
|
-
t.Error("RenderDetail missing second acceptance criterion")
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
94
|
func TestRenderDetail_containsChecklist(t *testing.T) {
|
|
100
95
|
tk := sampleTask()
|
|
101
96
|
tk.Checklist = []data.CheckItem{{Text: "first implementation item"}, {Text: "second implementation item", Done: true}}
|
|
102
|
-
got := RenderDetail(tk, 60,
|
|
97
|
+
got := RenderDetail(tk, 60, nil, 0, 0)
|
|
103
98
|
if !strings.Contains(got, "Implementation Plan:") {
|
|
104
99
|
t.Error("RenderDetail missing implementation plan heading")
|
|
105
100
|
}
|
|
@@ -111,10 +106,70 @@ func TestRenderDetail_containsChecklist(t *testing.T) {
|
|
|
111
106
|
}
|
|
112
107
|
}
|
|
113
108
|
|
|
109
|
+
func TestRenderDetail_checklistSingleSentenceGetsOneCheckbox(t *testing.T) {
|
|
110
|
+
tk := sampleTask()
|
|
111
|
+
tk.Checklist = []data.CheckItem{{Text: "single sentence task"}}
|
|
112
|
+
|
|
113
|
+
got := plainTerminal(RenderDetail(tk, 60, nil, 0, 0))
|
|
114
|
+
|
|
115
|
+
if count := strings.Count(got, "[ ]"); count != 1 {
|
|
116
|
+
t.Fatalf("RenderDetail checkbox count = %d, want 1\n%s", count, got)
|
|
117
|
+
}
|
|
118
|
+
if strings.Contains(got, "[x]") {
|
|
119
|
+
t.Fatal("RenderDetail should not render checked marker for unchecked item")
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func TestRenderDetail_checklistMultiSentenceGetsOneCheckboxPerSentence(t *testing.T) {
|
|
124
|
+
tk := sampleTask()
|
|
125
|
+
tk.Checklist = []data.CheckItem{{Text: "First sentence. Second sentence! Third sentence?"}}
|
|
126
|
+
|
|
127
|
+
got := plainTerminal(RenderDetail(tk, 60, nil, 0, 0))
|
|
128
|
+
|
|
129
|
+
if count := strings.Count(got, "[ ]"); count != 3 {
|
|
130
|
+
t.Fatalf("RenderDetail checkbox count = %d, want 3\n%s", count, got)
|
|
131
|
+
}
|
|
132
|
+
for _, want := range []string{"[ ] First sentence.", "[ ] Second sentence!", "[ ] Third sentence?"} {
|
|
133
|
+
if !strings.Contains(got, want) {
|
|
134
|
+
t.Fatalf("RenderDetail missing sentence checkbox line %q\n%s", want, got)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func TestRenderDetail_checklistHardWrappedSentenceDoesNotDuplicateCheckbox(t *testing.T) {
|
|
140
|
+
tk := sampleTask()
|
|
141
|
+
tk.Checklist = []data.CheckItem{{
|
|
142
|
+
Text: "This sentence is intentionally long enough to wrap inside a narrow detail overlay while remaining one semantic sentence.",
|
|
143
|
+
}}
|
|
144
|
+
|
|
145
|
+
got := plainTerminal(RenderDetail(tk, 34, nil, 0, 0))
|
|
146
|
+
|
|
147
|
+
if count := strings.Count(got, "[ ]"); count != 1 {
|
|
148
|
+
t.Fatalf("RenderDetail checkbox count = %d, want 1\n%s", count, got)
|
|
149
|
+
}
|
|
150
|
+
if !strings.Contains(got, " intentionally long enough") {
|
|
151
|
+
t.Fatalf("RenderDetail continuation line should align under checkbox text\n%s", got)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func TestRenderDetail_checklistCheckedSentenceUsesCheckedMarker(t *testing.T) {
|
|
156
|
+
tk := sampleTask()
|
|
157
|
+
tk.Checklist = []data.CheckItem{{Text: "already done. still done.", Done: true}}
|
|
158
|
+
|
|
159
|
+
got := plainTerminal(RenderDetail(tk, 60, nil, 0, 0))
|
|
160
|
+
|
|
161
|
+
if count := strings.Count(got, "[x]"); count != 2 {
|
|
162
|
+
t.Fatalf("RenderDetail checked checkbox count = %d, want 2\n%s", count, got)
|
|
163
|
+
}
|
|
164
|
+
if strings.Contains(got, "[ ]") {
|
|
165
|
+
t.Fatal("RenderDetail should not render unchecked marker for checked item")
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
114
169
|
func TestRenderDetail_wrapsLongDescription(t *testing.T) {
|
|
115
170
|
tk := sampleTask()
|
|
116
171
|
tk.Description = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda"
|
|
117
|
-
got := RenderDetail(tk, 30,
|
|
172
|
+
got := RenderDetail(tk, 30, nil, 0, 0)
|
|
118
173
|
if strings.Contains(got, tk.Description) {
|
|
119
174
|
t.Error("RenderDetail should wrap long description text")
|
|
120
175
|
}
|
|
@@ -124,7 +179,7 @@ func TestRenderDetail_wrapsLongDescription(t *testing.T) {
|
|
|
124
179
|
}
|
|
125
180
|
|
|
126
181
|
func TestRenderDetail_noAcceptanceSectionWhenEmpty(t *testing.T) {
|
|
127
|
-
got := RenderDetail(sampleTask(), 60,
|
|
182
|
+
got := RenderDetail(sampleTask(), 60, nil, 0, 0)
|
|
128
183
|
if strings.Contains(got, "Acceptance Criteria:") {
|
|
129
184
|
t.Error("RenderDetail should not show Acceptance section when empty")
|
|
130
185
|
}
|
|
@@ -216,7 +271,8 @@ func TestView_detailOverlayRendered(t *testing.T) {
|
|
|
216
271
|
|
|
217
272
|
func TestRenderDetail_routerPriorityLabel(t *testing.T) {
|
|
218
273
|
task := sampleTask()
|
|
219
|
-
|
|
274
|
+
router := &data.RouterState{Release: task.Release, Epic: task.Epic, Task: task.ID}
|
|
275
|
+
got := RenderDetail(task, 60, router, 0, 0)
|
|
220
276
|
if !strings.Contains(got, "(router priority)") {
|
|
221
277
|
t.Error("RenderDetail missing router priority label for matching task")
|
|
222
278
|
}
|
|
@@ -224,12 +280,47 @@ func TestRenderDetail_routerPriorityLabel(t *testing.T) {
|
|
|
224
280
|
|
|
225
281
|
func TestRenderDetail_noRouterPriorityLabelWhenNoMatch(t *testing.T) {
|
|
226
282
|
task := sampleTask()
|
|
227
|
-
|
|
283
|
+
router := &data.RouterState{Release: task.Release, Epic: task.Epic, Task: "other-id"}
|
|
284
|
+
got := RenderDetail(task, 60, router, 0, 0)
|
|
228
285
|
if strings.Contains(got, "(router priority)") {
|
|
229
286
|
t.Error("RenderDetail should not show router priority label for non-matching task")
|
|
230
287
|
}
|
|
231
288
|
}
|
|
232
289
|
|
|
290
|
+
func TestRenderDetail_viewportShowsScrollIndicators(t *testing.T) {
|
|
291
|
+
task := sampleTask()
|
|
292
|
+
task.Description = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron"
|
|
293
|
+
|
|
294
|
+
got := RenderDetail(task, 32, nil, 8, 2)
|
|
295
|
+
|
|
296
|
+
if !strings.Contains(got, "↑ 2 above") {
|
|
297
|
+
t.Error("RenderDetail missing above indicator")
|
|
298
|
+
}
|
|
299
|
+
if !strings.Contains(got, "↓") || !strings.Contains(got, "more") {
|
|
300
|
+
t.Error("RenderDetail missing more indicator")
|
|
301
|
+
}
|
|
302
|
+
if strings.Contains(got, "ID:") {
|
|
303
|
+
t.Error("RenderDetail should not render body lines above viewport")
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
func TestUpdate_detailOverlayScrollsWithJK(t *testing.T) {
|
|
308
|
+
m := NewModel([]data.Task{sampleTask()}, "v1", "E04-board-components")
|
|
309
|
+
m.Overlay = OverlayDetail
|
|
310
|
+
|
|
311
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
|
|
312
|
+
updated := requireModel(t, got)
|
|
313
|
+
if updated.DetailOffset != 1 {
|
|
314
|
+
t.Errorf("DetailOffset after j = %d, want 1", updated.DetailOffset)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
|
|
318
|
+
updated = requireModel(t, got)
|
|
319
|
+
if updated.DetailOffset != 0 {
|
|
320
|
+
t.Errorf("DetailOffset after k = %d, want 0", updated.DetailOffset)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
233
324
|
func TestOverlayWidth_clampMax(t *testing.T) {
|
|
234
325
|
if got := overlayWidth(120); got != 80 {
|
|
235
326
|
t.Errorf("overlayWidth(120) = %d, want 80", got)
|