app: mode-threaded completion, overlay, and validity indicator

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<Tab>` 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.
This commit is contained in:
claude@clouddev1
2026-05-19 21:48:21 +00:00
parent 6369066fe4
commit 83e0ddc2ff
4 changed files with 157 additions and 35 deletions
+72 -18
View File
@@ -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<outcome::Expectation> {
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<outcome::Severity> {
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<outcome::Severity> {
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<outcome::Expectation> {
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<outcome::Expectation> {
use crate::dsl::grammar::{REGISTRY, is_advanced_only};
let mode_filtered = || -> Vec<outcome::Expectation> {
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