Skip to content

Commit 971ee44

Browse files
LastStepclaude
andauthored
feat(init): Plan 22 Phase 3 — Vessel + Soil stages (real input) (#49)
Replace the Phase-2 Vessel and Soil stubs in `internal/tui/initflow/` with real input stages. Vessel collects NAME/DESCRIPTION/STATION via three composed bubbles/textinput models on a single page; Soil is a hand-rolled multi-select over scaffolding options with ◆/◇ glyphs, pinned REQUIRED badges, and focus highlighting. Branches and Observe remain stubs until Phases 4–5. Also fixes the user-reported header bug: `RenderHeader` no longer renders `station/` in its right block. The station subdir doesn't exist until the Phase 5 generate runs — showing it earlier claimed the path existed when it did not. `RenderHeader`'s signature drops the `stationSubdir` parameter and the `Stage.stationDir` field stays on Stage/StageContext for Phase 5 Planted to consume. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 83e7aa6 commit 971ee44

8 files changed

Lines changed: 1019 additions & 25 deletions

File tree

cmd/init_redesign.go

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import (
1515
"github.com/LastStep/Bonsai/internal/tui/initflow"
1616
)
1717

18-
// runInitRedesign is the Phase-2 stub entry point for Plan 22's cinematic
18+
// runInitRedesign is the Phase-3 entry point for Plan 22's cinematic
1919
// `bonsai init` flow. It renders the persistent chrome (header + enso rail
20-
// + footer) around four placeholder stages that advance on Enter and pop
21-
// on Esc. No files are written — the generate + planted pipeline lands in
22-
// Phases 3–5.
20+
// + footer) around four stages — Vessel and Soil are real input stages;
21+
// Branches and Observe remain stubs until Phases 4–5. No files are written
22+
// yet; the generate + planted pipeline lands in Phase 5.
2323
//
2424
// Routing: runInit at cmd/init.go:121 branches here when BONSAI_REDESIGN=1.
2525
// Without the env flag, the legacy flow runs unchanged.
@@ -56,7 +56,9 @@ func runInitRedesign(cmd *cobra.Command, args []string) error {
5656
}
5757

5858
// Shared context stamped on every stage. Station defaults to "station/"
59-
// until Phase 3's VesselStage captures a user-entered value.
59+
// until VesselStage captures a user-entered value; Phase 5's Planted
60+
// stage will read the post-Vessel value out of prev[0] when rendering
61+
// the generated file tree.
6062
ctx := initflow.StageContext{
6163
Version: Version,
6264
ProjectDir: cwd,
@@ -65,9 +67,14 @@ func runInitRedesign(cmd *cobra.Command, args []string) error {
6567
StartedAt: startedAt,
6668
}
6769

70+
// Phase 3 wires real Vessel + Soil stages; Branches + Observe remain
71+
// stubs until Phases 4–5 land. Legacy generate / conflict tail is still
72+
// skipped — runInitRedesign does NOT write files yet.
73+
soilOptions := scaffoldingToSoilOptions(cat)
74+
6875
steps := []harness.Step{
69-
initflow.NewStubStage(0, ctx.Version, ctx.ProjectDir, ctx.StationDir, ctx.AgentDisplay, ctx),
70-
initflow.NewStubStage(1, ctx.Version, ctx.ProjectDir, ctx.StationDir, ctx.AgentDisplay, ctx),
76+
initflow.NewVesselStage(ctx),
77+
initflow.NewSoilStage(ctx, soilOptions),
7178
initflow.NewStubStage(2, ctx.Version, ctx.ProjectDir, ctx.StationDir, ctx.AgentDisplay, ctx),
7279
initflow.NewStubStage(3, ctx.Version, ctx.ProjectDir, ctx.StationDir, ctx.AgentDisplay, ctx),
7380
}
@@ -80,7 +87,7 @@ func runInitRedesign(cmd *cobra.Command, args []string) error {
8087
_, err := harness.Run(bannerLine, "Initializing new project (redesign)", steps)
8188
if err != nil {
8289
if errors.Is(err, harness.ErrAborted) {
83-
// Ctrl-C — no config / files written in Phase 2 either way.
90+
// Ctrl-C — no config / files written in Phase 3 either way.
8491
return nil
8592
}
8693
var bpe *harness.BuilderPanicError
@@ -93,8 +100,30 @@ func runInitRedesign(cmd *cobra.Command, args []string) error {
93100
return err
94101
}
95102

96-
// Phase 2 does not write files, save config, or run a conflict picker —
97-
// those wire up in Phases 3–5. Returning cleanly after the flow exits
98-
// AltScreen is the expected behaviour for this phase.
103+
// Phase 3 does not write files, save config, or run a conflict picker —
104+
// the generate + planted pipeline wires up in Phases 4–5. Returning
105+
// cleanly after the flow exits AltScreen is the expected behaviour.
99106
return nil
100107
}
108+
109+
// scaffoldingToSoilOptions maps catalog scaffolding entries into the
110+
// initflow.ScaffoldingOption shape consumed by SoilStage. Parallels the
111+
// legacy `scaffoldingOptions` helper (which returns tui.ItemOption for the
112+
// MultiSelectStep) but keeps the redesign path decoupled from the tui
113+
// package's option type.
114+
func scaffoldingToSoilOptions(cat *catalog.Catalog) []initflow.ScaffoldingOption {
115+
out := make([]initflow.ScaffoldingOption, 0, len(cat.Scaffolding))
116+
for _, item := range cat.Scaffolding {
117+
desc := item.Description
118+
if !item.Required && item.Affects != "" {
119+
desc += " · if removed: " + item.Affects
120+
}
121+
out = append(out, initflow.ScaffoldingOption{
122+
Name: item.Name,
123+
DisplayName: item.DisplayName,
124+
Description: desc,
125+
Required: item.Required,
126+
})
127+
}
128+
return out
129+
}

internal/tui/initflow/chrome.go

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,22 @@ type KeyHint struct {
2121
//
2222
// Left: [盆] BONSAI · INITIALIZE · v<version>
2323
// Right: PLANTING INTO
24-
// ~/.../<project>/<station>
24+
// ~/.../<project>/
2525
//
2626
// version "dev" / "" hides the version segment. projectDir is the absolute
27-
// path to the project root; stationSubdir is the subdir under it (e.g.
28-
// "station/"). safe gates the single wide-char glyph so ASCII-only terminals
29-
// get a safe substitute.
30-
func RenderHeader(version, projectDir, stationSubdir string, width int, safe bool) string {
27+
// path to the project root — the only path segment rendered in the right
28+
// block; earlier iterations also rendered a "station/" suffix, but the
29+
// station subdir doesn't exist yet at any point before the Generate stage,
30+
// so showing it was misleading. safe gates the single wide-char glyph so
31+
// ASCII-only terminals get a safe substitute.
32+
func RenderHeader(version, projectDir string, width int, safe bool) string {
3133
if width <= 0 {
3234
width = 80
3335
}
3436

3537
primary := lipgloss.NewStyle().Foreground(tui.ColorPrimary).Bold(true)
3638
muted := lipgloss.NewStyle().Foreground(tui.ColorMuted)
3739
bark := lipgloss.NewStyle().Foreground(tui.ColorSecondary)
38-
leaf := lipgloss.NewStyle().Foreground(tui.ColorPrimary)
3940

4041
// ── Left block ───────────────────────────────────────────────────
4142
mark := "盆"
@@ -57,21 +58,17 @@ func RenderHeader(version, projectDir, stationSubdir string, width int, safe boo
5758
left := strings.Join(leftParts, " ")
5859

5960
// ── Right block ──────────────────────────────────────────────────
60-
// "PLANTING INTO" headline above "~/path/<project>/<station>"
61+
// "PLANTING INTO" headline above "~/path/<project>/".
6162
projectDisplay := collapseHome(projectDir)
6263
projectName := filepath.Base(projectDir)
6364
parent := filepath.Dir(projectDisplay)
64-
// Render parent muted, project name bark, station leaf.
65-
station := stationSubdir
66-
if station != "" && !strings.HasSuffix(station, "/") {
67-
station += "/"
68-
}
65+
// Render parent muted, project name bark, trailing slash muted.
6966
if parent == "." || parent == "" {
7067
parent = ""
7168
} else if !strings.HasSuffix(parent, "/") {
7269
parent += "/"
7370
}
74-
pathRow := muted.Render(parent) + bark.Render(projectName) + muted.Render("/") + leaf.Render(station)
71+
pathRow := muted.Render(parent) + bark.Render(projectName) + muted.Render("/")
7572
rightRow1 := muted.Render("PLANTING INTO")
7673

7774
// ── Compose two-row layout with left-padded right block ─────────
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package initflow
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// TestRenderHeader_CollapsesHome verifies that a project path rooted under
9+
// $HOME is rendered with the tilde-collapsed prefix (~/...) rather than the
10+
// full absolute path.
11+
func TestRenderHeader_CollapsesHome(t *testing.T) {
12+
t.Setenv("HOME", "/home/alice")
13+
14+
out := RenderHeader("0.1.2", "/home/alice/voyager-api", 120, true)
15+
16+
if !strings.Contains(out, "~/voyager-api") {
17+
t.Fatalf("expected tilde-collapsed project path in header, got:\n%s", out)
18+
}
19+
// The project name itself must appear.
20+
if !strings.Contains(out, "voyager-api") {
21+
t.Fatalf("expected project name to appear in header, got:\n%s", out)
22+
}
23+
}
24+
25+
// TestRenderHeader_AbsolutePathOutsideHome verifies that a project path that
26+
// is not under $HOME is rendered verbatim (no spurious ~ substitution).
27+
func TestRenderHeader_AbsolutePathOutsideHome(t *testing.T) {
28+
t.Setenv("HOME", "/home/bob")
29+
30+
out := RenderHeader("0.1.2", "/tmp/p", 120, true)
31+
32+
if !strings.Contains(out, "/tmp/p") {
33+
t.Fatalf("expected absolute project path in header, got:\n%s", out)
34+
}
35+
if strings.Contains(out, "~/") {
36+
t.Fatalf("expected no tilde substitution for path outside HOME, got:\n%s", out)
37+
}
38+
}
39+
40+
// TestRenderHeader_NoStationSegment is the regression guard for the Phase-3
41+
// bug fix — the station subdir doesn't exist until Phase 5 generate runs, so
42+
// the header must not claim "station/" in its path row. Covers both safe and
43+
// ASCII-fallback rendering modes.
44+
func TestRenderHeader_NoStationSegment(t *testing.T) {
45+
t.Setenv("HOME", "/home/alice")
46+
47+
for _, safe := range []bool{true, false} {
48+
out := RenderHeader("0.1.2", "/home/alice/voyager-api", 120, safe)
49+
if strings.Contains(out, "station") {
50+
t.Fatalf("safe=%v: header must not contain \"station\" substring, got:\n%s", safe, out)
51+
}
52+
}
53+
}
54+
55+
// TestRenderHeader_TrailingSlash verifies the project row renders a trailing
56+
// slash after the project name so the path reads as a directory.
57+
func TestRenderHeader_TrailingSlash(t *testing.T) {
58+
t.Setenv("HOME", "/home/alice")
59+
60+
out := RenderHeader("0.1.2", "/home/alice/voyager-api", 120, true)
61+
62+
if !strings.Contains(out, "voyager-api/") {
63+
t.Fatalf("expected trailing slash after project name, got:\n%s", out)
64+
}
65+
}

0 commit comments

Comments
 (0)