feat(hint): H2 Phase A — hint command + F1 keybinding skeleton (ADR-0053)
The mechanism for the contextual hint, with tier-2 fallback; the tier-3 corpus lands in later phases. - new CommandNode `hint_id` field (all None for now) - AppCommand::Hint + HINT grammar node + REGISTRY + dispatch - F1 read-only overlay in handle_key (buffer/cursor/memo untouched) - note_hint* renderers; hint_id_for_input_in_mode (shared selection helper refactored out of usage_keys_for_input_in_mode) - last_error_hint_key + friendly::error_hint_class classifier - catalogue: help.app.hint / parse.usage.hint / hint.getting_started - +12 tests; 2483 pass / 1 ignored, clippy clean
This commit is contained in:
+261
@@ -271,6 +271,13 @@ pub struct App {
|
||||
pub nav_focus: NavFocus,
|
||||
pub output: VecDeque<OutputLine>,
|
||||
pub hint: Option<String>,
|
||||
/// Catalog class key of the most recent runtime error (H2 /
|
||||
/// ADR-0053 D5), e.g. `foreign_key.child_side`. Set when a
|
||||
/// friendly error is rendered, cleared on the next successful
|
||||
/// command. The submitted `hint` command and empty-input F1 use
|
||||
/// it to render that error's tier-3 `hint.err.<class>` block.
|
||||
/// `None` → no recent error → the "getting started" pointer.
|
||||
pub last_error_hint_key: Option<String>,
|
||||
/// The validity indicator's currently-visible verdict
|
||||
/// (ADR-0027). `None` means the indicator shows nothing —
|
||||
/// the input is clean, or it is hidden mid-typing while the
|
||||
@@ -521,6 +528,7 @@ pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> {
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Tab, _) => Some("[TAB]"),
|
||||
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
|
||||
(KeyCode::F(1), _) => Some("[F1]"),
|
||||
(KeyCode::Enter, _) => Some("[ENTER]"),
|
||||
(KeyCode::Esc, _) => Some("[ESC]"),
|
||||
(KeyCode::Up, _) => Some("[UP]"),
|
||||
@@ -557,6 +565,7 @@ impl App {
|
||||
nav_focus: NavFocus::Input,
|
||||
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
||||
hint: None,
|
||||
last_error_hint_key: None,
|
||||
input_indicator: None,
|
||||
tables: Vec::new(),
|
||||
relationships: Vec::new(),
|
||||
@@ -1208,6 +1217,21 @@ impl App {
|
||||
return self.handle_nav_key(key);
|
||||
}
|
||||
|
||||
// H2 / ADR-0053: F1 is a read-only contextual-hint overlay —
|
||||
// it emits into the output journal and must NOT touch the input
|
||||
// buffer, cursor, or the completion memo, so it sits ahead of
|
||||
// the memo-clearing completion match below. Non-empty input →
|
||||
// a hint for the command being typed; empty input → expand on
|
||||
// the most recent error (or a getting-started pointer).
|
||||
if key.code == KeyCode::F(1) {
|
||||
if self.input.trim().is_empty() {
|
||||
self.note_hint_for_recent_error();
|
||||
} else {
|
||||
self.note_hint_for_input();
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// ADR-0022 stage 8 — non-modal completion. Tab /
|
||||
// Shift-Tab cycle; Esc / Backspace undo the whole
|
||||
// last-Tab insertion in one keystroke while the memo
|
||||
@@ -1774,6 +1798,13 @@ impl App {
|
||||
// recallable. The canonical (un-prefixed) text is what reaches
|
||||
// the journal via `ExecuteDsl.source`.
|
||||
let is_app = matches!(&parsed, Ok(Command::App(_)));
|
||||
// H2 / ADR-0053 D5: a new *DSL* command supersedes the previous
|
||||
// runtime error for `hint`. App commands (incl. `hint` itself)
|
||||
// and parse errors leave it intact, so `hint` still expands the
|
||||
// last real error after, say, a `help` in between.
|
||||
if matches!(&parsed, Ok(cmd) if !matches!(cmd, Command::App(_))) {
|
||||
self.last_error_hint_key = None;
|
||||
}
|
||||
let advanced = submission_mode.is_advanced() && !is_app;
|
||||
let ring_line = if advanced {
|
||||
format!(": {effective_input}")
|
||||
@@ -1814,6 +1845,13 @@ impl App {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
// H2 / ADR-0053: a submitted `hint` acts on the most recent
|
||||
// runtime error (the buffer is empty post-submit). The
|
||||
// live-input surface is the F1 keybinding (handle_key).
|
||||
AppCommand::Hint => {
|
||||
self.note_hint_for_recent_error();
|
||||
Vec::new()
|
||||
}
|
||||
AppCommand::Rebuild => vec![Action::PrepareRebuild],
|
||||
AppCommand::Save => self.handle_save_command(false),
|
||||
AppCommand::SaveAs => self.handle_save_command(true),
|
||||
@@ -2422,6 +2460,10 @@ impl App {
|
||||
// runtime built before posting the event.
|
||||
let ctx = self.build_translate_context(command, facts);
|
||||
let rendered = crate::friendly::translate_error(&error, &ctx).render();
|
||||
// H2 / ADR-0053 D5: remember this error's tier-3 class so a
|
||||
// following `hint` (or empty-input F1) can expand on it.
|
||||
self.last_error_hint_key =
|
||||
crate::friendly::error_hint_class(&error, &ctx).map(String::from);
|
||||
warn!(
|
||||
verb = command.verb(),
|
||||
error = %rendered,
|
||||
@@ -3091,6 +3133,94 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// ── H2 / ADR-0053: contextual `hint` ────────────────────────
|
||||
// Phase A wires the two surfaces (F1 → live input; the `hint`
|
||||
// command → most recent error) plus the tier-2 fallback. The
|
||||
// tier-3 corpus (`hint.cmd.*` / `hint.err.*`) is authored in later
|
||||
// phases; until a block exists, `emit_tier3_block` returns false
|
||||
// and the surface degrades to the ambient prose / getting-started
|
||||
// pointer — never blank.
|
||||
|
||||
/// F1 with a non-empty buffer: a tier-3 hint for the command form
|
||||
/// being typed, else the tier-2 ambient prose (ADR-0053 D2).
|
||||
/// Read-only — callers guarantee the buffer/cursor/memo are left
|
||||
/// untouched.
|
||||
fn note_hint_for_input(&mut self) {
|
||||
// `feedback_view` strips the `:` one-shot sigil and
|
||||
// `effective_mode` reflects the one-shot advanced surface, so
|
||||
// the hint matches the command the user is actually typing.
|
||||
let (view, cursor, _off) = self.feedback_view();
|
||||
let probe = view.to_string();
|
||||
let mode = self.effective_mode().as_mode();
|
||||
if let Some(id) = crate::dsl::grammar::hint_id_for_input_in_mode(&probe, mode)
|
||||
&& self.emit_tier3_block(&format!("hint.cmd.{id}"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Tier-2 fallback: surface the ambient prose as a persistent
|
||||
// line (computed exactly as the live panel does).
|
||||
let ambient = crate::input_render::ambient_hint_in_mode(
|
||||
&probe,
|
||||
cursor,
|
||||
self.last_completion.as_ref(),
|
||||
&self.schema_cache,
|
||||
mode,
|
||||
);
|
||||
match ambient {
|
||||
Some(crate::input_render::AmbientHint::Prose(text)) => {
|
||||
self.push_category_three_prose(text);
|
||||
}
|
||||
Some(crate::input_render::AmbientHint::Candidates { items, .. }) => {
|
||||
let names = items
|
||||
.iter()
|
||||
.map(|c| c.text.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names));
|
||||
}
|
||||
None => self.note_getting_started(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `hint` command (and empty-input F1): expand on the most
|
||||
/// recent runtime error, else point the user at how to start
|
||||
/// (ADR-0053 D2/D5).
|
||||
fn note_hint_for_recent_error(&mut self) {
|
||||
if let Some(class) = self.last_error_hint_key.clone()
|
||||
&& self.emit_tier3_block(&format!("hint.err.{class}"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.note_getting_started();
|
||||
}
|
||||
|
||||
fn note_getting_started(&mut self) {
|
||||
self.note_system(crate::t!("hint.getting_started"));
|
||||
}
|
||||
|
||||
/// Render a tier-3 block (`<stem>.what` / `.example` / `.concept`)
|
||||
/// when it has been authored; returns `false` if the `what` part is
|
||||
/// absent so the caller can fall back to tier 2. `what` is
|
||||
/// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
|
||||
/// polish (the framed block) lands with the corpus.
|
||||
fn emit_tier3_block(&mut self, stem: &str) -> bool {
|
||||
let cat = crate::friendly::catalog();
|
||||
if cat.get(&format!("{stem}.what")).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.note_system(crate::friendly::translate(&format!("{stem}.what"), &[]));
|
||||
if cat.get(&format!("{stem}.example")).is_some() {
|
||||
self.note_system(crate::friendly::translate(&format!("{stem}.example"), &[]));
|
||||
}
|
||||
if cat.get(&format!("{stem}.concept")).is_some() {
|
||||
self.push_category_three_prose(crate::friendly::translate(
|
||||
&format!("{stem}.concept"),
|
||||
&[],
|
||||
));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn note_system(&mut self, text: impl Into<String>) {
|
||||
self.push_multiline(text.into(), OutputKind::System);
|
||||
}
|
||||
@@ -5539,6 +5669,137 @@ mod tests {
|
||||
assert!(last.text.contains("Ghost"), "{}", last.text);
|
||||
}
|
||||
|
||||
// ── H2 / ADR-0053: contextual `hint` (Phase A skeleton) ──────
|
||||
|
||||
fn f1(app: &mut App) -> Vec<Action> {
|
||||
app.update(key(KeyCode::F(1)))
|
||||
}
|
||||
|
||||
fn no_such_table_failure() -> AppEvent {
|
||||
AppEvent::DslFailed {
|
||||
command: Command::DropTable {
|
||||
name: "Ghost".to_string(),
|
||||
},
|
||||
error: crate::db::DbError::Sqlite {
|
||||
message: "no such table: Ghost".to_string(),
|
||||
kind: crate::db::SqliteErrorKind::NoSuchTable,
|
||||
},
|
||||
facts: crate::friendly::FailureContext::default(),
|
||||
source: String::new(),
|
||||
advanced: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_command_parses_to_app_hint() {
|
||||
use crate::dsl::{parse_command, AppCommand, Command};
|
||||
assert!(matches!(
|
||||
parse_command("hint"),
|
||||
Ok(Command::App(AppCommand::Hint))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_command_with_no_recent_error_shows_getting_started() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "hint");
|
||||
submit(&mut app);
|
||||
assert!(output_contains(&app, "press F1"), "{}", error_lines(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_on_empty_input_with_no_error_shows_getting_started() {
|
||||
let mut app = App::new();
|
||||
let before = app.output.len();
|
||||
f1(&mut app);
|
||||
assert!(app.output.len() > before, "F1 must emit something");
|
||||
assert!(output_contains(&app, "press F1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_is_a_read_only_overlay() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "insert into T");
|
||||
let input = app.input.clone();
|
||||
let cursor = app.input_cursor;
|
||||
let before = app.output.len();
|
||||
f1(&mut app);
|
||||
assert_eq!(app.input, input, "F1 must not change the buffer");
|
||||
assert_eq!(app.input_cursor, cursor, "F1 must not move the cursor");
|
||||
assert!(app.output.len() > before, "F1 emits a hint line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_preserves_the_completion_memo() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "show ");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert!(app.last_completion.is_some(), "precondition: Tab sets the memo");
|
||||
let input = app.input.clone();
|
||||
f1(&mut app);
|
||||
assert!(app.last_completion.is_some(), "F1 must not clear the memo");
|
||||
assert_eq!(app.input, input, "F1 must not change the buffer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_failure_sets_hint_class_and_a_later_dsl_command_clears_it() {
|
||||
let mut app = App::new();
|
||||
app.update(no_such_table_failure());
|
||||
assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
|
||||
// A new DSL command supersedes the previous error.
|
||||
type_str(&mut app, "drop table Ghost");
|
||||
submit(&mut app);
|
||||
assert_eq!(app.last_error_hint_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_command_does_not_clear_the_hint_class() {
|
||||
let mut app = App::new();
|
||||
app.update(no_such_table_failure());
|
||||
assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
|
||||
// `help` (an app command) leaves the last error intact, so a
|
||||
// following `hint` still expands on it.
|
||||
type_str(&mut app, "help");
|
||||
submit(&mut app);
|
||||
assert_eq!(
|
||||
app.last_error_hint_key.as_deref(),
|
||||
Some("not_found"),
|
||||
"an app command must not clear the last error's hint class"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_after_error_emits_a_hint_without_panicking() {
|
||||
// Phase A: no tier-3 `hint.err.*` content exists yet, so the
|
||||
// error path falls back to the getting-started pointer. (Phase C
|
||||
// replaces this with the real error block.)
|
||||
let mut app = App::new();
|
||||
app.update(no_such_table_failure());
|
||||
let before = app.output.len();
|
||||
type_str(&mut app, "hint");
|
||||
submit(&mut app);
|
||||
assert!(app.output.len() > before, "hint must emit something");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_list_includes_hint() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "help");
|
||||
submit(&mut app);
|
||||
assert!(
|
||||
output_contains(&app, "explain the most recent error"),
|
||||
"help list must advertise the hint command"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_hint_describes_the_hint_command() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "help hint");
|
||||
submit(&mut app);
|
||||
assert!(output_contains(&app, "explain the most recent error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_command_toggles_verbosity_and_reports() {
|
||||
let mut app = App::new();
|
||||
|
||||
Reference in New Issue
Block a user