Skip to content

hyp init: show the user where they are in the onboarding flow - #434

Merged
philcunliffe merged 1 commit into
masterfrom
fix/issue-415
Jul 28, 2026
Merged

hyp init: show the user where they are in the onboarding flow#434
philcunliffe merged 1 commit into
masterfrom
fix/issue-415

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

hyp init was a sequence of standalone screens: each phase printed its own title and knew nothing about what came before or after, so you answered a question, another screen appeared, and you could not tell whether you were halfway through or two prompts from the end.

Implements the decision on the issue (option b, denominator resolved after the fork) including all four structural refinements.

What landed

The denominator resolves after the fork, never before. The pathway is what fixes the total, and the fork is the question that asks for it, so the fork carries no counter at all. One line after the fork resolves, the itinerary is known and never moves again. This is what makes the failed-join retry safe: it loops back to the fork (wizard/index.js), which states nothing, so a retry that lands on local starts the local count rather than contradicting a team total already on screen. The scoped re-entry skips the fork and knows its pathway from the gate.

Steps are prompt lanes, not phases.configure is narration plus a shelled-out command, the privacy narration is closing text, and first look is a report rather than a decision. None of them count, and configure needed no changes at all.

A lane counts once, however many prompts it contains. The whole join lane is one step even though runRemoteLogin can prompt for an org; the finale is likewise one step across install / attach / assets / consent / restart.

first look and privacy do not count and render with no counter.

Itineraries:

pathwaycounted lanesfork
teamStep 1 of 3 · Join your team, Step 2 of 3 · Choose what to collect, Step 3 of 3 · Finish setupshown, no counter
localStep 1 of 2 · Choose what to collect, Step 2 of 2 · Finish setupshown, no counter
scopedsame as localnever shown

Display names are the ones proposed on the issue, unchanged.

The seam

An optional progress field on the prompt spec, not composed into title. As flagged, this meant reaching six interfaces, so they now derive from a common PromptChrome base (title, hint, progress) covering the three specs and the three reducer states. render.js opens every frame with the position dim on its own line above the bold title, and the legacy numbered picker prompt prints the same text as plain text in the same position.

Two lanes own no prompt spec (join narrates then delegates; the finale is a sequence of actions), so they write the line themselves where the lane starts, once.

wizardStepProgress returns undefined whenever no position can be stated honestly, and every caller threads that through as an omitted field.

Verification

The new test file was written first and run against unmodified master: 7 of its 15 cases failed on real assertions (+ undefined - 'Step 1 of 2 · Choose what to collect', the join narration missing its line, render emitting the title as line 0). All 15 pass after.

Non-interactive byte-identity was checked for real, not just asserted in a unit test: hyp init --yes --no-daemon, --yes --dry-run --no-daemon, and --yes --dry-run --no-daemon --export keep-local were each run against this branch and against master with an identical temp HOME, and both stdout and stderr are byte-for-byte identical in all three.

  • npm test: 2798 tests, 2789 pass, 8 fail. The 8 are test/core/leave-command.test.js and fail identically on unmodified master in this container (no systemctl on PATH).
  • npm run typecheck: clean.
  • npm run smoke -- walkthrough_picker_to_first_query: ok.
  • npm run smoke -- walkthrough_to_first_query: FAILS, but fails identically on unmodified master here (ai_gateway_messages has exactly one row returns 0) - environmental, not caused by this change.

LLP 0135 gains a #progress section documenting the three rules, the itinerary table and the seam, and every piece of the computation carries an @ref back to it.

One deviation worth flagging

The issue names legacyMenuPrompt (wizard/fork.js:347) as the fallback that should print the plain-text form. That function is used only by the fork and the returning gate, and under option (b) neither carries a counter, so there is nothing for it to render. The pick lane's actual non-TUI fallback is legacyNumberedPromptFactory in walkthrough.js, and that is where the plain-text form landed. Adding an unused parameter to legacyMenuPrompt would have been dead code.

Fixes#415

The wizard was a sequence of standalone screens: each phase printed its
own title and knew nothing about what came before or after, so the user
could not tell whether they were halfway through or two prompts from the
end (#415).
Adds a position line, per the decision on the issue:
- The denominator resolves after the fork, never before. The pathway is
what fixes the total and the fork is the question that asks for it, so
the fork carries no counter. A failed join returns to the fork, which
states nothing, so a retry onto a different pathway never contradicts
a total already on screen.
- Steps are prompt lanes, not phases. `configure` and the privacy
narration are output, and `first look` is a report rather than a
decision, so none of them count and `configure` needs no changes.
- A lane counts once however many prompts it contains, so the whole join
lane (which delegates to a login that can prompt for an org) is one
step and the total never moves once the pathway is committed.
Itineraries: team = join, pick, finale; local and scoped = pick, finale.
The seam is an optional `progress` field rather than text folded into a
title. `PromptChrome` becomes the common base for all six prompt-spec
and prompt-state interfaces so the field reaches every kind and cannot
drift between them; `render` opens each frame with the position dim
above the bold title, and the legacy numbered picker prompt prints the
same text as plain text. Lanes with no prompt spec (join, finale) write
the line themselves where the lane starts.
`wizardStepProgress` returns undefined whenever no position can be
stated honestly, and callers thread that through as an omitted field, so
non-interactive runs emit no breadcrumb and their output is
byte-identical to before (verified by diffing `hyp init --yes`,
`--yes --dry-run` and `--yes --dry-run --export keep-local` stdout and
stderr against the same runs on master).
LLP 0135 gains a #progress section documenting the three rules and the
seam, and the computation carries an @ref back to it.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Verdict: clean, no actionable findings

Reviewed d66714aeb13d0a6f860c039d6214d31f703c70f9 in an isolated worktree off origin/fix/issue-415, against base 6a64273. Nothing was pushed; the branch is unchanged.

The implementation matches the specification in bgmcmullen's comment on #415 on every structural point, and I verified each one independently rather than reading the PR description. Details below, including the byte-identity comparison re-run from scratch.


1. The counter cannot lie

wizardStepProgress (src/core/cli/wizard/steps.js:74) is a lookup into a static table (steps.js:56), so M is a pure function of the pathway and cannot move once the pathway is committed. Traced every route through src/core/cli/wizard/index.js:

routewhere the pathway is fixedcounted screens
gate quitindex.js:75 returns before any lanenone
gate statusindex.js:76 returns before any lanenone
gate scoped-reconfigureindex.js:83, fork skipped entirelyStep 1 of 2, Step 2 of 2
fork to localindex.js:99Step 1 of 2, Step 2 of 2
fork to teamindex.js:124Step 1 of 3, Step 2 of 3, Step 3 of 3
non-interactive (opts.picks)never; the whole gate/fork block is behind interactive at index.js:66none

Both short-circuiting gate actions return before pickProgress / finaleProgress are even computed (index.js:135-136), so no counted screen can render with a wrong M.

Failed-join retry (index.js:120-123): the loop continues back to the fork, and the fork is passed no progress at all. I asserted this in a real run rather than only in the unit test, and it holds. The only sense in which a number changes across a retry is that a user who saw Step 1 of 3 · Join your team, failed, and then chose local next sees Step 1 of 2. That is exactly what option (b) buys and what the human signed off on: the abandoned lane's total belongs to a pathway that was never taken, and every number the taken pathway shows is self-consistent. It is not a contradiction the user is left holding, because the fork in between states nothing.

I mutation-tested this rather than trusting it, see section 6.

2. The join lane really is one step

runJoinFlow writes the line once, at src/core/cli/wizard/join.js:87, above the Joining your team... narration, then delegates to runLogin. runRemoteLogin is never handed a progress value, so the org-selection prompt inside it renders with no counter and the number cannot advance mid-lane. Same shape for the finale (src/core/cli/walkthrough.js:745), which prints once before install/attach/assets/consent/restart.

Verified in a real attended run under a pty on the branch (TUI path, NO_COLOR=1, ANSI stripped):

102: Step 1 of 2 · Choose what to collect
104: What do you want to collect? (space to toggle, enter to confirm)
...
184: Step 2 of 2 · Finish setup

and the fork frame above it carries no counter.

3. Byte-identical non-interactive output, re-verified

I did not take the author's word for this. Ran the packaged CLI from both worktrees under env -i with an identical fresh temp HOME, capturing stdout and stderr separately and normalising only the worktree path:

invocationstdoutstderrresult
init --yes --no-daemon2574 B7 Bidentical, md5 2fc5ec80f7f59ca7f67ab632b32a606e
init --yes --dry-run --no-daemon2834 B7 Bidentical, md5 1b7147640a445470dd6b408cee152357
init --yes --dry-run --no-daemon --export keep-local2834 B7 Bidentical, md5 1b7147640a445470dd6b408cee152357
init --from-file <cfg> --forceidenticalidenticalmd5 b1b8bee210a425f55cc0da2d9c6769a2

I added the --from-file case, which the author did not report, because the spec names it explicitly. All four match byte for byte, and grep -c "Step " is 0 in every non-interactive stdout.

The mechanism holds up structurally too: interactive = !opts.picks (index.js:57) gates the entire gate/fork/join block, so a non-interactive run never commits a pathway, wizardStepProgress(undefined, ...) returns undefined (steps.js:80 via wizardItinerary), and every call site spreads it as an omitted field.

4. The common base is sound

Diffed the effective member set of every interface in src/core/cli/tui/types.d.ts between base and head. The only delta is the addition of progress? to all six plus the new PromptChrome carrying title / hint? / progress?. No field lost, no optionality flipped, no shape changed:

> MultiSelectSpec.progress? > MultiselectState.progress?
> SelectSpec.progress? > SelectState.progress?
> TextSpec.progress? > TextState.progress?
> PromptChrome.title / .hint? / .progress?

npm run typecheck is clean locally (exit 0) and green in CI on this SHA. Repo type conventions hold: JSDoc not TypeScript in .js, no inline import('...') types, and steps.js:4 uses the root-anchored '../../../../src/core/cli/wizard/types.js' specifier. The reducer at src/core/cli/tui/keypress.js propagates state by spread throughout, so progress survives every transition without further changes.

5. The two disclosed deviations

(a) legacyNumberedPromptFactory rather than legacyMenuPrompt. The reasoning checks out. legacyMenuPrompt has exactly two callers repo-wide, src/core/cli/wizard/fork.js:117 (the fork, FORK_TITLE) and src/core/cli/wizard/fork.js:235 (the returning gate). Under option (b) neither carries a counter, so teaching it the field really would be dead code. Meanwhile the pick lane's actual non-TUI fallback is legacyNumberedPromptFactory, and that is where it landed (src/core/cli/walkthrough.js:87). Correct call.

(b) The finale states its position once at lane start. I judge this right, and consistent rather than a special case: it is the same rule the human mandated for the join lane, applied to the other multi-action lane. Repeating the identical string on the backfill consent spec would not change any number, so nothing about the counter's honesty depends on it. There is a real but small UX cost, since the consent select at src/core/cli/walkthrough.js:188 can appear well below the Step N of M line after install/attach/asset output, and now that SelectSpec carries progress it would cost one spread to echo it there. I would take that as a follow-up polish if a human prefers it; it is not a defect and not a deviation from the structural ask.

6. The regression test is real

test/core/cli/wizard/progress.test.js imports and drives the actual runInitWizard, runWizardJoin, runPickerFinale, defaultPromptFactory and render, not reimplementations. Rather than trust that, I mutated the branch in four ways and confirmed each mutant dies (15/15 pass unmutated):

mutationresult
drop ...(pickProgress ? ...) threading in index.js4 failures
delete the progress line from chromeLines in render.js1 failure
add a fourth lane to the team itinerary in steps.js2 failures
leak a pathway into non-interactive runs (pathway ?? 'local')1 failure

The last one matters most: the byte-identity contract has an executable guard, not just a manual check.

7. Checks, conventions and LLP

  • CI green on d66714a: test (22), test (24), typecheck (22), typecheck (24) all SUCCESS.
  • npm test locally: 2798 tests, 2789 pass, 8 fail. All 8 are test/core/leave-command.test.js. I ran that file alone on base 6a64273 and got the same 8 failures by name (# tests 11 / # pass 3 / # fail 8), so the baseline claim is confirmed, not assumed.
  • npm run smoke -- walkthrough_picker_to_first_query: ok on the branch.
  • npm run smoke -- walkthrough_to_first_query: FAILS on the branch (ai_gateway_messages has exactly one row for this dev_run_id, value=0), and I ran it on unmodified base in a separate worktree and got the identical failure with the identical assertion and value. Confirmed environmental and unchanged, so the issue's "stays green" criterion is only verifiable here as "not made worse".
  • No em dashes (U+2014) anywhere in the diff. The separator in the breadcrumb is U+00B7 MIDDLE DOT, which is fine.
  • No statement-terminating semicolons introduced.
  • All seven @ref LLP 0135#progress annotations resolve: the anchor exists at llp/0135-install-experience-overhaul.design.md:217 (## Position indicator {#progress}). Each gloss says something the code and filename do not (why the fork is uncounted, why the lane counts once, why the field is not folded into the title), so they are not mechanical. The empty gloss on the [tests] ref in the test file matches long-standing repo practice across ~20 existing test files.
  • configure.js is untouched, which is what "count prompts, not phases" implies.

Findings

None actionable. Two non-blocking nits, recorded for completeness only:

  • nitsrc/core/cli/walkthrough.js:87: the write is \n${question.progress} with no trailing newline, relying on the very next write (\n${question.title}\n) to terminate the line. Output is correct today (verified in a real legacy-path run), but the two writes are coupled and a future reorder would silently join them.
  • nitsrc/core/cli/walkthrough.js:745: on the HYP_NO_TUI legacy path the finale's line lands on the same terminal line as the readline prompt select (e.g. 1,3 or "all"): . I checked base and this is pre-existing, not introduced: on 6a64273 the next output (hyp init: spawn systemctl ENOENT) lands in exactly the same place. The default TUI path is unaffected because the picker uses clearOnResolve.

Nothing was changed or pushed.

@philcunliffe
philcunliffe marked this pull request as ready for review July 28, 2026 23:01
@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 28, 2026
@philcunliffe
philcunliffe merged commit 3955700 into masterJul 28, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-415 branch July 28, 2026 23:36
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hyp init: show the user where they are in the onboarding flow

1 participant

@philcunliffe