From 83e0ddc2ff3e94476cbe0ba991118d9b034004b6 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 19 May 2026 21:48:21 +0000 Subject: [PATCH] app: mode-threaded completion, overlay, and validity indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatch-layer mode gate (previous commit) made the submit behaviour correct — `select` runs in advanced mode and shows the SQL hint in simple mode. This commit extends that gating to the ambient assistance layer so simple-mode users do not see SQL leak through Tab completion, the live error overlay, or the `[ERR]`/`[WRN]` validity indicator either. `_in_mode` walker variants -------------------------- - `completion_probe_in_mode`, `expected_at_input_in_mode`, `input_verdict_in_mode`. Each sets `ctx.mode` before walking. The empty-input / unknown-entry fallback in `completion_probe` and `expected_at_input` filters the `REGISTRY` listing by `is_advanced_only` so Tab does not offer `select` in simple mode. Old signatures keep delegating to `Mode::Advanced` (back-compat for tests + other callers). `_in_mode` completion variants ------------------------------ - `candidates_at_cursor_in_mode`, `candidates_at_cursor_with_in_mode`. Internally they route the `parse_command` completeness probe through `parse_command_in_mode(input, mode)`, the `completion_probe` call through `completion_probe_in_mode`, and the `expected_at` fallback through `expected_at_input_in_mode`. Old signatures default to `Mode::Advanced`. `EffectiveMode::as_mode` ------------------------ - Collapses the persistent / one-shot distinction the UI cares about into the plain `Mode` the walker reads from `WalkContext::mode`. App-level call sites that thread mode into the walker chain use this. App / input-render wiring ------------------------- - `App::input_validity_verdict` runs only when effective mode is plain `Simple` (per ADR-0027), so it hardcodes `Mode::Simple` into the new `input_verdict_in_mode` call rather than threading. - `App::start_or_complete_at` / `_last` (the Tab handlers) pass `self.effective_mode().as_mode()` into `candidates_at_cursor_in_mode`, so a `:` one-shot or persistent advanced gives full SQL completion, persistent simple does not offer SQL. - `input_render::render_input_runs` and `ambient_hint` are invoked from `ui.rs` only when effective mode is plain `Simple` (advanced rendering uses `plain_input_spans` and skips ambient hinting per ADR-0022 §12). Their internal `classify_input_with_schema` / `candidates_at_cursor` / `parse_command` calls now go through the mode-aware variants with `Mode::Simple` hardcoded — a SQL form in simple mode surfaces as a definite-error overlay and the hint panel does not offer it. After this commit a simple-mode user typing `select` or `sel` sees nothing SQL-shaped: no live highlight, no Tab completion candidate, the `[ERR]` indicator lit, and the on- submit hint that names the recovery paths. An advanced-mode user or a `:` one-shot sees the full SQL surface. --- src/app.rs | 28 ++++++++++++-- src/completion.rs | 49 ++++++++++++++++++----- src/dsl/walker/mod.rs | 90 ++++++++++++++++++++++++++++++++++--------- src/input_render.rs | 25 ++++++++++-- 4 files changed, 157 insertions(+), 35 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1a577f8..3252808 100644 --- a/src/app.rs +++ b/src/app.rs @@ -110,6 +110,18 @@ impl EffectiveMode { pub const fn is_advanced(self) -> bool { matches!(self, Self::AdvancedPersistent | Self::AdvancedOneShot) } + + /// Collapse the persistent/one-shot distinction the UI cares + /// about into the plain [`Mode`] the walker reads from + /// `WalkContext::mode` (ADR-0030 §2). Both advanced variants + /// map to `Mode::Advanced`. + #[must_use] + pub const fn as_mode(self) -> Mode { + match self { + Self::Simple => Mode::Simple, + Self::AdvancedPersistent | Self::AdvancedOneShot => Mode::Advanced, + } + } } #[derive(Debug)] @@ -378,7 +390,15 @@ impl App { if !matches!(self.effective_mode(), EffectiveMode::Simple) { return None; } - crate::dsl::walker::input_verdict(&self.input, Some(&self.schema_cache)) + // ADR-0030 §2: the indicator is shown only in plain + // simple mode (the guard above), so the verdict reads + // the simple-mode walker view — a SQL form lights up + // ERROR via the walker's mode gate. + crate::dsl::walker::input_verdict_in_mode( + &self.input, + Some(&self.schema_cache), + Mode::Simple, + ) } /// Process one event from the runtime, mutating state and @@ -782,10 +802,11 @@ impl App { fn start_or_complete_at(&mut self, multi_start_idx: usize) { let cursor = self.input_cursor.min(self.input.len()); - let Some(comp) = crate::completion::candidates_at_cursor( + let Some(comp) = crate::completion::candidates_at_cursor_in_mode( &self.input, cursor, &self.schema_cache, + self.effective_mode().as_mode(), ) else { return; }; @@ -799,10 +820,11 @@ impl App { fn start_or_complete_last(&mut self) { let cursor = self.input_cursor.min(self.input.len()); - let Some(comp) = crate::completion::candidates_at_cursor( + let Some(comp) = crate::completion::candidates_at_cursor_in_mode( &self.input, cursor, &self.schema_cache, + self.effective_mode().as_mode(), ) else { return; }; diff --git a/src/completion.rs b/src/completion.rs index 99f150b..112337f 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -18,6 +18,8 @@ use crate::dsl::grammar::IdentSource; use crate::dsl::types::Type; use crate::dsl::walker::outcome::Expectation; use crate::dsl::{ParseError, parse_command}; +use crate::dsl::parser::parse_command_in_mode; +use crate::mode::Mode; /// Composite literal candidates whose lexed shape is more than /// one token but which the user types as a single fluent piece. @@ -97,8 +99,8 @@ impl SchemaCache { /// expressed as structured `Expectation`s direct from the /// walker (ADR-0024 §architecture, Phase F walker-driven /// completion). Replaces the `ParseError`-string round-trip. -fn expected_at(leading: &str) -> Vec { - crate::dsl::walker::expected_at_input(leading) +fn expected_at(leading: &str, mode: Mode) -> Vec { + crate::dsl::walker::expected_at_input_in_mode(leading, mode) } /// A single Tab-insertable item with its source (so the @@ -184,7 +186,20 @@ pub fn candidates_at_cursor( cursor: usize, cache: &SchemaCache, ) -> Option { - candidates_at_cursor_with(input, cursor, cache, identity_ranker) + candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced) +} + +/// Mode-aware [`candidates_at_cursor`] (ADR-0030 §2). Tab in +/// simple mode no longer offers advanced-mode-only commands; +/// the walker's mode gate flows through the probe inside. +#[must_use] +pub fn candidates_at_cursor_in_mode( + input: &str, + cursor: usize, + cache: &SchemaCache, + mode: Mode, +) -> Option { + candidates_at_cursor_with_in_mode(input, cursor, cache, identity_ranker, mode) } /// Variant of [`candidates_at_cursor`] that applies a custom @@ -197,6 +212,18 @@ pub fn candidates_at_cursor_with( cursor: usize, cache: &SchemaCache, ranker: Ranker, +) -> Option { + candidates_at_cursor_with_in_mode(input, cursor, cache, ranker, Mode::Advanced) +} + +/// Mode-aware [`candidates_at_cursor_with`]. +#[must_use] +pub fn candidates_at_cursor_with_in_mode( + input: &str, + cursor: usize, + cache: &SchemaCache, + ranker: Ranker, + mode: Mode, ) -> Option { let cursor = cursor.min(input.len()); @@ -225,20 +252,22 @@ pub fn candidates_at_cursor_with( // `pk` at the end of `create table T with pk`. The // optional-suffix case (`save ` → `as`) is preserved // because there `partial_prefix` is empty. - let input_parses_complete = parse_command(input).is_ok(); + let input_parses_complete = parse_command_in_mode(input, mode).is_ok(); // Schema-aware probe: one walk yields both the expected set // and the table-context snapshot (ADR-0024 §Phase D // §column-narrowing). The engine reads // `current_table_columns` to narrow column candidates to the // active table rather than the flat `cache.columns` (which - // unions every table's columns). - let probe = crate::dsl::walker::completion_probe(leading, cache); + // unions every table's columns). ADR-0030 §2: the probe runs + // with the active mode so simple-mode users don't see SQL + // commands offered. + let probe = crate::dsl::walker::completion_probe_in_mode(leading, cache, mode); let current_table_columns: Option<&[TableColumn]> = probe.current_table_columns.as_deref(); let expected = if probe.expected.is_empty() { - expected_at(leading) + expected_at(leading, mode) } else { probe.expected.clone() }; @@ -548,7 +577,7 @@ pub fn value_literal_hint_at_cursor(input: &str, cursor: usize) -> Option Option { } } let leading = &input[..start]; - let expected = expected_at(leading); + let expected = expected_at(leading, Mode::Advanced); let is_new_name_slot = expected.iter().any(|e| { matches!( e, @@ -697,7 +726,7 @@ pub fn invalid_ident_at_cursor( return None; } let leading = &input[..start]; - let expected = expected_at(leading); + let expected = expected_at(leading, Mode::Advanced); if expected.is_empty() { return None; } diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 490975a..e0108e9 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -250,26 +250,46 @@ pub fn completion_probe( source: &str, schema: &crate::completion::SchemaCache, ) -> CompletionProbe { - use crate::dsl::grammar::REGISTRY; + completion_probe_in_mode(source, schema, crate::mode::Mode::Advanced) +} + +/// Mode-aware [`completion_probe`] (ADR-0030 §2). +/// +/// In `Mode::Simple` the empty-input / fall-through fallback +/// omits advanced-only entry words so Tab does not offer SQL +/// commands in simple mode, and the walker — running with +/// `ctx.mode = mode` — gates SQL-only forms inline. +pub fn completion_probe_in_mode( + source: &str, + schema: &crate::completion::SchemaCache, + mode: crate::mode::Mode, +) -> CompletionProbe { + use crate::dsl::grammar::{REGISTRY, is_advanced_only}; + + let mode_filtered_entries = || -> Vec { + REGISTRY + .iter() + .filter(|c| { + mode == crate::mode::Mode::Advanced + || !is_advanced_only(c.entry.primary) + }) + .map(|c| outcome::Expectation::Word(c.entry.primary)) + .collect() + }; if source.trim().is_empty() { return CompletionProbe { - expected: REGISTRY - .iter() - .map(|c| outcome::Expectation::Word(c.entry.primary)) - .collect(), + expected: mode_filtered_entries(), current_table_columns: None, pending_hint_mode: None, }; } let mut ctx = context::WalkContext::with_schema(schema); + ctx.mode = mode; let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let Some(result) = result else { return CompletionProbe { - expected: REGISTRY - .iter() - .map(|c| outcome::Expectation::Word(c.entry.primary)) - .collect(), + expected: mode_filtered_entries(), current_table_columns: None, pending_hint_mode: None, }; @@ -320,6 +340,22 @@ pub fn completion_probe( pub fn input_verdict( source: &str, schema: Option<&crate::completion::SchemaCache>, +) -> Option { + input_verdict_in_mode(source, schema, crate::mode::Mode::Advanced) +} + +/// Mode-aware [`input_verdict`] (ADR-0030 §2). +/// +/// The `[ERR]` / `[WRN]` indicator reads this through +/// `App::input_verdict_for_indicator` passing the line's +/// effective mode, so a simple-mode `select` lights up ERROR +/// (the SQL-hint validation failure) and an advanced-mode +/// `select` does not. +#[must_use] +pub fn input_verdict_in_mode( + source: &str, + schema: Option<&crate::completion::SchemaCache>, + mode: crate::mode::Mode, ) -> Option { use outcome::Severity; if source.trim().is_empty() { @@ -329,6 +365,7 @@ pub fn input_verdict( context::WalkContext::new, context::WalkContext::with_schema, ); + ctx.mode = mode; let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let Some(result) = result else { // The first token is not a registered command word — @@ -685,24 +722,41 @@ fn pair_type_mismatch( /// produces. #[must_use] pub fn expected_at_input(source: &str) -> Vec { - use crate::dsl::grammar::REGISTRY; + expected_at_input_in_mode(source, crate::mode::Mode::Advanced) +} + +/// Mode-aware [`expected_at_input`] (ADR-0030 §2). Filters the +/// empty / unknown-entry fallback by mode so simple mode does +/// not surface advanced-only entry words. +#[must_use] +pub fn expected_at_input_in_mode( + source: &str, + mode: crate::mode::Mode, +) -> Vec { + use crate::dsl::grammar::{REGISTRY, is_advanced_only}; + + let mode_filtered = || -> Vec { + REGISTRY + .iter() + .filter(|c| { + mode == crate::mode::Mode::Advanced + || !is_advanced_only(c.entry.primary) + }) + .map(|c| outcome::Expectation::Word(c.entry.primary)) + .collect() + }; if source.trim().is_empty() { - return REGISTRY - .iter() - .map(|c| outcome::Expectation::Word(c.entry.primary)) - .collect(); + return mode_filtered(); } let mut ctx = context::WalkContext::new(); + ctx.mode = mode; let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let Some(result) = result else { // Walker didn't engage (unknown entry word): the // completion engine should still surface the available // entry words so the user can recover. - return REGISTRY - .iter() - .map(|c| outcome::Expectation::Word(c.entry.primary)) - .collect(); + return mode_filtered(); }; match result.outcome { // On Match, surface the outer-shape's skipped-Optional diff --git a/src/input_render.rs b/src/input_render.rs index b7f8bb0..f303895 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -24,7 +24,8 @@ use ratatui::style::{Color, Modifier, Style}; -use crate::dsl::parser::parse_command_with_schema; +use crate::dsl::parser::{parse_command_in_mode, parse_command_with_schema, parse_command_with_schema_in_mode}; +use crate::mode::Mode; use crate::dsl::walker; use crate::dsl::{ParseError, parse_command}; use crate::theme::Theme; @@ -67,7 +68,15 @@ pub fn render_input_runs( cache: &crate::completion::SchemaCache, ) -> Vec { let mut runs = lex_to_runs(input, theme); - if let InputState::DefiniteErrorAt(pos) = classify_input_with_schema(input, cache) { + // `render_input_runs` is invoked from `ui.rs` only when the + // effective mode is plain `Simple` (advanced rendering uses + // `plain_input_spans`), so the classification must use the + // simple-mode walker view (ADR-0030 §2): a SQL form here + // surfaces as a definite error overlay, consistent with the + // dispatch path's "this is SQL" hint on submit. + if let InputState::DefiniteErrorAt(pos) = + classify_parse_result(parse_command_with_schema_in_mode(input, cache, Mode::Simple)) + { overlay_error(&mut runs, pos, theme); } if let Some(inv) = crate::completion::invalid_ident_at_cursor(input, cursor_byte, cache) { @@ -320,7 +329,13 @@ pub fn ambient_hint( // Candidates win when any exist — the panel surfaces them // directly because they're more actionable than prose // framings. - if let Some(comp) = crate::completion::candidates_at_cursor(input, cursor, cache) { + // `ambient_hint` is only called for `EffectiveMode::Simple` + // (ui.rs gates it), so completion runs through the + // simple-mode walker view — `select` does not surface here + // (ADR-0030 §2). + if let Some(comp) = + crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, Mode::Simple) + { return Some(AmbientHint::Candidates { items: comp.candidates, selected: None, @@ -348,7 +363,9 @@ pub fn ambient_hint( ))); } // Otherwise fall back to the prose framings from stage 5. - match parse_command(input) { + // ADR-0030 §2: simple-mode hint uses the simple-mode walker + // view so a SQL form surfaces with the "this is SQL" hint. + match parse_command_in_mode(input, Mode::Simple) { Ok(_) => Some(AmbientHint::Prose(crate::t!("hint.ambient_complete"))), Err(ParseError::Empty) => None, Err(ParseError::Invalid {