feat(tui): semantic colour layer, folded pickers, and reveal-before-run (#556) - #564
feat(tui): semantic colour layer, folded pickers, and reveal-before-run (#556)#564fanhongy wants to merge 6 commits into
Conversation
…un (#556) Adds an ANSI-16 theme with NO_COLOR support and migrates the frame to styled Lines. Also bounds the left column: the command list and both pickers now window and scroll, and the first Enter reveals folded options instead of running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR enhances the cao tui entrypoint by introducing a semantic color/theme layer (ANSI-16, NO_COLOR aware) and by making the left column reliably usable on shorter terminals via windowing and foldable pickers, including “reveal-before-run” behavior for Enter.
Changes:
- Add
tui/src/theme.rswith six semantic roles over an ANSI-16 palette andNO_COLORsupport; thread the theme through the renderer and results pane. - Add/extend tripwire tests to enforce “no
Color::literals outside theme”, forbid RGB/Indexed and Black/White (with scoped allowances), and keep source scans coverage-complete. - Rework the left-column UX: window the command list, fold pickers by default with scrolling, and make first
Enterreveal folded options before running.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tui/tests/no_colour_literal_outside_theme.rs | New guard ensuring only src/theme.rs can name Color, plus palette-shape constraints and anti-vacuity checks. |
| tui/tests/no_backend_attach_call.rs | Extend embedded source list to include src/theme.rs so the subprocess tripwire still fully covers src/. |
| tui/tests/hermeticity_tripwire.rs | Extend embedded source list (including the new guard test) and add a mod-declaration cross-check to prevent unscanned files. |
| tui/src/theme.rs | Introduce the semantic theme (ANSI-16 only) with NO_COLOR selection logic and exhaustive self-tests. |
| tui/src/results_pane.rs | Store and apply Theme to pane rendering (footer/body/collapsed strip), with tests asserting style behavior and strip-styling invariants. |
| tui/src/renderer.rs | Migrate frame regions to Vec<Line>, apply semantic styles at one seam, add foldable/scrolled pickers + command list windowing + reveal-then-run behavior, and update tests accordingly. |
| tui/src/main.rs | Read NO_COLOR once at startup, set the renderer theme, and document piped output expectations with a test-backed invariant. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let window = self.picker_window(); | ||
| // `min` against a saturating end, so a scroll offset left over from a longer list cannot | ||
| // index past a shorter one — the pickers are re-fetched by `[ctrl+r]` and the new answer | ||
| // may be shorter than the old. | ||
| let start = scroll.min(rows.len().saturating_sub(1)); | ||
| let end = (start + window).min(rows.len()); | ||
| lines.extend(rows[start..end].iter().cloned()); |
There was a problem hiding this comment.
Summary: the semantic theme work looks sound, but the layout changes have two P2 usability defects. First, expanding all sections can overflow the non-scrolling left pane, so the options claimed as revealed remain clipped at supported terminal sizes; the first Enter still prevents execution, which is why this is P2 rather than P1. Second, loading, empty, and failed picker states remain in the focus ring without rendering any focus marker, making Tab appear to do nothing.
| self.agents_expanded = true; | ||
| } | ||
| if self.provider_row_count() > 0 { | ||
| self.providers_expanded = true; |
There was a problem hiding this comment.
[P2] Keep revealed controls inside the rendered viewport. I reproduced this with the existing fake at 120x40 and 25 agents: after the first Enter, this expands the optional section and both pickers into the one non-scrolling left Paragraph; the drawn buffer ends midway through --memory and contains neither the agent nor provider fold, while has_folded_options() is already false and the footer says the next Enter will run. The second Enter can therefore execute without the operator ever seeing the rows that were supposedly revealed. The sizing also fails before expansion at the supported 80x24 boundary: the collapsed provider header is clipped because command_list_window() bounds logical entries, not their wrapped screen rows. A scrollable left viewport that follows focus, or one shared rendered-row budget, is needed; independent rows/3 and rows/4 caps cannot guarantee that the composed column fits.
| @@ -160,6 +166,10 @@ pub enum Focus { | |||
| RequiredFields, | |||
| /// The collapsed-by-default optional section header (FR-2.3). | |||
There was a problem hiding this comment.
[P2] Preserve visible focus when a picker has no rows. The focus ring now always visits AgentPicker and ProviderPicker, but only the Loaded(non-empty) arm calls fold_lines(..., focused) and emits a > marker. Loading, empty, and Failed render an unmarked line. Reproduced with the server-down fake: tabbing to AgentPicker leaves the two failure lines byte-for-byte unchanged with no structural focus cue, so Tab appears to do nothing for two stops in the ring. Either skip non-foldable picker states in the focus order or render their state line with the same focus marker/style.
Addresses two P2 review findings on #564. Expanding all sections overflowed the non-scrolling left column, so revealed options stayed clipped at 80x24; it now scrolls to the focused region and says what is off-screen. Loading, empty and failed pickers stayed in the Tab ring without a focus marker, making Tab look dead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
call-me-ram
left a comment
There was a problem hiding this comment.
The colour layer is the strongest half of this and I'd take it as-is. I planted a Color::Yellow in renderer.rs's production region and the NFR-3 guard failed with the exact file:line; I moved the same literal into a comment and it went green — so the guard is genuinely load-bearing in both directions, not just an absence check. NO_COLOR holds end to end: with the monochrome theme installed and an agent fold expanded, 0 of 4800 drawn cells at 120x40 carried a non-Reset foreground. FR-1.5/FR-1.7 are asserted at both layers — every_dim_marker_dims_its_marker_and_not_its_row reads the drawn buffer and checks the row is not dimmed as a whole, and a_collapsed_fold_still_states_how_many_rows_are_unavailable pins the diagnosis into the collapsed header (agents (3, 2 unloadable), providers (2, 1 not installed)) with the , 0 unloadable negative case alongside it. cargo fmt --check and cargo clippy --locked --all-targets -- -D warnings are both clean, and the unstable-rendered-line-info claim that Cargo.lock is unchanged checks out — --locked passes with no lockfile in the diff.
The layout half has two defects I can reproduce, and one of them re-creates the exact accident this PR was written to prevent. Requesting changes on those two; both fixes are small.
Must-fix
1. [enter] on the expanded optional section launches the command, while the header it is sitting on advertises [enter] expand/collapse — renderer.rs:1625 (on_key_form's Enter arm) against the header string at renderer.rs:1051.
With focus on Focus::OptionalSection and the section already expanded, on_key_form(key, false) routes Enter to reveal_options_or_run(). Nothing is folded at that point, so it falls straight through to run_selected(). Driven through the real key handler:
header advertises: "> ▾ optional (9) — [enter] expand/collapse"
optional_expanded before = true
Enter consumed = true
optional_expanded after = true <- did not collapse
pending_action after = Some(Launch) <- it launched
running = true
banner = Some(Banner { severity: "info", what: "creating the session", ... })
The operator reads [enter] expand/collapse on the line their focus marker is on, presses [enter] to fold the section back up, and creates a session instead. That is the failure reveal_options_or_run's own docstring describes at renderer.rs:1723 — "the operator asking for this described running the CLI by accident while trying to open the options" — arriving one keystroke later than before.
The string is pre-existing (it is on main at renderer.rs:801), so this is not a regression you introduced. I'm calling it a must-fix here because this PR is what makes it inconsistent and what makes it reachable as a deliberate keystroke: your new picker folds advertise [enter] collapse when expanded and honour it (on_key_fold:1669), and your new footer says then [enter] to run. So the same advertised affordance now means "collapse" on two of three folds and "launch a session" on the third, and the operator has just been trained by the footer that [enter] is the reveal key.
Minimal fix — make the header advertise the key that is actually wired, since Esc already collapses at renderer.rs:1633:
// renderer.rs:1049-1053
let glyph = if self.optional_expanded { "▾" } else { "▸" };
let action = if self.optional_expanded { "[esc] collapse" } else { "[enter] expand" };
let mut lines = vec![format!("{marker} {glyph} optional ({count}) — {action}", ...)];The alternative — giving the section the picker folds' Enter-toggles semantics — is more consistent but costs you the "second [enter] runs" contract whenever focus happens to rest there, so I'd take the honest label.
2. fold_lines clamps the scroll offset to the last row, not the last window, contradicting its own docstring — renderer.rs:1231.
let start = scroll.min(rows.len().saturating_sub(1));on_key_fold's doc at renderer.rs:1649-1651 states the opposite and is what a reader will believe: "Scrolling is clamped to the last window, not the last row: allowing scroll to reach len - 1 would let the operator scroll into a view holding a single row with empty space below it." on_key_fold's Down arm does clamp correctly (rows.saturating_sub(window)), so the two disagree, and any path that changes rows or window without a keypress lands in the gap. Copilot flagged the line; here are the two concrete reproductions.
Terminal resized taller. 25 agents, expanded at 100x24 (picker_window() = 6), scrolled to the bottom (agent_scroll = 19), then resize(100, 200) so the window becomes 50 and the whole list fits:
--- pickers after resize to 200 rows --- (window = 50, rows = 25)
> ▾ agents (25) — [enter] collapse
agent-19 … agent-24
… 19 above, [↑↓] scroll
Six of 25 rows in a pane with room for all 25. Then pressing Down — the key the residue line advertises — sets last = 25.saturating_sub(50) = 0 and snaps scroll from 19 to 0, so Down scrolls up to the top of the list.
[ctrl+r] returns a shorter list. retry() does not reset agent_scroll, so with scroll still 19 and a refetch answering 2 profiles:
scroll after retry = 19, rows = 2
> ▾ agents (2) — [enter] collapse
only-two
… 1 above, [↑↓] scroll
The header says 2, one row is drawn, and only-one is off-window. Up decrements scroll 19→18→17… while start = scroll.min(1) stays pinned at 1, so the key advertised to reveal what is above does nothing visible for 18 presses. That is a row the operator cannot reach by the advertised means, which is the defect the fold was built to fix.
One-line fix, which I applied and re-ran:
let start = scroll.min(rows.len().saturating_sub(window));After it, the resize case shows all 25 from agent-00 with no residue, and the refetch case shows both only-one and only-two. The full binary suite stays green either way (200 passed with the fix in) — so nothing currently covers this and it wants a regression test. A resize-after-scroll case is the cheaper of the two to write, and resetting the two *_scroll fields in retry() would close the refetch path at the source as well as at the render.
Non-blocking
-
tui/Cargo.toml:108justifies taking the unstable ratatui feature by naming its pin: "wrapped_heights_agrees_with_what_is_actually_drawnpins the behaviour against a real rendered buffer". There is no test by that name —grep -rn wrapped_heights_agreesmatches only the comment. The real test isthe_wrap_measurement_agrees_with_what_is_actually_drawnatrenderer.rs:7737, and it does exactly what the comment claims. Worth fixing the citation precisely because this is the one place in the diff where a reviewer is asked to accept an unstable upstream dependency on the strength of a named guard. -
every_revealed_region_is_reachable_at_the_minimum_supported_size(renderer.rs:7676) asserts onlydrawn.lines().any(|line| line.starts_with('>')). That happens to be sound today because exactly one line carries the marker at a time (I checked — with focus onOptionalSectionthe count is 1), but it would pass if the wrong region's marker were on screen. Asserting the marked line also contains the region's own label would make it prove what its name says. -
render_form's bare" … form scrolled"branch (renderer.rs:2545) looks unreachable. The branch needsparts.is_empty(), i.e.scroll == 0 && below == 0; but it is only entered whentotal > area.height, andviewport = area.height - 1, so withscroll == 0you getbelow = total - viewport >= 2. It never fired in any of the sizes I rendered. Either drop it or note why it is kept. -
" … {above} below of {total} commands"atrenderer.rs:955reads as "34 below of 42 commands" in the drawn output. The number is right (8 shown + 34 = 42); the word order fights it.
Asks
The footer is clipped at the 80-column floor and loses [q] quit. Not yours — the hint string and the Constraint::Length(frame.footer.len()) sizing are both unchanged from main — but it is the same bug class this PR exists to fix, one region down, and you have just built the tool for it. The hint is 89 characters, the region is 80 wide and gets Length(2) for two logical lines, so the hint wraps to two rows into one row of space:
[tab] focus · [←] commands · [enter] run · [k] stop following · [ctrl+r] retry ·
drawn.contains("[q] quit") is false at 80x24. Sizing header and footer with wrapped_heights(&frame.footer, area.width).iter().sum() instead of .len() would fix it with the function you added in this PR. Separate commit or separate PR is fine — I'd just like it not to sit there, since NFR-6's rule is "wrap, never truncate" and this is a truncation.
Relatedly and also pre-existing: that same hint advertises [k] stop following in every non-text-entry focus, but [k] only does anything in Focus::Results. Given #547's precedent on advertising unwired keys, it belongs on the same list.
On the bundle. I'd take it, and I want the trade-off named rather than waved through. Change 2 has no tracking issue, and it is the half with both must-fixes — the themed refactor is mechanical and test-guarded, the layout work is where the judgement calls are. What makes the bundle reviewable anyway is that Vec<String> → Vec<Line<'static>> is a genuine prerequisite: render_form's viewport measures wrapped height per line, which needs the Line values, so splitting would mean landing the migration and then immediately reworking the same call sites. The cost is that a +2413/-98 diff on one file makes the layout logic hard to isolate, and it did — the two defects above are both in the un-issued half. Please file the issue for change 2 and cite it, so the layout work has a requirements trail like the colour layer does. Not a merge blocker.
On the red CI. Security Scan is the only failing check and it is not this PR. I confirmed rather than assumed: the same check is failure on main at e592b21e, which is not a commit from this branch, and #569 ("bump js-yaml to 4.3.1 and make the Trivy gate legible") is the fix in flight. Worth noting that the failing job's log contains no finding at all — the Trivy step emits SARIF and exits 1, so ##[error]Process completed with exit code 1 is the entire signal, which is exactly what #569's second half is for.
On haofeif's review. Both of their P2s were real and both are addressed by 75e85a7, which I verified at head rather than taking on trust:
- Revealed controls stayed outside the viewport at 80x24 — confirmed and now fixed. At 80x24 with 25 agents, after the reveal press, tabbing to
AgentPickerand thenProviderPickerbrings each fold into the drawn buffer with its>marker, and the residue line names what is off-screen. Their diagnosis that independentrows/3androws/4caps cannot bound the composed column was the right call, and the shared scrolling viewport is the right answer to it. Loading/empty/Failedpickers sat in the Tab ring with no focus marker — confirmed and now fixed by routing all four arms throughfocus_marked. One note: a focused failed picker takestheme.errorrather thantheme.focus, becausestyle_pickerstestsPICKER_FAILURE_MARKERbefore the>prefix (renderer.rs:2757vs2770). I think that's the right precedence and NFR-3 is satisfied by the textual>, but it is worth a line instyle_pickers' docs so it reads as a decision.
I'd dispute nothing in their review. What neither round caught is the two items above — the [enter]-launches-from-the-optional-header path, and the render-side scroll clamp behind the resize and refetch paths.
What I verified
No system Rust toolchain on this box, so I bootstrapped one: rustup stable-x86_64-unknown-linux-gnu into a scratch CARGO_HOME, and — with no cc and no root to install one — zig cc (zig 0.13.0) as the linker driver. Everything below therefore ran for real; nothing here is inferred from reading.
Worktree at 75e85a7f (PR head), git worktree add off refs/remotes/pr/564, removed afterwards.
cargo test --locked --no-fail-fast→ 226 passed, 3 failed. All three failures aretests/endpoint_contract.rsrefusing to reachcao-serverat127.0.0.1:9889(Connection refused) — environmental, and that file fails rather than skips by design (BR-7). Every other target green, including all 191 binary unit tests.cargo fmt --check→ clean.cargo clippy --locked --all-targets -- -D warnings→ clean, 0 warnings.- NFR-3 guard, both directions. Planted
let _planted = ratatui::style::Color::Yellow;insideplain()→no_production_module_outside_the_theme_names_a_colourFAILED, reportingsrc/renderer.rs:2580 — let _planted = ratatui::style::Color::Yellow;. Moved the identical literal into a//comment → all 7 guard tests pass. Also confirmed the guard's region split is sound:renderer.rs's first#[cfg(test)]is at line 3173 and the only top-level item after it ismod tests, so no production code sits outside the scanned region. NO_COLOR. WithTheme::monochrome()installed and the agent fold expanded, filtered all 4800 drawn cells at 120x40 for a non-Resetforeground → 0. Also verifiedRenderer::set_themepropagates toResultsPane(renderer.rs:654) and thatResultsPane::attachsets its fields individually and does not clobbertheme— so the palette survives a run.- Must-fix 1 reproduced through
on_keywith the launch form, required field filled, optional section revealed and focused:pending_actiongoes toSome(Launch)andrunningtotrue, with the header still reading[enter] expand/collapse. - Must-fix 2 reproduced twice — resize 100x24 → 100x200 after scrolling to the bottom, and a
[ctrl+r]refetch shrinking 25 profiles to 2. Applied the one-line clamp change, re-ran: both views correct, and the binary suite still 200 passed / 0 failed. - Extreme sizes, no panic. Drew at 1x1, 1x2, 2x1, 80x1, 1x24, 80x2, 80x3, 0x0, 3x3, 79x23 and 200x4, each after a reveal press, a Tab walk to
ProviderPickerand fiveDownpresses. No subtract-with-overflow anywhere — thesaturating_subdiscipline in the layout arithmetic holds up. - Footer clipping measured directly: hint line is 89 chars,
drawn.contains("[q] quit")isfalseat 80x24 while"stop following"istrue. - Focus markers: exactly one line carries
>with focus onOptionalSection, sofocused_line_index's first-match anchor cannot pick the wrong region today. - CI:
Security Scan=failureonmainate592b21eas well as on this PR; #569 is the in-flight fix.
…e last window Addresses both must-fixes from the review on PR #564. 1. `[enter]` on the EXPANDED optional header launched the command while the header it sat on advertised `[enter] expand/collapse`. With nothing left folded, `on_key_form` routes `Enter` to `reveal_options_or_run`, which falls through to `run_selected()` — so an operator folding the section back up created a session instead. Reproduced through the real key handler: `pending_action` went to `Some(Launch)`, `running` to `true`, and a tick later `create_session_calls` was 1. The expanded header now advertises `[esc] collapse`, which is the key `on_key_form` actually honours. #556 is what made the old label reachable as a deliberate keystroke: the new picker folds advertise `[enter] collapse` and honour it, and the new footer teaches `[enter]` as the reveal key, so the same advertised affordance meant "collapse" on two of three folds and "launch a session" on the third. The alternative — giving the section the picker folds' Enter-toggles semantics — costs the "second [enter] runs" contract whenever focus rests there, so this takes the honest label. 2. `fold_lines` clamped the scroll offset with `rows.len().saturating_sub(1)` while `on_key_fold`'s `Down` arm used `rows.saturating_sub(window)` and its docstring stated the latter. Any path changing `rows` or `window` WITHOUT a keypress landed in the gap, and there are two: - A taller terminal. Scrolled to the bottom of 25 agents at 24 rows then resized to 200, the window becomes 50 and the whole list fits, but the stale offset pinned the view to six rows in a pane with room for 25. Pressing `Down` recomputed `last` as 0 and snapped to the top, so the key the residue line advertises scrolled upwards. - A shorter refetch. `[ctrl+r]` did not reset the offsets, so an offset of 19 against a two-row answer drew one row and left the other off-window, with `Up` doing nothing visible for 18 presses. Fixed at both ends: the render clamps to the last window, and `retry()` resets the two offsets so the operator's position is not defined by a list that no longer exists. The fold flags are deliberately left alone. Two regression tests, both confirmed to fail before the change and verified individually load-bearing — reverting either half of fix 2 reddens its own assertion. `the_expanded_optional_header_advertises_the_key_that_collapses_it` asserts on `create_session_calls` rather than the label alone, so a build that relabelled the header and left the launch wired still fails. cargo fmt --check and cargo clippy --locked --all-targets -- -D warnings are clean; 193 binary unit tests plus every other target pass. Also swept 1x1 through 200x4 after a reveal, a Tab walk and five `Down` presses with a stale offset resized both taller and shorter: no panic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment justifying `unstable-rendered-line-info` named `wrapped_heights_agrees_with_what_is_actually_drawn` as the guard pinning the API's behaviour, and no such test existed. The real one is `the_wrap_measurement_agrees_with_what_is_actually_drawn` in `src/renderer.rs`, which does exactly what the comment claims. A reviewer is asked to accept an unstable upstream dependency on the strength of that citation, so it has to resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…word the list residue Three review follow-ups in the left column and its notices. `every_revealed_region_is_reachable_at_the_minimum_supported_size` asserted only that some line began with `>`. The column always holds three marker-capable headers, so the check passed once any of them was on screen — including when [tab] had moved focus to a region that scrolled away, the very invisible-focus defect the test is named for. It now matches the marker against the focused region's own label. `render_form`'s "form scrolled" fallback was dead code: emptiness needs scroll == 0 && below == 0, and the early return guarantees total > viewport, which forces scroll > 0 whenever below == 0. An assert in its place survived the whole suite, an exhaustive sweep of the function's arguments, and an integration sweep over every size, reveal state and focus stop. Replaced with a debug_assert so a change to the early return reddens a test instead. The command-list residue read `34 below of 42 commands`; the total now attaches to the noun once, giving `34 of 42 commands below`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nwired key
The footer was truncated at the 80-column floor and lost [q] quit. Header and
footer were sized with Constraint::Length(frame.footer.len()), and len() counts
LOGICAL lines: in Focus::Results the 89-character hint wraps to two rows at 80
columns while len() reported 2 lines and reserved 2 rows. Measured at 80x24,
neither "[q] quit" nor "stop following" reached the drawn buffer. NFR-6 says
wrap, never truncate, and the key silently dropped was the documented way out.
Both regions are now sized with wrapped_heights, the same measurement the left
column's viewport already uses.
The hint also promised [k] stop following in every non-text-entry focus, but
on_key routes Char('k') to the pane from the Focus::Results arm alone. Per
#547's precedent on unwired keys, it is now advertised only there.
The new tests assert on the DRAWN BUFFER, not the frame: the frame always held
the right text, so a frame-level assertion cannot see this defect class.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both must-fixes are in, plus the four non-blocking items and the footer ask. Four commits, Thanks for the two reproductions — both were real, and the second one was reachable by two paths I had not considered. Must-fix 1 —
|
What
Two related changes to the
cao tuifront door.1. The semantic colour layer (#556). A new
tui/src/theme.rsdefines sixsemantic roles over an ANSI-16 palette, honours
NO_COLOR, and the frame'sregions move from
Vec<String>toVec<Line<'static>>so styling lives at oneseam.
tui/tests/no_colour_literal_outside_theme.rsis the NFR-3 guard: noproduction module outside the theme may name a colour, and no colour may be
non-ANSI or inverting.
FR-1.5 / FR-1.7 are preserved — an unloadable profile and an uninstalled
provider stay listed with their textual marker, never filtered.
2. Bounding the left column (no issue — see below). The left column is one
non-scrolling
Paragraph, so anything past the last row was silently clipped.At 40 rows the 42-command list alone consumed the whole column and the pickers,
form and banner never painted at all.
▸ agents (25, 3 unloadable).Enter/Spacetoggles,Up/Downscroll the expanded window,Esccollapses. Every row stays reachable, and the residue count names what is
off-window in each direction so a partial view never reads as complete.
the rows without hiding the fact.
Enterreveals folded options; the second runs. PreviouslyEnteron the launch form ran the command immediately, before the operator had seen
the optional args. The condition is the visible fold state, not a press
counter — an operator who expanded with
Spacedoes not then owe a wastedEnter.Verification
cargo test --locked --no-fail-fast),186 of them unit tests on the binary.
cargo fmt --check,cargo clippy --locked --all-targets -- -D warningsandscripts/assert_no_ffi.pywere all clean on this exact tree; no sourcechanged between those runs and this commit, but CI is the authority here — I
did not re-run them immediately before committing.
(deleting the fold's diagnosis clause, because an existing assert matched
only a prefix of the header);
a_collapsed_fold_still_states_how_many_rows_are_unavailablecloses it.
scrolled (
… 12 above, 3 below), and oneEnterexpanding rather thanrunning. This mattered: the fold's own unit tests were green while nothing
rendered, because
frame.pickersis populated whether or not it surviveslayout.
at_a_realistic_size_every_region_survives_the_layoutnow asserts onthe drawn buffer instead.
Notes for review
not want to cite a number that resolves to unrelated work, so the new code
carries no issue citation. Happy to file one and amend if you'd prefer.
#556spec artifacts underdocs/issues/556-tui-semantic-colour-layer/are deliberately git-excluded and are not in this branch.
tasks.mdproposed splitting [Feat] cao tui: semantic colour layer — ANSI-16 theme tokens, NO_COLOR support, and an NFR-3 strip-styling guard #556 into two commits (migration, thenstyling); this ships as one, with the layout work folded in as a third
logical change. Say the word if you want it split for review.
sshpath nor the Linux CI leg has been exercised locally.🤖 Generated with Claude Code