Skip to content

Fix wt list column sizing, alignment, and headers - #3998

Open
max-sixty wants to merge 17 commits into
mainfrom
land/list-improvements
Open

Fix wt list column sizing, alignment, and headers#3998
max-sixty wants to merge 17 commits into
mainfrom
land/list-improvements

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

Eight fixes to wt list — four to how the table lays out, one to what a detached or prunable worktree shows, and three to the page that documents it.

The table

A single 58-character branch name sized the Branch column for every row: at 60 columns the table degenerated into a branch list with nine columns hidden, and at 200 it still lost Message. Branch now sizes to min(longest, 32) and elides with , the way Message already does. --format=json still carries the whole name.

Remote⇅ in a repo with no remote held its blank seven columns open while Message, Commit, Age and Path — each with something to say on every row — were dropped for want of them. The empty-column penalty now exceeds every base priority, and the allocation loop stops admitting empty columns once a populated one has failed to fit. Remote⇅ also learns it is empty before any task reports: a repo with no remote has no branch that can track one, read O(1) off the bulk config map.

main↕ and main…± said main in a repo whose default branch is trunk. Both columns are sized by their data (↑99 ↓99, +999 -999), so a name inside that budget costs the table nothing; a longer name, or one wt can't resolve, gives way to a generic base rather than widening two columns on every row. One decision serves both columns, taken from the tighter budget, so they can never name different things.

Four alignment defects: a right-aligned numeric header (HEAD±) stood over the deletions half of its two-part field, so a lone +1 landed to its left — the header now centres over the separator. Age was left-aligned, so now and 4m didn't line up on the unit; it is right-aligned with its header. Data rows carried up to 14 characters of trailing padding the header line never had, which a reader selecting a row picks up. The footer ran to 67 characters in a 40-column terminal the table itself fit — the hidden-column list is longest exactly where there is least room — and now wraps at the terminal width.

Detached and prunable rows

A detached worktree rendered as a bare hash under Branch, wearing on loan from branch_worktree_mismatch — which it was only flagged with because a worktree with no branch has no branch-implied path to sit at. It gets , and stops claiming to be off-template, so again means only what it says. A prunable worktree showed four · loading glyphs that never resolved: its directory is gone, so no task is ever spawned for it and no cell is coming. Those cells render blank, leaving in Status as the row's whole story.

The page

Documentation corrections
  • The summary footer counted hidden columns ("3 columns hidden") without saying which, and the page never explained that the table drops columns to fit. The footer now names them ("hidden: Path, Commit, Message"), and a paragraph under ## Columns covers the drop order, [list] columns, and that --format=json shows everything.
  • The JSON section documented deprecated schema 1 at 159 lines and nine sub-tables while schema 2 — what a future release makes the default — got 88 lines and one table. Schema 2 now carries the full reference (envelope, item fields, a sub-table per object, the three value vocabularies) and the worked jq recipes. Schema 1 keeps a paragraph, the deprecation pointer, and a schema-1 → schema-2 mapping table covering every field it documented.
  • The CI-cache line pointed at wt config state, a group with no default action; it names wt config state cache.
  • wt list statusline --format=json was documented as "a one-entry array in the wt list --format=json schema", which holds only under schema 1. It emits the current schema — an array under 1, the envelope under 2 — and the line now records why that surface stays silent about an unset [list] json-schema while plain wt list nags: a prompt consumer can't act on a warning drawn over its own line.
  • The Status-symbols section documented every subcolumn except the branch marker. Both JSON schemas gain an additive marker field, so the value is readable without parsing it back out of symbols.

UX survey items: #24, #34, #58, #89, #90, #91, #92, #93.

Reviewable files: src/commands/list/** (layout.rs, columns.rs, render.rs, model/item.rs, mod.rs, json_v2.rs), the list section of src/cli/mod.rs, src/styling/line.rs, and .claude/skills/writing-user-outputs/SKILL.md. Generated mirrors and snapshots are regenerated.

This was written by Claude Code on behalf of max-sixty

🤖 Generated with Claude Code

https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb

max-sixty and others added 9 commits September 1, 2026 16:22
…nsive dropping (#24)

The summary footer counted the columns the terminal was too narrow for
("3 columns hidden") without saying which, and the list page never
explained that the table drops columns to fit at all — `[list] columns`
was documented only on the config page.

The footer now names them ("hidden: Path, Commit, Message"), so the line
says what a wider terminal would add. `LayoutConfig` carries
`hidden_columns: Vec<String>` (headers, in display order) in place of the
count. A new paragraph under `## Columns` covers the drop order, the
footer, `[list] columns`, and `--format=json` showing everything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a 1 a mapping table (#34)

The list page documented deprecated schema 1 at 159 lines and nine
sub-tables while schema 2, the format a future release makes the default,
got 88 lines and one table.

Schema 2 now carries the full reference: envelope fields, item fields, and
a sub-table per object (head, worktree, changes, default_branch, upstream,
pr, checks, dev_server, display, repo) plus the three value vocabularies
(display.state, integration reasons, review states). The worked jq recipes
move with it and target schema 2. Schema 1 keeps a paragraph, the
deprecation pointer, and a schema-1 → schema-2 mapping table covering
every field it documented.

The Status-symbols and CI-status tables named their JSON fields in
schema-1 spelling; they now name the schema-2 paths, with the mapping
table as the schema-1 route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The CI-cache line pointed at `wt config state`, a group with no default
  action; it now names `wt config state cache`.
- `wt list statusline --format=json` was documented as "a one-entry array
  in the wt list --format=json schema", which holds only under schema 1.
  It now says it emits the current schema — a one-item array under 1, the
  envelope object under 2.
- The same line records why that surface stays silent when `[list]
  json-schema` is unset while plain `wt list --format=json` prints a
  deprecation notice: a prompt consumer can't act on a warning drawn over
  its own line, and the nag reaches the same user on their next
  interactive run. Keeping statusline silent is the existing behaviour
  (`suppress_warnings` on the surface); this documents it rather than
  changing it.
- The Status-symbols section documented every subcolumn except the branch
  marker. A "Marker" subsection covers it, and both JSON schemas gain an
  additive `marker` field (schema 1 `Option<String>` after `symbols`;
  schema 2 `Tri<String>` beside `vars`, absent when unset and null while
  unresolved), so the value is readable without parsing it back out of
  `symbols`. `docs/public/schema/list-v2.json` regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Branch column had no width cap, so one 58-character branch sized it
for every row: at 60 columns the table degenerated into a branch list with
nine columns hidden, and at 200 it still lost Message.

The column now sizes to min(longest, 32) and elides past that with `…`,
the way Message already does; `--format=json` still carries the whole
name. The skeleton row truncates the same way — it previously padded
without truncating, which overflowed into the next column once Branch
could be narrower than its content.

New `one_long_branch_does_not_size_the_table` snapshots the result at 60,
100, and 200 columns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`Remote⇅` in a repo with no remote held its blank seven columns open while
Message, Commit, Age and Path — each with something to say on every row —
were dropped for want of them. Two things let that happen: the empty
penalty (+10) was smaller than the largest base priority, so an empty
column could still outrank a populated one, and the allocation loop kept
going after a column failed to fit, letting a narrow blank column slip
into the gap a wider populated one had just failed to fill.

The penalty now exceeds every base priority, and the loop records the
first populated drop and skips every empty column after it. `Remote⇅`
also learns it is empty before any task reports: a repo with no remote
has no branch that can track one, read O(1) off the bulk config map.

`RepoFacts` collects what the layout needs and the items can't answer at
skeleton time — the remote, the URL template, the cached PR number — in
place of three positional arguments.

The help text claimed "a column that renders empty for every row is
dropped", which was only ever true of custom columns; it now says what
each kind does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGS51k25qzfeJEJ8dfxEEY
…91)

`main↕` and `main…±` said `main` in a repo whose default branch is
`trunk` — documented in `--help`, invisible in the terminal, and against
the project's own "default branch, not main branch" rule.

Both columns now carry the repo's own name where it fits: they are sized
by their data (`↑99 ↓99`, `+999 -999`), so a name within that budget
costs the table nothing and a `trunk` repo lays out exactly as a `main`
one does. A longer name would widen two columns on every row to spell out
a word the reader takes in once, so it gives way to a generic `base` —
as does a default branch `wt` can't resolve, where `base` is the honest
label for what the numbers are measured against.

One decision serves both columns, taken from the tighter of the two
budgets, so they can never name different things; `RepoFacts` carries the
branch name in, `LayoutMetadata` the resolved label out, so the header,
the width floor and the hidden-column footer all read the same value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGS51k25qzfeJEJ8dfxEEY
A detached worktree rendered as a bare hash under Branch with no marker
of its own — `⚑` on loan from `branch_worktree_mismatch`, which it was
only flagged with because a worktree with no branch has no branch-implied
path to sit at. It gets `⊘` instead, and stops claiming to be
off-template, so `⚑` again means only what it says. The Path column also
counts a detached row as informative: a hash names no directory, so
without the path nothing ties the row to one.

A prunable worktree showed four `·` loading glyphs that never resolved.
Its directory is gone, so `work_items_for_worktree` spawns no task for it
and no cell one would fill is ever coming; the glyphs promised data for
the life of the row. Those cells now render blank, leaving the `⊟` in
Status as the row's whole story.

`worktree.detached` was in `--format=json` all along; the table now uses
it. Schema 1's `worktree.state` stays silent for a detached row rather
than naming a state its sibling `detached` field already carries.

Not addressed: under `--branches`, the branch a detached worktree was
created from still lists as a second row. Git keeps no record of that
association once HEAD detaches, so the two rows are both true — the
branch genuinely has no worktree — and the Path cell is now the only
honest link between them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGS51k25qzfeJEJ8dfxEEY
Numeric headers sat over the deletions half of their field. A diff column
is two right-aligned halves — `+999` then `-999` — so a right-aligned
`HEAD±` stood over the second one and a lone `+1` landed to its left, out
from under the header naming it. The header now centres, straddling the
separator.

Age was left-aligned, so `now` and `4m` didn't line up on the unit. It is
a single right-aligned value, and its header goes right with it.

Data rows carried up to 14 characters of trailing padding the header line
never had — padding places a cell, and past the last one it places
nothing, but a reader who selects the row gets it anyway.

The footer ran to 67 characters in a 40-column terminal the table itself
fit; the hidden-column list grows with every column a narrow terminal
drops, so it is longest exactly where there is least room. It now wraps at
the terminal width, continuations indented under the text.

`Path` still renders `.` for the primary worktree — that is the same `.`
`git` and the shell use for the repo root, and left alone deliberately.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGS51k25qzfeJEJ8dfxEEY
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Three findings, all reproduced against a build of this head. The layout work itself reads well — the RepoFacts bundle, the base_label budget derivation, and dropped_populated all hold up under the cases I tried, including custom columns with an out-of-band priority.

The footer wrap doesn't survive progressive mode, which is every TTY run — see the inline comment on src/commands/list/mod.rs. ProgressiveTable::prepare puts the whole footer through truncate_visible, which counts \n as zero width, so the wrapped string is cut as if it were one line. At 40 columns the entire hidden-column list is replaced by a sitting on its own row:

○ Showing 4 worktrees, 3 ahead, hidden:
…

The buffered path (wt list | cat with COLUMNS set) wraps correctly, which is what the new width_30/width_60 snapshots cover — so the tests pass while the interactive case the PR describes is the one still broken.

Trailing padding still survives on a row whose last cell is emptysrc/styling/line.rs inline. Reproduced with [list] columns ending on a custom column that's empty for some rows; the header line and the populated row come out clean, the empty ones keep their five spaces.

The hidden-column list ignores [list] columns ordersrc/commands/list/layout.rs inline.

Repros

Four worktrees, no remote, target/debug/wt at 9c65cf2.

Progressive footer (script -qc "stty cols 40; wt list" /dev/null), at widths 40 and 60:

○ Showing 4 worktrees, 3 ahead, hidden:
…
○ Showing 4 worktrees, 3 ahead, hidden: Remote⇅, Path,
  Com…

Trailing padding ($ marks end of line):

$ COLUMNS=120 wt --config-set '[list]
columns = ["branch", "status", "ticket"]
[list.custom-columns.ticket]
template = "{% if branch == \"feature-beta\" %}T-1{% endif %}"' list | cat -A
  Branch         Status    ticket$
@ main               ^     $
+ feature-alpha      ↑     $
+ feature-beta       ↑     T-1$
+ feature-gamma      ↑     $

Hidden-column order — configured order is message, commit, branch, status, so in display order the dropped pair is Message, Commit:

$ COLUMNS=30 wt --config-set '[list]
columns = ["message", "commit", "branch", "status"]' list
  Branch         Status
...
○ Showing 4 worktrees, 3
  ahead, hidden: Commit,
  Message

One thing outside the diff: src/commands/list/model/status_symbols.rs still documents the Gate 2 priority as ✘ > ↻ > ⊟ > ⊞ > ⚑ > / — in the module docstring under # Gate 2: Worktree state, and again in the WORKTREE_STATE comment inside PositionMask::FULL. is missing from both, and the Gate 2 Inputs list doesn't mention the new has_branch argument. docs/src/content/docs/list.md and model/state.rs were both updated; this file is the one that wasn't. Happy to push that as a commit if you'd like.

Comment thread src/commands/list/mod.rs
Comment thread src/styling/line.rs
Comment thread src/commands/list/layout.rs Outdated
`Line::trim_end` stopped at the first segment with no trailing space, and an
empty segment qualifies — `render_text_cell` pushes exactly one for an empty
cell. So a row whose last allocated column rendered empty kept the padding run
before it, which is the defect the method was added to remove. Skip empty
segments rather than stopping on them.

The hidden-column list is documented as display order, but sorted by
`column_display_index`, the static registry order that display order falls back
to when no `[list] columns` selection is set. With a selection, the footer named
the dropped columns in an order they would never have appeared in. Sort by
`display_sort_key`, which the allocation pass already uses.

Also add `⊘` to the two Gate 2 priority lists in `status_symbols.rs`, and
`has_branch` to its inputs — the docs page and `model/state.rs` were updated for
the detached glyph and this file wasn't.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The three fixes from last round check out. I reproduced the hidden-column ordering against a build of this head (display_sort_key has selected in scope, and candidates are pre-filtered by the selection, so a hidden kind is always in it and never lands in the usize::MAX bucket — Gutter maps to (0, 0) and is always allocated anyway), and the empty-last-cell trim. Two things left.

is still missing from five places in status_symbols.rs. Three sites got it this push; these didn't. One is inline below; the other four are outside the diff:

  • PositionMask::FULL's WORKTREE_STATE entry, whose comment reads WORKTREE_STATE: ✘↻/⊟⊞⚑ (1 char, priority: conflicts > in-progress operation > prunable > locked > duplicate_branch > branch_worktree_mismatch > branch). This is the site I named last round; what moved was the const WORKTREE_STATE declaration a dozen lines above it (Worktree: ✘↻/⚑⊘⊟⊞). Both the glyph set and the priority prose need detached, sitting between locked and duplicate_branchWorktreeState's own docstring in model/state.rs gives the order as Prunable > Locked > Detached > DuplicateBranch > BranchWorktreeMismatch.
  • the Gate 2 walkthrough in the module docstring, step 4: "…continuing down through , , /, nothing."
  • StatusSymbols' display-order list: "Worktree state: ✘, ↻, /, ⚑, ⊟, ⊞ (operations + location)".
  • the Gate 2 comment inside refresh_status_symbols: "takes priority over metadata family (⚑⊟⊞/)".

Happy to push all five as one commit if you'd like.

Optional, on StyledLine::trim_end. The empty-segment branch I suggested last round is subsumed by moving the existing trimmed.is_empty() test above the length comparison: "".trim_end_matches(' ') is empty, so one branch covers both an empty segment and an all-space one, and the loop drops to three cases with no special case left to explain.

while let Some(last) = self.segments.last_mut() {
    let trimmed = last.text.trim_end_matches(' ');
    if trimmed.is_empty() {
        self.segments.pop();
        continue;
    }
    if trimmed.len() == last.text.len() {
        return;
    }
    last.text.truncate(trimmed.len());
    return;
}

Same behaviour on every input I could construct — it's a refinement of my own earlier suggestion rather than a defect, so take it or leave it. (Left out of an inline suggestion because the range would span the explanatory comment too.)

Holding the verdict while the progressive-footer decision on src/commands/list/mod.rs is still yours to make.

Comment thread src/commands/list/model/status_symbols.rs Outdated
max-sixty and others added 3 commits September 2, 2026 18:15
`calculate_layout_with_width` conflicted two ways: this branch folded
`url_template` and `max_pr_number` into `RepoFacts`, and main renamed
`main_worktree.path` to `main_worktree_path`. Kept both — the `RepoFacts`
bundle with main's binding name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb
PR #3992's bare-repo and malformed-config listings picked up this branch's
header centring and right-aligned Age.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb
Four more sites in `status_symbols.rs` still listed the Gate 2 glyph set and
its priority without `⊘`: the `PositionMask::FULL` entry (whose prose also
needed detached between locked and duplicate_branch), the module docstring's
Gate 2 walkthrough, the `StatusSymbols` display-order list, and the gate's own
comment in `refresh_status_symbols`.

Testing `trimmed.is_empty()` before the length comparison covers an empty
segment and an all-space one in one branch, which leaves no special case to
explain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb
@max-sixty

Copy link
Copy Markdown
Owner Author

All five sites are in now, including PositionMask::FULL's priority prose with detached between locked and duplicate_branch — I'd patched the const declaration a dozen lines above and taken it for the site you named.

Took the trim_end refinement too; hoisting trimmed.is_empty() above the length comparison covers both cases in one branch and leaves nothing to explain.

The progressive-footer decision is the only thing still open here.

This was written by Claude Code on behalf of max-sixty

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Four of the five sites landed — the Gate 2 walkthrough step, PositionMask::FULL, the display-order list, and the refresh_status_symbols comment all name it now. The fifth didn't: status_symbols.rs line 60 still reads the attribute family (⊟⊞⚑/) is metadata, always known, so the docstring now lists in the priority order on the line above and drops it from the family breakdown on the line below. The inline thread from last round is still open on that line with the one-click suggestion.

Nothing else new on this push — trim_end and the two realigned snapshots read as intended. Verdict still held on the footer decision, which remains yours.

The docstring listed `⊘` in the Gate 2 priority order and then omitted it from
the family breakdown on the next line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb
max-sixty pushed a commit that referenced this pull request Sep 4, 2026
…example (#4006)

## Problem

The `worktree-path` examples in the user-config guide are introduced as
being **"for repo at `~/code/myproject`"**, and every example states the
path it produces. For all but one, that arithmetic checks out. The
bare-repository example doesn't:

```toml
worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}"
```

Heading claimed `~/code/myproject/feature-auth`. With `repo_path` at
`~/code/myproject` as the section says, `{{ repo_path
}}/../feature-auth` resolves to `~/code/feature-auth` — one directory up
from the stated result.

## Fix

The template is right; the heading silently switched the repo's location
without saying so. `{{ repo_path }}` for a bare repo is the bare
directory itself (as the variable list a few lines above states), so the
claimed result holds only when that directory is a hidden child — the
`myproject/.git` layout that [tips-patterns.md
documents](https://github.com/max-sixty/worktrunk/blob/main/docs/src/content/docs/tips-patterns.md#bare-repository-layout),
and that `wt switch`'s bare-repo offer writes this exact template for.
So the heading now names it:

> Bare repository cloned to `~/code/myproject/.git`
(`~/code/myproject/feature-auth`):

Edited in `src/cli/mod.rs` (the primary source); the four generated
mirrors and two `--help` snapshots are regenerated output.

## Testing

No regression test — this is a documentation string with no behavior
attached. The generated mirrors are pinned by the existing sync tests,
which is what caught them here:

- `cargo test --test integration readme_sync` — 18 passed (regenerates
`dev/config.example.toml`, `docs/src/content/docs/config.md`, and both
`skills/.../reference/config.md` mirrors).
- `cargo insta test --accept --test integration -- test_help` — 47
passed (`help_config_create`, `help_config_long`).
- `cargo fmt --check` — clean.
- `cargo test --test integration` — 2048 passed, 1 failed.

<details><summary>The one integration failure is a sandbox artifact, not
a regression</summary>


`step_copy_ignored::test_copy_ignored_preserves_file_executable_permissions`
expects `0644` and gets `0664`. The tend sandbox runs with `umask 0002`
(group-writable) rather than the `0022` the test assumes.

Confirmed unrelated: it reproduces identically with this branch's
changes stashed, i.e. on the merge base. Both `ci` and `coverage` on
`main` are green at `2026-09-03T10:17:26Z`. This diff touches only doc
strings and snapshot files and cannot reach file-permission code.

</details>

<details><summary>Checked against the in-flight docs PRs</summary>

#4000, #3999, and #3998 each touch the same five files (`src/cli/mod.rs`
plus the four config mirrors), so I checked for the duplication that
sank #4001. None of them edits the `worktree-path` examples region, and
`git merge-tree` against each reports a clean merge with this branch.

</details>

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The footer fix is right, and the shape you took is better than the one I sketched: leaving redraw_line alone and giving finalize its own writer means the multi-row case never has to be re-derived from lines_up. Walking the escape stream by hand, the resting position comes out correct both when the write fits (24 rows: footer at rows 6–8, cursor rests at 9) and when write_prompt_reserve pushes past the bottom and the screen scrolls. The per-row budget in prepare is a no-op for single-line content — truncate_visible returns the input unchanged at width <= max_width, and the table's max_width is the same terminal_width() that format_summary_message wraps to, so a correctly wrapped footer row is never re-cut.

Two things, both about the invariant this makes load-bearing rather than about the fix.

The struct docstring still describes one physical row per lines entry. ProgressiveTable's docstring lists the structure as "Header row / N data rows / Spacer / Footer" and closes with "all redraw math is relative to that fixed resting position"; the lines field reads "Previously rendered content for each line (header + rows + spacer + footer)". After this change the final footer entry can span several rows, and prepare preserves \n for any entry it is handed — so "every entry except the final footer occupies exactly one physical row" is now what redraw_line's lines_up = self.lines.len() - line_idx and the overflow branch's MoveUp(self.lines.len()) rest on, and it is stated only in a comment inside finalize. CLAUDE.md asks for these specs to carry the contracts and invariants and be kept current.

A debug_assert! on the prepared string in update_row and update_footer would pin it as well as document it — a future multi-line row would fail a test instead of silently miscounting a redraw. I checked the producers: format_list_item_line, the header, the loading footer in collect/mod.rs, format_stall_footer, and sanitize_cell for custom columns (which maps \n to a space) — none can emit a newline today, so the assert stays quiet. Happy to push the docstring update and the asserts as one commit.

The new test pins the footer text but not the cursor accounting, which is what redraw_final_footer newly owns. A MoveUp that landed one row too high would clear the spacer and write the footer over it, and every assertion in the test still passes — split_once("Showing") only looks at what is below. The inline suggestion adds the spacer check. test_list_progressive_overflow also asserts final_cursor, and an equivalent assertion here would pin that the reserve survives a footer that grew into it; I left that out of the suggestion because the expected row depends on how many rows the footer wraps to at 40 columns, which I can't run here — say the word and I'll push it.

Comment thread tests/integration_tests/list_progressive.rs
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.

2 participants