fix(input): thread the : one-shot escape into live SQL feedback

The `:` one-shot escape (ADR-0003) is stripped at submission, but the
*live* feedback kept the leading `:` in the buffer it handed the
walker — so Tab completion, the validity verdict, and the highlight
overlays all bailed at the `:` and treated the SQL as an unknown
command. Effect: in `:`-mode, Tab completed nothing and a valid query
could flash an error, while the identical line in full `mode advanced`
worked. (The ambient hint already stripped it, which is why the hint
showed the right column name while Tab did nothing.)

Add `App::feedback_view()` — the `:`-stripped SQL view, the cursor
mapped into it, and the stripped byte offset — and route all four live
paths through it:

- completion (Tab): complete against the view, then shift the returned
  `replaced_range` back by the offset so the edit lands in the buffer;
- validity verdict: verdict the SQL, not the sigil;
- highlight/overlays: new `render_input_runs_feedback` highlights and
  diagnoses the view (shifted by the offset) while the `:` renders as
  plain text;
- ambient hint: consolidated onto `feedback_view`, replacing the
  duplicate local `strip_one_shot_prefix`.
This commit is contained in:
claude@clouddev1
2026-06-12 12:43:00 +00:00
parent 4cacb8261c
commit f7155ceafc
3 changed files with 254 additions and 53 deletions
+95 -7
View File
@@ -84,16 +84,60 @@ pub fn render_input_runs_in_mode(
cache: &crate::completion::SchemaCache,
mode: Mode,
) -> Vec<StyledRun> {
let mut runs = lex_to_runs_in_mode(input, theme, mode);
// Identity feedback view — highlight/overlay the whole input.
render_input_runs_feedback(input, cursor_byte, theme, cache, mode, input, cursor_byte, 0)
}
/// [`render_input_runs_in_mode`] with a separate **feedback view** for
/// the walker-driven highlighting and overlays.
///
/// Under the `:` one-shot escape (ADR-0003) the buffer carries a leading
/// `:` that is not advanced SQL; `view` is the stripped SQL (and
/// `view_cursor` the cursor within it) so the walker highlights and
/// diagnoses the SQL itself, while the `:` prefix renders as plain text.
/// `offset` is the byte length stripped from the front — base runs and
/// overlay positions are shifted by it back into `input` coordinates.
/// Callers without a one-shot escape pass `(input, cursor, 0)` (what
/// [`render_input_runs_in_mode`] does).
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn render_input_runs_feedback(
input: &str,
cursor_byte: usize,
theme: &Theme,
cache: &crate::completion::SchemaCache,
mode: Mode,
view: &str,
view_cursor: usize,
offset: usize,
) -> Vec<StyledRun> {
// Base highlighting runs over the SQL view, shifted into buffer
// coordinates; the stripped prefix (the `:` + space) renders as
// plain foreground text.
let mut runs: Vec<StyledRun> = if offset == 0 {
lex_to_runs_in_mode(input, theme, mode)
} else {
let mut r = vec![StyledRun {
byte_range: (0, offset),
style: ratatui::style::Style::default().fg(theme.fg),
}];
r.extend(lex_to_runs_in_mode(view, theme, mode).into_iter().map(|run| {
StyledRun {
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
..run
}
}));
r
};
if let InputState::DefiniteErrorAt(pos) =
classify_parse_result(parse_command_with_schema_in_mode(input, cache, mode))
classify_parse_result(parse_command_with_schema_in_mode(view, cache, mode))
{
overlay_error(&mut runs, pos, theme);
overlay_error(&mut runs, pos + offset, theme);
}
if let Some(inv) =
crate::completion::invalid_ident_at_cursor_in_mode(input, cursor_byte, cache, mode)
crate::completion::invalid_ident_at_cursor_in_mode(view, view_cursor, cache, mode)
{
overlay_error(&mut runs, inv.range.0, theme);
overlay_error(&mut runs, inv.range.0 + offset, theme);
}
// Schema-aware diagnostics (ADR-0027 §2): unknown table /
// column (ERROR), or a dubious comparison (WARNING), is
@@ -101,12 +145,12 @@ pub fn render_input_runs_in_mode(
// so a problem the user has typed past stays visible. The
// mode-aware walk picks up the SQL-specific diagnostics from
// ADR-0032 in advanced mode.
for diag in walker::input_diagnostics_in_mode(input, Some(cache), mode) {
for diag in walker::input_diagnostics_in_mode(view, Some(cache), mode) {
let colour = match diag.severity {
walker::Severity::Error => theme.tok_error,
walker::Severity::Warning => theme.warning,
};
overlay_span(&mut runs, diag.span, colour);
overlay_span(&mut runs, (diag.span.0 + offset, diag.span.1 + offset), colour);
}
inject_cursor(&mut runs, input, cursor_byte, theme);
runs
@@ -1108,6 +1152,50 @@ mod tests {
assert!(reversed(&runs[0]));
}
#[test]
fn one_shot_colon_highlights_the_sql_and_overlays_no_error() {
// ADR-0003 `:` one-shot: the SQL after the `:` must highlight and
// diagnose like real advanced mode — the `:` prefix renders as
// plain text and a valid query carries no error overlay (the old
// path let the walker choke on the `:` and mark it red).
use crate::completion::{SchemaCache, TableColumn};
use crate::dsl::types::Type;
let theme = dark();
let mut cache = SchemaCache::default();
cache.tables.push("Customers".into());
cache.columns.push("name".into());
cache
.table_columns
.insert("Customers".into(), vec![TableColumn::new("name", Type::Text)]);
let input = ": select name from Customers";
let view = "select name from Customers";
let offset = 2; // ": "
let runs = render_input_runs_feedback(
input,
input.len(),
&theme,
&cache,
Mode::Advanced,
view,
view.len(),
offset,
);
assert!(
runs.iter().all(|r| r.style.fg != Some(theme.tok_error)),
"a valid one-shot query must carry no error overlay: {runs:?}",
);
assert!(
runs.iter()
.any(|r| r.byte_range.0 == offset && r.style.fg == Some(theme.tok_keyword)),
"the `select` keyword (past the `: ` prefix) is keyword-coloured: {runs:?}",
);
assert_eq!(
runs.first().unwrap().byte_range.0,
0,
"the `:` prefix is rendered from byte 0",
);
}
#[test]
fn keyword_token_takes_keyword_colour() {
let theme = dark();