fix(tui): survive a terminal that reports no size (#326) (#334)
## Summary `all-smi local` aborted the moment it was handed a pty with no window size. `TIOCGWINSZ` reports zero on such a pty, `crossterm::terminal::size` faithfully returns `Ok((0, 0))`, and `rows - 1` in `print_function_keys` underflowed. The fix makes the arithmetic safe, but the substance of it is deciding what a zero size actually means and what the TUI should do at sizes where a frame is meaningless. ## The design decision **Zero is "no geometry available", not "a terminal of size zero".** A pty allocated without a `TIOCSWINSZ` has never been told how big it is, and there is almost always an ordinary terminal on the far end. Rendering nothing there would be the wrong reading of the signal, and it would keep the TUI untestable, which is the reason PR #317 had to call `print_cpu_info` directly instead of driving a real session. So a missing dimension is replaced, per dimension, by `$COLUMNS` / `$LINES` when the environment supplies a usable value and by the conventional 80x24 otherwise. That is the fallback order ncurses uses. **A tiny terminal is real and is believed.** 12x2 is not missing geometry, it is an operator who dragged the window that small. Substituting a size there would be a lie. Below the minimum the loop renders a single-line notice and skips composition; `UiEvent::Resize` already wakes the loop, so growing the window recovers on its own. Exiting was rejected because no TUI quits when you drag it narrow, and blocking is just this without a repaint. **The minimum is 20x3, and the width half is measured rather than chosen by taste.** 20 sits above every unchecked width subtraction in the renderer set: the binding constraint is the three-gauge GPU row in `gpu_renderer.rs`, which needs 14, followed by the Apple Silicon CPU row at 12 and the chassis, storage and single-gauge CPU rows at 5. It is also where the shortest status bar text (`h:Help q:Exit`, 13 columns) stops being the entire line. 3 rows is a header row, one row of content, and the status bar that always owns the last row. The gate and the saturating arithmetic are both required. A gate alone leaves the help, alert and replay paths underflowing and leaves a window between sampling the size and rendering; saturation alone closes the issue while drawing garbage into a one-row window. ## Sweep `ast-grep` over `src/ui` and `src/view` for `$A - $B`, `$A / $B` and `$A % $B`, filtered to dimension operands. Beyond the two sites the issue named it found three more reachable faults and a set of already-safe matches. Fixed: | Site | Fault | |---|---| | `chrome.rs:113` | `rows - 1` underflows at 0 rows. The reported panic. | | `chrome.rs:80` | `(rows - status_start_y) - 1` underflows whenever the status block starts at or past the last row. | | `chrome.rs:43` | `cols as usize - SCREEN_MARGIN` underflows below 10 columns. Not in the issue. | | `chrome.rs:49` | `% (bar_width * 2)` divides by zero at exactly 10 columns. Not in the issue. | | `event_handler.rs:1220` | `half_rows - 1` underflows at 0 or 1 rows, on the mouse-click path. Not in the issue. | Left alone, with reasons: `chrome.rs:56` (`position` is a modulo of `bar_width * 2`, so the result cannot go below zero, and it now sits inside the `bar_width > 0` guard); `chrome.rs:237` and `frame_renderer.rs:168` (subtrahend bounded by `.min()` / an `if`); `help.rs:37,47,119` (evaluated only inside `for row in 0..height`, so `height >= 1`); `help.rs:527`, `led_grid.rs:146`, `topology/graph_render.rs:274`, `topology/matrix_render.rs:174` (guarded by an early return on the narrow case); `braille.rs:225`, `activity_panel.rs:290`, `user_renderer.rs:513` (loop-bound indices, and `graph_row_color` already special-cases zero); `layout.rs:162,192` (guarded, and `#[allow(dead_code)]`); `process_renderer.rs:91,649` (both behind explicit width comparisons). Not fixed and reported rather than silently widened: the gauge renderers (`gpu_renderer.rs:439,444`, `cpu_renderer.rs:444,449,504,687`, `chassis_renderer.rs:247`, `storage_renderer.rs:135`) and `help.rs:348` still use unchecked subtraction. They are unreachable below the 20-column floor, which the sweep test pins with a populated snapshot rather than by assertion. Hardening all eight would be a wide diff across files #325 is also touching, for no behavior change below the gate. ## What changed - `src/ui/viewport.rs` (new): `Viewport` with `resolve`, `current`, `is_renderable`, `too_small_notice`, and the `MIN_COLS` / `MIN_ROWS` / `FALLBACK_*` constants, each carrying the reasoning for its value. - `src/ui/chrome.rs`: guarded and saturating arithmetic in both functions, plus an early return when there are no cells to draw into. The policy question is explicitly delegated to `ui::viewport` in comments so the two layers do not drift. - `src/view/ui_loop.rs`: geometry now comes from `Viewport::current`; the too-small branch renders the notice and forces a repaint on the way in and out. - `src/view/event_handler.rs`: same geometry source, which also retires three `size().unwrap()` calls that would have aborted on a failed ioctl, and one saturating fix. ## Test plan - [x] `cargo test --lib ui::` — 559 passed. Includes 7 new `ui::chrome` and 11 new `ui::viewport` tests. - [x] `cargo test --bin all-smi view::` — 123 passed. `view` is only in the binary target, so `--lib` never compiles it; the 3 new `frame_renderer` tests live here. - [x] `cargo clippy --lib --tests -- -D warnings` — clean. - [x] `cargo clippy --bin all-smi --tests -- -D warnings` — clean. Run separately on purpose: the crate compiles its module tree twice and this caught a `pub` item that was live in the library and dead in the binary. - [x] `cargo fmt --check` — clean. Regression coverage is at 0 and 1 in **both** axes, not just width: the reported panic was on rows, so a width-only sweep would have missed it. `chrome.rs` drives the real `print_function_keys` and `print_loading_indicator` across eleven degenerate geometries including the mixed `(0, 24)` and `(80, 0)` cases and the `cols == SCREEN_MARGIN` zero-width-bar case. `frame_renderer.rs` sweeps every size from 20x3 to 32x9 through `render_main`, `render_loading`, `render_help` and `render_alert_panel` with a snapshot carrying two GPUs and a CPU, which is what actually proves the 20-column floor clears the gauge cliffs. ## End-to-end verification `script` needs a controlling terminal, which the agent shell does not have, so verification used a `forkpty()` harness that leaves the window size unset. That produces the identical condition: `TIOCGWINSZ` returns zeros and `crossterm::terminal::size` returns `Ok((0, 0))`. Before, on `1f540e1`: ``` [pty winsize: rows=0 cols=0] thread 'main' (32556106) panicked at src/ui/chrome.rs:113:38: attempt to subtract with overflow [exit status: 25856] # 101 << 8 ``` After, same harness, screen reconstructed from the captured escape stream at the 80x24 fallback: ``` [pty winsize: rows=0 cols=0] [captured 38927 bytes, child status None] 0 |all-smi - 2026-08-06 21:21:08 v0.25.0 1 |Host cube.local · Apple M1 Ultra · arch arm64 · up 7d 0h 24m ● Live 2 |CPU 10.9%↓ ⢸⣤⣄ GPU 31.1%↑ ⢀⣼⣾ RAM 42/128GB→ ⢰⣶⣶ Pwr 5.0W↘ 3 |NODE cube.loca Pwr: 5.0W Thermal: Nominal │ CPU: 4.4W GPU: 0.7W ANE: 0.0W 4 | Power: [▬▬─────────────────────────────────────────────────── 5.0W] 5 | Energy session: 43.7 J | $0.00 (at $0.12/kWh) 6 |GPU a GPU Apple M Util: 31.1% VRAM: 42.3/128GB Temp: 51°C Freq:638MHz Pwr: 7 | Util : [▬▬▬▬─ 31.1%] Mem : [▬▬▬▬─ 42.3GB] ANE : [───── 0.0W] 8 |CPU Apple M1 Ultra Arch:arm64 Sockets: 1 Cores:16P+ 4E Freq: 1.30+1.58GHz Temp 9 | P-CPU: [▬──────────────── 5.4%] E-CPU: [▬▬▬▬▬▬▬▬───────── 33.2%] 10 |Host Memory Total: 128GB Used: 42.3GB Avail: 120.1GB Util: 33.0% ... 20 |── Processes ─────────────────────────────────────────────────────────────────── 21 | PID USER PRI NI VIRT RES S CPU% MEM% GPU% VRAM TIME 23 |Showing 1-0 of 500 processes (Use ↑↓ to navigate, PgUp/PgDn for pages) ``` A real 12x2 terminal takes the other branch and emits 82 bytes rather than a garbled frame: ``` [pty winsize: rows=2 cols=12] [captured 82 bytes, child status None] 0 |12x2 < 20x3 ``` 1x1 degrades the same way, to the one character that fits. `COLUMNS=100 LINES=12` over a zero-size pty renders a full 100x12 frame, which is the mechanism that makes an automated session capture reproducible at a chosen size. ## Not verified Recovery from the notice back to a normal frame relies on `UiEvent::Resize`, which the loop already handles; the transition is covered by reading the code and by the `force_clear` on both edges, but it was not driven end to end, since resizing a pty mid-session was outside what the harness needed to do. The whole-suite `cargo test` was not run under the agent's time budget; the scoped runs above cover every module this PR touches and CI runs the rest. Closes #326
J
Jeongkyu Shin committed
c4c17d8d3c5f99b44ae7c2c8eab73662ae9a0e89
Parent: 4646da6
Committed by GitHub <noreply@github.com>
on 8/6/2026, 1:15:59 PM