pm: tri-state interactive update picker - #512
Conversation
One row per outdated direct dep in nub update -i; space/arrows cycle each row through keep -> in-range target -> latest dist-tag, laid out as aligned radio cells with semver-diff coloring. Rows default to keep, so updates are opt-in. A latest that would downgrade a prerelease pin is never offered (per-cell form of the preserve-pin guard). One invocation now covers both in-range refreshes and range-crossing bumps per package; a latest pick routes through the same per-key machinery as an explicit <pkg>@latest. Gated behind a new Embedder::rich_update_picker profile flag: nub opts in, standalone aube keeps the demand multiselect picker byte-for-byte.
Named indirect deps still update when no direct dep has an offerable row (the NothingOutdated arm now falls through with an empty direct selection instead of short-circuiting). The range cell gains the same semver downgrade guard as the latest cell for lockfiles pinned above the manifest range. Selection building is extracted and unit-tested.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — an embedder-gated tri-state interactive update picker that subsumes -i and -i --latest into one keep/range/latest radio-cell table, defaulting every row to keep.
- Add
update_pickermodule —PickerRow/PickState/PickerSelectionplus aconsole-basedTerm::stderrTUI loop (build_row,run,build_selection), with per-cell semver downgrade guarding, filter mode, cycle-all, cursor restore, and ANSI-aware truncation. Backed by eight focused unit tests covering drift-drop, equal-target collapse, prerelease-pin downgrade hiding, unparseable-current handling, selection split, state cycling, and column alignment. - Wire the rich picker into
update.rs— newpick_update_rich+RichPickenum and an extractedfetch_packumentshelper; a rich "latest" pick is merged intoexplicit_specsas"latest"so it flows through the identical per-key machinery as an explicit<pkg>@latest. - Neutralize the global
--latestflag in rich mode —latestis rebound tolatest && !rich_pickerafter the picker so per-row choices are authoritative, and thepreserve_pinpre-fetch is skipped becausebuild_row's per-cell guard already covers both range and latest downgrades. - Expose
colorize_diffwith anon_stderrparam — the picker renders on stderr (estylegating) whileoutdated's table stays on stdout (nstyle). - Add the
Embedder::rich_update_pickergate — nub opts in;AUBEand all test embedders setfalse, keeping standalone aube'sdemand::MultiSelectUX byte-for-byte unchanged. - Document the picker —
install/index.mdxgains the-irow and a rendered example frame.
The behavioral core traces cleanly: the latest rebinding propagates to every downstream consumer (resolver_manifest, should_rewrite_key, effective_latest, both rewrite loops), so the non-interactive and demand-picker paths see values identical to before; rich-mode explicit_specs only carries direct keys, so it can't collide with the indirect-arg spec forwarding; and both the range and latest cells are downgrade-guarded in build_row, which makes skipping the preserve_pin pre-fetch in rich mode safe. The rich and demand paths stay consistent on the edges too (both skip lockfile-absent packages, both apply the workspace/link:/file: filter). The gate defaults keep standalone aube unchanged, and dependencies (console, clx, node-semver) are already direct deps of the aube crate. Mergeable as-is.
Claude Opus | 𝕏
Swaps the (•)/( ) radio glyphs for the filled/hollow squares bun's interactive updater uses; the selected box on an update cell carries the same semver-severity color as the version tail. Cell width math moves to char counts (the squares are multibyte), and the alignment test compares char columns instead of byte offsets.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the single new commit since the prior pullfrog review, swapping the picker's radio glyphs for bun-style filled/hollow squares with bump-severity coloring.
- Replace radio glyphs with severity-colored squares — selected cells now render a filled
■(colored by semver bump via the newseverity_box) and unselected cells a dimmed hollow□, instead of(•)/( ). Both glyphs are exactly one display column, so column alignment is unchanged. - Count cell widths in chars, not bytes —
range_cell_w/latest_cell_wswitched from.len()to.chars().count()because■/□are multibyte; the blank-fill for absent columns stays consistent with real cells. - Update the alignment test to char columns —
rows_render_as_aligned_columnsnow computes column positions in chars from ANSI-stripped rows, matching the multibyte-glyph reality, and assertsrangesits left oflatest. - Refresh the docs example frame —
install/index.mdx's rendered picker now shows the square markers.
The change is self-contained UI cosmetics. severity_box mirrors the existing colorize_diff palette (major→red, minor→cyan, patch→green, else magenta) and falls back to a plain ■ when either version fails to parse; the marker is only threaded into update cells, keep stays plain. Alignment reasoning holds: selected (■) and unselected (□) markers are both a single display cell, and the width helpers/blank-fill account for that in chars — so the columns line up whether a row carries a cell or blank-fills it. colorize_diff and Layout remain internal to the picker/outdated modules, so there's no downstream contract impact. Mergeable as-is.
Claude Opus | 𝕏
The keep / latest-in-range / latest labels render once as a dim heading row under the title; cells carry only the box and version. Column widths are floored by the heading labels so short version columns never squeeze them.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the single new commit since the prior pullfrog review, which pulls the picker's per-cell state labels out into a single column-heading line.
- Move state labels into column headings — the per-cell
keep/range/latestlabels are gone; a newheader_linerenders them once as a dim heading row under the title (keep/latest in range/latest), and eachcellnow carries only box + version. - Floor column widths by their header label — width helpers renamed
*_cell_w→*_col_w; each column width is nowmax(2 + version_w, HDR.len())so a short version column never squeezes its heading, andcelltrailing-pads each cell to the full column width. - Rewrite the alignment test —
rows_render_as_aligned_columnsnow asserts the■/□box char-positions AND the header-label char-positions all land on the same computed columns, across rows with and without each cell. - Refresh the docs example frame —
install/index.mdx's rendered picker drops the in-cell labels and shows the new heading row.
The layout math traces cleanly: header_line and format_row derive every column start from the same *_col_w() helpers (keep_col = 4 + name_w + 2, then each subsequent column = prior start + prior col_w + 2), and cell emits exactly col_w display chars (box + space + padded-version + pad), so headers and boxes stay column-aligned whether a row carries a cell or blank-fills it. The header floor guarantees each label fits its column, and the test's use of rfind(HDR_LATEST) correctly skips the latest substring inside latest in range. The docs frame is internally consistent and matches the code's column formula. Mergeable as-is.
Claude Opus | 𝕏
# Conflicts: # site/content/docs/install/index.mdx
Composes the picker with the dist-tag update args from main: a tag spec (update -i typescript@beta, or a manifest dist-tag specifier) resolves through the packument's dist-tags for the middle cell, so picking it applies the tag — previously the spec failed range-parsing and the tag could never apply interactively.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the one new commit on this branch since the prior pullfrog review (a merge main in between only pulled in already-merged, unrelated PRs), which composes the picker with dist-tag update specs so a tag pick can apply from the interactive picker.
- Resolve dist-tag specs into the picker's range cell — in
pick_update_rich, the range-cell target now falls back topackument.dist_tags.get(spec)when the spec fails to parse as a semver range. A tag spec (update -i typescript@beta, or a manifest"typescript": "beta") previously rendered no in-range cell, so the tag could never apply interactively; it now surfaces as the middle cell.
The change traces cleanly. The fallback is a strict superset of prior behavior — it only fires when max_satisfying_version returns None, so range specs are unaffected. The resolved dist-tag version flows into the range cell and gets the same per-cell downgrade guard in build_row as any range target. A Range pick on a dist-tag key resolves correctly downstream both ways: an arg spec (typescript@beta) lands in explicit_specs and the resolver_manifest entry is rewritten to beta, while a manifest-only tag keeps effective_latest=false and the resolver re-resolves the original beta spec from the unchanged manifest. The latest cell is unaffected since dist_tags.get("latest") is supplied independently.
Coverage note (informational)
The picker unit tests exercise build_row with pre-resolved wanted values, and the arg-parsing tests only assert that is-odd@next/@beta are accepted as args. Nothing exercises the new dist_tags.get(spec) resolution step in pick_update_rich — i.e. that a tag spec produces a range cell pointing at the tag's version. This is hard to unit-test as written (needs a packument fixture plus the non-pub rich path), so it's a note rather than an ask; the logic itself is trivially correct.
Claude Opus | 𝕏
The range cell is always populated — duplicating the current version when there is no in-range drift, and duplicating latest when the in-range max is the latest — so every row carries the same cells and space/'a' cycling moves column-in-unison. Only a downgrade-guarded latest cell still goes missing.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the one new commit since the prior pullfrog review, which makes every picker row cycle keep→range→latest in unison instead of skipping the range step when a dep has no in-range drift.
- Always populate
range_target—build_rownow backfills the range cell withcurrentwhen the in-range max equals current (or would be a downgrade) rather than dropping it toNone. The row-drop filter changed from "no range and no latest" torange_target == current && latest_target.is_none(), and the old "collapse equal targets to a single latest cell" branch was removed. - Update the picker tests to the new invariant —
build_row_duplicates_equal_targets_and_backfills_range,state_cycle_wraps_and_skips_missing_latest,cycle_all_walks_..._in_unison, and thechalkrow inrows_render_as_aligned_columns(now asserts all three boxes render) reflect the always-present range cell.
The change traces cleanly. range_target stays Option<String> but is always Some on a live row, so has(Range) / first_update_state / max_update_state always report a range state and cycle_all steps every row through range→latest→keep together. A Range pick on a no-op cell (range == current) lands the key in in_range and re-resolves to the same version; the manifest-rewrite idempotency guard (if new_spec == original { continue; }) and the idempotent lockfile refresh prevent any churn, matching the doc comment. The drop filter still eliminates any row that would offer only a no-op range cell (no latest), so no rendered row offers only a no-op action. Mergeable as-is.
Claude Opus | 𝕏
A downgrade-guarded latest cell now duplicates the range value instead of going blank, so every row carries all three boxes. Selection routes a value-equal latest pick through the in-range machinery, keeping the real latest dist-tag unappliable where it would downgrade past the displayed version.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the one new commit since the prior pullfrog review, which makes every picker row carry a box in every column by duplicating the range value into a downgrade-guarded latest cell rather than dropping it.
- Always populate
latest_target—build_rownow backfills the latest cell with therange_targetvalue when the reallatestdist-tag is downgrade-guarded (at or belowcurrent) orcurrentis unparseable, instead of dropping it toNone. Combined with the prior commit's always-Somerange_target, every live row now renders a box in all three columns and cycles keep→range→latest in unison. - Route duplicate-latest picks through the in-range machinery —
build_selectionnow sends aPickState::Latestpick whoselatest_target == range_targetintoin_rangerather thanto_latest, so the reallatestdist-tag can't be applied through a cell that only ever showed the range value. - Update the picker tests to the new invariant —
build_row_masks_latest_downgrade_with_range_duplicateand the unparseable-current case now assert the latest cell duplicates the range value; a guarded-row routing assertion was added toselection_splits_states_into_range_and_latest_sets;state_cycle_walks_all_three_states_on_every_rowand therows_render_as_aligned_columnsloop assert all three states/boxes on every row.
The change traces cleanly. The duplicate keeps the downgrade guard intact — a value-equal latest pick reaches only the range version via the in-range path, never the real dist-tag. The genuinely-equal case (wanted == latest, both a real upgrade) is equivalence-safe because range_target is the in-range max, so an in-range refresh lands on the same version the cell displayed; and where latest equals the in-range max, routing through in_range (cosmetic floor-bump) rather than to_latest (manifest-range rewrite) is correct since no range widening is needed. The now-unreachable None-target branches in has/max_update_state/first_update_state and the None => blank-fill arms in format_row/header_line are harmless defensive leftovers. Mergeable as-is.
Claude Opus | 𝕏
Each row shows its package.json specifier between the name and the cells, dimmed, so the picker answers what the manifest says today next to what each pick would do. The displayed spec is always the manifest's, even when an explicit CLI spec drives the targets.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the one new commit since the prior pullfrog review, which adds a dim manifest-specifier annotation column to the picker between the dependency name and the radio cells.
- Show each row's manifest spec as a dim column —
PickerRowgains aspecfield andbuild_rowaspecparam;pick_update_richsources it from the manifest specifier (^4.1.0,~5.3.0) rather than the CLI-driven target, so the annotation answers "what doespackage.jsonsay today".Layoutgainsspec_w,format_rowrenders a dim left-paddedspec_col, andheader_linewidens its leading blank run to keep the headings aligned. - Update the alignment test and docs frame —
rows_render_as_aligned_columns'skeep_colformula now includesspec_w + 2, and theinstall/index.mdxexample frame shows the new spec column.
The layout math traces cleanly: format_row places the keep box at 4 + name_w + 2 + spec_w + 2 and header_line widens its leading blank by the same spec_w + 2, so headers and boxes stay column-aligned; the spec_w == 0 path yields an empty spec_col and zero gap, reproducing the pre-commit layout exactly. The alignment test asserts boxes and header labels land on identical char columns with the updated formula, and the docs frame is internally consistent (name_w=21, spec_w=13 → keep box at char 42). Mergeable as-is.
Claude Opus | 𝕏
Replaces the separate spec column: each row's title glues the manifest specifier onto the name (ai@^4.3.0) with the @ and everything after it dim.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the single new commit since the prior pullfrog review replaces the picker's separate dim spec column with a glued name@spec title.
- Fold spec into the row title — the manifest specifier now renders as a dimmed
@specsuffix on the name (chalk@^4.1.0) instead of a separate padded column;Layoutdropsspec_w, and a newPickerRow::title_w()drives all column-start math. - Simplify the alignment formula —
header_lineand the alignment test'skeep_colboth collapse to4 + name_w + 2, since the spec width is now absorbed intoname_w. - Update the docs frame —
install/index.mdxshows thename@spectitle form, internally consistent with the code's column formula.
Traced sound: title, box, and header column starts all derive from the same name_w = max(title_w()), so headers and boxes stay aligned; the empty-spec path renders a bare name with identical layout; the docs frame's widest title (@effect/opentelemetry@^4.0.0-beta.1, 35 chars) puts the keep box/label at char 41 (4+35+2), matching the code. Tests updated to pass a spec through the row() helper and the aligned-columns assertion.
Claude Opus | 𝕏
The filled box renders plain instead of severity-colored, and the semver-diff palette moves from red/cyan/green to the natural red/yellow/green progression (magenta stays for prerelease-only changes, deliberately outside the ramp). Applies to the picker cells and the outdated table, which share the colorizer.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the single new commit since the prior pullfrog review, which drops the per-severity marker coloring and shifts the semver-diff palette onto a traffic-light ramp.
- Plain selected box —
severity_boxis removed and themarkerparam is dropped fromcell; a selected cell now renders a plain filled■(unselected stays a dimmed hollow□). The bump-severity signal now lives solely in the version text's coloring, whichcolorize_diffalready computes. - Traffic-light minor color —
colorize_diff's minor-bump color changes cyan → yellow, giving a red (major) / yellow (minor) / green (patch) ramp, with magenta reserved for prerelease-only changes. This applies to both the interactive picker and theoutdatedtable, since both callcolorize_diff.
The change traces cleanly. severity_box has no remaining references; node_semver is still used by build_row's downgrade guard and clx::style by the dimmed □, so both imports stay live, and every cell caller drops the marker argument. Alignment is unaffected — ■/□ are each one display column, unchanged from before. The colorize_tests strip ANSI before asserting, so the cyan→yellow swap doesn't touch them, and no picker test asserts a marker color. Applying the ramp to the outdated stdout table alongside the picker is intentional per the doc comment (matching pnpm's interactive-updater ramp), not a regression. Mergeable as-is.
Claude Opus | 𝕏
|
Shipped in v0.6.0: https://github.com/nubjs/nub/releases/tag/v0.6.0 |

One row per outdated direct dep in
nub update -i; space/←→ cycles keep → in-range target → latest, as aligned radio cells with semver-diff coloring. Rows default to keep. A latest below a prerelease pin is never offered.-i --latestis the same picker. Gated by a newEmbedder::rich_update_pickerflag: nub opts in, standalone aube unchanged.Closes #483
Verification: aube suite green (760 passed) + new picker unit tests; driven e2e in a real terminal (mixed picks rewrite manifest/lockfile per row, cancel exits 130 unmutated); clippy + fmt clean.
https://claude.ai/code/session_01AwLJZRoRYeTXcTibVB8dQw