//! Application state and the single `update` entry point. //! //! `update` is pure with respect to the runtime: it mutates //! state in place and returns a list of `Action`s. Side effects //! (DB execution, quit, etc.) live in the runtime. This keeps //! every behaviour drivable from synthetic events in tests, //! which is what makes ADR-0008's Tier 1/3 testing tractable. use std::collections::VecDeque; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::layout::Rect; use tracing::{debug, trace, warn}; use crate::action::Action; use crate::db::{ AddColumnResult, CascadeEffect, ChangeColumnTypeResult, DataResult, DeleteResult, DropColumnResult, InsertResult, TableDescription, UpdateResult, }; use crate::dsl::{Command, ParseError, parse_command}; use crate::event::AppEvent; use crate::mode::Mode; /// Maximum number of output lines kept in the rolling buffer. const OUTPUT_CAPACITY: usize = 1000; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutputKind { Echo, System, Error, /// The DSL → SQL teaching echo (ADR-0038 §4). Visually a `[system]` /// line, but rendered with a custom path: a dimmed `Executing SQL:` /// prefix followed by the SQL re-lexed through `input_render:: /// lex_to_runs_in_mode(Advanced)` — same syntax highlighting the /// input echo gets, so the suggested SQL reads like code (ADR-0028 /// §5 styled-runs). TeachingEcho, } /// Completion state of an `OutputKind::Echo` line (ADR-0040). /// /// An echo for an *executed* command is pushed `Pending` (rendered /// `running: `) and resolves to `Ok`/`Err` when the result /// arrives — rendered ` ✓` / ` ✗`, replacing the old /// `[ok]`/`failed:` summary line. Parse-time and pre-flight /// rejections are not executed and carry `None` (they keep the /// `running:` + caret rendering); non-echo lines also carry `None`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EchoStatus { Pending, Ok, Err, } /// The semantic style class of an [`OutputSpan`] (ADR-0028 §5). /// /// A general output-styling vocabulary, resolved to a concrete /// theme colour at render time — never a baked-in colour. The /// query-plan renderer (ADR-0028) is its first consumer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutputStyleClass { /// Default foreground — connectors, names, structural text. Neutral, /// An efficient query-plan step — an index search, a /// covering index, a primary-key lookup. Efficient, /// An expensive query-plan step — a full table scan or a /// temp B-tree. Expensive, /// An automatic-index step — the engine built a temporary /// index because none existed; the strongest "add an index /// here" signal. AutomaticIndex, /// De-emphasised text — `Executing SQL:` prefix on teaching /// echo lines (ADR-0038 §4), the DontConvert caveat, and /// every `[client-side]` category-3 prose note (ADR-0038 §6). /// Resolves to `theme.muted`. Hint, /// A relationship-diagram box's title row — the table name /// (ADR-0044 §2.1). Bold accent so it cannot read as a column. DiagramTableName, /// A relationship-diagram key marker — `(PK)` / `●` on the /// participating columns (ADR-0044 §2.2). DiagramKey, /// A relationship-diagram cardinality label — `1` / `n` /// (ADR-0044 §2). DiagramCardinality, /// A relationship-diagram connector — box-drawing line, elbows /// and arrowhead between the two boxes (ADR-0044 §2.3). Muted so /// the structure, not the wiring, leads. DiagramConnector, } /// A styled span of an output line: a byte range over the /// line's text and the semantic class it carries (ADR-0028 §5). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct OutputSpan { /// Half-open byte range `[start, end)` into the line text. pub byte_range: (usize, usize), pub class: OutputStyleClass, } #[derive(Debug, Clone)] pub struct OutputLine { pub text: String, pub kind: OutputKind, pub mode_at_submission: Mode, /// Optional per-span styling (ADR-0028 §5). When `Some`, /// `render_output_line` colours the text span-by-span from /// these runs; when `None` it falls back to whole-line /// styling by `kind`. pub styled_runs: Option>, /// Echo completion state (ADR-0040). `Some(_)` only on /// `OutputKind::Echo` lines for executed commands; `None` /// everywhere else (non-echo lines, parse/pre-flight echoes). pub status: Option, } impl OutputLine { /// An output line carrying per-span styled runs (ADR-0028 /// §5) — the text is coloured per `runs`, not by `kind`. #[must_use] pub const fn styled( text: String, kind: OutputKind, mode_at_submission: Mode, runs: Vec, ) -> Self { Self { text, kind, mode_at_submission, styled_runs: Some(runs), status: None, } } /// A `running: ` echo for an executed command, pushed /// `Pending` and resolved to `Ok`/`Err` on completion (ADR-0040). #[must_use] pub fn echo(input: &str, mode: Mode) -> Self { Self { text: crate::t!("dsl.running", input = input), kind: OutputKind::Echo, mode_at_submission: mode, styled_runs: None, status: Some(EchoStatus::Pending), } } /// The plain-text form of this line *as rendered* (ADR-0041) — the /// `[tag]`, the body, and the echo decoration (`running:` prefix or /// trailing `✓`/`✗`) — without colour, viewport padding, or /// wrapping. This is what the `copy` command puts on the clipboard. /// /// It mirrors the content `render_output_line` (`ui.rs`) produces; /// the line content is theme-independent (only colour is not), so no /// theme is needed. A drift-lock test in `ui.rs` /// (`plain_text_matches_rendered_line_content`) pins the two /// together so a renderer change can't silently desync the copy. #[must_use] pub fn plain_text(&self) -> String { let tag = match self.kind { OutputKind::Echo => { format!("[{}] ", self.mode_at_submission.label().to_lowercase()) } OutputKind::System | OutputKind::TeachingEcho => "[system] ".to_string(), OutputKind::Error => "[error] ".to_string(), }; if self.kind == OutputKind::Echo { // Pending / untracked echoes keep the `running: ` prefix; // completed ones drop it and gain a ✓/✗ marker (ADR-0040). let input = self .text .strip_prefix(crate::dsl::ECHO_PREFIX) .unwrap_or(self.text.as_str()); let mut s = tag; if !matches!(self.status, Some(EchoStatus::Ok | EchoStatus::Err)) { s.push_str(crate::dsl::ECHO_PREFIX); } s.push_str(input); match self.status { Some(EchoStatus::Ok) => s.push_str(" ✓"), Some(EchoStatus::Err) => s.push_str(" ✗"), _ => {} } return s; } // System / Error / TeachingEcho / styled lines: the body is // `self.text` verbatim (the teaching-echo `Executing SQL: ` // label and the styled-run slices all tile `self.text`). format!("{tag}{}", self.text) } } /// What mode the next submission would be evaluated in. /// /// Derived from the persistent mode and the current input /// buffer. The UI uses this to give immediate visual feedback /// for the `:` one-shot escape: the moment a leading `:` is /// typed in simple mode, the prompt flips to advanced styling, /// and reverts as soon as the `:` is deleted. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EffectiveMode { Simple, AdvancedPersistent, AdvancedOneShot, } impl EffectiveMode { #[must_use] 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, } } } /// Navigation-mode focus cursor (ADR-0046 DC1). /// /// `Input` means not in navigation mode — keystrokes edit the command /// input as usual. `Ctrl-O` cycles Input → SidebarTables → /// SidebarRelationships → Input; while a sidebar panel is focused the /// sidebar is revealed (peek) and expanded as an overlay, and scroll /// keys drive it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum NavFocus { #[default] Input, SidebarTables, SidebarRelationships, } impl NavFocus { /// True while a sidebar panel is focused (navigation mode is active). pub const fn in_sidebar(self) -> bool { matches!(self, Self::SidebarTables | Self::SidebarRelationships) } } #[derive(Debug)] pub struct App { pub mode: Mode, /// User's preferred verbosity for friendly-error rendering. /// In-session state per ADR-0019 §5; persistence will land /// alongside the broader settings-persistence ADR. pub messages_verbosity: crate::friendly::Verbosity, pub input: String, /// Byte offset into `input` where the next character will be /// inserted. Always lies on a UTF-8 character boundary. pub input_cursor: usize, /// First visible display column of the input line when it is too /// long to fit the input panel (ADR-0046 DA3). The renderer keeps /// the cursor in view by adjusting this; it resets to 0 whenever the /// buffer is replaced wholesale (submit / history navigation). pub input_scroll_offset: usize, /// Navigation-mode focus cursor (ADR-0046 DC1). `Input` when not in /// navigation mode. Driven by `Ctrl-O` / `Esc`; the renderer reveals /// + expands the focused sidebar panel as an overlay. pub nav_focus: NavFocus, pub output: VecDeque, pub hint: Option, /// 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.` block. /// `None` → no recent error → the "getting started" pointer. pub last_error_hint_key: Option, /// 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 /// debounce settles. The runtime owns the timing: it clears /// this on a keystroke and sets it from /// [`App::input_validity_verdict`] once typing pauses. pub input_indicator: Option, pub tables: Vec, /// All relationships as full schema records, for the sidebar /// relationships panel (ADR-0046 DB2). Refreshed by the runtime /// alongside `tables`. Kept on the App (not `SchemaCache`) because /// only the UI needs the details — the walker/completion need just /// the names, which stay in `SchemaCache::relationships`. pub relationships: Vec, /// Last successfully described table, shown in the output /// pane until the next DDL operation. pub current_table: Option, /// In-memory history of submitted lines, oldest first. /// Persistent history across sessions (I2 second half) lands /// when track 2's project storage is in place. pub history: Vec, /// Position within `history` while navigating with Up/Down. /// `None` means "not navigating; `input` is the user's /// in-progress draft." history_cursor: Option, /// Snapshot of the user's in-progress draft taken when they /// start navigating history, restored if they navigate back /// past the most recent entry. history_draft: Option, /// Number of lines from the bottom we've scrolled up. `0` /// means "showing the most recent lines"; positive values /// reveal older lines. Reset to `0` whenever a new output /// line is appended so newly-arrived results are always /// visible after a command. The full V4 session-log spec /// supersedes this; we ship a minimal subset now to address /// the immediate "ran out of space" UX problem. pub output_scroll: usize, /// The most recent visible-row count of the output panel, /// reported by the renderer. Used to cap `output_scroll` — /// without this, scrolling past `len - visible` would slide /// the visible window off the top of the buffer and shrink /// what the user sees. pub last_output_visible: usize, /// The most recent total *wrapped* row count of the output /// panel — counted in display rows after wrapping, not in /// logical OutputLines. Required for accurate scroll capping /// when long lines wrap to multiple display rows. pub last_output_total_wrapped: usize, /// The most recent inner width (in columns) of the output panel, /// recorded by the renderer (ADR-0044 §3). Drives the relationship /// diagram's side-by-side vs vertical layout choice. Defaults to /// `80` until the first render measures the real width. pub last_output_width: u16, /// The most recent **inner area** (inside the border) of the output /// panel, recorded by the renderer (ADR-0047 D4). The demo overlays /// anchor to its bottom-right corner; read at the top-level draw /// pass, which otherwise does not know where the output panel sits. /// Zero-sized until the first render measures it. pub last_output_area: Rect, /// Top visible row of the Tables / Relationships sidebar panels /// while scrolled in navigation mode (ADR-0046 DC3), with the most /// recent visible-row count the renderer reported for each (used to /// page-scroll and to clamp the offset). `0` = showing from the top. pub tables_scroll: usize, pub relationships_scroll: usize, pub last_tables_visible: usize, pub last_relationships_visible: usize, /// Prettified display name of the currently-open project, /// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None` /// during very-early startup before the runtime has opened a /// project; otherwise always populated. pub project_name: Option, /// Whether the open project is auto-named temporary or /// user-named permanent. Drives the `[TEMP]` prefix in the /// status bar and the `save` command's behaviour. pub project_is_temp: bool, /// Set when a fatal persistence failure has occurred /// (ADR-0015 §8). The runtime reads this after the event /// loop exits and prints it to stderr post-teardown so the /// banner remains above the shell prompt. pub fatal_message: Option, /// Active modal dialog (rebuild confirmation, save-as path /// prompt, load picker, …). While `Some`, `update` /// dispatches keys to the modal instead of the input /// field. pub modal: Option, /// Memo of the most recent Tab-completion (ADR-0022 /// stage 8). Carries enough state to cycle to the next / /// previous candidate on subsequent Tab / Shift-Tab /// presses, and to undo the whole insertion in one /// keystroke via Esc / Backspace. Cleared by *any* other /// keystroke — no completion mode, just a transient /// memory of "the last thing Tab did." pub last_completion: Option, /// Per-project schema lookup cache feeding Tab completion /// for identifier slots (ADR-0022 §9 + stage 8c). Empty /// by default; refreshed by the runtime on project load /// and after successful DDL. pub schema_cache: crate::completion::SchemaCache, /// Whether the undo/snapshot machinery is active this session /// (ADR-0006 Amendment 1). `false` under the `--no-undo` CLI /// flag; the `undo` / `redo` commands then report undo is off /// rather than emitting a prepare action. pub undo_enabled: bool, /// Whether **demonstration mode** is active this session (ADR-0047, /// issue #22). `true` under `--demo` / `RDBMS_PLAYGROUND_DEMO`. When /// off (the default) none of the demo key handling or overlay /// rendering runs — zero footprint. When on, otherwise-invisible /// keys raise a transient badge (`demo_badge`) and `Ctrl+]` drives /// the stealth step-caption buffer (`demo_caption` / `demo_capturing`, /// Phase C). pub demo_mode: bool, /// The keystroke badge currently displayed in demo mode (ADR-0047 /// D2), e.g. `"[TAB]"`. Set in `update()` when an otherwise-invisible /// key is handled; cleared by the runtime when its ~1.5 s timer /// elapses (the timing lives in the runtime, mirroring how /// `input_indicator` is driven from `IndicatorDebounce`). `None` when /// no badge is showing. pub demo_badge: Option<&'static str>, /// Monotonic counter bumped every time `demo_badge` is (re)set /// (ADR-0047 D5). The runtime watches it so a *new* badge — even the /// same label twice in a row (Tab, Tab) — restarts the expiry timer. pub demo_badge_seq: u64, /// The step-caption currently displayed in demo mode (ADR-0047 D3), /// or `None`. Committed from the stealth buffer on the closing /// `Ctrl+]`; cleared by the next ordinary keystroke (or an empty /// commit). Rendered as a wrapped box stacked above the badge. pub demo_caption: Option, /// Whether the stealth caption buffer is open (ADR-0047 D3): between /// the opening and closing `Ctrl+]`, typed characters accumulate into /// `demo_caption_buffer` invisibly and every other key is inert. pub demo_caption_capturing: bool, /// The invisible accumulator for the caption being typed while /// `demo_caption_capturing` (ADR-0047 D3). Never rendered directly; /// its trimmed contents become `demo_caption` on commit. pub demo_caption_buffer: String, /// The DSL → SQL teaching echo (ADR-0038) for the command currently /// being rendered: set from the success event just before its handler /// runs, consumed by `note_ok_summary` (which pushes it beneath /// `[ok]`), within the same synchronous `update()` call. `None` when /// the command has no echo. pending_echo: Option>, } /// Dialogs that take over keyboard input when active. /// /// Track-2 lifecycle commands (`rebuild`, `save as`, `load`, /// `new`) need confirmation prompts or path entry that the /// single-line input field can't naturally express. Each /// modal owns a small state machine; the renderer draws an /// overlay and `App::update` routes keys through it. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Modal { /// `rebuild` confirmation. Shows a summary of what would /// be reconstructed; `Y` confirms, `N` / `Esc` dismisses. RebuildConfirm(RebuildConfirmModal), /// One-line text prompt used by `save` / `save as` for /// the target name/path. PathEntry(PathEntryModal), /// Load picker. Shows a list of projects in the active /// data root; `b` switches to a path-entry sub-mode for /// projects outside the data root (ADR-0015 §7). LoadPicker(LoadPickerModal), /// `undo` / `redo` confirmation (ADR-0006 Amendment 1). Names /// the command that will be undone / re-applied; `Y` confirms, /// `N` / `Esc` dismisses. UndoConfirm(UndoConfirmModal), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct UndoConfirmModal { /// The command text of the snapshot being restored — the thing /// that will be undone (or re-applied, for redo). pub command: String, /// When that snapshot was taken (ISO-8601 `Z`). pub timestamp: String, /// `false` for undo, `true` for redo — selects the wording. pub is_redo: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RebuildConfirmModal { /// One-line summary derived from `project.yaml` + `data/` /// (e.g. `"3 tables, 47 rows will be reconstructed"`). pub summary: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PathEntryModal { pub title: String, pub prompt: String, pub input: String, /// Byte offset of the insertion point inside `input`. pub cursor: usize, pub purpose: PathEntryPurpose, } /// What the runtime should do with the path the user typed. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PathEntryPurpose { /// Save the current project to the typed name/path. /// Relative names resolve against `/projects/`. SaveAs, /// Load the project at the typed path. Used by the load /// picker's `b` (browse) sub-mode. LoadByPath, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct LoadPickerModal { pub entries: Vec, pub selected: usize, /// Sub-mode: list-of-recents (default) or path-entry /// (after `b`). pub sub_mode: LoadPickerSubMode, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct LoadPickerEntry { pub display_name: String, pub modified: String, pub path: std::path::PathBuf, pub is_temp: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum LoadPickerSubMode { List, /// Switched to via `b`. Same input/cursor surface as /// `PathEntryModal`; kept inline so the picker can flip /// back to List with `Esc`. PathEntry { input: String, cursor: usize }, } const PAGE_SCROLL_LINES: usize = 5; const HISTORY_CAPACITY: usize = 1000; /// The demo-mode keystroke badge for `key`, or `None` if the key /// produces a glyph of its own (and so needs no badge) — ADR-0047 D2. /// /// The set is exactly the *otherwise-invisible* keys: motion, editing, /// submission, and the `Ctrl-O` navigation toggle. Plain character keys /// already appear on the input line, and `Ctrl-C` (quit) / `Ctrl+]` /// (the caption toggle) are deliberately excluded. Pure and total, so /// it is exhaustively unit-testable without a running app. 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]"), (KeyCode::Down, _) => Some("[DOWN]"), (KeyCode::Left, _) => Some("[LEFT]"), (KeyCode::Right, _) => Some("[RIGHT]"), (KeyCode::Home, _) => Some("[HOME]"), (KeyCode::End, _) => Some("[END]"), (KeyCode::PageUp, _) => Some("[PGUP]"), (KeyCode::PageDown, _) => Some("[PGDN]"), (KeyCode::Backspace, _) => Some("[BKSP]"), (KeyCode::Delete, _) => Some("[DEL]"), // The only badged control chord: the ADR-0046 navigation toggle. (KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => Some("[CTRL-O]"), _ => None, } } impl Default for App { fn default() -> Self { Self::new() } } impl App { #[must_use] pub fn new() -> Self { Self { mode: Mode::Simple, messages_verbosity: crate::friendly::Verbosity::default(), input: String::new(), input_cursor: 0, input_scroll_offset: 0, 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(), current_table: None, history: Vec::new(), history_cursor: None, history_draft: None, output_scroll: 0, last_output_visible: 0, last_output_total_wrapped: 0, last_output_width: 80, last_output_area: Rect::new(0, 0, 0, 0), tables_scroll: 0, relationships_scroll: 0, last_tables_visible: 0, last_relationships_visible: 0, project_name: None, project_is_temp: false, fatal_message: None, modal: None, last_completion: None, schema_cache: crate::completion::SchemaCache::default(), // Undo is on by default; the runtime flips this off for // a `--no-undo` session (ADR-0006 Amendment 1). undo_enabled: true, // Demo mode is off by default; the runtime flips it on for // a `--demo` session (ADR-0047). demo_mode: false, demo_badge: None, demo_badge_seq: 0, demo_caption: None, demo_caption_capturing: false, demo_caption_buffer: String::new(), pending_echo: None, } } /// Called by the renderer with the current output-panel /// dimensions (row count + total wrapped-row count for the /// current buffer) so subsequent scroll input is capped /// correctly. Without `total_wrapped`, scroll math would /// incorrectly assume one logical line = one display row. pub const fn note_output_viewport(&mut self, visible_rows: usize, total_wrapped_rows: usize) { self.last_output_visible = visible_rows; self.last_output_total_wrapped = total_wrapped_rows; // If a previous PageUp drifted past the maximum useful // scroll (e.g. the user kept paging up past the top), // bring it back so the next PageDown is responsive. let max = total_wrapped_rows.saturating_sub(visible_rows); if self.output_scroll > max { self.output_scroll = max; } } /// Replace the in-memory navigable history with `entries`, /// truncating to the in-memory cap. /// /// Used by the runtime to hydrate from the project's /// `history.log` on open (I2-persist, ADR-0015 §12). /// Entries should arrive in chronological order (oldest /// first); the most recent stays at the back, which is /// where Up/Down navigation expects it. /// /// Cancels any in-flight history navigation so a hydrate /// during a session (e.g. after `load`) doesn't leave a /// dangling cursor pointing at a now-removed entry. pub fn seed_history(&mut self, entries: Vec) { self.history = entries; while self.history.len() > HISTORY_CAPACITY { self.history.remove(0); } self.history_cursor = None; self.history_draft = None; } /// Effective mode for the *next* submission, given the /// persistent mode and the current input buffer. See /// [`EffectiveMode`]. #[must_use] pub fn effective_mode(&self) -> EffectiveMode { match self.mode { Mode::Advanced => EffectiveMode::AdvancedPersistent, Mode::Simple if self.input.trim_start().starts_with(':') => { EffectiveMode::AdvancedOneShot } Mode::Simple => EffectiveMode::Simple, } } /// Whether the user is currently browsing a recalled history entry /// (Up/Down navigation, unedited). Exposes the private /// `history_cursor` predicate so the context-aware status strip /// (ADR-0051) can select its history-navigation state. Editing the /// recalled line ends navigation (`cancel_history_navigation`), so /// this is `false` again the moment the user types. #[must_use] pub const fn is_browsing_history(&self) -> bool { self.history_cursor.is_some() } /// The input view the **live-feedback** walkers (completion, ambient /// hint, validity verdict, highlight overlays) should see, plus the /// byte offset stripped from the front and the cursor mapped into the /// view. /// /// Under the `:` one-shot escape (ADR-0003) the buffer carries a /// leading `:` (and an auto-inserted space) that is *not* advanced /// SQL — submission already strips it before parsing, but the live /// feedback did not, so the walker bailed at the `:` and resolved /// nothing (no completion / hint, a spurious error overlay). This /// returns the stripped SQL exactly as submission sees it, so the /// feedback matches a real advanced-mode session. `offset` maps any /// walker-returned byte position (completion `replaced_range`, /// overlay spans) back to real-buffer coordinates. /// /// For every non-one-shot input this is the identity /// `(&input, cursor, 0)`. #[must_use] pub fn feedback_view(&self) -> (&str, usize, usize) { if matches!(self.effective_mode(), EffectiveMode::AdvancedOneShot) { // The first non-whitespace char is the `:` (per // `effective_mode`); strip up to and including it, then any // following whitespace — mirroring submission's // `trimmed[1..].trim()`. let leading_ws = self.input.len() - self.input.trim_start().len(); let mut offset = leading_ws + 1; // past the `:` while offset < self.input.len() && self.input.as_bytes()[offset].is_ascii_whitespace() { offset += 1; } let view = &self.input[offset..]; let cursor = self.input_cursor.saturating_sub(offset).min(view.len()); return (view, cursor, offset); } (&self.input, self.input_cursor, 0) } /// The validity-indicator verdict for the current input /// (ADR-0027 §3). `None` when the input would run clean. /// /// Computed only in simple mode — advanced mode is raw SQL, /// which the DSL walker does not parse (ADR-0027 §7). A /// pure query the runtime calls once the typing debounce /// settles; the result is stored in `input_indicator`. /// /// ADR-0032 §10.6 — the verdict reads the walker view of /// the *active* effective mode so a SQL form in Advanced /// mode lights up the same `[ERR]` / `[WRN]` indicator the /// DSL surface uses. Without this the SQL predicate /// warnings (ADR-0032 §11.6) would emit but never reach /// the validity indicator the user sees. #[must_use] pub fn input_validity_verdict(&self) -> Option { let mode = match self.effective_mode() { EffectiveMode::Simple => Mode::Simple, EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => Mode::Advanced, }; // Strip the `:` one-shot prefix so the walker verdicts the SQL // itself, not the escape marker (which it can't parse). let (view, _cursor, _offset) = self.feedback_view(); crate::dsl::walker::input_verdict_in_mode(view, Some(&self.schema_cache), mode) } /// Process one event from the runtime, mutating state and /// returning any actions for the runtime to enact. pub fn update(&mut self, event: AppEvent) -> Vec { match event { AppEvent::Key(key) => self.handle_key(key), AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(), AppEvent::DslSucceeded { command, description, echo, } => { // Stash the teaching echo (ADR-0038) for `note_ok_summary` // to render beneath `[ok]` — consumed synchronously below. self.pending_echo = echo; self.handle_dsl_success(&command, description); Vec::new() } AppEvent::DslCreateSkipped { command, description, } => { // No-op (CREATE TABLE IF NOT EXISTS on an existing // table, ADR-0035 §4): a successful no-op — mark the // echo ✓ (ADR-0040), then the skip note + structure. self.mark_oldest_pending_echo(EchoStatus::Ok); self.note_system(crate::t!( "ddl.create_skipped_exists", name = command.target_table() )); for line in crate::output_render::render_structure(&description) { self.note_system(line); } self.current_table = Some(description); Vec::new() } AppEvent::DslDropSkipped { command } => { // No-op (DROP TABLE IF EXISTS on an absent table, // ADR-0035 §4, 4c): successful no-op — echo ✓ + skip note. self.mark_oldest_pending_echo(EchoStatus::Ok); self.note_system(crate::t!( "ddl.drop_skipped_absent", name = command.target_table() )); Vec::new() } AppEvent::DslDropIndexSkipped { command } => { // No-op (DROP INDEX IF EXISTS on an absent index, // ADR-0035 §4d): successful no-op — echo ✓ + skip note. // `target_table()` returns the index name for `SqlDropIndex`. self.mark_oldest_pending_echo(EchoStatus::Ok); self.note_system(crate::t!( "ddl.drop_index_skipped_absent", name = command.target_table() )); Vec::new() } AppEvent::DslCreateIndexSkipped { command: _, name } => { // No-op (CREATE INDEX IF NOT EXISTS on an existing index // name, ADR-0035 §4d): successful no-op — echo ✓ + skip // note (the resolved index name; unnamed form's auto-name // isn't on the command). self.mark_oldest_pending_echo(EchoStatus::Ok); self.note_system(crate::t!("ddl.create_index_skipped_exists", name = name)); Vec::new() } AppEvent::DslDataSucceeded { command, data, echo, } => { // Stash the teaching echo (ADR-0038) for `note_ok_summary` // to render beneath `[ok]` — consumed synchronously below. self.pending_echo = echo; self.handle_dsl_query_success(&command, &data); Vec::new() } AppEvent::DslExplainSucceeded { command, plan } => { self.handle_dsl_explain_success(&command, &plan); Vec::new() } AppEvent::DslShowListSucceeded { command, lines } => { // Mark the echo ✓ (ADR-0040), then emit the // worker-formatted list as system output lines. self.note_ok_summary(&command); for line in lines { self.note_system(line); } Vec::new() } AppEvent::DslShowRelationshipSucceeded { command, data } => { self.handle_dsl_show_relationship_success(&command, data.as_ref()); Vec::new() } AppEvent::DslInsertSucceeded { command, result } => { self.handle_dsl_insert_success(&command, &result); Vec::new() } AppEvent::DslSeedSucceeded { command, result } => { self.handle_dsl_seed_success(&command, &result); Vec::new() } AppEvent::DslUpdateSucceeded { command, result, echo, } => { self.pending_echo = echo; self.handle_dsl_update_success(&command, &result); Vec::new() } AppEvent::DslDeleteSucceeded { command, result, echo, } => { self.pending_echo = echo; self.handle_dsl_delete_success(&command, &result); Vec::new() } AppEvent::DslChangeColumnSucceeded { command, result, echo, dont_convert_caveat, } => { self.pending_echo = echo; self.handle_dsl_change_column_success(&command, result, dont_convert_caveat); Vec::new() } AppEvent::DslAddColumnSucceeded { command, result, echo, } => { self.pending_echo = echo; self.handle_dsl_add_column_success(&command, result); Vec::new() } AppEvent::DslDropColumnSucceeded { command, result, echo, } => { self.pending_echo = echo; self.handle_dsl_drop_column_success(&command, result); Vec::new() } AppEvent::DslFailed { command, error, facts, source, advanced, } => { self.handle_dsl_failure(&command, error, facts); // ADR-0034 §1/§2: an execution failure is journalled // `err` so it is recallable across sessions (the // worker only journals successful commands). The App // emits the intent; the runtime does the append. The // mode rides along (ADR-0052) so an advanced failure // tags `err:adv`. vec![Action::JournalFailure { source, advanced }] } AppEvent::TablesRefreshed(tables) => { trace!(count = tables.len(), "tables refreshed"); self.tables = tables; Vec::new() } AppEvent::SchemaCacheRefreshed(cache) => { trace!( tables = cache.tables.len(), columns = cache.columns.len(), relationships = cache.relationships.len(), "schema cache refreshed", ); self.schema_cache = cache; Vec::new() } AppEvent::RelationshipsRefreshed(relationships) => { trace!(count = relationships.len(), "relationships refreshed"); self.relationships = relationships; Vec::new() } AppEvent::PersistenceFatal { operation, path, message, } => { // ADR-0040: if a command's persistence failed fatally, // resolve its (pending) echo to ✗ before the quit banner, // so the dying session doesn't leave a `running:` line. self.mark_oldest_pending_echo(EchoStatus::Err); let banner = crate::t!( "fatal.persistence", operation = operation, path = path.display(), message = message ); self.note_error(banner.clone()); self.fatal_message = Some(banner); vec![Action::Quit] } AppEvent::RebuildPrepared { summary } => { self.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal { summary })); Vec::new() } AppEvent::RebuildSucceeded { summary } => { self.modal = None; self.note_system(crate::t!("project.rebuild_ok", summary = summary)); Vec::new() } AppEvent::RebuildFailed { error } => { self.modal = None; self.note_error(crate::t!("project.rebuild_failed", error = error)); Vec::new() } AppEvent::UndoPrepared { command, timestamp, is_redo, } => { self.modal = Some(Modal::UndoConfirm(UndoConfirmModal { command, timestamp, is_redo, })); Vec::new() } AppEvent::UndoUnavailable { is_redo } => { if is_redo { self.note_system(crate::t!("undo.nothing_to_redo")); } else { self.note_system(crate::t!("undo.nothing_to_undo")); } Vec::new() } AppEvent::UndoSucceeded { command, is_redo } => { self.modal = None; if is_redo { self.note_system(crate::t!("undo.redone_ok", command = command)); } else { self.note_system(crate::t!("undo.undone_ok", command = command)); } Vec::new() } AppEvent::UndoFailed { error, is_redo } => { self.modal = None; if is_redo { self.note_error(crate::t!("undo.redo_failed", error = error)); } else { self.note_error(crate::t!("undo.undo_failed", error = error)); } Vec::new() } AppEvent::LoadPickerReady { entries } => { if entries.is_empty() { // Empty data root: jump straight to path-entry // mode so the user can still browse to a // project elsewhere. self.modal = Some(Modal::LoadPicker(LoadPickerModal { entries, selected: 0, sub_mode: LoadPickerSubMode::PathEntry { input: String::new(), cursor: 0, }, })); } else { self.modal = Some(Modal::LoadPicker(LoadPickerModal { entries, selected: 0, sub_mode: LoadPickerSubMode::List, })); } Vec::new() } AppEvent::ProjectSwitched { display_name, is_temp, history_entries, mode, } => { self.note_system(crate::t!( "project.switched_ok", display_name = display_name )); self.project_name = Some(display_name); self.project_is_temp = is_temp; self.tables.clear(); self.current_table = None; self.seed_history(history_entries); // Restore the switched-to project's stored input // mode (ADR-0015 mode-restore amendment, issue #14). self.mode = mode; Vec::new() } AppEvent::ProjectSwitchFailed { error } => { self.note_error(crate::t!("project.switch_failed", error = error)); Vec::new() } AppEvent::ExportSucceeded { path } => { self.note_system(crate::t!( "project.export_ok", path = path.display() )); Vec::new() } AppEvent::ExportFailed { error } => { self.note_error(crate::t!("project.export_failed", error = error)); Vec::new() } AppEvent::ReplayCompleted { path, count, warnings, } => { // ADR-0040: the `replay` echo resolves ✓; the // `[ok] replay — N command(s)` summary is payload-bearing // (the count) and stays. self.mark_oldest_pending_echo(EchoStatus::Ok); self.note_system(crate::t!( "replay.completed", path = path, count = count )); // ADR-0034: surface `[skip]` warnings for app-lifecycle // commands whose omission can leave the replayed state // incomplete (`import`, nested `replay`). for warning in warnings { self.note_system(warning); } Vec::new() } AppEvent::ReplayFailed { path, line_number, command, error, } => { // ADR-0040: the `replay` echo resolves ✗. self.mark_oldest_pending_echo(EchoStatus::Err); // line_number == 0 is the runtime's signal that // file-open itself failed (no per-line context to // surface). Otherwise we lead with the line-number // header and echo the offending command beneath // it, mirroring how the interactive `running: …` // path renders source-line context above an error. if line_number == 0 { self.note_error(crate::t!( "replay.failed_open", path = path, error = error )); } else { self.note_error(crate::t!( "replay.failed_at_line", path = path, line_number = line_number, error = error )); if !command.is_empty() { self.note_error(crate::t!( "replay.command_echo", command = command )); } } Vec::new() } } } /// ADR-0046 DC1: advance the navigation focus cycle. From `Input` /// it enters navigation mode on the Tables panel (revealing + /// expanding the sidebar via the renderer); the third press returns /// to the command input. fn nav_advance(&mut self) { self.nav_focus = match self.nav_focus { NavFocus::Input => NavFocus::SidebarTables, NavFocus::SidebarTables => NavFocus::SidebarRelationships, NavFocus::SidebarRelationships => NavFocus::Input, }; trace!(nav_focus = ?self.nav_focus, "navigation focus advanced"); } /// Leave navigation mode, returning focus to the command input /// (ADR-0046 DC1 — the `Esc` shortcut for the cycle's last step). const fn nav_exit(&mut self) { self.nav_focus = NavFocus::Input; } /// ADR-0046 DC3/DC4: key handling while a sidebar panel is focused. /// `Esc` exits navigation mode; scroll keys drive the focused panel /// (wired in DC3); every other key is inert because the command /// input is occluded by the expanded sidebar overlay. fn handle_nav_key(&mut self, key: KeyEvent) -> Vec { match key.code { KeyCode::Esc => self.nav_exit(), KeyCode::Up => self.nav_scroll(-1), KeyCode::Down => self.nav_scroll(1), KeyCode::PageUp => self.nav_scroll_page(-1), KeyCode::PageDown => self.nav_scroll_page(1), _ => {} } Vec::new() } /// Scroll the focused sidebar panel by `lines` (ADR-0046 DC3); the /// renderer clamps the offset to the panel's content on the next /// frame, so over-scrolling is harmless. const fn nav_scroll(&mut self, lines: i32) { let slot = match self.nav_focus { NavFocus::SidebarTables => &mut self.tables_scroll, NavFocus::SidebarRelationships => &mut self.relationships_scroll, NavFocus::Input => return, }; *slot = slot.saturating_add_signed(lines as isize); } /// Page-scroll the focused panel by its last reported visible-row /// count (ADR-0046 DC3). fn nav_scroll_page(&mut self, dir: i32) { let visible = match self.nav_focus { NavFocus::SidebarTables => self.last_tables_visible, NavFocus::SidebarRelationships => self.last_relationships_visible, NavFocus::Input => return, }; self.nav_scroll(dir * (visible.max(1) as i32)); } fn handle_key(&mut self, key: KeyEvent) -> Vec { // On Windows, key events fire for both Press and Release; // honour only Press to avoid double-handling. Other // platforms emit Press only, so this is a no-op there. if key.kind != KeyEventKind::Press { return Vec::new(); } trace!(?key, "handle_key"); // ADR-0047 D3: the demo step-caption stealth buffer runs before // every other gate — even ahead of the badge and the modal gate — // so it can be authored over the load picker (the `#24` cast) and // so captured keystrokes never leak into the input, a badge, or a // command. `Ctrl+]` toggles capture; while capturing, the key is // consumed here. if self.demo_mode { if let Some(actions) = self.handle_demo_caption_key(key) { return actions; } // Not a caption key: any ordinary keystroke dismisses a // visible caption (it then falls through to normal handling). self.demo_caption = None; } // ADR-0047 D2: in demo mode raise a transient badge for an // otherwise-invisible key. Done before the modal / nav gates so // it fires even while a modal is open (the `#24` projects cast) // or in navigation mode. The runtime times its expiry (D5). if self.demo_mode && let Some(label) = demo_badge_label(&key) { self.demo_badge = Some(label); self.demo_badge_seq = self.demo_badge_seq.wrapping_add(1); } // While a modal is open it owns the keyboard. Normal // input editing, history navigation, and command // submission are all gated behind closing the modal. if self.modal.is_some() { return self.handle_modal_key(key); } // ADR-0046 DC1: `Ctrl-O` cycles navigation focus from any state // (Input → Tables → Relationships → Input), inert only behind a // modal (handled above). if (key.code, key.modifiers) == (KeyCode::Char('o'), KeyModifiers::CONTROL) { self.nav_advance(); return Vec::new(); } // DC3/DC4: in navigation mode, keys drive the focused sidebar // panel (scroll) or are inert; the command input is occluded. if self.nav_focus.in_sidebar() { 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 // is alive (per the user's symmetry preference: one // keystroke to insert, one to remove). Any other key // clears the memo before being processed normally. match (key.code, key.modifiers) { (KeyCode::Tab, _) => return self.completion_tab_forward(), (KeyCode::BackTab, _) => return self.completion_tab_backward(), (KeyCode::Esc, _) if self.last_completion.is_some() => { self.undo_last_completion(); return Vec::new(); } (KeyCode::Backspace, _) if self.last_completion.is_some() => { self.undo_last_completion(); return Vec::new(); } _ => self.last_completion = None, } match (key.code, key.modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit], (KeyCode::Enter, _) => self.submit(), // ADR-0049 (issue #29): Esc clears a partly-typed command. // Reached only when no completion memo is alive — the memo // block above consumes Esc first to undo a completion. (KeyCode::Esc, _) => { self.clear_input(); Vec::new() } (KeyCode::Up, _) => { self.history_back(); Vec::new() } (KeyCode::Down, _) => { self.history_forward(); Vec::new() } (KeyCode::Left, _) => { self.cursor_left(); Vec::new() } (KeyCode::Right, _) => { self.cursor_right(); Vec::new() } // ADR-0049: Ctrl-A / Ctrl-E are readline aliases for // Home / End — line start / end — for keyboards without // those keys. Cursor-only, so (like Home/End) they do not // cancel history navigation. (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => { self.input_cursor = 0; Vec::new() } (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => { self.input_cursor = self.input.len(); Vec::new() } (KeyCode::Backspace, _) => { self.cancel_history_navigation(); self.delete_before_cursor(); Vec::new() } (KeyCode::Delete, _) => { self.cancel_history_navigation(); self.delete_at_cursor(); Vec::new() } // ADR-0049: readline kill shortcuts. Each mutates the // buffer, so each ends history navigation like Backspace. (KeyCode::Char('w'), KeyModifiers::CONTROL) => { self.cancel_history_navigation(); self.delete_prev_word(); Vec::new() } (KeyCode::Char('k'), KeyModifiers::CONTROL) => { self.cancel_history_navigation(); self.kill_to_end(); Vec::new() } (KeyCode::Char('u'), KeyModifiers::CONTROL) => { self.cancel_history_navigation(); self.kill_to_start(); Vec::new() } (KeyCode::PageUp, _) => { self.scroll_output_up(); Vec::new() } (KeyCode::PageDown, _) => { self.scroll_output_down(); Vec::new() } (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { self.cancel_history_navigation(); let was_empty = self.input.is_empty(); self.insert_at_cursor(c); // Convenience: when `:` becomes the leading character in // simple mode, auto-insert a space after it so the input // reads ": foo" rather than ":foo". The trailing space is // an ordinary character — backspace removes it normally. if c == ':' && was_empty && self.mode == Mode::Simple { self.insert_at_cursor(' '); } Vec::new() } _ => Vec::new(), } } /// Drive the demo step-caption stealth buffer (ADR-0047 D3). /// /// Returns `Some(_)` when the key belongs to the caption mechanism /// (the `Ctrl+]` toggle, or any key while capturing) — the caller /// then returns it and processes nothing else. Returns `None` when /// the key is not consumed, so normal handling continues. /// /// `Ctrl+]` decodes to `Char('5') + CONTROL` (ADR-0047 D3, verified /// against crossterm 0.29). Only active in demo mode (the caller /// gates on `self.demo_mode`). fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option> { let is_toggle = key.code == KeyCode::Char('5') && key.modifiers.contains(KeyModifiers::CONTROL); if self.demo_caption_capturing { if is_toggle { // Commit: a trimmed, non-empty buffer becomes the caption; // an empty commit dismisses any caption (explicit clear). self.demo_caption_capturing = false; let text = std::mem::take(&mut self.demo_caption_buffer); let trimmed = text.trim(); self.demo_caption = (!trimmed.is_empty()).then(|| trimmed.to_string()); } else { match key.code { // Plain characters accumulate invisibly; the prompt // and output are untouched. KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { self.demo_caption_buffer.push(c); } KeyCode::Backspace => { self.demo_caption_buffer.pop(); } // Every other key (Enter, arrows, Tab, …) is inert // while capturing. _ => {} } } return Some(Vec::new()); } if is_toggle { // Open capture. Starting a new annotation clears any caption // currently on screen. self.demo_caption_capturing = true; self.demo_caption_buffer.clear(); self.demo_caption = None; return Some(Vec::new()); } None } fn cursor_left(&mut self) { let mut idx = self.input_cursor; while idx > 0 { idx -= 1; if self.input.is_char_boundary(idx) { self.input_cursor = idx; return; } } self.input_cursor = 0; } fn cursor_right(&mut self) { let mut idx = self.input_cursor; while idx < self.input.len() { idx += 1; if self.input.is_char_boundary(idx) { self.input_cursor = idx; return; } } self.input_cursor = self.input.len(); } fn insert_at_cursor(&mut self, c: char) { // Defensive clamp: callers (and tests) may mutate // `input` directly; keep the cursor inside the buffer. if self.input_cursor > self.input.len() { self.input_cursor = self.input.len(); } self.input.insert(self.input_cursor, c); self.input_cursor += c.len_utf8(); } fn delete_before_cursor(&mut self) { if self.input_cursor == 0 { return; } // Find the start of the previous character. let mut idx = self.input_cursor - 1; while !self.input.is_char_boundary(idx) { idx -= 1; } self.input.replace_range(idx..self.input_cursor, ""); self.input_cursor = idx; } /// Tab key handler — insert / cycle forward through the /// candidates at the cursor (ADR-0022 stage 8). Behaviour /// is split between single and multi-candidate cases per /// the user's stage-8 feedback round 2: /// /// - **Single candidate**: insert with trailing space, /// no memo. The space is the natural commit; subsequent /// Tab fresh-computes at the new cursor. Esc/Backspace /// are normal. /// - **Multi candidate**: insert WITHOUT trailing space, /// create memo for cycling. Tab again cycles; any other /// key clears the memo. Pressing space (the natural /// "I'm done picking" gesture) clears the memo and adds /// the space, completing the chosen candidate. /// - **Memo present**: cycle to next candidate, replacing /// the inserted text in place (still no trailing space). /// - **No candidates**: no-op. fn completion_tab_forward(&mut self) -> Vec { self.cancel_history_navigation(); if let Some(memo) = self.last_completion.take() { let next = memo.next_idx(); self.last_completion = Some(self.replace_inserted(memo, next)); return Vec::new(); } self.start_or_complete_at(0); Vec::new() } /// Shift-Tab key handler — symmetric to forward; on a /// fresh multi-candidate position starts from the last /// candidate (per the user's #2 wrap-from-both-ends). /// Single candidate behaves identically to Tab. fn completion_tab_backward(&mut self) -> Vec { self.cancel_history_navigation(); if let Some(memo) = self.last_completion.take() { let prev = memo.prev_idx(); self.last_completion = Some(self.replace_inserted(memo, prev)); return Vec::new(); } self.start_or_complete_last(); Vec::new() } /// Esc / Backspace handler while a completion memo is /// alive — restore the original text in `inserted_range` /// and place the cursor where the user was when they hit /// Tab. The memo is cleared. Only fires on multi-candidate /// completions (single-candidate paths don't create a /// memo); the user accepts that single-candidate Tab /// requires regular backspace to undo. fn undo_last_completion(&mut self) { let Some(memo) = self.last_completion.take() else { return; }; let (start, end) = memo.inserted_range; self.input.replace_range(start..end, &memo.original_text); self.input_cursor = start + memo.original_text.len(); } fn start_or_complete_at(&mut self, multi_start_idx: usize) { let Some(comp) = self.completion_for_feedback() else { return; }; if comp.candidates.len() == 1 { self.commit_unique(&comp); } else { let idx = multi_start_idx % comp.candidates.len(); self.last_completion = Some(self.commit_multi(comp, idx)); } } fn start_or_complete_last(&mut self) { let Some(comp) = self.completion_for_feedback() else { return; }; if comp.candidates.len() == 1 { self.commit_unique(&comp); } else { let idx = comp.candidates.len() - 1; self.last_completion = Some(self.commit_multi(comp, idx)); } } /// Completion at the cursor, computed against the `:`-stripped /// feedback view (ADR-0003 one-shot) with its `replaced_range` /// mapped back to real-buffer coordinates so `commit_*` edit the /// right span. Identity for non-one-shot input (offset 0). fn completion_for_feedback(&self) -> Option { let (view, view_cursor, offset) = self.feedback_view(); let mut comp = crate::completion::candidates_at_cursor_in_mode( view, view_cursor.min(view.len()), &self.schema_cache, self.effective_mode().as_mode(), )?; comp.replaced_range = (comp.replaced_range.0 + offset, comp.replaced_range.1 + offset); Some(comp) } /// Single-candidate commit: insert " " (with trailing /// space) and DO NOT create a memo. The user can keep /// typing or press Tab again to fresh-complete at the new /// cursor. fn commit_unique(&mut self, comp: &crate::completion::Completion) { let inserted = format!("{} ", comp.candidates[0].text); self.input .replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted); self.input_cursor = comp.replaced_range.0 + inserted.len(); } /// Multi-candidate commit: insert just the candidate text /// (no trailing space) and return the memo carrying the /// full candidate list for cycling. The user presses space /// (or any other non-Tab key) to commit the choice — that /// clears the memo and inserts whatever they typed /// normally, naturally producing " " as the /// completed text. fn commit_multi( &mut self, comp: crate::completion::Completion, idx: usize, ) -> crate::completion::LastCompletion { let inserted = comp.candidates[idx].text.clone(); let original_text = self.input[comp.replaced_range.0..comp.replaced_range.1].to_string(); self.input .replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted); let new_end = comp.replaced_range.0 + inserted.len(); self.input_cursor = new_end; crate::completion::LastCompletion { inserted_range: (comp.replaced_range.0, new_end), original_text, candidates: comp.candidates, selection_idx: idx, } } /// Replace the inserted text with `candidates[idx]` (no /// trailing space — same multi-candidate convention) and /// return an updated memo. Used by Tab/Shift-Tab cycling. fn replace_inserted( &mut self, memo: crate::completion::LastCompletion, idx: usize, ) -> crate::completion::LastCompletion { let new_inserted = memo.candidates[idx].text.clone(); let (start, end) = memo.inserted_range; self.input.replace_range(start..end, &new_inserted); let new_end = start + new_inserted.len(); self.input_cursor = new_end; crate::completion::LastCompletion { inserted_range: (start, new_end), selection_idx: idx, ..memo } } fn delete_at_cursor(&mut self) { if self.input_cursor >= self.input.len() { return; } // Find the end of the character at the cursor. let mut idx = self.input_cursor + 1; while idx < self.input.len() && !self.input.is_char_boundary(idx) { idx += 1; } self.input.replace_range(self.input_cursor..idx, ""); } /// Esc — clear a partly-typed command (ADR-0049). Empties the /// buffer, parks the cursor at the start, drops any horizontal /// scroll, and ends history navigation (the cleared line *is* the /// new draft). Only reached when no completion memo is alive — Esc /// undoes a live completion first (handle_key precedence). fn clear_input(&mut self) { self.cancel_history_navigation(); self.input.clear(); self.input_cursor = 0; self.input_scroll_offset = 0; } /// Ctrl-W — delete the word before the cursor (ADR-0049). Eats any /// run of trailing whitespace, then the preceding run of /// non-whitespace, readline-style. UTF-8 safe: word boundaries are /// found on char boundaries, so multi-byte words delete cleanly. fn delete_prev_word(&mut self) { if self.input_cursor == 0 { return; } let prefix = &self.input[..self.input_cursor]; // Strip trailing whitespace, then locate the start of the // word that now ends the prefix. let after_ws = prefix.trim_end_matches(char::is_whitespace); // `idx` is the byte offset of the last whitespace char before // the word; the word starts at the next char. No whitespace at // all → the word starts at the buffer start. let start = after_ws.rfind(char::is_whitespace).map_or(0, |idx| { idx + after_ws[idx..].chars().next().map_or(0, char::len_utf8) }); self.input.replace_range(start..self.input_cursor, ""); self.input_cursor = start; } /// Ctrl-K — kill from the cursor to the end of the line (ADR-0049). /// The cursor is always a char boundary, so a plain truncate is /// safe. fn kill_to_end(&mut self) { self.input.truncate(self.input_cursor); } /// Ctrl-U — kill from the start of the line to the cursor /// (ADR-0049). The cursor moves to the start. fn kill_to_start(&mut self) { self.input.replace_range(0..self.input_cursor, ""); self.input_cursor = 0; } /// Move backwards in history (towards older entries). fn history_back(&mut self) { if self.history.is_empty() { return; } let next_index = match self.history_cursor { None => { // Starting navigation: save the current draft so the // user can return to it. self.history_draft = Some(self.input.clone()); self.history.len() - 1 } Some(0) => 0, Some(i) => i - 1, }; self.history_cursor = Some(next_index); let stored = self.history[next_index].clone(); self.input = self.recall_display(&stored); self.input_cursor = self.input.len(); self.input_scroll_offset = 0; } /// The display form of a stored history entry for the current mode /// (ADR-0052, issue #30). An advanced entry is stored in its /// `:`-prefixed simple-mode runnable form; in **advanced** mode the /// `:` is stripped so it runs as bare SQL, while in **simple** mode it /// stays prefixed and runs via the one-shot escape. A simple entry /// (never starting with `:`) is returned unchanged in either mode. fn recall_display(&self, stored: &str) -> String { if self.mode == Mode::Advanced && let Some(rest) = stored.strip_prefix(':') { return rest.trim_start().to_string(); } stored.to_string() } /// Move forwards in history (towards newer entries; eventually /// returning to the user's saved draft). fn history_forward(&mut self) { let Some(i) = self.history_cursor else { return; }; if i + 1 < self.history.len() { self.history_cursor = Some(i + 1); let stored = self.history[i + 1].clone(); self.input = self.recall_display(&stored); } else { // Past the most recent entry — restore the draft and // exit navigation mode. self.history_cursor = None; self.input = self.history_draft.take().unwrap_or_default(); } self.input_cursor = self.input.len(); self.input_scroll_offset = 0; } fn cancel_history_navigation(&mut self) { self.history_cursor = None; // Drop the saved draft: the user has begun editing again, // so what's in `input` *is* the new draft. self.history_draft = None; } fn push_history(&mut self, line: &str) { // Submitting a command always ends history navigation — // the next Up restarts from the newest entry. Reset here, // before the early-return guards below, so a recalled // command re-submitted unchanged (a consecutive duplicate) // doesn't strand the cursor at its old position. self.history_cursor = None; self.history_draft = None; // Skip empties and consecutive duplicates — the same // trick most shells use to keep navigation pleasant. if line.is_empty() { return; } if self.history.last().map(String::as_str) == Some(line) { return; } self.history.push(line.to_string()); while self.history.len() > HISTORY_CAPACITY { self.history.remove(0); } } fn submit(&mut self) -> Vec { let raw = std::mem::take(&mut self.input); self.input_cursor = 0; self.input_scroll_offset = 0; let trimmed = raw.trim(); if trimmed.is_empty() { return Vec::new(); } // `:` one-shot escape: in simple mode, a leading `:` means // treat *this single submission* as advanced. The persistent // mode is unchanged. The three-way `EffectiveMode` (ADR-0037) is // carried through dispatch so the runtime can gate the DSL → SQL // teaching echo (ADR-0038) on an advanced effective mode. let (submission_mode, effective_input) = if self.mode == Mode::Simple && trimmed.starts_with(':') { (EffectiveMode::AdvancedOneShot, trimmed[1..].trim().to_string()) } else if self.mode == Mode::Advanced { (EffectiveMode::AdvancedPersistent, trimmed.to_string()) } else { (EffectiveMode::Simple, trimmed.to_string()) }; if effective_input.is_empty() { // A bare `:` (one-shot with nothing after it) executes // nothing and is not recorded — the push moved below the // strip (ADR-0052), so it no longer lands in history. return Vec::new(); } debug!( persistent_mode = ?self.mode, submission_mode = ?submission_mode, len = effective_input.len(), "submit" ); // Parse-first: app-level commands and DSL commands share the // parser. App commands work in both modes — they're not gated by // `effective_mode`. Anything that parses to a non-App variant (or // fails to parse) falls through to the mode-specific path. let parsed = parse_command(&effective_input); // ADR-0052 (issue #30): record the command for cross-mode recall. // An **advanced** (SQL) command is stored in its `:`-prefixed // simple-mode runnable form, so it can be recalled and re-run in // simple mode (recall strips the `:` again in advanced mode). A // simple command — and **any app command**, which runs in either // mode and so must not gain a `:` — is stored bare. Recorded // regardless of whether it parses, so typo'd commands stay // 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}") } else { effective_input.clone() }; self.push_history(&ring_line); if let Ok(Command::App(app_cmd)) = parsed { return self.dispatch_app_command(app_cmd, &effective_input); } // For everything else: unified dispatch. `dispatch_dsl` // parses with `effective_mode` (ADR-0030 §2), so a SQL // form in advanced mode runs and a SQL form in simple // mode yields the precise "this is SQL" hint through the // walker's mode gate — no separate placeholder branch. self.dispatch_dsl(&effective_input, submission_mode) } /// Dispatch a parsed app-lifecycle command. Works in both /// simple and advanced modes; the parse-first refactor /// (round-5) routes app commands here before the /// mode-specific DSL/SQL paths. fn dispatch_app_command( &mut self, cmd: crate::dsl::AppCommand, source: &str, ) -> Vec { use crate::dsl::{AppCommand, MessagesValue, ModeValue}; debug!(command = ?cmd, "dispatch app command"); match cmd { AppCommand::Quit => vec![Action::Quit], AppCommand::Help { topic } => { match &topic { Some(t) => self.note_help_topic(t), None => self.note_help(), } 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), AppCommand::New => vec![Action::NewProject { source: "new".to_string(), }], AppCommand::Load => vec![Action::OpenLoadPicker], AppCommand::Export { path } => path.map_or_else( || { vec![Action::Export { target: None, source: "export".to_string(), }] }, |target| { vec![Action::Export { source: format!("export {target}"), target: Some(target), }] }, ), AppCommand::Import { path, target } => { // A path-bearing import carries a non-empty path // from the walker. Bare `import` parses with an // empty path string — surface the usage hint here // at dispatch (not a parse error; ADR-0024 replaced // the old chumsky source-slice path). if path.is_empty() { self.note_error(crate::t!("project.import_usage")); return Vec::new(); } vec![Action::Import { zip_path: path, as_target: target, source: source.to_string(), }] } AppCommand::Mode { value } => { let arg = match value { ModeValue::Simple => "simple", ModeValue::Advanced => "advanced", }; self.handle_mode_command(&format!("mode {arg}")); // Persist the new mode so it is restored on the next // open (ADR-0015 mode-restore amendment, issue #14). vec![Action::PersistMode(self.mode)] } AppCommand::Messages { value } => { let raw = match value { None => "messages".to_string(), Some(MessagesValue::Short) => "messages short".to_string(), Some(MessagesValue::Verbose) => "messages verbose".to_string(), }; self.handle_messages_command(&raw); Vec::new() } AppCommand::Undo => self.handle_undo_command(false), AppCommand::Redo => self.handle_undo_command(true), AppCommand::Copy { scope } => self.handle_copy_command(scope), } } /// `copy` / `copy all` / `copy last` (ADR-0041). Builds the /// plain-text payload from the output panel and hands it to the /// runtime via [`Action::CopyToClipboard`]; the confirmation note /// is pushed *after* the text is captured, so it is never copied. fn handle_copy_command(&mut self, scope: crate::dsl::CopyScope) -> Vec { match self.copy_text(scope) { None => { self.note_system(crate::t!("copy.nothing")); Vec::new() } Some(text) => { let count = text.lines().count(); self.note_system(crate::t!("copy.done", count = count)); vec![Action::CopyToClipboard(text)] } } } /// The clipboard payload for `scope`: every output line's /// [`OutputLine::plain_text`] joined by `\n`. `All` is the whole /// buffer; `Last` is from the most recent echo line to the end /// (ADR-0041). `None` when there is nothing to copy (empty buffer, /// or `Last` with no echo line). fn copy_text(&self, scope: crate::dsl::CopyScope) -> Option { let start = match scope { crate::dsl::CopyScope::All => 0, crate::dsl::CopyScope::Last => self .output .iter() .rposition(|l| l.kind == OutputKind::Echo)?, }; let lines: Vec = self .output .iter() .skip(start) .map(OutputLine::plain_text) .collect(); if lines.is_empty() { return None; } Some(lines.join("\n")) } /// `undo` / `redo` dispatch. When undo is disabled (`--no-undo`) /// the command reports that and does nothing; otherwise it asks /// the runtime to peek the snapshot and open the confirmation /// modal (ADR-0006 Amendment 1). fn handle_undo_command(&mut self, is_redo: bool) -> Vec { if !self.undo_enabled { self.note_system(crate::t!("undo.disabled")); return Vec::new(); } if is_redo { vec![Action::PrepareRedo] } else { vec![Action::PrepareUndo] } } fn dispatch_dsl(&mut self, input: &str, submission_mode: EffectiveMode) -> Vec { // The two-way mode the walker + the `[mode]` render tag read; the // three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for // the runtime's echo gate (ADR-0038). let mode = submission_mode.as_mode(); // ADR-0024 §Phase D: parse with the live schema so typed // value slots (insert-into-T-values-…) dispatch on the // column's actual user-facing type instead of accepting // any literal at bind time. // // ADR-0030 §2: parse with the submission's effective // mode so the walker gates SQL-only forms — simple-mode // `select` returns the "this is SQL" hint as a normal // parse error and is rendered through the Err arm below. match crate::dsl::parser::parse_command_with_schema_in_mode( input, &self.schema_cache, mode, ) { Ok(Command::Replay { path }) => { // `replay` is parsed as a DSL command for the // sake of grammar uniformity, but its execution // model is fundamentally different from every // other command — it loops over file content and // re-enters the dispatch pipeline once per line. // Sending it down the ExecuteDsl path would push // the recursion through the database worker // thread, which is wrong: the worker has no // filesystem context, and replay would also land // in `history.log` (where it would re-trigger // itself on the next replay-of-history). So we // hand it off as a dedicated `Action::Replay`, // keeping the worker out of the loop and the // history.log clean. self.push_output(OutputLine::echo(input, mode)); vec![Action::Replay { path }] } Ok(cmd) => { // Issue #1 sub-task 3: advanced-mode positional // `INSERT INTO T VALUES (…)` (no column list) with a // value count that doesn't match the column count gets // a teaching note here, *before* dispatch. The engine // would otherwise surface a raw NOT-NULL / type error // that doesn't mention the column-list override. if let Some(note) = crate::input_render::form_b_positional_count_mismatch_note( &cmd, &self.schema_cache, ) { // Pre-flight rejection (not executed): plain // `running:` echo, `status: None` (ADR-0040 scope). self.push_output(OutputLine { text: crate::t!("dsl.running", input = input), kind: OutputKind::Echo, mode_at_submission: mode, styled_runs: None, status: None, }); self.note_error(note); return vec![Action::JournalFailure { source: input.to_string(), advanced: submission_mode.is_advanced(), }]; } // Issue #17: simple-mode (DSL) counterpart. A wrong-count // DSL insert now parses `Ok` (so the typing-time arity // diagnostic can fire), so dispatch is gated here — the // same teaching the old parse-error path showed, now with // the insert reliably blocked from reaching the worker. if let Some(notes) = crate::input_render::dsl_insert_count_mismatch_notes( input, &cmd, &self.schema_cache, ) { // Pre-flight rejection (not executed): plain // `running:` echo, `status: None` (ADR-0040 scope). self.push_output(OutputLine { text: crate::t!("dsl.running", input = input), kind: OutputKind::Echo, mode_at_submission: mode, styled_runs: None, status: None, }); for note in notes { self.note_error(note); } self.note_error(render_usage_block(input, mode)); return vec![Action::JournalFailure { source: input.to_string(), advanced: submission_mode.is_advanced(), }]; } self.push_output(OutputLine::echo(input, mode)); vec![Action::ExecuteDsl { command: cmd, source: input.to_string(), submission_mode, }] } Err(ParseError::Empty) => Vec::new(), Err(err) => { // Echo the source line so the user can see what // got submitted (and copy-paste it back to fix). // Parse error (not executed): plain `running:` echo, // `status: None` — the caret aligns to `running: ` // (ADR-0040 scope). self.push_output(OutputLine { text: crate::t!("dsl.running", input = input), kind: OutputKind::Echo, mode_at_submission: mode, styled_runs: None, status: None, }); // Caret pointer at the failure position, when we // have one. Aligned to the "running: " prefix so // the caret sits under the offending character. // // Note: the prefix length is hardcoded against // the en-US `dsl.running` template ("running: // {input}"). A translator changing that prefix // must update this width too — the constraint is // captured in the catalog comment block. // // ADR-0020: positions returned by `parse_command` // are byte offsets into the *original* input // (the lexer doesn't trim before lexing). We // convert to a character count for caret padding. if let ParseError::Invalid { position, .. } = &err { let prefix = "running: "; let chars_before = input .get(..*position) .map_or(*position, |s| s.chars().count()); let pad = prefix.chars().count() + chars_before; self.note_error(crate::t!( "parse.caret", padding = " ".repeat(pad) )); } self.note_error(crate::t!( "parse.error", detail = parse_error_message(&err) )); // ADR-0033 Amendment 3: combine the DSL error with a // pointer to advanced mode when the same line would // run as SQL there. Only in simple mode (a one-shot // `:` or persistent advanced submission uses the SQL // surface already). This mirrors the live hint and // covers SQL constructs that surface only on submit // (e.g. `delete … returning`, where the live hint // shows WHERE-completion rather than an error). if mode == Mode::Simple && let Some(note) = crate::input_render::advanced_alternative_note(input, &self.schema_cache) { self.note_error(note); } // Issue #1 sub-task 2's Form B teaching note used to be // appended here, because a wrong-count Form B insert // failed to parse and landed in this Err arm. As of issue // #17 such tuples parse `Ok` (so the typing-time arity // diagnostic fires) and the teaching + dispatch block now // live in the Ok arm's `dsl_insert_count_mismatch_notes` // pre-flight — a single model shared with advanced mode. // ADR-0021 §2: append the usage block (if a // known command-entry keyword was consumed) or // the available-commands fallback (§5). if let ParseError::Invalid { .. } = &err { self.note_error(render_usage_block(input, mode)); } // ADR-0034 §1/§2: a submitted line that failed to // parse is journalled `err` so it is recallable // across sessions (the same `source` an `ok` // command would record). The runtime does the // append; the App only emits the intent. vec![Action::JournalFailure { source: input.to_string(), advanced: submission_mode.is_advanced(), }] } } } /// Emit the standard `[ok] ` header used by /// every successful DSL command. Routes through the i18n /// catalog (ADR-0019 §9 sweep). /// Resolve the oldest still-`Pending` echo to `status` /// (ADR-0040). Results arrive in submission order (the db worker /// is FIFO), so the oldest pending echo is this command's; a /// finished command can never leave an earlier one stuck on /// `running:`. No-op if there is no pending echo (e.g. a result /// for a command whose echo path didn't mark one). fn mark_oldest_pending_echo(&mut self, status: EchoStatus) { if let Some(line) = self .output .iter_mut() .find(|l| l.kind == OutputKind::Echo && l.status == Some(EchoStatus::Pending)) { line.status = Some(status); } } /// Mark a command's echo successful (ADR-0040 — replaces the old /// `[ok] ` summary line) and emit the ADR-0038 /// DSL → SQL teaching echo that was stashed on the success event. fn note_ok_summary(&mut self, command: &Command) { debug!(verb = command.verb(), "dsl command succeeded"); self.mark_oldest_pending_echo(EchoStatus::Ok); // ADR-0038 §4: one `OutputKind::TeachingEcho` line per // statement — the dimmed `Executing SQL:` prefix + the SQL // re-lexed in advanced mode for highlighting (see // `ui::render_output_line`'s `TeachingEcho` branch). if let Some(lines) = self.pending_echo.take() { for line in lines { self.push_teaching_echo(&line); } } } fn handle_dsl_success(&mut self, command: &Command, description: Option) { self.note_ok_summary(command); if let Some(desc) = description.as_ref() { // ADR-0044 §1 "relationship-relevant" reach: when a // relationship is the subject of the command (`show table`, // `add`/`drop relationship`), render the table's // relationships as compact diagrams. Every other (incidental // DDL) echo renders structure only — no relationship block // at all (ADR-0050, issue #28; supersedes ADR-0044 §1's // prose retention for these surfaces). if matches!( command, Command::ShowTable { .. } | Command::AddRelationship { .. } | Command::DropRelationship { .. } ) { debug!(verb = command.verb(), width = self.last_output_width, "render: relationship diagrams (ADR-0044)"); for line in crate::output_render::render_structure_with_diagrams( desc, self.last_output_width, self.mode, ) { self.push_output(line); } } else { for line in crate::output_render::render_structure(desc) { self.note_system(line); } } } self.current_table = description; } fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) { self.note_ok_summary(command); for line in crate::output_render::render_data_table(data) { self.note_system(line); } } fn handle_dsl_explain_success( &mut self, command: &Command, plan: &crate::db::QueryPlan, ) { self.note_ok_summary(command); // ADR-0028 §3: the display SQL, then the plan tree. // `render_explain_plan` returns ready-built `OutputLine`s // so it can carry the per-span styling (ADR-0028 §5). for line in crate::output_render::render_explain_plan(plan, self.mode) { self.push_output(line); } } /// `show relationship ` (ADR-0044): render the relationship /// as a styled two-table diagram, App-side, sized to the current /// output-panel width. `None` is the friendly not-found line. fn handle_dsl_show_relationship_success( &mut self, command: &Command, data: Option<&crate::db::RelationshipDiagramData>, ) { self.note_ok_summary(command); match data { Some(data) => { for line in crate::output_render::render_relationship_diagram( data, self.last_output_width, self.mode, ) { self.push_output(line); } } None => { let name = match command { Command::ShowList { name: Some(n), .. } => n.as_str(), _ => "", }; self.note_system(format!("No relationship named `{name}`.")); } } } fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) { self.note_ok_summary(command); self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected)); for line in crate::output_render::render_data_table(&result.data) { self.note_system(line); } } /// Render a successful `seed` (ADR-0048): the ✓ echo, the seeded-row /// count (with a cap note when the unique-value space ran out), the /// capped preview table (D18), and a Hint-styled advisory naming /// columns filled with generic text that look like fixed value sets /// (D12/D13). fn handle_dsl_seed_success(&mut self, command: &Command, result: &crate::db::SeedResult) { self.note_ok_summary(command); let mut summary = crate::t!( "ok.rows_seeded", count = result.produced, table = result.table ); if result.produced < result.requested { summary.push(' '); summary.push_str(&crate::t!("seed.capped", requested = result.requested)); } self.note_system(summary); for line in crate::output_render::render_data_table(&result.data) { self.note_system(line); } if !result.advisory_columns.is_empty() { // `column` (the first advised column) seeds the concrete // repair examples (D13 Phase 2/3 wording); `columns` lists // them all. self.push_category_three_prose(crate::t!( "seed.advisory_generic", columns = result.advisory_columns.join(", "), column = result.advisory_columns[0], table = result.table )); } } fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) { self.note_ok_summary(command); self.note_system(crate::t!("ok.rows_updated", count = result.rows_affected)); // A column-less result carries no rows to tabulate (the SQL // UPDATE path before `RETURNING`, ADR-0033 sub-phase 3e): // surface just the count rather than a misleading // "(no rows)" band. The DSL UPDATE always has columns. if !result.data.columns.is_empty() { for line in crate::output_render::render_data_table(&result.data) { self.note_system(line); } } } fn handle_dsl_add_column_success( &mut self, command: &Command, result: AddColumnResult, ) { self.note_ok_summary(command); // ADR-0018 §9 / ADR-0038 §6 category 3: emit auto-fill note(s) // before the structure render so the pedagogical "the tool did // this for you" line is in the user's eye-line next to the // success summary. De-emphasised per §6 (illuminating prose). for note in result.client_side_notes { self.push_category_three_prose(note); } for line in crate::output_render::render_structure(&result.description) { self.note_system(line); } self.current_table = Some(result.description); } fn handle_dsl_drop_column_success( &mut self, command: &Command, result: DropColumnResult, ) { self.note_ok_summary(command); // ADR-0025: when `--cascade` removed covering indexes, // name each one so the learner sees the side effect. for index in &result.dropped_indexes { self.note_system(crate::t!( "ok.index_dropped_with_column", index = index, )); } for line in crate::output_render::render_structure(&result.description) { self.note_system(line); } self.current_table = Some(result.description); } fn handle_dsl_change_column_success( &mut self, command: &Command, result: ChangeColumnTypeResult, dont_convert_caveat: bool, ) { self.note_ok_summary(command); if let Some(note) = result.client_side { // ADR-0017 §6 + ADR-0018 §9: pedagogical hook // telling the learner "the tool did this for you; // raw SQL would need explicit CAST / UPDATE / etc." // // When both transformations and auto-fills happen // in the same operation, both note lines are // emitted in order (ADR-0018 §9 explicit rule). // ADR-0038 §6 category 3: both lines are illuminating prose // (the SQL line is equivalent; the note merely reveals a // value-add SQL doesn't show). De-emphasised. if note.transformed > 0 { let line = if note.lossy > 0 { crate::t!( "client_side.transformed_lossy", count = note.transformed, lossy = note.lossy ) } else { crate::t!( "client_side.transformed", count = note.transformed ) }; self.push_category_three_prose(line); } if note.auto_filled > 0 { let kind = match note.auto_fill_kind { Some(crate::db::AutoFillKind::Serial) => "serial", Some(crate::db::AutoFillKind::ShortId) => "shortid", None => "auto-generated", }; self.push_category_three_prose(crate::t!( "client_side.auto_fill_transition", count = note.auto_filled, kind = kind )); } } // ADR-0038 §6 category 3 caveat: `--dont-convert` skips the // client-side layer entirely, so the headline echo is the // nearest SQL but *not* equivalent (the only Bucket A caveat — // every other category-3 line is illuminating). Sits between // the client-side notes and the structure render so it reads // alongside the echo, not after the table view. De-emphasised // prose per §6. if dont_convert_caveat { self.push_category_three_prose(crate::t!("client_side.dont_convert_caveat")); } for line in crate::output_render::render_structure(&result.description) { self.note_system(line); } self.current_table = Some(result.description); } fn handle_dsl_delete_success(&mut self, command: &Command, result: &DeleteResult) { self.note_ok_summary(command); self.note_system(crate::t!("ok.rows_deleted", count = result.rows_affected)); for effect in &result.cascade { self.note_system(render_cascade_effect(effect)); } // A `RETURNING` clause (ADR-0033 §5, 3g) carries the deleted // rows; the cascade summary above surfaces alongside them. A // column-less result (the DSL `delete` and SQL `DELETE` // without RETURNING) is skipped, exactly as for UPDATE. if !result.data.columns.is_empty() { for line in crate::output_render::render_data_table(&result.data) { self.note_system(line); } } } fn handle_dsl_failure( &mut self, command: &Command, error: crate::db::DbError, facts: crate::friendly::FailureContext, ) { // Render through the friendly-error layer (ADR-0019). // The translator picks operation-tailored wording from // the catalog and applies the user's current verbosity. // `facts` carries schema-resolved enrichment (parent // tables, attempted values, pinpointed rows) the // 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, "dsl command failed" ); // ADR-0040: the echo line carries the ✗; the redundant // `" " failed:` prefix is dropped — only the // rendered reason is shown. `note_error` splits on newlines // internally — refusal diagnostics from `change column …` // (ADR-0017 §7) flow through as a multi-line bordered table. self.mark_oldest_pending_echo(EchoStatus::Err); self.note_error(crate::t!("dsl.failed", rendered = rendered)); } /// Construct a [`TranslateContext`] from a [`Command`] + schema- /// resolved [`FailureContext`], using the App's current verbosity. /// Thin wrapper over [`Self::translate_context_for`], which is shared /// with the replay path (it supplies its own verbosity — ADR-0035 /// Amendment 1, F2 follow-up). fn build_translate_context( &self, command: &Command, facts: crate::friendly::FailureContext, ) -> crate::friendly::TranslateContext { Self::translate_context_for(command, facts, self.messages_verbosity) } /// Combine the runtime-supplied [`FailureContext`] (schema-resolved /// facts) with the operation derived from the originating [`Command`] /// and an explicit `verbosity`. Schema-resolved facts win over /// Command-derived fallbacks where the runtime supplied them /// (typically the FK-relationship lookup yields a `parent_table` the /// Command alone can't reveal). Shared by interactive rendering and /// the replay failure path (ADR-0035 Amendment 1, F2 follow-up), so a /// replayed failing command shows real names instead of leaking /// `{name}` placeholders. pub(crate) fn translate_context_for( command: &Command, facts: crate::friendly::FailureContext, verbosity: crate::friendly::Verbosity, ) -> crate::friendly::TranslateContext { use crate::dsl::{AlterTableAction, Command as C, IndexSelector, RelationshipSelector}; use crate::friendly::{Operation, TranslateContext}; let (operation, fallback_table, fallback_column) = match command { C::CreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None), // SQL `ALTER TABLE` routes engine/validation errors through // the operation matching its action, with the parsed table // (and column, where the action names one) — ADR-0035 §4e. C::SqlAlterTable { table, action } => match action { AlterTableAction::AddColumn(spec) => ( Operation::AddColumn, Some(table.as_str()), Some(spec.name.as_str()), ), AlterTableAction::DropColumn { column } => ( Operation::DropColumn, Some(table.as_str()), Some(column.as_str()), ), AlterTableAction::RenameColumn { old, .. } => ( Operation::RenameColumn, Some(table.as_str()), Some(old.as_str()), ), AlterTableAction::AlterColumnType { column, .. } => ( Operation::ChangeColumnType, Some(table.as_str()), Some(column.as_str()), ), // ADR-0035 Amendment 2: the column-attribute set/drop forms // decompose to add/drop constraint, and name a column (so // the friendly error can pinpoint it). AlterTableAction::SetColumnNotNull { column } | AlterTableAction::SetColumnDefault { column, .. } => ( Operation::AddConstraint, Some(table.as_str()), Some(column.as_str()), ), AlterTableAction::DropColumnNotNull { column } | AlterTableAction::DropColumnDefault { column } => ( Operation::DropConstraint, Some(table.as_str()), Some(column.as_str()), ), AlterTableAction::AddTableConstraint { .. } => { (Operation::AddConstraint, Some(table.as_str()), None) } AlterTableAction::DropConstraint { .. } => { (Operation::DropConstraint, Some(table.as_str()), None) } // `RENAME TO ` — the failure concerns the table being // renamed (the old name); the executor authors the // existing-target / same-name refusals (ADR-0035 §6, 4h). AlterTableAction::RenameTable { .. } => { (Operation::RenameTable, Some(table.as_str()), None) } }, C::SqlCreateTable { name, .. } => { (Operation::CreateTable, Some(name.as_str()), None) } C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None), C::SqlDropTable { name, .. } => (Operation::DropTable, Some(name.as_str()), None), C::AddColumn { table, column, .. } => ( Operation::AddColumn, Some(table.as_str()), Some(column.as_str()), ), C::DropColumn { table, column, .. } => ( Operation::DropColumn, Some(table.as_str()), Some(column.as_str()), ), C::RenameColumn { table, .. } => (Operation::RenameColumn, Some(table.as_str()), None), C::ChangeColumnType { table, column, .. } => ( Operation::ChangeColumnType, Some(table.as_str()), Some(column.as_str()), ), C::AddRelationship { parent_table, parent_columns, .. } => ( Operation::AddRelationship, Some(parent_table.as_str()), // Single-column facts model (ADR-0019): the first PK // column for a compound FK (ADR-0043). parent_columns.first().map(String::as_str), ), // m:n builds a junction table; its errors (missing parent, // no PK, self-reference, name collision) name the relevant // table in the message, so no fallback table/column here. C::CreateM2nRelationship { .. } => (Operation::CreateTable, None, None), C::DropRelationship { selector } => match selector { RelationshipSelector::Endpoints { parent_table, parent_column, .. } => ( Operation::DropRelationship, Some(parent_table.as_str()), Some(parent_column.as_str()), ), RelationshipSelector::Named { .. } => (Operation::DropRelationship, None, None), }, C::AddIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None), // SQL `CREATE [UNIQUE] INDEX` shares the add-index operation // (it reuses `do_add_index`); route engine/validation errors // through it with the parsed table. C::SqlCreateIndex { table, .. } => { (Operation::AddIndex, Some(table.as_str()), None) } C::AddConstraint { table, column, .. } => ( Operation::AddConstraint, Some(table.as_str()), Some(column.as_str()), ), C::DropConstraint { table, column, .. } => ( Operation::DropConstraint, Some(table.as_str()), Some(column.as_str()), ), C::DropIndex { selector } => match selector { IndexSelector::Columns { table, .. } => { (Operation::DropIndex, Some(table.as_str()), None) } IndexSelector::Named { .. } => (Operation::DropIndex, None, None), }, // The SQL `DROP INDEX` is name-only (the table is resolved by // the executor), like the named DSL drop. C::SqlDropIndex { .. } => (Operation::DropIndex, None, None), C::Insert { table, .. } => (Operation::Insert, Some(table.as_str()), None), // Seed generates inserts; FK/constraint failures read as // insert errors (ADR-0048). C::Seed { table, .. } => (Operation::Insert, Some(table.as_str()), None), C::Update { table, .. } => (Operation::Update, Some(table.as_str()), None), C::Delete { table, .. } => (Operation::Delete, Some(table.as_str()), None), C::ShowData { name, .. } | C::ShowTable { name } => { (Operation::Query, Some(name.as_str()), None) } // A `show ` list spans no single table; a failure // routes through Query with no table fallback. C::ShowList { .. } => (Operation::Query, None, None), // A SQL `SELECT` carries only its statement text — // no single table name to fall back on. A query // failure routes through `Operation::Query`. C::Select { .. } => (Operation::Query, None, None), // A SQL `INSERT` (ADR-0033) — route engine errors // (FK / UNIQUE / NOT NULL) through the insert operation // with the parsed target table. C::SqlInsert { target_table, .. } => { (Operation::Insert, Some(target_table.as_str()), None) } // A SQL `UPDATE` (ADR-0033 §2) — route engine errors // through the update operation with the parsed target. C::SqlUpdate { target_table, .. } => { (Operation::Update, Some(target_table.as_str()), None) } // A SQL `DELETE` (ADR-0033 §1/§7) — route engine errors // (e.g. an FK violation with no cascade) through the // delete operation with the parsed target. C::SqlDelete { target_table, .. } => { (Operation::Delete, Some(target_table.as_str()), None) } C::Replay { .. } => (Operation::Replay, None, None), // An `explain` failure (e.g. unknown table) is best // described by the wrapped query it failed to plan. C::Explain { query } => { return Self::translate_context_for(query, facts, verbosity); } // App-lifecycle commands never reach this path — // `dispatch_input` routes them through // `dispatch_app_command` before the DSL execution // pipeline that this context builder feeds. C::App(_) => unreachable!( "App commands are dispatched before reaching dsl execution" ), }; TranslateContext { operation: Some(operation), table: facts .table .or_else(|| fallback_table.map(str::to_string)), column: facts .column .or_else(|| fallback_column.map(str::to_string)), child_table: facts.child_table, parent_table: facts.parent_table, parent_column: facts.parent_column, src_type: None, target_type: None, value: facts.value, diagnostic_table: facts.diagnostic_table, check_rule: facts.check_rule, verbosity, } } /// Parse the argument tail of an `import` command and /// return the corresponding `Action::Import`. /// /// Grammar: `import [as ]`. The /// separator is the literal ` as ` (whitespace + "as" + /// whitespace) so a zip path containing the substring /// "as" is fine — the separator only matches when /// surrounded by spaces. `split_once` is used (first /// occurrence wins), which is the natural reading. /// Dispatch for the `save` and `save as` commands. /// /// `save` on a temp project is identical to `save as` /// (prompts for a target). `save` on a named project is a /// no-op with a friendly hint, since auto-save guarantees /// the named project is already persistent (ADR-0015 §11). fn handle_save_command(&mut self, force_save_as: bool) -> Vec { if !force_save_as && !self.project_is_temp { self.note_system(crate::t!("save.already_saved")); return Vec::new(); } let title = if force_save_as { crate::t!("save.title_as") } else { crate::t!("save.title_save") }; self.modal = Some(Modal::PathEntry(PathEntryModal { title, prompt: crate::t!("save.path_prompt"), input: String::new(), cursor: 0, purpose: PathEntryPurpose::SaveAs, })); Vec::new() } /// Route a keypress through whichever modal is active. /// /// Each modal owns its own tiny state machine. On /// confirmation, the modal yields one or more `Action`s /// for the runtime to enact. On dismissal it simply /// closes itself. fn handle_modal_key(&mut self, key: KeyEvent) -> Vec { let Some(modal) = self.modal.clone() else { return Vec::new(); }; match modal { Modal::RebuildConfirm(_) => self.handle_rebuild_confirm_key(key), Modal::PathEntry(state) => self.handle_path_entry_key(key, state), Modal::LoadPicker(state) => self.handle_load_picker_key(key, state), Modal::UndoConfirm(state) => self.handle_undo_confirm_key(key, &state), } } fn handle_undo_confirm_key(&mut self, key: KeyEvent, state: &UndoConfirmModal) -> Vec { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { self.modal = None; if state.is_redo { vec![Action::Redo] } else { vec![Action::Undo] } } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { self.modal = None; if state.is_redo { self.note_system(crate::t!("modal.redo_cancelled")); } else { self.note_system(crate::t!("modal.undo_cancelled")); } Vec::new() } _ => Vec::new(), } } fn handle_rebuild_confirm_key(&mut self, key: KeyEvent) -> Vec { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { self.modal = None; vec![Action::Rebuild { source: "rebuild".to_string(), }] } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { self.modal = None; self.note_system(crate::t!("modal.rebuild_cancelled")); Vec::new() } _ => Vec::new(), } } fn handle_path_entry_key( &mut self, key: KeyEvent, mut state: PathEntryModal, ) -> Vec { match key.code { KeyCode::Esc => { self.modal = None; self.note_system(crate::t!( "modal.generic_cancelled", title = state.title.to_lowercase() )); Vec::new() } KeyCode::Enter => { let target = state.input.trim().to_string(); if target.is_empty() { self.note_error(crate::t!("modal.path_entry_empty_name")); self.modal = Some(Modal::PathEntry(state)); return Vec::new(); } self.modal = None; match state.purpose { PathEntryPurpose::SaveAs => vec![Action::SaveAs { target, source: "save as".to_string(), }], PathEntryPurpose::LoadByPath => vec![Action::LoadProject { path: std::path::PathBuf::from(target), source: "load".to_string(), }], } } KeyCode::Char(c) => { state.input.insert(state.cursor, c); state.cursor += c.len_utf8(); self.modal = Some(Modal::PathEntry(state)); Vec::new() } KeyCode::Backspace => { if state.cursor > 0 { let before = state.input[..state.cursor].chars().next_back(); if let Some(c) = before { let new_cursor = state.cursor - c.len_utf8(); state.input.drain(new_cursor..state.cursor); state.cursor = new_cursor; } } self.modal = Some(Modal::PathEntry(state)); Vec::new() } KeyCode::Left => { if state.cursor > 0 && let Some(c) = state.input[..state.cursor].chars().next_back() { state.cursor -= c.len_utf8(); } self.modal = Some(Modal::PathEntry(state)); Vec::new() } KeyCode::Right => { if state.cursor < state.input.len() && let Some(c) = state.input[state.cursor..].chars().next() { state.cursor += c.len_utf8(); } self.modal = Some(Modal::PathEntry(state)); Vec::new() } KeyCode::Home => { state.cursor = 0; self.modal = Some(Modal::PathEntry(state)); Vec::new() } KeyCode::End => { state.cursor = state.input.len(); self.modal = Some(Modal::PathEntry(state)); Vec::new() } _ => { self.modal = Some(Modal::PathEntry(state)); Vec::new() } } } fn handle_load_picker_key( &mut self, key: KeyEvent, mut state: LoadPickerModal, ) -> Vec { match &mut state.sub_mode { LoadPickerSubMode::List => match key.code { KeyCode::Esc => { self.modal = None; self.note_system(crate::t!("modal.load_cancelled")); Vec::new() } // `k` mirrors Up; vi-style keys keep the picker drivable by // autocast, which can only emit typeable characters (#24). KeyCode::Up | KeyCode::Char('k') => { if state.selected > 0 { state.selected -= 1; } self.modal = Some(Modal::LoadPicker(state)); Vec::new() } // `j` mirrors Down (see the Up arm above). KeyCode::Down | KeyCode::Char('j') => { if state.selected + 1 < state.entries.len() { state.selected += 1; } self.modal = Some(Modal::LoadPicker(state)); Vec::new() } // `g` jumps to the first entry, `G` to the last (vi convention). KeyCode::Char('g') => { state.selected = 0; self.modal = Some(Modal::LoadPicker(state)); Vec::new() } KeyCode::Char('G') => { state.selected = state.entries.len().saturating_sub(1); self.modal = Some(Modal::LoadPicker(state)); Vec::new() } KeyCode::Enter => { if let Some(entry) = state.entries.get(state.selected).cloned() { self.modal = None; return vec![Action::LoadProject { path: entry.path, source: "load".to_string(), }]; } self.note_error(crate::t!("modal.load_picker_nothing")); self.modal = Some(Modal::LoadPicker(state)); Vec::new() } KeyCode::Char('b') | KeyCode::Char('B') => { state.sub_mode = LoadPickerSubMode::PathEntry { input: String::new(), cursor: 0, }; self.modal = Some(Modal::LoadPicker(state)); Vec::new() } _ => { self.modal = Some(Modal::LoadPicker(state)); Vec::new() } }, LoadPickerSubMode::PathEntry { input, cursor } => match key.code { KeyCode::Esc => { state.sub_mode = LoadPickerSubMode::List; self.modal = Some(Modal::LoadPicker(state)); Vec::new() } KeyCode::Enter => { let target = input.trim().to_string(); if target.is_empty() { self.note_error(crate::t!("modal.path_entry_empty_path")); self.modal = Some(Modal::LoadPicker(state)); return Vec::new(); } self.modal = None; vec![Action::LoadProject { path: std::path::PathBuf::from(target), source: "load".to_string(), }] } KeyCode::Char(c) => { input.insert(*cursor, c); *cursor += c.len_utf8(); self.modal = Some(Modal::LoadPicker(state)); Vec::new() } KeyCode::Backspace => { if *cursor > 0 && let Some(c) = input[..*cursor].chars().next_back() { let new_cursor = *cursor - c.len_utf8(); input.drain(new_cursor..*cursor); *cursor = new_cursor; } self.modal = Some(Modal::LoadPicker(state)); Vec::new() } KeyCode::Left => { if *cursor > 0 && let Some(c) = input[..*cursor].chars().next_back() { *cursor -= c.len_utf8(); } self.modal = Some(Modal::LoadPicker(state)); Vec::new() } KeyCode::Right => { if *cursor < input.len() && let Some(c) = input[*cursor..].chars().next() { *cursor += c.len_utf8(); } self.modal = Some(Modal::LoadPicker(state)); Vec::new() } _ => { self.modal = Some(Modal::LoadPicker(state)); Vec::new() } }, } } /// Note the list of currently-supported commands to the /// output panel. /// /// Assembled from the command REGISTRY (ADR-0024 §help_id): /// the framing (`help.intro`, `help.dsl_section`, /// `help.types_reference`) comes from the catalog, and each /// command's body is the catalog entry named by its /// `help_id`. A newly-registered command appears here /// automatically — no edit to this function or a hand-kept /// list. Each catalog line becomes its own `OutputLine` so /// the scroll-position math (one logical line = one display /// row) stays accurate per the renderer's invariant. fn note_help(&mut self) { use crate::dsl::grammar::REGISTRY; let mut lines: Vec = Vec::new(); lines.push(crate::t!("help.intro")); // REGISTRY is ordered app-commands first; emit the // "DSL data commands" sub-header at the first command // whose help_id leaves the `app.` namespace. let mut dsl_header_done = false; for (command, _category) in REGISTRY { let Some(help_id) = command.help_id else { continue; }; if !dsl_header_done && !help_id.starts_with("app.") { lines.push(crate::t!("help.dsl_section")); dsl_header_done = true; } let key = format!("help.{help_id}"); let body = crate::friendly::translate(&key, &[]); lines.extend(body.lines().map(str::to_string)); } lines.extend( crate::t!("help.types_reference") .lines() .map(str::to_string), ); // H3: point at the focused per-command form. lines.push(crate::t!("help.detail_hint")); for line in lines { self.note_system(line); } } /// Focused per-command help (H3): `help `, where `topic` /// is a command entry word (`insert`, `create`, `show`, …) or /// the special `types`. Renders the help block(s) of every /// command sharing that entry word — so `help create` covers /// both the DSL and SQL create forms — or a friendly pointer /// back to `help` when nothing matches. fn note_help_topic(&mut self, topic: &str) { use crate::dsl::grammar::REGISTRY; let topic = topic.trim(); // `help types` re-shows just the type reference. if topic.eq_ignore_ascii_case("types") { for line in crate::t!("help.types_reference").lines() { self.note_system(line.to_string()); } return; } let mut lines: Vec = Vec::new(); for (command, _category) in REGISTRY { let Some(help_id) = command.help_id else { continue; }; if command.entry.matches(topic) { let key = format!("help.{help_id}"); let body = crate::friendly::translate(&key, &[]); lines.extend(body.lines().map(str::to_string)); } } if lines.is_empty() { // No command owns that entry word — name it and point // back at the full list rather than failing silently. self.note_system(crate::t!("help.unknown_topic", topic = topic)); return; } for line in lines { self.note_system(line); } } fn handle_messages_command(&mut self, raw: &str) { let arg = raw.strip_prefix("messages").unwrap_or(raw).trim(); match arg { "" => { let current = match self.messages_verbosity { crate::friendly::Verbosity::Short => "short", crate::friendly::Verbosity::Verbose => "verbose", }; self.note_system(crate::t!("messages.show", current = current)); } "short" => { self.messages_verbosity = crate::friendly::Verbosity::Short; self.note_system(crate::t!("messages.set_short")); } "verbose" => { self.messages_verbosity = crate::friendly::Verbosity::Verbose; self.note_system(crate::t!("messages.set_verbose")); } other => self.note_error(crate::t!("messages.unknown", value = other)), } } fn handle_mode_command(&mut self, raw: &str) { let arg = raw.strip_prefix("mode").unwrap_or(raw).trim(); match arg { "simple" => { self.mode = Mode::Simple; self.note_system(crate::t!("mode.set_simple")); } "advanced" => { self.mode = Mode::Advanced; self.note_system(crate::t!("mode.set_advanced")); } "" => self.note_error(crate::t!("mode.usage")), other => self.note_error(crate::t!("mode.unknown", value = other)), } } // ── 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_key_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::>() .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 (`.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(); let what_key = format!("{stem}.what"); if cat.get(&what_key).is_none() { return false; } // Labelled block (ADR-0053 D4): a `Hint` heading, then aligned // `What:` / `Example:` / `Concept:` lines. `concept` renders // muted (`OutputStyleClass::Hint`); the rest are plain system. let labelled = |label: &str, value: &str| { // Pad `