Fix wt list column sizing, alignment, and headers - #3998
Conversation
…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
left a comment
There was a problem hiding this comment.
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 empty — src/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 order — src/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.
`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
left a comment
There was a problem hiding this comment.
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'sWORKTREE_STATEentry, whose comment readsWORKTREE_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 theconst WORKTREE_STATEdeclaration a dozen lines above it (Worktree: ✘↻/⚑⊘⊟⊞). Both the glyph set and the priority prose need detached, sitting betweenlockedandduplicate_branch—WorktreeState's own docstring inmodel/state.rsgives the order asPrunable > 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.
`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
|
All five Took the The progressive-footer decision is the only thing still open here.
|
worktrunk-bot
left a comment
There was a problem hiding this comment.
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
…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
left a comment
There was a problem hiding this comment.
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.
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=jsonstill 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↕andmain…±saidmainin a repo whose default branch istrunk. 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 onewtcan't resolve, gives way to a genericbaserather 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+1landed to its left — the header now centres over the separator.Agewas left-aligned, sonowand4mdidn'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 frombranch_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
## Columnscovers the drop order,[list] columns, and that--format=jsonshows everything.jqrecipes. Schema 1 keeps a paragraph, the deprecation pointer, and a schema-1 → schema-2 mapping table covering every field it documented.wt config state, a group with no default action; it nameswt config state cache.wt list statusline --format=jsonwas documented as "a one-entry array in thewt list --format=jsonschema", 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-schemawhile plainwt listnags: a prompt consumer can't act on a warning drawn over its own line.markerfield, so the value is readable without parsing it back out ofsymbols.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 ofsrc/cli/mod.rs,src/styling/line.rs, and.claude/skills/writing-user-outputs/SKILL.md. Generated mirrors and snapshots are regenerated.🤖 Generated with Claude Code
https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb