Skip to content

fix(tui): always render selected columns; stop dropping by terminal width#497

Merged
avihut merged 2 commits into
masterfrom
daft-494/fix/render-all-columns
May 15, 2026
Merged

fix(tui): always render selected columns; stop dropping by terminal width#497
avihut merged 2 commits into
masterfrom
daft-494/fix/render-all-columns

Conversation

@avihut
Copy link
Copy Markdown
Owner

@avihut avihut commented May 15, 2026

Summary

Stops the TUI from silently dropping low-priority columns (LastCommit, Hash, Owner, Age, ...) when the natural table width exceeds the terminal. The full intended column set now renders every time; fit_widths_to_available shrinks Branch / Path / LastCommit with ellipses and accepts overflow, mirroring the blocking-list renderer's tabled::Width::truncate(...).priority(Priority::max(true)).

Affects daft list (LiveTable), daft sync, daft clone, daft prune, daft repo remove. All five flow through src/output/tui/render.rs::render_table so the fix is centralized to one match-expression collapse.

Behavior change worth surfacing

For phased commands (sync, clone, prune, repo remove) with no --columns flag, the Hash column is now always visible. Pre-fix it was the first column dropped on most realistic terminal widths, so most users have never seen it by default. Consistent with the spec — flagging here in case reviewers want to push back.

A pre-existing asymmetry is preserved: daft sync with no flag falls back to ALL_COLUMNS (includes Hash); daft sync --columns +size uses the modifier-mode base ListColumn::tui_defaults() which doesn't include Hash. Out of scope for this PR; can be reconciled in a follow-up.

AC deviation

Issue acceptance criterion #4 asked for a YAML regression scenario verifying narrow-terminal behavior. YAML can't exercise this path: xtask/src/manual_test/runner.rs runs commands via Command::new("bash").output() with piped stdio, so terminal_size returns None and the renderer's narrow-width branches never fire. Setting COLUMNS=60 in scenario env: doesn't help — terminal_size reads ioctl, not env. Replaced with two TestBackend tests in render.rs::tests that exercise render_table directly at a constrained width with a long branch name (forcing fit_widths_to_available to shrink Branch from natural to min while keeping all 11 columns).

Cleanup deferred

columns_explicit: bool is threaded through live_table.rs, operation_table.rs, state.rs, list_live.rs, prune.rs, clone.rs, sync.rs, repo/remove.rs. After this fix no render-layer logic branches on it. Marked with // Unused by render after #494; pending removal in a follow-up. comments on the two config-struct fields; full rip is a separate PR.

Test plan

  • mise run fmt clean
  • mise run clippy clean (zero warnings)
  • mise run test:unit — 1701 pass, 0 fail (4 deleted column_selection_* tests replaced by 2 TestBackend regression tests in render.rs)
  • Manual smoke per test-plans/render-all-columns.md: 3 widths (60/100/160) × 2 commands (daft list, daft sync), plus mid-sync resize regression check

Fixes #494

🤖 Generated with Claude Code

…idth

When the natural table width exceeded the terminal, select_columns() was
removing whole low-priority columns (LastCommit, Hash, Owner, Age, ...).
This produced confusing UX: columns popped in and out mid-sync as
column_content_width widened on streamed data, terminal resizes silently
changed the visible set, and the auto-decided defaults diverged from what
explicit --columns rendered.

Render the full intended column set unconditionally and let
fit_widths_to_available shrink Branch / Path / LastCommit with ellipses,
accepting overflow rather than removing data — mirroring the blocking-list
renderer's tabled::Width::truncate(...).priority(Priority::max(true)).

Behavior note: phased commands (sync, clone, prune, repo remove) without
--columns now always show the Hash column. Pre-fix it was the first column
dropped on most realistic widths, so most users have never seen it.

YAML regression scenarios cannot exercise this path — the manual-test
runner pipes stdio, so terminal_size returns None and the renderer's
narrow-width branches never fire. Regression is pinned via two TestBackend
tests in render.rs instead.

Fixes #494

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@avihut avihut added this to the Public Launch milestone May 15, 2026
@avihut avihut added the fix Bug fix label May 15, 2026
@avihut avihut self-assigned this May 15, 2026
@avihut
Copy link
Copy Markdown
Owner Author

avihut commented May 15, 2026

/claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 15, 2026

Claude finished @avihut's task in 3m 13s —— View job


Code Review: fix(tui): always render selected columns; stop dropping by terminal width

  • Read changed files
  • Analyze Rust correctness and idioms
  • Check test coverage (regression test for bug fix)
  • Review shell-eval'd hot-path constraints
  • Check security and performance
  • Post findings

Overall assessment: The fix is correct and well-scoped. The select_columns drop loop was the root cause and its removal is clean. fit_widths_to_available correctly mirrors the blocking renderer's priority-shrink-then-accept-overflow approach. No security issues, no unsafe code, no hot-path impact (this is TUI render-loop code, not shell-init / completions eval path).


Issues

1. Stale // Priority N doc comments on Column variants — columns.rs:9-32

After removing the priority() method, the doc comments on the enum variants still say "Priority N (always shown)":

/// Sync/prune status indicator. Priority 0 (always shown).
Status,
/// Current/default branch annotation. Priority 1 (always shown).
Annotation,
/// Branch name. Priority 2 (always shown).
Branch,
/// Worktree path. Priority 3.
Path,

"Always shown" is now false for Branch (it can be shrunk to min width), and the priority numbers are orphaned — the priority() method that consumed them was deleted in this same PR. These comments will mislead future maintainers.

Fix this →

2. Deleted size_excluded_from_default_responsive_columns test leaves an unchecked contract

The removed test verified that Size never appears in the default column set. Its property still holds by inspection (ALL_COLUMNS at columns.rs:96-108 explicitly omits Column::Size), but there's now no unit test pinning this invariant. The deleted test's comment said: "Regression: Size must never appear in the default responsive set". Given that Size triggers an expensive filesystem walk, this is worth keeping as a compile-time or test-time assertion.

Minimal addition to columns.rs tests:

#[test]
fn all_columns_never_includes_size() {
    assert!(
        !ALL_COLUMNS.contains(&Column::Size),
        "Size requires --columns +size; it must not appear in the default set"
    );
}

Fix this →

3. Regression test width (100) is actually already overflowing — comment it

At width=100 with a long branch name, fit_widths_to_available hits all three column minimums and still overflows by ~4 chars (11 columns × content + 10 × 2 spacing ≈ 104 at minimums). The test still passes because header.contains("Commit") checks the 6-char header label whose start position (~88) is within the buffer. The test is correct but this is subtle: it's simultaneously testing both "no column dropped" (the regression) and "accept overflow" (the spec). A short comment in the test would clarify this isn't a test of "all columns fit" but of "all column headers are present regardless":

// At width=100 with 11 columns the table exceeds the buffer by ~4 chars
// (all shrinkable columns hit their minimums). This is correct behavior:
// the spec accepts overflow rather than dropping columns. We verify column
// *presence* (header label visible), not pixel-level fit.

4. Minor: columns_explicit dead field (noted, deferred — no action needed)

Both LiveTableConfig::columns_explicit and TableConfig::columns_explicit are written but not read after this PR. The PR explicitly flags these with // Unused by render after #494; pending removal in a follow-up. on both. This is acceptable; public struct fields don't trigger Clippy dead_code warnings. No action needed in this PR.


Positive notes

  • fit_widths_to_available is a clean algorithm: widest-first shrink to per-column minimums then accept overflow. The u32 accumulator guards against overflow on pathological content widths. O(overflow_pixels × 3) — trivially fast.
  • The if columns.contains(&Column::Status) { columns } else { prepend } restructuring (was if !contains { prepend } else { columns }) is semantically identical — correct inversion.
  • The centralization to a single render_table match-expression collapse is the right architectural call; the old per-command columns_explicit/select_columns branching was the compounding factor that made the bug hard to spot.
  • TestBackend regression tests are a pragmatic replacement for the YAML scenarios that can't exercise ioctl-based terminal_size. The AC deviation is well-reasoned and documented.
  • truncate_with_ellipsis handles the last_commit_age + last_commit_subject split correctly (only the subject is truncated, preserving the relative-age prefix).

Blocking? No. Issues 1 and 2 are minor (stale comments / missing pin test). Issue 3 is a clarity suggestion. The fix itself is correct and the test coverage is adequate for the regression case.

- Strip orphaned "Priority N (always shown)" notes from Column enum
  variants. The priority() method that consumed them was deleted in this
  PR, and "always shown" is now inaccurate for Branch (shrinkable to min).
  Replace Size's doc with a why-it's-opt-in note pointing at ALL_COLUMNS.
- Add all_columns_never_includes_size unit test to replace the deleted
  size_excluded_from_default_responsive_columns. Size triggers a
  filesystem walk; pin the invariant that it stays out of the default set.
- Comment the phased regression test to clarify it verifies header
  *presence* (substring), not pixel-level fit — the table technically
  overflows at width=100 by design ("accept overflow rather than
  dropping data").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@avihut avihut merged commit a195968 into master May 15, 2026
12 checks passed
@avihut avihut deleted the daft-494/fix/render-all-columns branch May 15, 2026 10:03
avihut added a commit that referenced this pull request May 15, 2026
…cells

The `daft list --columns +size` TOTAL summary row's value silently truncates
when the formatted total is wider than any per-row cell — `10.2G` renders as
`10.2`, losing the unit suffix.

Two paths, two distinct mechanisms:

- **TUI** (`render.rs` + `columns.rs::column_content_width`): the summary
  row is appended to `all_rows` AFTER `column_content_width` computes
  natural widths from `state.live.rows` only. The Size column gets sized
  to the widest data cell; the wider TOTAL cell then truncates against
  that width via ratatui's `Constraint::Length`.
- **Blocking** (`list.rs::print_table`): tabled's `Builder` sees the TOTAL
  row, so the column's natural width fits it. But
  `Width::truncate(term).priority(Priority::max(true))` shrinks the widest
  column when the table needs to fit a narrow terminal — and once
  Branch/Path hit their floor, Size becomes a candidate. `MinWidth` /
  `Width::increase` doesn't help because `Width::truncate` derives floors
  from `EmptyRecords` (padding only), not from `MinWidth` settings.
  Verified empirically.

Fixes:

- `column_content_width` gains an `extra_width: u16` parameter; the call
  site passes the formatted TOTAL width for Size. `fit_widths_to_available`
  already excludes Size from shrink candidates, so once the natural width
  is right, no further fitting work is needed.
- The TUI's summary-row build is refactored to reuse the `total_size`
  String computed once for the natural-width hint, keeping the displayed
  cell and the column width in lockstep.
- A custom `PriorityMaxExcept` Peaker replaces `Priority::max(true)` in
  the blocking path when the Size column is selected. It honors `mins`
  so the shrink loop terminates cleanly.

Tests:

- `column_content_width` + `extra_width`: 3 unit tests (wider, narrower,
  Status floor preserved).
- Blocking path: 3 `PriorityMaxExcept` algorithmic tests + 3
  `size_column_index` wire-up tests + a `format_human_size` no-whitespace
  contract test.
- TUI regression: a `TestBackend` test in `render.rs` that constructs
  three 5 GiB worktrees (TOTAL `15.0G`, per-row `5.0G`) and asserts the
  full `15.0G` string appears in the rendered buffer. Verified to fail
  pre-fix (renders `15.0`).
- YAML smoke scenario `list/size-total.yml` for end-to-end wire-up. Note:
  the manual-test runner pipes stdio so `terminal_size()` returns `None`,
  meaning `Width::truncate` doesn't fire — true truncation regression
  coverage lives in unit + TestBackend tests, mirroring how #497 handled
  the same constraint.

The other fixed-width metric columns (`Hash`, `Changes`, `Base`, `Remote`,
`Age`, `Owner`) have the same class of bug under `Priority::max(true)` in
very narrow terminals — left out of scope; noted in `PriorityMaxExcept`'s
docstring.

Fixes #501

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
avihut added a commit that referenced this pull request May 15, 2026
…cells

The `daft list --columns +size` TOTAL summary row's value silently truncates
when the formatted total is wider than any per-row cell — `10.2G` renders as
`10.2`, losing the unit suffix.

Two paths, two distinct mechanisms:

- **TUI** (`render.rs` + `columns.rs::column_content_width`): the summary
  row is appended to `all_rows` AFTER `column_content_width` computes
  natural widths from `state.live.rows` only. The Size column gets sized
  to the widest data cell; the wider TOTAL cell then truncates against
  that width via ratatui's `Constraint::Length`.
- **Blocking** (`list.rs::print_table`): tabled's `Builder` sees the TOTAL
  row, so the column's natural width fits it. But
  `Width::truncate(term).priority(Priority::max(true))` shrinks the widest
  column when the table needs to fit a narrow terminal — and once
  Branch/Path hit their floor, Size becomes a candidate. `MinWidth` /
  `Width::increase` doesn't help because `Width::truncate` derives floors
  from `EmptyRecords` (padding only), not from `MinWidth` settings.
  Verified empirically.

Fixes:

- `column_content_width` gains an `extra_width: u16` parameter; the call
  site passes the formatted TOTAL width for Size. `fit_widths_to_available`
  already excludes Size from shrink candidates, so once the natural width
  is right, no further fitting work is needed.
- The TUI's summary-row build is refactored to reuse the `total_size`
  String computed once for the natural-width hint, keeping the displayed
  cell and the column width in lockstep.
- A custom `PriorityMaxExcept` Peaker replaces `Priority::max(true)` in
  the blocking path when the Size column is selected. It honors `mins`
  so the shrink loop terminates cleanly.

Tests:

- `column_content_width` + `extra_width`: 3 unit tests (wider, narrower,
  Status floor preserved).
- Blocking path: 3 `PriorityMaxExcept` algorithmic tests + 3
  `size_column_index` wire-up tests + a `format_human_size` no-whitespace
  contract test.
- TUI regression: a `TestBackend` test in `render.rs` that constructs
  three 5 GiB worktrees (TOTAL `15.0G`, per-row `5.0G`) and asserts the
  full `15.0G` string appears in the rendered buffer. Verified to fail
  pre-fix (renders `15.0`).
- YAML smoke scenario `list/size-total.yml` for end-to-end wire-up. Note:
  the manual-test runner pipes stdio so `terminal_size()` returns `None`,
  meaning `Width::truncate` doesn't fire — true truncation regression
  coverage lives in unit + TestBackend tests, mirroring how #497 handled
  the same constraint.

The other fixed-width metric columns (`Hash`, `Changes`, `Base`, `Remote`,
`Age`, `Owner`) have the same class of bug under `Priority::max(true)` in
very narrow terminals — left out of scope; noted in `PriorityMaxExcept`'s
docstring.

Fixes #501

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
avihut added a commit that referenced this pull request May 15, 2026
…cells (#507)

The `daft list --columns +size` TOTAL summary row's value silently truncates
when the formatted total is wider than any per-row cell — `10.2G` renders as
`10.2`, losing the unit suffix.

Two paths, two distinct mechanisms:

- **TUI** (`render.rs` + `columns.rs::column_content_width`): the summary
  row is appended to `all_rows` AFTER `column_content_width` computes
  natural widths from `state.live.rows` only. The Size column gets sized
  to the widest data cell; the wider TOTAL cell then truncates against
  that width via ratatui's `Constraint::Length`.
- **Blocking** (`list.rs::print_table`): tabled's `Builder` sees the TOTAL
  row, so the column's natural width fits it. But
  `Width::truncate(term).priority(Priority::max(true))` shrinks the widest
  column when the table needs to fit a narrow terminal — and once
  Branch/Path hit their floor, Size becomes a candidate. `MinWidth` /
  `Width::increase` doesn't help because `Width::truncate` derives floors
  from `EmptyRecords` (padding only), not from `MinWidth` settings.
  Verified empirically.

Fixes:

- `column_content_width` gains an `extra_width: u16` parameter; the call
  site passes the formatted TOTAL width for Size. `fit_widths_to_available`
  already excludes Size from shrink candidates, so once the natural width
  is right, no further fitting work is needed.
- The TUI's summary-row build is refactored to reuse the `total_size`
  String computed once for the natural-width hint, keeping the displayed
  cell and the column width in lockstep.
- A custom `PriorityMaxExcept` Peaker replaces `Priority::max(true)` in
  the blocking path when the Size column is selected. It honors `mins`
  so the shrink loop terminates cleanly.

Tests:

- `column_content_width` + `extra_width`: 3 unit tests (wider, narrower,
  Status floor preserved).
- Blocking path: 3 `PriorityMaxExcept` algorithmic tests + 3
  `size_column_index` wire-up tests + a `format_human_size` no-whitespace
  contract test.
- TUI regression: a `TestBackend` test in `render.rs` that constructs
  three 5 GiB worktrees (TOTAL `15.0G`, per-row `5.0G`) and asserts the
  full `15.0G` string appears in the rendered buffer. Verified to fail
  pre-fix (renders `15.0`).
- YAML smoke scenario `list/size-total.yml` for end-to-end wire-up. Note:
  the manual-test runner pipes stdio so `terminal_size()` returns `None`,
  meaning `Width::truncate` doesn't fire — true truncation regression
  coverage lives in unit + TestBackend tests, mirroring how #497 handled
  the same constraint.

The other fixed-width metric columns (`Hash`, `Changes`, `Base`, `Remote`,
`Age`, `Owner`) have the same class of bug under `Priority::max(true)` in
very narrow terminals — left out of scope; noted in `PriorityMaxExcept`'s
docstring.

Fixes #501

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(tui): always render selected columns; stop dropping by terminal width

1 participant