savepoint 1.0.2 → 1.0.4
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 +12 -1
- package/.github/workflows/ci.yml +20 -0
- package/.golangci.yml +11 -0
- package/.savepoint/Design.md +40 -38
- package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
- package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Audit.md +272 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +60 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +34 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +33 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T008-ci-and-release-automation.md +46 -0
- package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
- package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
- package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
- package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
- package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
- package/.savepoint/router.md +10 -17
- package/AGENTS.md +39 -24
- package/Makefile +3 -1
- package/README.md +0 -1
- package/agent-skills/savepoint-audit/SKILL.md +86 -34
- package/agent-skills/savepoint-build-task/SKILL.md +7 -2
- package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
- package/agent-skills/savepoint-create-task/SKILL.md +44 -31
- package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
- package/agent-skills/savepoint-system-design/SKILL.md +7 -2
- package/agent_skills_test.go +91 -0
- package/cmd/board.go +59 -0
- package/cmd/board_test.go +137 -0
- package/cmd/doctor.go +53 -0
- package/cmd/doctor_test.go +146 -0
- package/cmd/init.go +63 -0
- package/cmd/init_test.go +104 -0
- package/internal/board/board.go +44 -36
- package/internal/board/board_test.go +27 -82
- package/internal/board/card.go +43 -23
- package/internal/board/card_test.go +74 -5
- package/internal/board/column.go +75 -15
- package/internal/board/column_test.go +76 -2
- package/internal/board/debug.go +26 -0
- package/internal/board/debug_test.go +108 -0
- package/internal/board/detail.go +33 -47
- package/internal/board/detail_test.go +48 -0
- package/internal/board/epic_panel.go +120 -22
- package/internal/board/epic_panel_test.go +302 -17
- package/internal/board/help.go +1 -0
- package/internal/board/help_test.go +1 -0
- package/internal/board/integration_test.go +266 -0
- package/internal/board/interfaces.go +65 -0
- package/internal/board/interfaces_test.go +114 -0
- package/internal/board/io.go +93 -0
- package/internal/board/model.go +79 -118
- package/internal/board/plain.go +88 -0
- package/internal/board/plain_test.go +117 -0
- package/internal/board/release.go +1 -9
- package/internal/board/release_test.go +6 -6
- package/internal/board/status.go +4 -4
- package/internal/board/theme.go +24 -0
- package/internal/board/theme_test.go +31 -0
- package/internal/board/transitions.go +113 -88
- package/internal/board/transitions_test.go +164 -141
- package/internal/board/tui.go +32 -0
- package/internal/board/update.go +344 -215
- package/internal/board/update_test.go +326 -18
- package/internal/board/util.go +76 -0
- package/internal/board/view.go +31 -28
- package/internal/board/view_test.go +74 -2
- package/internal/board/watch.go +41 -5
- package/internal/buildtool/main.go +45 -15
- package/internal/buildtool/main_test.go +224 -0
- package/internal/data/config.go +17 -3
- package/internal/data/config_test.go +49 -0
- package/internal/data/discover.go +26 -0
- package/internal/data/discover_test.go +34 -10
- package/internal/data/errors.go +4 -0
- package/internal/data/fuzz_test.go +75 -0
- package/internal/data/lifecycle.go +13 -6
- package/internal/data/lifecycle_test.go +14 -11
- package/internal/data/parser.go +22 -6
- package/internal/data/parser_test.go +31 -7
- package/internal/data/task.go +0 -9
- package/internal/data/testdata/fuzz/FuzzSplitFrontmatterBody/68eb66b0fe91e7e3 +2 -0
- package/internal/data/write.go +88 -11
- package/internal/data/write_test.go +167 -0
- package/internal/doctor/checks.go +567 -0
- package/internal/doctor/checks_test.go +716 -0
- package/internal/doctor/gates.go +193 -0
- package/internal/doctor/gates_test.go +166 -0
- package/internal/doctor/interfaces.go +64 -0
- package/internal/doctor/interfaces_test.go +104 -0
- package/internal/doctor/repairs.go +80 -0
- package/internal/doctor/repairs_test.go +81 -0
- package/internal/doctor/report.go +157 -0
- package/internal/doctor/report_test.go +89 -0
- package/internal/init/clipboard.go +146 -0
- package/internal/init/clipboard_test.go +74 -0
- package/internal/init/install.go +16 -0
- package/internal/init/integration_test.go +197 -0
- package/internal/init/prompt.go +14 -0
- package/internal/init/prompt_test.go +77 -0
- package/internal/init/scaffold.go +59 -0
- package/internal/init/scaffold_test.go +179 -0
- package/internal/init/template_freshness_test.go +56 -0
- package/internal/init/validate.go +85 -0
- package/internal/init/validate_test.go +141 -0
- package/internal/init/write.go +73 -0
- package/internal/init/write_test.go +91 -0
- package/internal/styles/styles_test.go +133 -0
- package/internal/testutil/fixture.go +113 -0
- package/internal/testutil/fs.go +26 -0
- package/main.go +120 -4
- package/package.json +2 -2
- package/project-audit/audit_report_glm_5.1.md +411 -0
- package/project-audit/audit_report_opus_4.6.md +406 -0
- package/project-audit/consolidated-audit-report.md +456 -0
- package/templates/project/.savepoint/Design.md +2 -2
- package/templates/project/.savepoint/router.md +10 -10
- package/templates/project/AGENTS.md +33 -21
- package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
- package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
- package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
- package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
- package/templates/prompts/audit-reconciliation.prompt.md +33 -28
- package/templates/prompts/design.prompt.md +3 -1
- package/.savepoint/audit/v1/E01/proposals.md +0 -168
- package/.savepoint/audit/v1/E01/snapshot.md +0 -78
- package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
- package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
- package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
- package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
- package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
- package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
- package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
- package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
- package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
- package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
- package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
- package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
- package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
- package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
- package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
- package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
- package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
- package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
- package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
- package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
- package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
- package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
- package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
- package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
- package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
- package/ink-cli-ui-design.zip +0 -0
- package/savepoint +0 -0
- package/savepoint.exe +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package board
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"fmt"
|
|
4
5
|
"strings"
|
|
5
6
|
"testing"
|
|
6
7
|
|
|
@@ -118,12 +119,15 @@ func TestRenderColumn_viewportShowsScrollIndicators(t *testing.T) {
|
|
|
118
119
|
if !strings.Contains(got, "↑ 1 above") {
|
|
119
120
|
t.Error("RenderColumn missing above indicator")
|
|
120
121
|
}
|
|
121
|
-
if !strings.Contains(got, "↓
|
|
122
|
-
t.
|
|
122
|
+
if !strings.Contains(got, "↓ 2 more") {
|
|
123
|
+
t.Errorf("RenderColumn missing more indicator, got:\n%s", got)
|
|
123
124
|
}
|
|
124
125
|
if strings.Contains(got, "Task one") {
|
|
125
126
|
t.Error("RenderColumn should not render tasks above viewport")
|
|
126
127
|
}
|
|
128
|
+
if strings.Contains(got, "Task three") {
|
|
129
|
+
t.Error("RenderColumn should not render tasks that don't fit budget")
|
|
130
|
+
}
|
|
127
131
|
if strings.Contains(got, "Task four") {
|
|
128
132
|
t.Error("RenderColumn should not render tasks below viewport")
|
|
129
133
|
}
|
|
@@ -134,3 +138,73 @@ func TestVisibleColumnTaskLimitDefaultsToFourAtStandardHeight(t *testing.T) {
|
|
|
134
138
|
t.Errorf("visibleColumnTaskLimit(standard height) = %d, want 4", got)
|
|
135
139
|
}
|
|
136
140
|
}
|
|
141
|
+
|
|
142
|
+
func BenchmarkRenderColumn_empty(b *testing.B) {
|
|
143
|
+
b.ReportAllocs()
|
|
144
|
+
for b.Loop() {
|
|
145
|
+
RenderColumn(nil, data.ColumnPlanned, 30, 20, 0, 0, false, nil)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func BenchmarkRenderColumn_fewTasks(b *testing.B) {
|
|
150
|
+
tasks := []data.Task{
|
|
151
|
+
{ID: "E06/T001", Title: "First task", Column: data.ColumnPlanned, Stage: data.StageBuild},
|
|
152
|
+
{ID: "E06/T002", Title: "Second task", Column: data.ColumnPlanned, Stage: data.StageTest},
|
|
153
|
+
{ID: "E06/T003", Title: "Third task", Column: data.ColumnPlanned, Stage: data.StageAudit},
|
|
154
|
+
}
|
|
155
|
+
b.ReportAllocs()
|
|
156
|
+
for b.Loop() {
|
|
157
|
+
RenderColumn(tasks, data.ColumnPlanned, 30, 20, 0, 0, false, nil)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func BenchmarkRenderColumn_manyTasks(b *testing.B) {
|
|
162
|
+
tasks := make([]data.Task, 20)
|
|
163
|
+
stages := []data.ProgressStage{data.StageBuild, data.StageTest, data.StageAudit}
|
|
164
|
+
for i := range tasks {
|
|
165
|
+
tasks[i] = data.Task{
|
|
166
|
+
ID: fmt.Sprintf("E06/T%03d", i+1),
|
|
167
|
+
Title: fmt.Sprintf("Task number %d with a reasonable length title", i+1),
|
|
168
|
+
Column: data.ColumnPlanned,
|
|
169
|
+
Stage: stages[i%3],
|
|
170
|
+
Release: "v1.1",
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
b.ReportAllocs()
|
|
174
|
+
for b.Loop() {
|
|
175
|
+
RenderColumn(tasks, data.ColumnPlanned, 40, 24, 0, 0, false, nil)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func BenchmarkRenderColumn_focused(b *testing.B) {
|
|
180
|
+
tasks := []data.Task{
|
|
181
|
+
{ID: "E06/T001", Title: "First task", Column: data.ColumnInProgress, Stage: data.StageBuild},
|
|
182
|
+
{ID: "E06/T002", Title: "Second task", Column: data.ColumnInProgress, Stage: data.StageTest},
|
|
183
|
+
{ID: "E06/T003", Title: "Third task", Column: data.ColumnInProgress, Stage: data.StageAudit},
|
|
184
|
+
}
|
|
185
|
+
router := &data.RouterState{Release: "v1", Epic: "E06", Task: "T001"}
|
|
186
|
+
b.ReportAllocs()
|
|
187
|
+
for b.Loop() {
|
|
188
|
+
RenderColumn(tasks, data.ColumnInProgress, 40, 20, 0, 0, true, router)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func TestRenderColumn_focusedLastTaskVisibleWhenScrolled(t *testing.T) {
|
|
193
|
+
// When scrolled (offset > 0) the top scroll indicator steals 1 line.
|
|
194
|
+
// The focused last-in-page task must still appear, not be cut off.
|
|
195
|
+
tasks := []data.Task{
|
|
196
|
+
{ID: "T1", Title: "Task one", Column: data.ColumnPlanned},
|
|
197
|
+
{ID: "T2", Title: "Task two", Column: data.ColumnPlanned},
|
|
198
|
+
{ID: "T3", Title: "Task three", Column: data.ColumnPlanned},
|
|
199
|
+
{ID: "T4", Title: "Task four", Column: data.ColumnPlanned},
|
|
200
|
+
{ID: "T5", Title: "Task five", Column: data.ColumnPlanned},
|
|
201
|
+
}
|
|
202
|
+
// offset=2, focusedTask=4 (last task), maxHeight=14 (24-line terminal)
|
|
203
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, 14, 2, 4, true, nil)
|
|
204
|
+
if !strings.Contains(got, "Task five") {
|
|
205
|
+
t.Errorf("focused last task must be visible when scrolled, got:\n%s", got)
|
|
206
|
+
}
|
|
207
|
+
if !strings.Contains(got, "↑") {
|
|
208
|
+
t.Error("scroll indicator above must appear when offset > 0")
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"sync/atomic"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
var debugEnabled atomic.Bool
|
|
10
|
+
|
|
11
|
+
// SetDebug enables or disables debug logging for the board package.
|
|
12
|
+
func SetDebug(enabled bool) {
|
|
13
|
+
debugEnabled.Store(enabled)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// DebugEnabled reports whether debug logging is active.
|
|
17
|
+
func DebugEnabled() bool {
|
|
18
|
+
return debugEnabled.Load()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func debugf(format string, args ...any) {
|
|
22
|
+
if !debugEnabled.Load() {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
fmt.Fprintf(os.Stderr, "[savepoint debug] "+format+"\n", args...)
|
|
26
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"os"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestSetDebugToggle(t *testing.T) {
|
|
13
|
+
t.Cleanup(func() { SetDebug(false) })
|
|
14
|
+
|
|
15
|
+
if DebugEnabled() {
|
|
16
|
+
t.Fatal("debug should be off by default")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
SetDebug(true)
|
|
20
|
+
if !DebugEnabled() {
|
|
21
|
+
t.Fatal("debug should be on after SetDebug(true)")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
SetDebug(false)
|
|
25
|
+
if DebugEnabled() {
|
|
26
|
+
t.Fatal("debug should be off after SetDebug(false)")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func TestDebugfWritesToStderr(t *testing.T) {
|
|
31
|
+
t.Cleanup(func() { SetDebug(false) })
|
|
32
|
+
|
|
33
|
+
r, w, err := os.Pipe()
|
|
34
|
+
if err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
orig := os.Stderr
|
|
38
|
+
os.Stderr = w
|
|
39
|
+
|
|
40
|
+
SetDebug(true)
|
|
41
|
+
debugf("test message %d", 42)
|
|
42
|
+
|
|
43
|
+
os.Stderr = orig
|
|
44
|
+
w.Close()
|
|
45
|
+
|
|
46
|
+
var buf bytes.Buffer
|
|
47
|
+
io.Copy(&buf, r)
|
|
48
|
+
r.Close()
|
|
49
|
+
|
|
50
|
+
out := buf.String()
|
|
51
|
+
if !strings.Contains(out, "test message 42") {
|
|
52
|
+
t.Fatalf("expected debug output, got: %q", out)
|
|
53
|
+
}
|
|
54
|
+
if !strings.Contains(out, "[savepoint debug]") {
|
|
55
|
+
t.Fatalf("expected debug prefix, got: %q", out)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func TestDebugfSilentWhenDisabled(t *testing.T) {
|
|
60
|
+
t.Cleanup(func() { SetDebug(false) })
|
|
61
|
+
|
|
62
|
+
r, w, err := os.Pipe()
|
|
63
|
+
if err != nil {
|
|
64
|
+
t.Fatal(err)
|
|
65
|
+
}
|
|
66
|
+
orig := os.Stderr
|
|
67
|
+
os.Stderr = w
|
|
68
|
+
|
|
69
|
+
SetDebug(false)
|
|
70
|
+
debugf("should not appear")
|
|
71
|
+
|
|
72
|
+
os.Stderr = orig
|
|
73
|
+
w.Close()
|
|
74
|
+
|
|
75
|
+
var buf bytes.Buffer
|
|
76
|
+
io.Copy(&buf, r)
|
|
77
|
+
r.Close()
|
|
78
|
+
|
|
79
|
+
if buf.Len() != 0 {
|
|
80
|
+
t.Fatalf("expected no debug output when disabled, got: %q", buf.String())
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func TestDebugfFormat(t *testing.T) {
|
|
85
|
+
t.Cleanup(func() { SetDebug(false) })
|
|
86
|
+
|
|
87
|
+
r, w, err := os.Pipe()
|
|
88
|
+
if err != nil {
|
|
89
|
+
t.Fatal(err)
|
|
90
|
+
}
|
|
91
|
+
orig := os.Stderr
|
|
92
|
+
os.Stderr = w
|
|
93
|
+
|
|
94
|
+
SetDebug(true)
|
|
95
|
+
debugf("key=%q value=%d", "hello", 7)
|
|
96
|
+
|
|
97
|
+
os.Stderr = orig
|
|
98
|
+
w.Close()
|
|
99
|
+
|
|
100
|
+
var buf bytes.Buffer
|
|
101
|
+
io.Copy(&buf, r)
|
|
102
|
+
r.Close()
|
|
103
|
+
|
|
104
|
+
want := fmt.Sprintf("[savepoint debug] key=%q value=%d\n", "hello", 7)
|
|
105
|
+
if buf.String() != want {
|
|
106
|
+
t.Fatalf("expected %q, got %q", want, buf.String())
|
|
107
|
+
}
|
|
108
|
+
}
|
package/internal/board/detail.go
CHANGED
|
@@ -85,6 +85,36 @@ func renderChecklistSentences(text, glyph string, width int, style lipgloss.Styl
|
|
|
85
85
|
return lines
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// knownAbbreviations is the set of dot-terminated tokens that must not trigger
|
|
89
|
+
// sentence splits. Add entries (lowercase, trailing dot) to extend the list.
|
|
90
|
+
var knownAbbreviations = map[string]bool{
|
|
91
|
+
"e.g.": true,
|
|
92
|
+
"i.e.": true,
|
|
93
|
+
"vs.": true,
|
|
94
|
+
"etc.": true,
|
|
95
|
+
"fig.": true,
|
|
96
|
+
"no.": true,
|
|
97
|
+
"mr.": true,
|
|
98
|
+
"mrs.": true,
|
|
99
|
+
"dr.": true,
|
|
100
|
+
"st.": true,
|
|
101
|
+
"jr.": true,
|
|
102
|
+
"sr.": true,
|
|
103
|
+
"prof.": true,
|
|
104
|
+
"approx.": true,
|
|
105
|
+
"est.": true,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// isKnownAbbreviation reports whether the period at dotPos in s is the trailing
|
|
109
|
+
// dot of a known abbreviation (e.g. "e.g.", "Dr.").
|
|
110
|
+
func isKnownAbbreviation(s string, dotPos int) bool {
|
|
111
|
+
start := dotPos
|
|
112
|
+
for start > 0 && s[start-1] != ' ' {
|
|
113
|
+
start--
|
|
114
|
+
}
|
|
115
|
+
return knownAbbreviations[strings.ToLower(s[start:dotPos+1])]
|
|
116
|
+
}
|
|
117
|
+
|
|
88
118
|
func splitChecklistSentences(text string) []string {
|
|
89
119
|
fields := strings.Fields(text)
|
|
90
120
|
if len(fields) == 0 {
|
|
@@ -98,6 +128,9 @@ func splitChecklistSentences(text string) []string {
|
|
|
98
128
|
if r != '.' && r != '!' && r != '?' {
|
|
99
129
|
continue
|
|
100
130
|
}
|
|
131
|
+
if r == '.' && isKnownAbbreviation(normalized, i) {
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
101
134
|
end := i + len(string(r))
|
|
102
135
|
if end < len(normalized) && normalized[end] != ' ' {
|
|
103
136
|
continue
|
|
@@ -182,51 +215,4 @@ func phaseLabel(s data.ProgressStage) string {
|
|
|
182
215
|
}
|
|
183
216
|
}
|
|
184
217
|
|
|
185
|
-
func WrapText(s string, width int) []string {
|
|
186
|
-
if width < 4 {
|
|
187
|
-
width = 4
|
|
188
|
-
}
|
|
189
|
-
words := strings.Fields(s)
|
|
190
|
-
if len(words) == 0 {
|
|
191
|
-
return nil
|
|
192
|
-
}
|
|
193
|
-
lines := []string{}
|
|
194
|
-
current := ""
|
|
195
|
-
for _, word := range words {
|
|
196
|
-
if len([]rune(word)) > width {
|
|
197
|
-
if current != "" {
|
|
198
|
-
lines = append(lines, current)
|
|
199
|
-
current = ""
|
|
200
|
-
}
|
|
201
|
-
lines = append(lines, SplitLongWord(word, width)...)
|
|
202
|
-
continue
|
|
203
|
-
}
|
|
204
|
-
if current == "" {
|
|
205
|
-
current = word
|
|
206
|
-
continue
|
|
207
|
-
}
|
|
208
|
-
if len([]rune(current))+1+len([]rune(word)) <= width {
|
|
209
|
-
current += " " + word
|
|
210
|
-
continue
|
|
211
|
-
}
|
|
212
|
-
lines = append(lines, current)
|
|
213
|
-
current = word
|
|
214
|
-
}
|
|
215
|
-
if current != "" {
|
|
216
|
-
lines = append(lines, current)
|
|
217
|
-
}
|
|
218
|
-
return lines
|
|
219
|
-
}
|
|
220
218
|
|
|
221
|
-
func SplitLongWord(word string, width int) []string {
|
|
222
|
-
runes := []rune(word)
|
|
223
|
-
lines := []string{}
|
|
224
|
-
for len(runes) > width {
|
|
225
|
-
lines = append(lines, string(runes[:width]))
|
|
226
|
-
runes = runes[width:]
|
|
227
|
-
}
|
|
228
|
-
if len(runes) > 0 {
|
|
229
|
-
lines = append(lines, string(runes))
|
|
230
|
-
}
|
|
231
|
-
return lines
|
|
232
|
-
}
|
|
@@ -321,6 +321,54 @@ func TestUpdate_detailOverlayScrollsWithJK(t *testing.T) {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
func TestSplitChecklistSentences_abbreviationEg(t *testing.T) {
|
|
325
|
+
got := splitChecklistSentences("Use e.g. a widget. Done.")
|
|
326
|
+
if len(got) != 2 {
|
|
327
|
+
t.Fatalf("splitChecklistSentences with e.g. = %d sentences, want 2: %v", len(got), got)
|
|
328
|
+
}
|
|
329
|
+
if got[0] != "Use e.g. a widget." {
|
|
330
|
+
t.Errorf("sentence[0] = %q, want %q", got[0], "Use e.g. a widget.")
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func TestSplitChecklistSentences_abbreviationIe(t *testing.T) {
|
|
335
|
+
got := splitChecklistSentences("Call i.e. the function. Done.")
|
|
336
|
+
if len(got) != 2 {
|
|
337
|
+
t.Fatalf("splitChecklistSentences with i.e. = %d sentences, want 2: %v", len(got), got)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func TestSplitChecklistSentences_abbreviationDr(t *testing.T) {
|
|
342
|
+
got := splitChecklistSentences("Dr. Smith approved it. Done.")
|
|
343
|
+
if len(got) != 2 {
|
|
344
|
+
t.Fatalf("splitChecklistSentences with Dr. = %d sentences, want 2: %v", len(got), got)
|
|
345
|
+
}
|
|
346
|
+
if got[0] != "Dr. Smith approved it." {
|
|
347
|
+
t.Errorf("sentence[0] = %q, want %q", got[0], "Dr. Smith approved it.")
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
func TestSplitChecklistSentences_abbreviationEtc(t *testing.T) {
|
|
352
|
+
got := splitChecklistSentences("Add widgets, buttons, etc. to the panel. Done.")
|
|
353
|
+
if len(got) != 2 {
|
|
354
|
+
t.Fatalf("splitChecklistSentences with etc. = %d sentences, want 2: %v", len(got), got)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
func TestSplitChecklistSentences_abbreviationCaseInsensitive(t *testing.T) {
|
|
359
|
+
got := splitChecklistSentences("See Fig. 3 for details. Done.")
|
|
360
|
+
if len(got) != 2 {
|
|
361
|
+
t.Fatalf("splitChecklistSentences with Fig. = %d sentences, want 2: %v", len(got), got)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
func TestSplitChecklistSentences_normalSplitUnaffected(t *testing.T) {
|
|
366
|
+
got := splitChecklistSentences("First sentence. Second sentence.")
|
|
367
|
+
if len(got) != 2 {
|
|
368
|
+
t.Fatalf("splitChecklistSentences normal split = %d sentences, want 2: %v", len(got), got)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
324
372
|
func TestOverlayWidth_clampMax(t *testing.T) {
|
|
325
373
|
if got := overlayWidth(120); got != 80 {
|
|
326
374
|
t.Errorf("overlayWidth(120) = %d, want 80", got)
|
|
@@ -8,33 +8,40 @@ import (
|
|
|
8
8
|
)
|
|
9
9
|
|
|
10
10
|
// RenderEpicDetail renders an overlay showing the content of an E##-Detail.md file.
|
|
11
|
-
func RenderEpicDetail(epicSlug, content string, overlayW, maxHeight, offset int) string {
|
|
11
|
+
func RenderEpicDetail(epicSlug, content string, overlayW, maxHeight, offset int, tab int) string {
|
|
12
12
|
inner := overlayW - detailBorderPad
|
|
13
13
|
if inner < 4 {
|
|
14
14
|
inner = 4
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
tabIndicator := renderTabIndicator(tab, inner)
|
|
17
18
|
lines := []string{
|
|
18
19
|
styles.EpicTitleFocused.Render("EPIC DETAIL"),
|
|
19
|
-
|
|
20
|
+
tabIndicator,
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
body := epicDetailBody(content, inner)
|
|
23
|
-
body = append(body, "", styles.CardMeta.Render("esc:close"))
|
|
24
|
-
lines = append(lines, visibleDetailLines(body, maxHeight-detailVerticalOverhead, offset)...)
|
|
24
|
+
body = append(body, "", styles.CardMeta.Render("1:Detail 2:Audit esc:close"))
|
|
25
|
+
lines = append(lines, visibleDetailLines(body, maxHeight-detailVerticalOverhead-1, offset)...)
|
|
25
26
|
|
|
26
27
|
return styles.EpicDetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if
|
|
32
|
-
|
|
30
|
+
func renderTabIndicator(tab int, width int) string {
|
|
31
|
+
var detail, audit string
|
|
32
|
+
if tab == 0 {
|
|
33
|
+
detail = styles.EpicItemFocused.Render("DETAIL [1]")
|
|
34
|
+
audit = styles.CardMeta.Render("AUDIT [2]")
|
|
35
|
+
} else {
|
|
36
|
+
detail = styles.CardMeta.Render("DETAIL [1]")
|
|
37
|
+
audit = styles.EpicItemFocused.Render("AUDIT [2]")
|
|
33
38
|
}
|
|
39
|
+
return detail + styles.CardMeta.Render(" │ ") + audit
|
|
40
|
+
}
|
|
34
41
|
|
|
42
|
+
// stripFrontmatter removes YAML frontmatter (between leading --- markers) from content.
|
|
43
|
+
func stripFrontmatter(content string) []string {
|
|
35
44
|
lines := strings.Split(content, "\n")
|
|
36
|
-
|
|
37
|
-
// Strip YAML frontmatter between leading --- markers.
|
|
38
45
|
start := 0
|
|
39
46
|
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
|
|
40
47
|
for i := 1; i < len(lines); i++ {
|
|
@@ -44,7 +51,16 @@ func epicDetailBody(content string, width int) []string {
|
|
|
44
51
|
}
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
|
-
|
|
54
|
+
return lines[start:]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// epicDetailBody parses markdown content into display lines, stripping frontmatter.
|
|
58
|
+
func epicDetailBody(content string, width int) []string {
|
|
59
|
+
if strings.TrimSpace(content) == "" || content == "(no detail available)" {
|
|
60
|
+
return []string{styles.CardMeta.Render("(no detail available)")}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
lines := stripFrontmatter(content)
|
|
48
64
|
|
|
49
65
|
var body []string
|
|
50
66
|
skip := false
|
|
@@ -77,11 +93,89 @@ func epicDetailBody(content string, width int) []string {
|
|
|
77
93
|
return body
|
|
78
94
|
}
|
|
79
95
|
|
|
96
|
+
// RenderEpicAuditTab renders an overlay showing audit findings from an E##-Audit.md file.
|
|
97
|
+
func RenderEpicAuditTab(epicSlug, content string, overlayW, maxHeight, offset int, tab int) string {
|
|
98
|
+
inner := overlayW - detailBorderPad
|
|
99
|
+
if inner < 4 {
|
|
100
|
+
inner = 4
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
tabIndicator := renderTabIndicator(tab, inner)
|
|
104
|
+
lines := []string{
|
|
105
|
+
styles.GlyphAudit.Render("EPIC AUDIT"),
|
|
106
|
+
tabIndicator,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
body := epicAuditBody(content, inner)
|
|
110
|
+
body = append(body, "", styles.CardMeta.Render("1:Detail 2:Audit esc:close"))
|
|
111
|
+
lines = append(lines, visibleDetailLines(body, maxHeight-detailVerticalOverhead-1, offset)...)
|
|
112
|
+
|
|
113
|
+
return styles.EpicDetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// epicAuditHiddenSectionHeadings lists markdown section headings suppressed in the audit tab overlay.
|
|
117
|
+
// Sections that are implementation details or planning artifacts clutter the summary view.
|
|
118
|
+
var epicAuditHiddenSectionHeadings = map[string]struct{}{
|
|
119
|
+
"12. Distribution & build": {},
|
|
120
|
+
"Acceptance Criteria": {},
|
|
121
|
+
"Architectural notes": {},
|
|
122
|
+
"Boundaries": {},
|
|
123
|
+
"Context Files": {},
|
|
124
|
+
"Implemented As": {},
|
|
125
|
+
"Implemented as": {},
|
|
126
|
+
"Implementation Plan": {},
|
|
127
|
+
"Manual audit override": {},
|
|
128
|
+
"Proposed Changes": {},
|
|
129
|
+
"Quality Review": {},
|
|
130
|
+
"With": {},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func epicAuditBody(content string, width int) []string {
|
|
134
|
+
if strings.TrimSpace(content) == "" || content == "(no audit available)" {
|
|
135
|
+
return []string{styles.CardMeta.Render("(no audit available)")}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
lines := stripFrontmatter(content)
|
|
139
|
+
|
|
140
|
+
var body []string
|
|
141
|
+
inHiddenSection := false
|
|
142
|
+
|
|
143
|
+
for _, line := range lines {
|
|
144
|
+
trimmed := strings.TrimRight(line, " \t\r")
|
|
145
|
+
switch {
|
|
146
|
+
case strings.HasPrefix(trimmed, "## "):
|
|
147
|
+
sectionName := strings.TrimPrefix(trimmed, "## ")
|
|
148
|
+
_, inHiddenSection = epicAuditHiddenSectionHeadings[sectionName]
|
|
149
|
+
if !inHiddenSection {
|
|
150
|
+
body = append(body, "", styles.EpicItemFocused.Render(sectionName))
|
|
151
|
+
}
|
|
152
|
+
case inHiddenSection:
|
|
153
|
+
case strings.HasPrefix(trimmed, "### "):
|
|
154
|
+
body = append(body, styles.EpicItemFocused.Render(strings.TrimPrefix(trimmed, "### ")))
|
|
155
|
+
case strings.HasPrefix(trimmed, "- [x] ") || strings.HasPrefix(trimmed, "- [X] "):
|
|
156
|
+
text := strings.TrimPrefix(strings.TrimPrefix(trimmed, "- [x] "), "- [X] ")
|
|
157
|
+
body = append(body, renderChecklistSentences(text, "[x] ", width, styles.TagDone)...)
|
|
158
|
+
case strings.HasPrefix(trimmed, "- [ ] "):
|
|
159
|
+
text := strings.TrimPrefix(trimmed, "- [ ] ")
|
|
160
|
+
body = append(body, renderChecklistSentences(text, "[ ] ", width, styles.CardMeta)...)
|
|
161
|
+
case strings.HasPrefix(trimmed, "- "):
|
|
162
|
+
body = append(body, styles.CardMeta.Render("• "+strings.TrimPrefix(trimmed, "- ")))
|
|
163
|
+
case trimmed == "":
|
|
164
|
+
body = append(body, "")
|
|
165
|
+
default:
|
|
166
|
+
for _, wrapped := range WrapText(trimmed, width) {
|
|
167
|
+
body = append(body, styles.CardMeta.Render(wrapped))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return body
|
|
172
|
+
}
|
|
173
|
+
|
|
80
174
|
const epicActiveMarker = "►"
|
|
81
175
|
|
|
82
176
|
// RenderEpicSidebar renders the fixed left sidebar listing epics with active indicator.
|
|
83
177
|
// If epics is empty and selected is non-empty, selected is shown as the sole entry.
|
|
84
|
-
func RenderEpicSidebar(epics []string, selected string, width int, focus bool, cursor int, status map[string]string) string {
|
|
178
|
+
func RenderEpicSidebar(epics []string, selected string, width int, focus bool, cursor int, status map[string]string, maxHeight int) string {
|
|
85
179
|
inner := width - epicPanelOverhead
|
|
86
180
|
if inner < 2 {
|
|
87
181
|
inner = 2
|
|
@@ -114,6 +208,20 @@ func RenderEpicSidebar(epics []string, selected string, width int, focus bool, c
|
|
|
114
208
|
if len(list) == 0 {
|
|
115
209
|
lines = append(lines, styles.TaskItem.Render("(none)"))
|
|
116
210
|
}
|
|
211
|
+
if maxHeight > 0 && len(lines) > maxHeight {
|
|
212
|
+
items := lines[2:]
|
|
213
|
+
available := maxHeight - 3
|
|
214
|
+
if available < 1 {
|
|
215
|
+
available = 1
|
|
216
|
+
}
|
|
217
|
+
clipped := make([]string, 0, maxHeight)
|
|
218
|
+
clipped = append(clipped, lines[0], lines[1])
|
|
219
|
+
clipped = append(clipped, items[:min(available, len(items))]...)
|
|
220
|
+
if len(items) > available {
|
|
221
|
+
clipped = append(clipped, renderScrollIndicator("↓", len(items)-available, "more"))
|
|
222
|
+
}
|
|
223
|
+
lines = clipped
|
|
224
|
+
}
|
|
117
225
|
style := styles.EpicPanel.Width(width)
|
|
118
226
|
if focus && len(epics) > 0 {
|
|
119
227
|
style = styles.EpicPanelFocused.Width(width)
|
|
@@ -154,13 +262,3 @@ func RenderEpicDropdown(epics []string, cursor int, width int) string {
|
|
|
154
262
|
lines = append(lines, "", styles.CardMeta.Render("↑↓:nav enter:select esc:cancel"))
|
|
155
263
|
return styles.EpicPanel.Width(width).Render(strings.Join(lines, "\n"))
|
|
156
264
|
}
|
|
157
|
-
|
|
158
|
-
// epicIndex returns the index of selected in epics, or 0 if not found.
|
|
159
|
-
func epicIndex(epics []string, selected string) int {
|
|
160
|
-
for i, e := range epics {
|
|
161
|
-
if e == selected {
|
|
162
|
-
return i
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return 0
|
|
166
|
-
}
|