Skip to content

os/v3/puppy: a console-TUI message costs the legend a word, not an action - #1026

Merged
bong-water-water-bong merged 3 commits into
mainfrom
os/v3-tui-msg
Nov 4, 2026
Merged

bong-water-water-bong merged 3 commits into
mainfrom
os/v3-tui-msg

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Closes #1015.

Root cause

draw() built the footer as [keys line] + [message] and charged both as fixed rows. A frame that was exactly the
nine actions plus the two rows the legend wraps to (40x12 - a tmux split, an ssh window on a phone) was therefore
over-subscribed the moment a key said anything, and ratatui clipped the end of the MENU for the message's twelve
seconds (main.rs, Duration::from_secs(12)):

40x12, shipped binary, pane is exactly 40x12, one keypress (Ctrl-C):
1 Show the API key
...
8 Logs (Lemonade, kernel)
1-9 actions arrows scroll f newest r
refresh 8 logs q quits
this console never exits: 7 opens a
shell, 9 reboots

9 Reboot / power off is gone - and it is a string vm-check.sh's U2 menu row and menugate grep for. No gate
impact (the gates run at 80x25 and 240x67), but the ninth action is off the screen in any short window for as long as
the message lives.

What changed

The actions are ROWS and the footer is WORDS, so the words give way, in a fixed order: the legend's tail words first
(keys_segments()/footer_lines()), then the legend line itself, and only if even the message alone cannot share the
frame with the nine actions does the message go. The nine actions are never charged.

size before (any keypress) after
40x12 8 actions, no 9 Reboot / power off, legend + message 9 actions + the whole message (the frame has no spare row for the legend)
40x13 8 actions, legend + message 9 actions + the legend's first row + the whole message
40x11 8 actions 9 actions, the message dropped (ten menu rows leave one footer row; the message wraps to two)
80x10 / 80x11 / 100x13 / 30x16 / 56x12 8 or 9 depending on the wrap 9 actions, message whole
80x25 / 240x67 unchanged unchanged - whole legend + message; the two committed snapshot frames are byte-identical

The message is never cut mid-word: it is drawn whole, or not at all.

Evidence

  • Reproduced first on the shipped binary (main 434db3d2's build, 0edbe573..., 1,554,664 B) in a real pty whose pane
    is exactly 40x12; the same size after this tree is the table's first row.
  • a_message_gives_way_before_an_action_does sweeps 40x12, 40x13, 56x12, 80x10, 80x11, 100x13, 30x16, 80x25, 240x67
    (frame is the terminal, all nine actions, the message whole and never cut), asserts the legend is untouched where it
    fits (80x25, 240x67, 100x13), and asserts 40x11 drops the message rather than an action. Putting the old footer
    construction back turns it red at 40x12; the fix turns it green.
  • Real ptys, TERM=linux, the static binary: 40x12 -> nine actions + message; q on the console (no
    SSH_CONNECTION) still stays with its message whole at 40x12; q with SSH_CONNECTION still exits 0.
  • cargo test 47/47, clippy --all-targets -D warnings clean, cargo fmt --check clean,
    tools/copyright.py --check rc 0. Static rebuild 5c39b9182135b90d, 1,562,048 B, no PT_INTERP, no shared
    libraries; --snapshot 80x25 --fixture matches the committed frame.
  • 2 files, +152/−20, no new files, no gate file touched (vm-check.sh, puppy-check.sh, menugate.sh untouched), no
    image build, no board run, no device ticket.

Not in this PR

…tion

`draw()` built the footer as `[keys line] + [message]` and charged both as fixed rows. A window whose frame was
exactly the nine actions plus the two rows the legend wraps to (40x12: a tmux split, an ssh window on a phone) was
therefore over-subscribed the moment a key said anything, and ratatui clipped the END of the MENU for the message's
twelve seconds (`main.rs`, `Duration::from_secs(12)`):

  40x12 with `this console never exits: 7 opens a shell, 9 reboots` on screen: actions 1-8, `9 Reboot / power off`
  gone, footer still promising "1-9 actions". Measured on the shipped binary before this commit, in a pty whose pane
  is exactly 40x12; `9 Reboot / power off` is a string `vm-check.sh`'s `U2 menu` row and `menugate` grep for.

The actions are ROWS and the footer is WORDS, so the words now give way, in a fixed order: the legend's tail words
first, then the legend line itself, and only if even the message alone cannot share the frame with the nine actions
does the message go. The nine actions are never charged.

  40x12 after a keypress: all nine actions + the whole message (legend gone - the frame has no spare row).
  40x13: all nine + the legend's first row + the whole message.
  40x11: ten menu rows leave one footer row, the message wraps to two, so the message goes and the legend stays.
  80x10: the one row left over goes to the message.
  80x25 and 240x67: nothing is trimmed - the whole legend and the message, exactly as before (the two committed
  snapshot frames are byte-identical, `renders_at_the_two_console_sizes`).

`a_message_gives_way_before_an_action_does` asserts the nine sizes above (frame is the terminal, all nine actions,
the message whole and never cut mid-word), that the legend is untouched where it fits (80x25, 240x67, 100x13), and
that 40x11 drops the message rather than an action. Putting the old footer construction back turns it red at 40x12.

Measured on the static binary: `5c39b9182135b90d` (1,562,048 B), no INTERP, `--snapshot 80x25 --fixture` matches the
committed frame; `cargo test` 47/47, clippy `-D warnings` clean, `cargo fmt --check` clean,
`tools/copyright.py --check` clean, no new files.

Closes #1015

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1026 ready for review at f245bb9e (rebased on main 013bb16b; git diff against the pre-rebase tree is empty).

For the reviewer, the one measurement to check: 40x12 in a real pty whose pane is exactly 40x12 - a keypress used to draw actions 1-8 with the footer still promising 1-9 actions (the missing row is 9 Reboot / power off); it now draws all nine plus the whole message, with the legend gone because the frame has no spare row. 80x25 and 240x67 are byte-identical to the committed snapshots, so no gate string moved.

No gate file touched, no image build, no board run, no device ticket.

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit b91fd26)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1015 - Partially compliant

Compliant requirements:

  • The fix ensures that in short frames, the legend's tail words give way first, then the legend line, and only if even the message alone cannot share the frame with the nine actions does the message go
  • The nine actions are never charged and are never trimmed
  • The message is drawn whole or not at all
  • The fix does not impact the gates running at 80x25 and 240x67

Non-compliant requirements:

  • None

Requires further human verification:

  • None
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Message Trimming Logic

The new logic for trimming the footer when the frame is short correctly prioritizes rows (actions) over words (legend and message). However, the implementation of footer_lines and the trimming loop could be made more robust to prevent potential edge cases where the message might be incorrectly trimmed in very narrow frames. Specifically, the check if segments.len() > 1 and else if !segments.is_empty() might not cover all cases where the message should be the last to be trimmed.

/// The legend line(s), then - while something is being said - the message under them.
fn footer_lines(segments: &[&str], message: Option<&str>, width: usize) -> Vec<Line<'static>> {
    let mut rows = vec![segments.join("   ")];
    if let Some(m) = message {
        rows.push(m.to_string());
    }
    rows.iter()
        .flat_map(|s| wrap_words(s, width))
        .map(|s| styled(s, Some(Color::DarkGray), false))
        .collect()
}

pub fn draw(f: &mut Frame, a: &App, out_links: &mut Vec<Link>) {
    let area = f.area();
    let w = area.width as usize;
    let h = area.height as usize;
    let wide = w >= 100;

    // header, CONNECT, then the status facts - the "technical information" the owner wants the menu below
    let mut top: Vec<Line<'static>> = Vec::new();
    let mut menu: Vec<Line<'static>> = Vec::new();
    let mut facts: Vec<Line<'static>> = Vec::new();
    let mut qr: Option<(u16, Vec<Line<'static>>)> = None;

    // The nine actions are ROWS and the footer is WORDS, so when the frame is short the words give way first.
    // Before this, in a 40-column window whose pane is 12 rows, the frame was exactly the nine actions and two
    // footer rows; one keypress added a two-row message and ratatui clipped the END of the MENU - `9 Reboot /
    // power off`, a `vm-check.sh` / `menugate` string - for the message's 12 s, with the footer still promising
    // `1-9 actions` (issue #1015). So: the legend's tail words, then the legend line, and the nine rows are
    // never charged for any of it.
    let actions = if a.full_logs {
        Vec::new()
    } else {
        menu_rows(w)
    };
    let full = keys_segments(a);
    let mut segments = full.clone();
    let mut message = a.message.as_ref().map(|(m, _)| m.clone());
    let budget = h.saturating_sub(actions.len());
    let mut footer = footer_lines(&segments, message.as_deref(), w);
    // the legend's tail gives way first, then the legend itself, and the message is the last thing dropped: it is
    // the feedback for the key that was just pressed, and `1-9 actions` is only read by the gates at 80x25 and
    // 240x67, where none of this trims.
    while footer.len() > budget {
        if segments.len() > 1 {
            segments.pop();
        } else if !segments.is_empty() {
            segments.clear();
        } else if message.take().is_some() {
            segments = full.clone();
        } else {
            break;
        }
        footer = footer_lines(&segments, message.as_deref(), w);
    }
Test Coverage

The new test a_message_gives_way_before_an_action_does covers the main issue, but it might be beneficial to add more edge case tests to ensure that the trimming logic works correctly for all possible combinations of window sizes and message lengths. This would help prevent regressions in the future.

#[test]
fn a_message_gives_way_before_an_action_does() {
    // Issue #1015, reproduced on the shipped binary first: a 40-column window whose pane is 12 rows is exactly
    // the nine actions plus the two rows the legend wraps to. One keypress added the message's own two rows,
    // the frame was over-subscribed, and ratatui clipped the END of the MENU - `9 Reboot / power off`, a
    // `vm-check.sh` / `menugate` string - for the message's 12 s, with the footer still promising
    // `1-9 actions`. The legend is words, the actions are rows: the legend gives way, whole, for the message.
    const SAID: &str = "this console never exits: 7 opens a shell, 9 reboots";
    let mut a = app(c16_board());
    a.say(SAID);
    for (w, h) in [
        (40u16, 12u16), // the reproduced case: the message fits only once the legend is gone
        (40, 13),
        (56, 12),
        (80, 10), // nine actions leave one row, and that row is the message
        (80, 11),
        (100, 13),
        (30, 16),
        (80, 25),
        (240, 67),
    ] {
        let text = render_snapshot(&a, w, h);
        let lines: Vec<&str> = text.lines().collect();
        assert_eq!(
            lines.len(),
            h as usize,
            "{w}x{h}: frame is not the terminal:\n{text}"
        );
        for n in 1..=9 {
            assert!(
                text.contains(&format!("{n} ")),
                "{w}x{h}: action {n} is off the screen for the message:\n{text}"
            );
        }
        // the message is never CUT: it is drawn whole, or not at all
        let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
        assert!(
            flat.contains(SAID),
            "{w}x{h}: the message was cut instead of the legend:\n{text}"
        );
    }
    // where the legend still has the rows it is untouched: the fix is not a permanent diet (80x25, 240x67 are
    // the sizes the gates read)
    for (w, h) in [(80u16, 25u16), (240, 67), (100, 13)] {
        let text = render_snapshot(&a, w, h);
        assert!(
            text.contains("1-9 actions") && text.contains("q stays"),
            "{w}x{h}: the whole legend fits here and was trimmed:\n{text}"
        );
    }
    // ...and where not even the message fits beside the nine actions (40x11: ten menu rows, one footer row,
    // the message wraps to two), the message is the thing that goes - the actions and the gate string stay
    let text = render_snapshot(&a, 40, 11);
    let lines: Vec<&str> = text.lines().collect();
    assert_eq!(lines.len(), 11, "40x11: frame is not the terminal");
    for n in 1..=9 {
        assert!(
            text.contains(&format!("{n} ")),
            "40x11: action {n} is off the screen:\n{text}"
        );
    }
    assert!(
        text.contains("1-9 actions"),
        "40x11: no gate string:\n{text}"
    );
    assert!(
        !text.contains("this console never exits"),
        "40x11: the message is on a frame with no row for it:\n{text}"
    );
}

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

State: build pass on f245bb9e, Analyze ×5 + CodeQL pass, hw_gate skipped (TUI-only). mergeStateStatus is BEHIND - main moved to bdcd71e6 after the push, and git diff says main has not touched os/v3/puppy/console-tui/ since this branch's base, so Update branch is conflict-free and a no-op for the reviewed diff. I am not rebasing again tonight (each rebase restarts a ~5-minute build and the fleet is merging every few minutes); ask and I will, or just press Update branch at merge time.

pr_agent red is the workflow-concurrency cancel that arrives with the push, not a review (not a required context either).

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

VERDICT: READY at f245bb9e

$ cd os/v3/puppy/console-tui && cargo test --offline
test result: ok. 47 passed; 0 failed; 0 ignored

47 is 46 + the one this PR adds, and that test is the review: a_message_gives_way_before_an_action_does asserts, at 40x12 / 40x13 / 56x12 / 80x10 / 80x11 / 100x13 / 30x16 / 80x25 / 240x67, that the frame is exactly the terminal, that all nine actions are still on screen, and that the message is drawn whole or not at all; then that the legend is untouched where it fits (80x25, 240x67 — the sizes the gates read); then the case where not even the message fits (40x11): the message is what goes, and 1-9 actions stays.

The ordering in draw() matches that intent: keys_segments() gives words, menu_rows() gives rows, budget = h - actions.len(), and the loop drops the legend's tail, then the legend, then the message — never an action. Charging the QR and the rule against the same footer.len() keeps the frame-is-the-terminal invariant that the neighbouring tests already pinned.

Nit: keys_segments() returns 'static segments while the message is cloned per draw; fine, but the else arm of the trimming loop resets segments = full.clone() after taking the message, which reads as "give up the legend to keep the message" — a one-line comment there would save the next reader the trace I just did.

@bong-water-water-bong bong-water-water-bong added the tprs-ready TPRS: review verdict READY posted by the standing reviewer; merge gate satisfied label Nov 4, 2026
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Updated to main (8c1eaea3) so this is no longer BEHIND: head 39da6108, git diff f245bb9e HEAD -- os/v3/puppy/console-tui is empty (the update carried another lane's 7 files), cargo test 47/47 on the merged tree. build re-running; if main moves again before the merge, Update branch is still conflict-free - main has not touched the console-tui tree since this branch's base.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

VERDICT: READY at 39da6108

Rebase-only, verified twice over:

own contribution (+/- lines vs merge base), f245bb9e1 vs 39da6108:
  +149/-20, sha256 of the diff lines = a3aa9b718158  (both heads)

$ cd os/v3/puppy/console-tui && cargo test --offline      (at 39da6108, on main 8c1eaea3)
test result: ok. 47 passed; 0 failed; 0 ignored

39da6108 is a merge of main rather than a rebase (Merge branch 'main' into os/v3-tui-msg), which is why git diff against the merge base is byte-identical to the head I reviewed. I re-ran the suite at the new head instead of assuming the merge was harmless, because ui.rs is the one file both this and #1028 rewrite; 47/47 there is also 46 + a_message_gives_way_before_an_action_does, the test this PR adds.

Review stands as posted. Nothing new to flag.

bong-water-water-bong pushed a commit that referenced this pull request Nov 4, 2026
…e order is conflict-free

No behaviour change, no line of logic moved: the modal test now sits above
`the_menu_rule_gives_way_before_an_action_does` (which #1026 appends after) and the README paragraph now sits above the
MENU-rule paragraph, so the two PRs no longer share merge context. Measured: before this, merging #1026 into this
branch conflicted on both os/v3/puppy/console-tui/{README.md,src/ui.rs}; after it, both orders merge cleanly and
`cargo test` is 48/48 on the combined tree.
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit b91fd26

@bong-water-water-bong
bong-water-water-bong merged commit 9315118 into main Nov 4, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the os/v3-tui-msg branch November 4, 2026 21:53
bong-water-water-bong pushed a commit that referenced this pull request Nov 4, 2026
#1026 merged as 9315118 and this branch is rebased onto it, so the count line is 46 + one test each = 48. It is the
one bookkeeping nit the #1028 review comment raised.
bong-water-water-bong added a commit that referenced this pull request Nov 4, 2026
…1028)

* os/v3/puppy: a console-TUI dialog loses its question before its hint

`draw()` sizes the modal box to its body and clamps it to the frame (`bh = (body.len() + 2).min(area.height)`), then
draws the body with `Paragraph`, so a body that does not fit is clipped at its END - and the last line of every dialog
is the hint, the only row that says how to answer.

Measured on the shipped static binary before this commit (main `434db3d2`, `0edbe573…`), real ptys, `TERM=linux`, key
**1** on a blank-key box:

  80x25, 60x12   `y mints one and restarts Lemonade, n cancels`  intact
  40x12          `y mints one and restarts`                      half
  40x11, 40x10, 30x12, 24x12                                     absent

`Modal::Confirm { yes: Yes::Reboot }` carries `y reboots the box, n cancels` and `Modal::Power` carries
`r reboots, p powers off, any other key cancels`, so in a 40x11 ssh window a screen that is about to reboot the box
explains neither key that reboots it - while `y` still does what the question says. No gate impact (the gates run at
80x25 and 240x67, where the whole box fits).

The fix is the same rule the rest of this file follows - the block's most load-bearing row is the last one to go:
the dialog is now `title | question | hint` as three sections, and a frame too short for all of it loses the question
rows first (whole rows at a time), then the title, and never the hint.

  40x12 after key 1: hint whole (`y mints one and restarts` / `Lemonade, n cancels`), one question row given up
  40x11            : hint whole, two question rows given up
  24x12            : hint whole and wrapped (`y mints one and` / `restarts Lemonade, n` / `cancels`)
  80x25, 240x67    : unchanged - the whole box, byte-identical frames

`the_modal_hint_is_never_the_row_that_goes` renders four dialogs (reboot confirm, blank-key confirm, power, the API
key info box) at 24x12, 30x12, 40x10, 40x11, 40x12, 60x12, 80x25 and 240x67, and requires the frame to be the terminal
and the hint to be on screen, whole, at every one of them. Putting the old body construction back turns it red at
24x12; the fix turns it green.

Measured on the static binary `42e11547b870428c` (1,556,736 B, no `PT_INTERP`): `cargo test` 47/47, clippy
`--all-targets -D warnings` clean, `cargo fmt --check` clean, `tools/copyright.py --check` rc 0,
`--snapshot 80x25 --fixture` matches the committed frame. 2 files +~85/-17, no new files, no gate file touched, no
image build, no board run, no device ticket.

Closes #1027

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* placement only: keep this PR's hunks clear of #1026's, so either merge order is conflict-free

No behaviour change, no line of logic moved: the modal test now sits above
`the_menu_rule_gives_way_before_an_action_does` (which #1026 appends after) and the README paragraph now sits above the
MENU-rule paragraph, so the two PRs no longer share merge context. Measured: before this, merging #1026 into this
branch conflicted on both os/v3/puppy/console-tui/{README.md,src/ui.rs}; after it, both orders merge cleanly and
`cargo test` is 48/48 on the combined tree.

* README: the suite is 48 tests once #1026 and this PR are both in

#1026 merged as 9315118 and this branch is rebased onto it, so the count line is 46 + one test each = 48. It is the
one bookkeeping nit the #1028 review comment raised.

---------

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Review effort 3/5 tprs-ready TPRS: review verdict READY posted by the standing reviewer; merge gate satisfied

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os/v3/puppy: a console-TUI message takes 9 Reboot / power off off the screen for 12 s in a 13-row window

1 participant