Skip to content

pm: tri-state interactive update picker - #512

Merged
colinhacks merged 11 commits into
mainfrom
update-picker-483
Jul 22, 2026
Merged

pm: tri-state interactive update picker#512
colinhacks merged 11 commits into
mainfrom
update-picker-483

Conversation

@colinhacks

Copy link
Copy Markdown
Contributor

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 --latest is the same picker. Gated by a new Embedder::rich_update_picker flag: 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

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.
Copilot AI review requested due to automatic review settings July 22, 2026 00:07
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview, Comment Jul 22, 2026 10:30pm

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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_picker modulePickerRow / PickState / PickerSelection plus a console-based Term::stderr TUI 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 — new pick_update_rich + RichPick enum and an extracted fetch_packuments helper; a rich "latest" pick is merged into explicit_specs as "latest" so it flows through the identical per-key machinery as an explicit <pkg>@latest.
  • Neutralize the global --latest flag in rich modelatest is rebound to latest && !rich_picker after the picker so per-row choices are authoritative, and the preserve_pin pre-fetch is skipped because build_row's per-cell guard already covers both range and latest downgrades.
  • Expose colorize_diff with an on_stderr param — the picker renders on stderr (estyle gating) while outdated's table stays on stdout (nstyle).
  • Add the Embedder::rich_update_picker gate — nub opts in; AUBE and all test embedders set false, keeping standalone aube's demand::MultiSelect UX byte-for-byte unchanged.
  • Document the pickerinstall/index.mdx gains the -i row 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.

Pullfrog  | View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 new severity_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 bytesrange_cell_w/latest_cell_w switched 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 columnsrows_render_as_aligned_columns now computes column positions in chars from ANSI-stripped rows, matching the multibyte-glyph reality, and asserts range sits left of latest.
  • Refresh the docs example frameinstall/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.

Pullfrog  | View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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/latest labels are gone; a new header_line renders them once as a dim heading row under the title (keep / latest in range / latest), and each cell now carries only box + version.
  • Floor column widths by their header label — width helpers renamed *_cell_w*_col_w; each column width is now max(2 + version_w, HDR.len()) so a short version column never squeezes its heading, and cell trailing-pads each cell to the full column width.
  • Rewrite the alignment testrows_render_as_aligned_columns now 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 frameinstall/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.

Pullfrog  | View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 to packument.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.

Pullfrog  | View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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_targetbuild_row now backfills the range cell with current when the in-range max equals current (or would be a downgrade) rather than dropping it to None. The row-drop filter changed from "no range and no latest" to range_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 invariantbuild_row_duplicates_equal_targets_and_backfills_range, state_cycle_wraps_and_skips_missing_latest, cycle_all_walks_..._in_unison, and the chalk row in rows_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.

Pullfrog  | View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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_targetbuild_row now backfills the latest cell with the range_target value when the real latest dist-tag is downgrade-guarded (at or below current) or current is unparseable, instead of dropping it to None. Combined with the prior commit's always-Some range_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 machinerybuild_selection now sends a PickState::Latest pick whose latest_target == range_target into in_range rather than to_latest, so the real latest dist-tag can't be applied through a cell that only ever showed the range value.
  • Update the picker tests to the new invariantbuild_row_masks_latest_downgrade_with_range_duplicate and the unparseable-current case now assert the latest cell duplicates the range value; a guarded-row routing assertion was added to selection_splits_states_into_range_and_latest_sets; state_cycle_walks_all_three_states_on_every_row and the rows_render_as_aligned_columns loop 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.

Pullfrog  | View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 columnPickerRow gains a spec field and build_row a spec param; pick_update_rich sources it from the manifest specifier (^4.1.0, ~5.3.0) rather than the CLI-driven target, so the annotation answers "what does package.json say today". Layout gains spec_w, format_row renders a dim left-padded spec_col, and header_line widens its leading blank run to keep the headings aligned.
  • Update the alignment test and docs framerows_render_as_aligned_columns's keep_col formula now includes spec_w + 2, and the install/index.mdx example 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.

Pullfrog  | View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 @spec suffix on the name (chalk@^4.1.0) instead of a separate padded column; Layout drops spec_w, and a new PickerRow::title_w() drives all column-start math.
  • Simplify the alignment formulaheader_line and the alignment test's keep_col both collapse to 4 + name_w + 2, since the spec width is now absorbed into name_w.
  • Update the docs frameinstall/index.mdx shows the name@spec title 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.

Pullfrog  | View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 boxseverity_box is removed and the marker param is dropped from cell; 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, which colorize_diff already computes.
  • Traffic-light minor colorcolorize_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 the outdated table, since both call colorize_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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@colinhacks
colinhacks merged commit 58799e7 into main Jul 22, 2026
64 checks passed
@pullfrog pullfrog Bot mentioned this pull request Jul 23, 2026
@colinhacks

Copy link
Copy Markdown
Contributor Author

Shipped in v0.6.0: https://github.com/nubjs/nub/releases/tag/v0.6.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cli-update interactive colors

2 participants