Merge pull request #233 from fiddlerwoaroof/fix/cq3d-status-expiry

A TUI status message lasts until the next key, then the hint returns (cq3d)
This commit is contained in:
Ed Langley
2026-09-23 10:48:51 -07:00
committed by GitHub
3 changed files with 118 additions and 2 deletions
+45
View File
@@ -379,3 +379,48 @@ again.
hint is width-constrained and the history lives on help page 5 under "View
history" with both halves. That is the #149 lesson — a hint that does not fit is
never drawn, so the test pinning the widget cannot see it.
## A status message lasts until the next key (improvise-cq3d)
`draw_status` draws `status_msg` *instead of* the mode's derived hint, and
nothing in production ever cleared `status_msg`: it was only replaced by the next
message. So after the first "Saved", "Loaded" or error of a session, the derived
key hint (the one key table on screen, improvise-rr8n) was never drawn again, in
any mode.
**The rule: a message expires at the start of the next keypress.** `draw.rs::press`
applies `SetStatus("")` and then dispatches the key, so:
- a message a key produces is drawn after that key, whatever it is, errors
included. It stays until the user presses something, which is the moment they
have acted on it. An error is never wiped before it has been drawn.
- a message that arrives outside a key, such as one from startup or an import,
also stands until the next key.
- the next key clears it *before* its own effects run. So a key that writes a
new message shows that message, and a key that writes none shows the hint.
**Rejected alternatives.**
- *Expire on mode change.* This fails the ticket's own case: a message written in
Normal would stand in Normal indefinitely.
- *Expire on the next successful command.* Effects carry no notion of success,
and a failed command would leave the stale message in place. That is exactly
the case where the user most needs the hint back.
- *Clear after dispatch.* Every message would be wiped before it is drawn. The
test's middle step exists to catch that.
- *Compose message and hint on one line.* A long message still eats the hint, and
the stale message still stands forever.
**Why here and not in `App::dispatch_key`.** `dispatch_key` is shared with the
lockstep harness (`reduce_key`), which compares `status_msg` on the host and the
browser after every key. Expiring there without changing the browser's resolver
too would make the two ends disagree. The expiry is also a presentation decision
about the TUI's one-line status row, so it belongs to the TUI front end.
**The browser already behaves this way.** Its `status_msg` is state that is never
cleared either, but `web/app.js` shows it in a toast that hides after
`TOAST_MS` (`web/shell.js::createToasts`), and `#hint` is a separate element that
is always drawn. So a browser user sees a message briefly and never loses the
hint. The TUI now does the same, on a keypress rather than a timer, because a
terminal status row has one line to share and a keypress is the event the TUI
already has.
+23
View File
@@ -923,3 +923,26 @@ exactly, and first asserts that hint is non-empty and pages.
the protocol keymap). The test then fails: the copy still advertises `n`,
which the keymap no longer binds. That rebinding case is exactly the one the
ticket said the old footer would lie through.
### a_status_message_gives_way_to_the_hint_on_the_next_key
**What broke (improvise-cq3d).** Nothing in production ever cleared
`status_msg`, and `draw_status` prints it instead of the derived hint. After the
first message of a session, the TUI never drew the key hint again, in any mode.
**The case, in three steps, each guarding one direction.**
1. A standing "Saved to x.improv", then `T`: in TileSelect, a mode other than
the one the message was written in, the row must hold TileSelect's hint and
not the stale message. This is the ticket's assertion.
2. `Space` cycles an axis and writes a message: that message must be on the row
after its own key. This guards against expiring too eagerly.
3. `Esc` back to Normal: the axis message is gone and Normal's hint is drawn.
It drives keys through `draw.rs::press`, the function `run_tui` calls, rather
than `App::handle_key`, because the expiry is the front end's rule.
**Red, and the rival.**
- With `press` delegating straight to `handle_key` (the old behaviour), it fails
at step 1: `" TILES Saved to x.improv …"`.
- With the expiry moved *after* dispatch, it fails at step 2: "the axis cycle
must say what it did", since every message is wiped before it can be drawn.
+50 -2
View File
@@ -79,7 +79,7 @@ pub fn run_tui(
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
app.handle_key(convert_key_event(key))?;
press(&mut app, convert_key_event(key))?;
}
Event::Resize(w, h) => {
app.term_width = w;
@@ -99,6 +99,13 @@ pub fn run_tui(
Ok(())
}
/// One keypress in the TUI. A message lasts until the next key, so it is drawn
/// once and then gives way to the hint (improvise-cq3d, `context/code/tui.md`).
fn press(app: &mut App, key: key::KeyEvent) -> Result<()> {
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]);
app.handle_key(key)
}
// ── Key event conversion (TUI boundary) ──────────────────────────────────────
// The only place crossterm's key types and `command::key` may both appear.
@@ -566,7 +573,8 @@ fn draw_welcome(f: &mut Frame, area: Rect) {
#[cfg(test)]
mod bottom_bar_tests {
use super::{draw_bottom_bar, minibuffer_line};
use super::{draw_bottom_bar, minibuffer_line, press};
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
use crate::ui::app::{App, AppMode};
use crate::workbook::Workbook;
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
@@ -587,6 +595,46 @@ mod bottom_bar_tests {
.collect()
}
/// A message lasts until the next key, then the derived hint is back
/// (improvise-cq3d). Why each step: `context/test-evidence/tui.md`.
#[test]
fn a_status_message_gives_way_to_the_hint_on_the_next_key() {
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
let mut app = App::new(Workbook::new("T"), None);
app.view_state.status_msg = "Saved to x.improv".to_string();
press(&mut app, key(KeyCode::Char('T'))).unwrap();
assert!(matches!(app.view_state.mode, AppMode::TileSelect));
let row = bottom_row(&app, 240);
assert!(
!row.contains("Saved to"),
"a stale message outlived a key: {row:?}"
);
assert!(
row.contains(&app.hint_text()),
"no hint in TileSelect: {row:?}"
);
press(&mut app, key(KeyCode::Char(' '))).unwrap();
let said = app.view_state.status_msg.clone();
assert!(!said.is_empty(), "the axis cycle must say what it did");
assert!(
bottom_row(&app, 240).contains(&said),
"a message was wiped before it was drawn"
);
press(&mut app, key(KeyCode::Esc)).unwrap();
let row = bottom_row(&app, 240);
assert!(
!row.contains(&said),
"the axis message outlived the next key: {row:?}"
);
assert!(
row.contains(&app.hint_text()),
"no hint back in Normal: {row:?}"
);
}
/// The wizard overlay draws correct per-step hints; the status bar stacked a
/// second static table under it (improvise-rr8n) — `context/test-evidence/tui.md`.
#[test]