From 9e10997ffdb6579f2958117246ecabde1ac65905 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 19 May 2026 07:30:47 +0000 Subject: [PATCH] runtime: debounce the validity indicator (ADR-0027 step E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app.rs | 37 +++++++++++++++++++++++++++++++++++++ src/runtime.rs | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 7044f90..0deae18 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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); + } } diff --git a/src/runtime.rs b/src/runtime.rs index d8a3dfe..337ee94 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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")?;