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
+25 -3
View File
@@ -110,6 +110,18 @@ impl EffectiveMode {
pub const fn is_advanced(self) -> bool { pub const fn is_advanced(self) -> bool {
matches!(self, Self::AdvancedPersistent | Self::AdvancedOneShot) 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)] #[derive(Debug)]
@@ -378,7 +390,15 @@ impl App {
if !matches!(self.effective_mode(), EffectiveMode::Simple) { if !matches!(self.effective_mode(), EffectiveMode::Simple) {
return None; 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 /// 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) { fn start_or_complete_at(&mut self, multi_start_idx: usize) {
let cursor = self.input_cursor.min(self.input.len()); 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, &self.input,
cursor, cursor,
&self.schema_cache, &self.schema_cache,
self.effective_mode().as_mode(),
) else { ) else {
return; return;
}; };
@@ -799,10 +820,11 @@ impl App {
fn start_or_complete_last(&mut self) { fn start_or_complete_last(&mut self) {
let cursor = self.input_cursor.min(self.input.len()); 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, &self.input,
cursor, cursor,
&self.schema_cache, &self.schema_cache,
self.effective_mode().as_mode(),
) else { ) else {
return; return;
}; };
+39 -10
View File
@@ -18,6 +18,8 @@ use crate::dsl::grammar::IdentSource;
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::dsl::walker::outcome::Expectation; use crate::dsl::walker::outcome::Expectation;
use crate::dsl::{ParseError, parse_command}; 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 /// Composite literal candidates whose lexed shape is more than
/// one token but which the user types as a single fluent piece. /// 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 /// expressed as structured `Expectation`s direct from the
/// walker (ADR-0024 §architecture, Phase F walker-driven /// walker (ADR-0024 §architecture, Phase F walker-driven
/// completion). Replaces the `ParseError`-string round-trip. /// completion). Replaces the `ParseError`-string round-trip.
fn expected_at(leading: &str) -> Vec<Expectation> { fn expected_at(leading: &str, mode: Mode) -> Vec<Expectation> {
crate::dsl::walker::expected_at_input(leading) crate::dsl::walker::expected_at_input_in_mode(leading, mode)
} }
/// A single Tab-insertable item with its source (so the /// A single Tab-insertable item with its source (so the
@@ -184,7 +186,20 @@ pub fn candidates_at_cursor(
cursor: usize, cursor: usize,
cache: &SchemaCache, cache: &SchemaCache,
) -> Option<Completion> { ) -> Option<Completion> {
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<Completion> {
candidates_at_cursor_with_in_mode(input, cursor, cache, identity_ranker, mode)
} }
/// Variant of [`candidates_at_cursor`] that applies a custom /// Variant of [`candidates_at_cursor`] that applies a custom
@@ -197,6 +212,18 @@ pub fn candidates_at_cursor_with(
cursor: usize, cursor: usize,
cache: &SchemaCache, cache: &SchemaCache,
ranker: Ranker, ranker: Ranker,
) -> Option<Completion> {
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<Completion> { ) -> Option<Completion> {
let cursor = cursor.min(input.len()); 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 // `pk` at the end of `create table T with pk`. The
// optional-suffix case (`save ` → `as`) is preserved // optional-suffix case (`save ` → `as`) is preserved
// because there `partial_prefix` is empty. // 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 // Schema-aware probe: one walk yields both the expected set
// and the table-context snapshot (ADR-0024 §Phase D // and the table-context snapshot (ADR-0024 §Phase D
// §column-narrowing). The engine reads // §column-narrowing). The engine reads
// `current_table_columns` to narrow column candidates to the // `current_table_columns` to narrow column candidates to the
// active table rather than the flat `cache.columns` (which // active table rather than the flat `cache.columns` (which
// unions every table's columns). // unions every table's columns). ADR-0030 §2: the probe runs
let probe = crate::dsl::walker::completion_probe(leading, cache); // 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]> = let current_table_columns: Option<&[TableColumn]> =
probe.current_table_columns.as_deref(); probe.current_table_columns.as_deref();
let expected = if probe.expected.is_empty() { let expected = if probe.expected.is_empty() {
expected_at(leading) expected_at(leading, mode)
} else { } else {
probe.expected.clone() probe.expected.clone()
}; };
@@ -548,7 +577,7 @@ pub fn value_literal_hint_at_cursor(input: &str, cursor: usize) -> Option<String
return None; return None;
} }
let leading = &input[..start]; let leading = &input[..start];
let expected = expected_at(leading); let expected = expected_at(leading, Mode::Advanced);
if !is_value_literal_signature(&expected) { if !is_value_literal_signature(&expected) {
return None; return None;
} }
@@ -608,7 +637,7 @@ pub fn typing_name_at_cursor(input: &str, cursor: usize) -> Option<TypingName> {
} }
} }
let leading = &input[..start]; 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| { let is_new_name_slot = expected.iter().any(|e| {
matches!( matches!(
e, e,
@@ -697,7 +726,7 @@ pub fn invalid_ident_at_cursor(
return None; return None;
} }
let leading = &input[..start]; let leading = &input[..start];
let expected = expected_at(leading); let expected = expected_at(leading, Mode::Advanced);
if expected.is_empty() { if expected.is_empty() {
return None; return None;
} }
+72 -18
View File
@@ -250,26 +250,46 @@ pub fn completion_probe(
source: &str, source: &str,
schema: &crate::completion::SchemaCache, schema: &crate::completion::SchemaCache,
) -> CompletionProbe { ) -> 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() { if source.trim().is_empty() {
return CompletionProbe { return CompletionProbe {
expected: REGISTRY expected: mode_filtered_entries(),
.iter()
.map(|c| outcome::Expectation::Word(c.entry.primary))
.collect(),
current_table_columns: None, current_table_columns: None,
pending_hint_mode: None, pending_hint_mode: None,
}; };
} }
let mut ctx = context::WalkContext::with_schema(schema); let mut ctx = context::WalkContext::with_schema(schema);
ctx.mode = mode;
let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
let Some(result) = result else { let Some(result) = result else {
return CompletionProbe { return CompletionProbe {
expected: REGISTRY expected: mode_filtered_entries(),
.iter()
.map(|c| outcome::Expectation::Word(c.entry.primary))
.collect(),
current_table_columns: None, current_table_columns: None,
pending_hint_mode: None, pending_hint_mode: None,
}; };
@@ -320,6 +340,22 @@ pub fn completion_probe(
pub fn input_verdict( pub fn input_verdict(
source: &str, source: &str,
schema: Option<&crate::completion::SchemaCache>, 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> { ) -> Option<outcome::Severity> {
use outcome::Severity; use outcome::Severity;
if source.trim().is_empty() { if source.trim().is_empty() {
@@ -329,6 +365,7 @@ pub fn input_verdict(
context::WalkContext::new, context::WalkContext::new,
context::WalkContext::with_schema, context::WalkContext::with_schema,
); );
ctx.mode = mode;
let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
let Some(result) = result else { let Some(result) = result else {
// The first token is not a registered command word — // The first token is not a registered command word —
@@ -685,24 +722,41 @@ fn pair_type_mismatch(
/// produces. /// produces.
#[must_use] #[must_use]
pub fn expected_at_input(source: &str) -> Vec<outcome::Expectation> { 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() { if source.trim().is_empty() {
return REGISTRY return mode_filtered();
.iter()
.map(|c| outcome::Expectation::Word(c.entry.primary))
.collect();
} }
let mut ctx = context::WalkContext::new(); let mut ctx = context::WalkContext::new();
ctx.mode = mode;
let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
let Some(result) = result else { let Some(result) = result else {
// Walker didn't engage (unknown entry word): the // Walker didn't engage (unknown entry word): the
// completion engine should still surface the available // completion engine should still surface the available
// entry words so the user can recover. // entry words so the user can recover.
return REGISTRY return mode_filtered();
.iter()
.map(|c| outcome::Expectation::Word(c.entry.primary))
.collect();
}; };
match result.outcome { match result.outcome {
// On Match, surface the outer-shape's skipped-Optional // On Match, surface the outer-shape's skipped-Optional
+21 -4
View File
@@ -24,7 +24,8 @@
use ratatui::style::{Color, Modifier, Style}; 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::walker;
use crate::dsl::{ParseError, parse_command}; use crate::dsl::{ParseError, parse_command};
use crate::theme::Theme; use crate::theme::Theme;
@@ -67,7 +68,15 @@ pub fn render_input_runs(
cache: &crate::completion::SchemaCache, cache: &crate::completion::SchemaCache,
) -> Vec<StyledRun> { ) -> Vec<StyledRun> {
let mut runs = lex_to_runs(input, theme); 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); overlay_error(&mut runs, pos, theme);
} }
if let Some(inv) = crate::completion::invalid_ident_at_cursor(input, cursor_byte, cache) { 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 // Candidates win when any exist — the panel surfaces them
// directly because they're more actionable than prose // directly because they're more actionable than prose
// framings. // 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 { return Some(AmbientHint::Candidates {
items: comp.candidates, items: comp.candidates,
selected: None, selected: None,
@@ -348,7 +363,9 @@ pub fn ambient_hint(
))); )));
} }
// Otherwise fall back to the prose framings from stage 5. // 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"))), Ok(_) => Some(AmbientHint::Prose(crate::t!("hint.ambient_complete"))),
Err(ParseError::Empty) => None, Err(ParseError::Empty) => None,
Err(ParseError::Invalid { Err(ParseError::Invalid {