runtime: debounce the validity indicator (ADR-0027 step E)

The event loop now time-boxes `recv` while an indicator
recompute is owed: every keystroke hides the indicator and
arms an `INDICATOR_DEBOUNCE` (1s) window; once typing pauses
that long the runtime computes `App::input_validity_verdict`
and shows `[ERR]` / `[WRN]`. An idle session (nothing owed)
still blocks plainly on `recv` — no wake-ups.

`update()` stays pure — the debounce timer lives in the
runtime; `App` only holds the resulting `input_indicator`
state, which the runtime clears on a keystroke and sets when
the quiet interval elapses.

`App::input_validity_verdict` is tested directly (a
simple-mode verdict, and silence in advanced mode / the `:`
one-shot); the debounce timing itself is runtime-loop glue,
covered at the integration level.
This commit is contained in:
claude@clouddev1
2026-05-19 07:30:47 +00:00
parent 1a9d950cc2
commit 9e10997ffd
2 changed files with 77 additions and 1 deletions
+37
View File
@@ -3042,4 +3042,41 @@ mod tests {
},
);
}
// ---- Validity-indicator verdict (ADR-0027) ----------------
#[test]
fn input_validity_verdict_flags_a_broken_simple_command() {
let mut app = App::new();
app.input = "create table".to_string();
assert_eq!(
app.input_validity_verdict(),
Some(crate::dsl::walker::Severity::Error),
);
}
#[test]
fn input_validity_verdict_is_none_for_clean_input() {
let mut app = App::new();
app.input = "quit".to_string();
assert_eq!(app.input_validity_verdict(), None);
}
#[test]
fn input_validity_verdict_silent_in_advanced_mode() {
// Advanced mode is raw SQL — the DSL walker must not
// pass a verdict on it (ADR-0027 §7).
let mut app = App::new();
app.mode = Mode::Advanced;
app.input = "create table".to_string();
assert_eq!(app.input_validity_verdict(), None);
}
#[test]
fn input_validity_verdict_silent_for_colon_one_shot() {
// A `:`-prefixed line is a one-shot advanced escape.
let mut app = App::new();
app.input = ":create table".to_string();
assert_eq!(app.input_validity_verdict(), None);
}
}
+40 -1
View File
@@ -44,6 +44,10 @@ use crate::ui;
const EVENT_CHANNEL_CAPACITY: usize = 64;
const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
/// Quiet interval before the input-validity indicator
/// reappears once typing stops (ADR-0027 §3).
const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000);
/// Run the application until a `Quit` action is enacted or the
/// terminal closes.
pub async fn run(args: Args) -> Result<()> {
@@ -274,7 +278,35 @@ async fn run_loop(
.context("initial draw")?;
info!("entering main event loop");
while let Some(event) = event_rx.recv().await {
// ADR-0027 §3: the validity indicator is debounced — hidden
// on every keystroke, recomputed and shown once typing has
// paused for `INDICATOR_DEBOUNCE`. `indicator_pending` is
// true while a recompute is owed; only then is `recv`
// time-boxed, so an idle session still does no wake-ups.
let mut indicator_pending = false;
loop {
let event = if indicator_pending {
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await {
Ok(Some(event)) => event,
Ok(None) => break,
Err(_elapsed) => {
// Typing has been quiet for the debounce
// interval — settle the indicator.
app.input_indicator = app.input_validity_verdict();
indicator_pending = false;
terminal
.draw(|f| ui::render(&mut app, &theme, f))
.context("redraw")?;
continue;
}
}
} else {
match event_rx.recv().await {
Some(event) => event,
None => break,
}
};
let is_key = matches!(event, AppEvent::Key(_));
let actions = app.update(event);
let mut should_quit = false;
for action in actions {
@@ -381,6 +413,13 @@ async fn run_loop(
}
}
}
if is_key {
// A keystroke hides the indicator and re-arms the
// debounce (ADR-0027 §3) — it reappears once typing
// pauses.
app.input_indicator = None;
indicator_pending = true;
}
terminal
.draw(|f| ui::render(&mut app, &theme, f))
.context("redraw")?;