//! 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: <input>`) and resolves to `Ok`/`Err` when the result
/// arrives — rendered `<input> ✓` / `<input> ✗`, 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<Vec<OutputSpan>>,
    /// 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<EchoStatus>,
}

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<OutputSpan>,
    ) -> Self {
        Self {
            text,
            kind,
            mode_at_submission,
            styled_runs: Some(runs),
            status: None,
        }
    }

    /// A `running: <input>` 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<OutputLine>,
    pub hint: Option<String>,
    /// Catalog class key of the most recent runtime error (H2 /
    /// ADR-0053 D5), e.g. `foreign_key.child_side`. Set when a
    /// friendly error is rendered, cleared on the next successful
    /// command. The submitted `hint` command and empty-input F1 use
    /// it to render that error's tier-3 `hint.err.<class>` block.
    /// `None` → no recent error → the "getting started" pointer.
    pub last_error_hint_key: Option<String>,
    /// The validity indicator's currently-visible verdict
    /// (ADR-0027). `None` means the indicator shows nothing —
    /// the input is clean, or it is hidden mid-typing while the
    /// 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<crate::dsl::walker::Severity>,
    pub tables: Vec<String>,
    /// 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<crate::persistence::RelationshipSchema>,
    /// Last successfully described table, shown in the output
    /// pane until the next DDL operation.
    pub current_table: Option<TableDescription>,
    /// 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<String>,
    /// Position within `history` while navigating with Up/Down.
    /// `None` means "not navigating; `input` is the user's
    /// in-progress draft."
    history_cursor: Option<usize>,
    /// 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<String>,
    /// 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<String>,
    /// 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<String>,
    /// 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<Modal>,
    /// 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<crate::completion::LastCompletion>,
    /// 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,
    /// Issue #39: `true` while a dispatched DSL command's async
    /// schema-cache refresh is still in flight. The runtime applies a
    /// command on the worker thread and only *afterwards* sends back a
    /// `SchemaCacheRefreshed` event; until that lands, `schema_cache` is
    /// stale w.r.t. the command just dispatched. Validating a follow-up
    /// submission against that stale cache is the issue #39 bug (a Form-B
    /// insert after `add column` sees the pre-DDL columns and is wrongly
    /// rejected as "trying to write SQL?"). While this flag is set, new
    /// `dispatch_dsl` submissions are *held* in `held_submissions` rather
    /// than validated, then drained when the refresh arrives. Armed on
    /// every `ExecuteDsl` dispatch, so at most one *DSL* command is ever in
    /// flight — refreshes are then strictly one-per-dispatch and in order,
    /// keeping the gate a simple, provably-correct boolean. (App-lifecycle
    /// commands — `load` / `undo` / `rebuild` — also refresh the cache but
    /// bypass this gate; they are modal/picker-gated and so cannot overlap a
    /// rapid DSL paste, the only thing this guards.) Cleared in the
    /// `SchemaCacheRefreshed` handler.
    awaiting_schema_refresh: bool,
    /// Issue #39: submissions deferred while `awaiting_schema_refresh` is
    /// set, in submission order. Each is the canonical input line plus the
    /// effective mode it was submitted under; drained through
    /// `dispatch_dsl` (re-validated against the now-fresh cache) when the
    /// pending refresh lands. `push_history` already ran for these at
    /// `submit` time, so draining re-enters at `dispatch_dsl`, not `submit`.
    held_submissions: std::collections::VecDeque<(String, EffectiveMode)>,
    /// 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<String>,
    /// 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<Vec<String>>,
}

/// 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 `<data-root>/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<LoadPickerEntry>,
    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, the `Ctrl-O` navigation toggle, and the `Ctrl-G` F1-alias
/// (ADR-0047 amendment). 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 ADR-0046 navigation toggle.
        (KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => Some("[CTRL-O]"),
        // ADR-0047 amendment: the Ctrl-G F1-alias badges AS [F1] so a
        // cast recorded by a tool that can't send F1 looks identical to a
        // real F1 press. (Only consulted in demo mode — the caller gates.)
        (KeyCode::Char('g'), m) if m.contains(KeyModifiers::CONTROL) => Some("[F1]"),
        _ => 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(),
            // Issue #39: no command is in flight at construction; the
            // schema-refresh gate starts open with an empty hold queue.
            awaiting_schema_refresh: false,
            held_submissions: std::collections::VecDeque::new(),
            // 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<String>) {
        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<crate::dsl::walker::Severity> {
        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<Action> {
        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;
                // Issue #39: the in-flight command's refresh has landed, so
                // the gate opens. Drain any submissions held while it was in
                // flight, re-validating each against the now-fresh cache.
                // Stop as soon as a drained command dispatches (re-arming the
                // gate via its own `ExecuteDsl`): the remaining held commands
                // then wait for *its* refresh, preserving submission order. A
                // held command that does not dispatch (parse error / pre-flight
                // rejection) leaves the gate open, so the loop continues to the
                // next held submission.
                self.awaiting_schema_refresh = false;
                let mut actions = Vec::new();
                while !self.awaiting_schema_refresh {
                    let Some((input, submission_mode)) = self.held_submissions.pop_front() else {
                        break;
                    };
                    actions.extend(self.dispatch_dsl(&input, submission_mode));
                }
                actions
            }
            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<Action> {
        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<Action> {
        // 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).
        //
        // ADR-0047 amendment: in demo mode, Ctrl-G is an alias for F1.
        // The cast recorder (autocast) can't emit F1 (an escape
        // sequence) but can send the single control byte Ctrl-G; it
        // badges AS [F1] (see `demo_badge_label`) so the cast is visually
        // identical to a real F1 press. Demo-gated, so the shipped keymap
        // stays F1-only.
        let hint_key = key.code == KeyCode::F(1)
            || (self.demo_mode
                && (key.code, key.modifiers) == (KeyCode::Char('g'), KeyModifiers::CONTROL));
        if hint_key {
            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<Vec<Action>> {
        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<Action> {
        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<Action> {
        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<crate::completion::Completion> {
        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 "<text> " (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 "<chosen> " 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<Action> {
        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<Action> {
        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()
            }
            // ADR-0054: the in-app twin of `--version`. Reports the same
            // single source of truth (`CARGO_PKG_VERSION`, via cli::version_text).
            AppCommand::Version => {
                self.note_system(crate::cli::version_text());
                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<Action> {
        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<String> {
        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<String> = 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<Action> {
        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<Action> {
        // Issue #39: if a previously-dispatched command's schema-cache
        // refresh is still in flight, `schema_cache` is stale w.r.t. that
        // command. Validating this submission now would race it (the bug:
        // a Form-B insert after `add column` rejected against the pre-DDL
        // schema). Hold it in submission order; the `SchemaCacheRefreshed`
        // handler drains the queue once the fresh schema lands. App-level
        // commands (`quit`, `help`, `load`, …) route through
        // `dispatch_app_command` *before* here, so they are never held.
        if self.awaiting_schema_refresh {
            debug!(input, "holding submission until schema cache refresh lands");
            self.held_submissions
                .push_back((input.to_string(), submission_mode));
            return Vec::new();
        }
        // 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));
                // Issue #39: a command is now in flight; its schema-cache
                // refresh will land asynchronously. Arm the gate so any
                // follow-up submission waits for the fresh schema rather
                // than racing the stale cache. Armed on every dispatch (not
                // just DDL) so only one command is ever in flight, which
                // keeps the gate a simple boolean — refreshes are then
                // strictly one-per-dispatch and in order.
                self.awaiting_schema_refresh = true;
                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] <verb> <subject>` 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] <verb> <subject>` 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<TableDescription>) {
        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 <name>` (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
        // `"<verb> <subject>" 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 <new>` — 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 <kind>` 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 <zip-path> [as <target>]`. 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<Action> {
        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<Action> {
        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<Action> {
        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<Action> {
        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<Action> {
        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<Action> {
        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.simple_section`,
    /// `help.advanced_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.
    ///
    /// Issue #36: the commands group by mode. App-lifecycle commands
    /// (`help_id` in the `app.*` namespace) work in either mode and
    /// list first, unlabelled, under the intro. The rest split by
    /// [`CommandCategory`] into a simple-mode group and an
    /// advanced-mode (SQL) group — replacing the old single "DSL data
    /// commands (in simple mode)" header, which both used the banned
    /// "DSL" term and wrongly claimed simple mode for the advanced SQL
    /// forms the section already contained.
    fn note_help(&mut self) {
        use crate::dsl::grammar::{CommandCategory, REGISTRY};

        let mut lines: Vec<String> = Vec::new();
        lines.push(crate::t!("help.intro"));

        let mut simple: Vec<String> = Vec::new();
        let mut advanced: Vec<String> = Vec::new();
        for (command, category) in REGISTRY {
            let Some(help_id) = command.help_id else {
                continue;
            };
            let body = crate::friendly::translate(&format!("help.{help_id}"), &[]);
            let block: Vec<String> = body.lines().map(str::to_string).collect();
            if help_id.starts_with("app.") {
                lines.extend(block);
            } else if matches!(category, CommandCategory::Advanced) {
                advanced.extend(block);
            } else {
                simple.extend(block);
            }
        }
        if !simple.is_empty() {
            lines.push(crate::t!("help.simple_section"));
            lines.extend(simple);
        }
        if !advanced.is_empty() {
            lines.push(crate::t!("help.advanced_section"));
            lines.extend(advanced);
        }
        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 <topic>`, 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<String> = 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}"), "hint.block.heading")
        {
            // Clause-concept layer (issue #37 / clause-concept-hints D6):
            // if the cursor sits inside a recognized clause, append its
            // `hint.concept.<topic>` block beneath the per-form block.
            // Only when the per-form block rendered — the concept layer
            // never appears alone.
            if let Some(topic) = crate::dsl::walker::concept_topic_at_cursor(
                &probe,
                cursor,
                Some(&self.schema_cache),
                mode,
            ) {
                self.emit_tier3_block(
                    &format!("hint.concept.{topic}"),
                    "hint.block.concept_heading",
                );
            }
            return;
        }
        // Tier-2 fallback: surface the ambient prose as a persistent
        // line (computed exactly as the live panel does).
        let ambient = crate::input_render::ambient_hint_in_mode(
            &probe,
            cursor,
            self.last_completion.as_ref(),
            &self.schema_cache,
            mode,
        );
        match ambient {
            Some(crate::input_render::AmbientHint::Prose(text)) => {
                self.push_category_three_prose(text);
            }
            Some(crate::input_render::AmbientHint::Candidates { items, .. }) => {
                let names = items
                    .iter()
                    .map(|c| c.text.clone())
                    .collect::<Vec<_>>()
                    .join(", ");
                self.push_category_three_prose(crate::t!(
                    "hint.ambient_expected",
                    expected = names
                ));
            }
            None => self.note_getting_started(),
        }
    }

    /// The `hint` command (and empty-input F1): expand on the most
    /// recent runtime error, else point the user at how to start
    /// (ADR-0053 D2/D5).
    fn note_hint_for_recent_error(&mut self) {
        if let Some(class) = self.last_error_hint_key.clone()
            && self.emit_tier3_block(&format!("hint.err.{class}"), "hint.block.heading")
        {
            return;
        }
        self.note_getting_started();
    }

    fn note_getting_started(&mut self) {
        self.note_system(crate::t!("hint.getting_started"));
    }

    /// Render a tier-3 block (`<stem>.what` / `.example` / `.concept`)
    /// when it has been authored; returns `false` if the `what` part is
    /// absent so the caller can fall back to tier 2. `what` is
    /// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
    /// polish (the framed block) lands with the corpus.
    /// Render a tier-3 block (`<stem>.what` / `.example` / `.concept`)
    /// under `heading_key` (ADR-0053 D4; clause-concept-hints D6).
    /// Returns `false` if the `what` part is absent so the caller can
    /// fall back. `heading_key` lets the clause-concept block carry a
    /// distinct sub-heading ("About this clause") beneath the per-form
    /// `Hint` block. The example lookup is **mode-keyed**
    /// (clause-concept-hints D5/D6): it prefers `<stem>.example.<mode>`
    /// (`simple`/`advanced` from the live effective mode) and falls
    /// back to a plain `<stem>.example` — so `hint.cmd.*` / `hint.err.*`
    /// (plain example) are unaffected, while a both-mode
    /// `hint.concept.*` topic shows the mode-correct example.
    fn emit_tier3_block(&mut self, stem: &str, heading_key: &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): the heading, then aligned
        // `What:` / `Example:` / `Concept:` lines. `concept` renders
        // muted (`OutputStyleClass::Hint`); the rest are plain system.
        let labelled = |label: &str, value: &str| {
            // Pad `<Label>:` to a common width so the values align.
            format!("  {:<9}{value}", format!("{label}:"))
        };
        let mode_str = match self.effective_mode().as_mode() {
            crate::mode::Mode::Simple => "simple",
            crate::mode::Mode::Advanced => "advanced",
        };
        let example_key = {
            let keyed = format!("{stem}.example.{mode_str}");
            if cat.get(&keyed).is_some() {
                keyed
            } else {
                format!("{stem}.example")
            }
        };
        self.note_system(crate::friendly::translate(heading_key, &[]));
        self.note_system(labelled(
            &crate::t!("hint.block.what"),
            &crate::friendly::translate(&what_key, &[]),
        ));
        if cat.get(&example_key).is_some() {
            self.note_system(labelled(
                &crate::t!("hint.block.example"),
                &crate::friendly::translate(&example_key, &[]),
            ));
        }
        if cat.get(&format!("{stem}.concept")).is_some() {
            self.push_category_three_prose(labelled(
                &crate::t!("hint.block.concept"),
                &crate::friendly::translate(&format!("{stem}.concept"), &[]),
            ));
        }
        true
    }

    fn note_system(&mut self, text: impl Into<String>) {
        self.push_multiline(text.into(), OutputKind::System);
    }

    /// Push one teaching-echo line (ADR-0038 §4 styled-runs polish).
    /// The text is `"<TEACHING_ECHO_LABEL><sql>"`; the `TeachingEcho`
    /// kind triggers `ui::render_output_line`'s custom branch, which
    /// renders the prefix dimmed (`theme.muted`) and lexes the SQL in
    /// advanced mode for syntax highlighting — the same treatment the
    /// input echo receives.
    fn push_teaching_echo(&mut self, sql: &str) {
        let text = format!("{}{sql}", crate::echo::TEACHING_ECHO_LABEL);
        self.push_output(OutputLine {
            text,
            kind: OutputKind::TeachingEcho,
            mode_at_submission: self.mode,
            styled_runs: None,
            status: None,
        });
    }

    /// Push one category-3 prose line (ADR-0038 §6) — the
    /// `--dont-convert` caveat and the existing illuminating
    /// `client_side.*` notes (shortid auto-fill, type-conversion
    /// transforms). The whole line text is rendered dimmed
    /// (`OutputStyleClass::Hint` → `theme.muted`); the `[system]`
    /// tag keeps its kind styling. De-emphasised, per §6.
    fn push_category_three_prose(&mut self, text: impl Into<String>) {
        let text = text.into();
        let runs = if text.is_empty() {
            Vec::new()
        } else {
            vec![OutputSpan {
                byte_range: (0, text.len()),
                class: OutputStyleClass::Hint,
            }]
        };
        self.push_output(OutputLine::styled(
            text,
            OutputKind::System,
            self.mode,
            runs,
        ));
    }

    fn note_error(&mut self, text: impl Into<String>) {
        self.push_multiline(text.into(), OutputKind::Error);
    }

    /// Push possibly-multi-line `text` as a sequence of single-line
    /// `OutputLine`s. Keeping one display row per `OutputLine` is
    /// what makes the scroll-position math (line count = display
    /// rows) accurate; the renderer therefore truncates rather
    /// than wraps long lines.
    fn push_multiline(&mut self, text: String, kind: OutputKind) {
        if text.is_empty() {
            self.push_output(OutputLine {
                text,
                kind,
                mode_at_submission: self.mode,
                styled_runs: None,
                status: None,
            });
            return;
        }
        for line in text.split('\n') {
            self.push_output(OutputLine {
                text: line.to_string(),
                kind,
                mode_at_submission: self.mode,
                styled_runs: None,
                status: None,
            });
        }
    }

    fn push_output(&mut self, line: OutputLine) {
        self.output.push_back(line);
        while self.output.len() > OUTPUT_CAPACITY {
            self.output.pop_front();
        }
        // Any new line resets the scroll so freshly-arrived
        // output is always visible. The user can PageUp again
        // to inspect history.
        self.output_scroll = 0;
    }

    fn scroll_output_up(&mut self) {
        // Cap at `total_wrapped - visible` (display rows, not
        // logical lines) so the topmost visible chunk is the
        // first `visible` rendered rows; going past that would
        // shrink the view by sliding the window off the top.
        let max = self
            .last_output_total_wrapped
            .saturating_sub(self.last_output_visible.max(1));
        self.output_scroll = (self.output_scroll + PAGE_SCROLL_LINES).min(max);
    }

    const fn scroll_output_down(&mut self) {
        self.output_scroll = self.output_scroll.saturating_sub(PAGE_SCROLL_LINES);
    }
}

fn parse_error_message(err: &ParseError) -> String {
    match err {
        ParseError::Invalid { message, .. } => message.clone(),
        ParseError::Empty => crate::t!("parse.empty"),
    }
}

/// Compose the third block of a parse-error rendering
/// (ADR-0021 §2): "usage: …" when at least one
/// command-entry keyword was consumed, otherwise an
/// "available commands:" fallback (§5).
///
/// Driven by the walker registry (ADR-0024 §architecture).
/// If the input's first identifier-shape token is a registered
/// `CommandNode` entry word, the node's `usage_ids` slice
/// renders every catalog template — multi-form families like
/// `drop` show every variant. Otherwise the fallback lists every
/// entry keyword alphabetically.
fn render_usage_block(input: &str, mode: Mode) -> String {
    // A multi-form command that has committed to a form
    // (`add index …`) shows only that form's usage; a bare
    // multi-form entry word (`add`) shows the whole family.
    // Mode-aware (ADR-0042 G3): in advanced mode a shared entry
    // word shows its SQL forms, not the DSL templates.
    let catalog_keys: Vec<&'static str> =
        crate::dsl::grammar::usage_key_for_input_in_mode(input, mode)
            .map(|key| vec![key])
            .or_else(|| {
                crate::dsl::grammar::usage_keys_for_input_in_mode(input, mode)
                    .map(|(_word, all)| all)
            })
            .unwrap_or_default();
    if !catalog_keys.is_empty() {
        let mut out = String::from("usage:");
        for key in catalog_keys {
            let template = crate::friendly::translate(key, &[]);
            for line in template.lines() {
                out.push('\n');
                out.push_str("  ");
                out.push_str(line);
            }
        }
        return out;
    }
    // No-prefix fallback. Each entry word renders backticked
    // verbatim (replaces the old `parse.token.keyword.*` catalog
    // lookup; ADR-0024 §cleanup-pass §F prescribes the same
    // wrapping helper).
    let names: Vec<String> = crate::dsl::grammar::entry_words_alphabetised()
        .into_iter()
        .map(|w| format!("`{w}`"))
        .collect();
    crate::t!("parse.available_commands", commands = names.join(", "))
}

fn render_cascade_effect(effect: &CascadeEffect) -> String {
    use crate::dsl::ReferentialAction;
    let action_key = match effect.action {
        ReferentialAction::Cascade => "db.cascade.action_deleted",
        ReferentialAction::SetNull => "db.cascade.action_set_null",
        ReferentialAction::Restrict | ReferentialAction::NoAction => "db.cascade.action_blocked",
    };
    crate::t!(
        "db.cascade.summary",
        count = effect.rows_changed,
        action = crate::friendly::translate(action_key, &[]),
        child_table = effect.child_table,
        rel = effect.relationship_name,
        on_delete = effect.action,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::ColumnDescription;
    use crate::dsl::Type;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    use pretty_assertions::assert_eq;

    fn key(code: KeyCode) -> AppEvent {
        AppEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
    }

    fn key_mod(code: KeyCode, mods: KeyModifiers) -> AppEvent {
        AppEvent::Key(KeyEvent::new(code, mods))
    }

    // ---- ADR-0047 (issue #22): demo-mode keystroke badges ----

    fn ke(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
        KeyEvent::new(code, mods)
    }

    #[test]
    fn demo_badge_label_maps_the_invisible_keys() {
        let none = KeyModifiers::NONE;
        assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]"));
        assert_eq!(
            demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)),
            Some("[SHIFT-TAB]")
        );
        assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]"));
        assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]"));
        assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]"));
        assert_eq!(demo_badge_label(&ke(KeyCode::Down, none)), Some("[DOWN]"));
        assert_eq!(demo_badge_label(&ke(KeyCode::Left, none)), Some("[LEFT]"));
        assert_eq!(demo_badge_label(&ke(KeyCode::Right, none)), Some("[RIGHT]"));
        assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]"));
        assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]"));
        assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]"));
        assert_eq!(
            demo_badge_label(&ke(KeyCode::PageDown, none)),
            Some("[PGDN]")
        );
        assert_eq!(
            demo_badge_label(&ke(KeyCode::Backspace, none)),
            Some("[BKSP]")
        );
        assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]"));
        assert_eq!(
            demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)),
            Some("[CTRL-O]")
        );
        // ADR-0047 amendment: the Ctrl-G F1-alias badges AS [F1], so a
        // cast recorded with autocast (which can't send F1) is visually
        // identical to a real F1 press.
        assert_eq!(
            demo_badge_label(&ke(KeyCode::Char('g'), KeyModifiers::CONTROL)),
            Some("[F1]")
        );
    }

    #[test]
    fn demo_badge_label_none_for_glyphs_and_excluded_chords() {
        // Plain characters render their own glyph — no badge.
        assert_eq!(
            demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)),
            None
        );
        assert_eq!(
            demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)),
            None
        );
        // Quit and the (Phase C) caption toggle are deliberately excluded.
        assert_eq!(
            demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)),
            None
        );
        // Ctrl+] decodes to Char('5')+CONTROL — must not badge.
        assert_eq!(
            demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)),
            None
        );
    }

    #[test]
    fn demo_mode_off_never_sets_a_badge() {
        let mut app = App::new();
        assert!(!app.demo_mode);
        app.update(key(KeyCode::Tab));
        assert_eq!(app.demo_badge, None);
        assert_eq!(app.demo_badge_seq, 0);
    }

    #[test]
    fn demo_mode_on_sets_badge_and_bumps_seq() {
        let mut app = App::new();
        app.demo_mode = true;

        app.update(key(KeyCode::Tab));
        assert_eq!(app.demo_badge, Some("[TAB]"));
        assert_eq!(app.demo_badge_seq, 1);

        app.update(key(KeyCode::Enter));
        assert_eq!(app.demo_badge, Some("[ENTER]"));
        assert_eq!(app.demo_badge_seq, 2);

        // The same label twice still bumps the seq so the runtime
        // restarts the expiry timer.
        app.update(key(KeyCode::Enter));
        assert_eq!(app.demo_badge, Some("[ENTER]"));
        assert_eq!(app.demo_badge_seq, 3);

        // A glyph key leaves the badge (and seq) untouched — the
        // runtime's timer is what clears it, not the next key.
        app.update(key(KeyCode::Char('x')));
        assert_eq!(app.demo_badge, Some("[ENTER]"));
        assert_eq!(app.demo_badge_seq, 3);
    }

    #[test]
    fn demo_badge_fires_over_an_open_modal() {
        // Badges are set before the modal gate, so the `#24` projects
        // cast can show [ENTER]/[DOWN] while the load picker is up.
        let mut app = App::new();
        app.demo_mode = true;
        app.modal = Some(Modal::LoadPicker(LoadPickerModal {
            entries: Vec::new(),
            selected: 0,
            sub_mode: LoadPickerSubMode::List,
        }));
        app.update(key(KeyCode::Down));
        assert_eq!(app.demo_badge, Some("[DOWN]"));
        assert_eq!(app.demo_badge_seq, 1);
    }

    // ---- ADR-0047 (issue #22): demo-mode step-caption stealth buffer ----

    /// `Ctrl+]` — the caption toggle (decodes to Char('5')+CONTROL).
    fn caption_toggle() -> AppEvent {
        key_mod(KeyCode::Char('5'), KeyModifiers::CONTROL)
    }

    #[test]
    fn demo_caption_toggle_captures_then_commits() {
        let mut app = App::new();
        app.demo_mode = true;

        app.update(caption_toggle());
        assert!(app.demo_caption_capturing, "first Ctrl+] opens capture");
        assert_eq!(app.demo_caption, None);

        type_str(&mut app, "Press Tab");
        // The text accumulates invisibly — nothing on the input line.
        assert_eq!(app.input, "");
        assert_eq!(app.demo_caption_buffer, "Press Tab");
        assert_eq!(app.demo_caption, None, "not shown until committed");

        app.update(caption_toggle());
        assert!(!app.demo_caption_capturing, "second Ctrl+] commits");
        assert_eq!(app.demo_caption.as_deref(), Some("Press Tab"));
        assert_eq!(app.demo_caption_buffer, "", "buffer drained on commit");
        assert_eq!(app.input, "", "input never touched");
    }

    #[test]
    fn demo_caption_backspace_edits_the_buffer() {
        let mut app = App::new();
        app.demo_mode = true;
        app.update(caption_toggle());
        type_str(&mut app, "Helloo");
        app.update(key(KeyCode::Backspace));
        assert_eq!(app.demo_caption_buffer, "Hello");
        assert_eq!(app.input, "");
    }

    #[test]
    fn demo_caption_other_keys_are_inert_while_capturing() {
        let mut app = App::new();
        app.demo_mode = true;
        app.update(caption_toggle());
        type_str(&mut app, "note");
        // Enter must not submit, Tab must not complete, arrows do nothing.
        let a1 = app.update(key(KeyCode::Enter));
        let a2 = app.update(key(KeyCode::Tab));
        let a3 = app.update(key(KeyCode::Up));
        assert!(a1.is_empty() && a2.is_empty() && a3.is_empty());
        assert!(app.demo_caption_capturing, "still capturing");
        assert_eq!(app.demo_caption_buffer, "note");
        assert_eq!(app.input, "");
        assert_eq!(
            app.demo_badge, None,
            "inert keys raise no badge while capturing"
        );
    }

    #[test]
    fn demo_caption_empty_commit_dismisses() {
        let mut app = App::new();
        app.demo_mode = true;
        app.demo_caption = Some("old".to_string());
        // Open (clears the visible caption) then commit empty.
        app.update(caption_toggle());
        assert_eq!(app.demo_caption, None, "opening clears the visible caption");
        app.update(caption_toggle());
        assert_eq!(app.demo_caption, None, "empty commit leaves nothing");
        assert!(!app.demo_caption_capturing);
    }

    #[test]
    fn demo_caption_cleared_by_next_ordinary_keystroke() {
        let mut app = App::new();
        app.demo_mode = true;
        app.demo_caption = Some("step 1".to_string());
        // An ordinary key clears the caption, then is processed normally.
        app.update(key(KeyCode::Char('a')));
        assert_eq!(app.demo_caption, None);
        assert_eq!(app.input, "a", "the key still reaches the input");
    }

    #[test]
    fn demo_caption_captures_over_an_open_modal() {
        // The stealth buffer sits before the modal gate, so captions can
        // be authored while the load picker is up (the `#24` cast).
        let mut app = App::new();
        app.demo_mode = true;
        app.modal = Some(Modal::LoadPicker(LoadPickerModal {
            entries: Vec::new(),
            selected: 0,
            sub_mode: LoadPickerSubMode::List,
        }));
        app.update(caption_toggle());
        type_str(&mut app, "pick one");
        app.update(caption_toggle());
        assert_eq!(app.demo_caption.as_deref(), Some("pick one"));
        // The modal is untouched by the capture.
        assert!(matches!(app.modal, Some(Modal::LoadPicker(_))));
    }

    #[test]
    fn demo_mode_off_makes_ctrl_rbracket_inert() {
        let mut app = App::new();
        assert!(!app.demo_mode);
        app.update(caption_toggle());
        type_str(&mut app, "x");
        assert!(!app.demo_caption_capturing);
        assert_eq!(app.demo_caption, None);
        // Ctrl+] did nothing; the later 'x' is an ordinary character.
        assert_eq!(app.input, "x");
    }

    fn type_str(app: &mut App, s: &str) {
        for c in s.chars() {
            app.update(key(KeyCode::Char(c)));
        }
    }

    fn submit(app: &mut App) -> Vec<Action> {
        app.update(key(KeyCode::Enter))
    }

    /// Render every error-kind output line, one per line, for
    /// failed-assertion error messages.
    fn error_lines(app: &App) -> String {
        app.output
            .iter()
            .filter(|l| l.kind == OutputKind::Error)
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn output_contains(app: &App, needle: &str) -> bool {
        app.output.iter().any(|l| l.text.contains(needle))
    }

    // ---- undo / redo dispatch + modal (ADR-0006 Amendment 1) ----

    #[test]
    fn undo_command_emits_prepare_undo() {
        let mut app = App::new();
        type_str(&mut app, "undo");
        assert_eq!(submit(&mut app), vec![Action::PrepareUndo]);
    }

    #[test]
    fn redo_command_emits_prepare_redo() {
        let mut app = App::new();
        type_str(&mut app, "redo");
        assert_eq!(submit(&mut app), vec![Action::PrepareRedo]);
    }

    #[test]
    fn undo_when_disabled_notes_and_emits_no_action() {
        let mut app = App::new();
        app.undo_enabled = false;
        type_str(&mut app, "undo");
        let actions = submit(&mut app);
        assert!(actions.is_empty(), "no action when disabled: {actions:?}");
        assert!(
            output_contains(&app, "turned off"),
            "expected a 'turned off' note, output: {:?}",
            app.output
        );
    }

    #[test]
    fn undo_prepared_opens_modal_naming_the_command() {
        let mut app = App::new();
        let actions = app.update(AppEvent::UndoPrepared {
            command: "delete from Customers where id = 2".to_string(),
            timestamp: "2026-05-24T10:00:00Z".to_string(),
            is_redo: false,
        });
        assert!(actions.is_empty());
        match &app.modal {
            Some(Modal::UndoConfirm(m)) => {
                assert_eq!(m.command, "delete from Customers where id = 2");
                assert!(!m.is_redo);
            }
            other => panic!("expected UndoConfirm modal, got {other:?}"),
        }
    }

    #[test]
    fn undo_modal_y_confirms_and_emits_undo() {
        let mut app = App::new();
        app.update(AppEvent::UndoPrepared {
            command: "x".to_string(),
            timestamp: "t".to_string(),
            is_redo: false,
        });
        let actions = app.update(key(KeyCode::Char('y')));
        assert_eq!(actions, vec![Action::Undo]);
        assert!(app.modal.is_none(), "modal closes on confirm");
    }

    #[test]
    fn redo_modal_y_emits_redo() {
        let mut app = App::new();
        app.update(AppEvent::UndoPrepared {
            command: "x".to_string(),
            timestamp: "t".to_string(),
            is_redo: true,
        });
        assert_eq!(app.update(key(KeyCode::Char('y'))), vec![Action::Redo]);
    }

    #[test]
    fn undo_modal_esc_cancels_without_action() {
        let mut app = App::new();
        app.update(AppEvent::UndoPrepared {
            command: "x".to_string(),
            timestamp: "t".to_string(),
            is_redo: false,
        });
        let actions = app.update(key(KeyCode::Esc));
        assert!(actions.is_empty());
        assert!(app.modal.is_none());
        assert!(output_contains(&app, "cancelled"));
    }

    #[test]
    fn undo_unavailable_notes_nothing_to_undo() {
        let mut app = App::new();
        let actions = app.update(AppEvent::UndoUnavailable { is_redo: false });
        assert!(actions.is_empty());
        assert!(output_contains(&app, "nothing to undo"));
    }

    #[test]
    fn undo_succeeded_closes_modal_and_notes_command() {
        let mut app = App::new();
        app.modal = Some(Modal::UndoConfirm(UndoConfirmModal {
            command: "x".to_string(),
            timestamp: "t".to_string(),
            is_redo: false,
        }));
        app.update(AppEvent::UndoSucceeded {
            command: "delete from T --all-rows".to_string(),
            is_redo: false,
        });
        assert!(app.modal.is_none());
        assert!(output_contains(&app, "delete from T --all-rows"));
    }

    // ---- ADR-0022 stage 8: Tab completion + Esc/Backspace undo ----

    #[test]
    fn tab_with_unique_candidate_inserts_with_space_and_no_memo() {
        // Single-candidate path: insert "<text> ", no memo.
        // Stage-8 follow-up #2 (testing-round-2): no memo
        // for unique completions so subsequent Tab fresh-
        // computes at the new cursor.
        let mut app = App::new();
        type_str(&mut app, "cre");
        let actions = app.update(key(KeyCode::Tab));
        assert!(actions.is_empty());
        assert_eq!(app.input, "create ");
        assert_eq!(app.input_cursor, 7);
        assert!(app.last_completion.is_none());
    }

    #[test]
    fn tab_at_word_boundary_inserts_next_expected_keyword() {
        // `change ` → expects only `column`. Single candidate;
        // insert "column " with space, no memo. (Uses `change`, not
        // `create`: ADR-0045 made `create ` ambiguous — `table` vs
        // `m:n` — so it is no longer a single-candidate boundary.)
        let mut app = App::new();
        type_str(&mut app, "change ");
        let actions = app.update(key(KeyCode::Tab));
        assert!(actions.is_empty());
        assert_eq!(app.input, "change column ");
        assert!(app.last_completion.is_none());
    }

    #[test]
    fn tab_with_no_candidates_is_a_noop() {
        // After `create table T with pk` the parser succeeds —
        // no candidates, Tab does nothing.
        let mut app = App::new();
        type_str(&mut app, "create table T with pk");
        let len = app.input.len();
        app.update(key(KeyCode::Tab));
        assert_eq!(app.input.len(), len);
        assert!(app.last_completion.is_none());
    }

    #[test]
    fn tab_with_multi_candidates_inserts_without_space_and_creates_memo() {
        // Multi-candidate path: insert WITHOUT trailing space
        // so the user can press space to commit. Memo carries
        // the candidate list for cycling.
        let mut app = App::new();
        type_str(&mut app, "show ");
        app.update(key(KeyCode::Tab));
        assert_eq!(app.input, "show data");
        assert!(app.last_completion.is_some());
    }

    #[test]
    fn tab_cycles_forward_through_multi_candidate_set() {
        // `show ` offers five subcommands in grammar order:
        // data / table / tables / relationships / indexes (V5).
        let mut app = App::new();
        type_str(&mut app, "show ");
        for expected in [
            "show data",
            "show table",
            "show tables",
            "show relationships",
            "show indexes",
            "show relationship",
            "show index",
        ] {
            app.update(key(KeyCode::Tab));
            assert_eq!(app.input, expected);
        }
        // Wrap-around back to the first.
        app.update(key(KeyCode::Tab));
        assert_eq!(app.input, "show data");
    }

    #[test]
    fn shift_tab_cycles_backward_starting_from_last() {
        let mut app = App::new();
        type_str(&mut app, "show ");
        // Backward starts from the last candidate (`index`, the
        // V5a singular form).
        for expected in ["show index", "show relationship", "show indexes"] {
            app.update(key(KeyCode::BackTab));
            assert_eq!(app.input, expected);
        }
    }

    #[test]
    fn space_after_multi_candidate_tab_commits_the_choice() {
        // The natural commit gesture for multi-candidate Tab:
        // press space. Memo clears (any non-Tab key clears),
        // space is inserted normally → "show data " ready
        // for the next position.
        let mut app = App::new();
        type_str(&mut app, "show ");
        app.update(key(KeyCode::Tab)); // → "show data" (no space)
        type_str(&mut app, " ");
        assert_eq!(app.input, "show data ");
        assert!(app.last_completion.is_none());
    }

    #[test]
    fn esc_after_multi_tab_restores_original_in_one_keystroke() {
        // Multi-candidate Tab leaves a memo; Esc undoes the
        // whole insertion regardless of cycle depth.
        let mut app = App::new();
        type_str(&mut app, "show ");
        app.update(key(KeyCode::Tab)); // → "show data"
        app.update(key(KeyCode::Esc));
        assert_eq!(app.input, "show ");
        assert!(app.last_completion.is_none());
    }

    #[test]
    fn backspace_after_multi_tab_restores_original_in_one_keystroke() {
        let mut app = App::new();
        type_str(&mut app, "show ");
        app.update(key(KeyCode::Tab));
        app.update(key(KeyCode::Backspace));
        assert_eq!(app.input, "show ");
        assert!(app.last_completion.is_none());
    }

    #[test]
    fn esc_after_multiple_tabs_restores_original_state_not_previous_cycle() {
        // Tab Tab Tab cycled through three candidates; Esc
        // restores the pre-completion state (no insertion at
        // all), not the previous cycle.
        let mut app = App::new();
        type_str(&mut app, "drop ");
        app.update(key(KeyCode::Tab)); // column
        app.update(key(KeyCode::Tab)); // relationship
        app.update(key(KeyCode::Tab)); // table
        assert_eq!(app.input, "drop table");
        app.update(key(KeyCode::Esc));
        assert_eq!(app.input, "drop ");
    }

    #[test]
    fn typing_a_letter_clears_the_completion_memo() {
        let mut app = App::new();
        type_str(&mut app, "show ");
        app.update(key(KeyCode::Tab));
        assert!(app.last_completion.is_some());
        // Typing any non-Tab key clears the memo. The
        // inserted text stays in the buffer.
        type_str(&mut app, "x");
        assert_eq!(app.input, "show datax");
        assert!(app.last_completion.is_none());
        // Backspace now does its normal job — delete one char.
        app.update(key(KeyCode::Backspace));
        assert_eq!(app.input, "show data");
    }

    #[test]
    fn cursor_movement_clears_the_completion_memo() {
        let mut app = App::new();
        type_str(&mut app, "show ");
        app.update(key(KeyCode::Tab));
        // Cursor movement clears the memo. After this,
        // Esc / Backspace behave normally — no whole-span
        // undo.
        app.update(key(KeyCode::Home));
        assert!(app.last_completion.is_none());
    }

    #[test]
    fn unique_tab_then_another_unique_tab_chains_naturally() {
        // Stage-8 follow-up #2 (testing-round-2): the
        // single-candidate-no-memo design lets the user chain
        // Tabs through unique completions without getting
        // stuck. From "ch", Tab → "change ", Tab → "change
        // column ". (Round 5 added the app-lifecycle commands —
        // single-letter prefixes like `i` are now ambiguous
        // (`insert` vs. `import`), so the test starts from a
        // disambiguated two-letter prefix. `change` is used rather
        // than `create`: ADR-0045 made `create ` ambiguous (`table`
        // vs `m:n`), so it no longer chains as a unique completion.)
        let mut app = App::new();
        type_str(&mut app, "ch");
        app.update(key(KeyCode::Tab));
        assert_eq!(app.input, "change ");
        app.update(key(KeyCode::Tab));
        assert_eq!(app.input, "change column ");
        assert!(app.last_completion.is_none());
    }

    fn sample_description(name: &str) -> TableDescription {
        TableDescription {
            name: name.to_string(),
            columns: vec![ColumnDescription {
                name: "id".to_string(),
                user_type: Some(Type::Serial),
                sqlite_type: "INTEGER".to_string(),
                notnull: false,
                primary_key: true,
                unique: false,
                default: None,
                check: None,
            }],
            outbound_relationships: Vec::new(),
            inbound_relationships: Vec::new(),
            indexes: Vec::new(),
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
        }
    }

    #[test]
    fn typing_accumulates_in_input_buffer() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        assert_eq!(app.input, "hello");
        assert!(app.output.is_empty());
    }

    #[test]
    fn backspace_removes_last_char() {
        let mut app = App::new();
        type_str(&mut app, "abc");
        app.update(key(KeyCode::Backspace));
        assert_eq!(app.input, "ab");
    }

    #[test]
    fn valid_dsl_in_simple_mode_emits_execute_action() {
        let mut app = App::new();
        type_str(&mut app, "create table Customers with pk");
        let actions = submit(&mut app);
        assert_eq!(actions.len(), 1);
        let Action::ExecuteDsl { command, .. } = &actions[0] else {
            panic!("expected ExecuteDsl, got {:?}", actions[0]);
        };
        assert_eq!(
            command,
            &Command::CreateTable {
                name: "Customers".to_string(),
                columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)],
                primary_key: vec!["id".to_string()],
            },
        );
        // The input is echoed back as a "running:" notice so the
        // user sees something happened while the DB worker runs.
        assert!(!app.output.is_empty());
    }

    // ---- ADR-0015 mode-restore amendment (issue #14) ----

    #[test]
    fn mode_command_changes_mode_and_emits_persist_action() {
        // The `mode` command flips the live mode AND emits
        // `PersistMode` so the runtime records it to project.yaml.
        let mut app = App::new();
        app.mode = Mode::Advanced;
        type_str(&mut app, "mode simple");
        let actions = submit(&mut app);
        assert_eq!(app.mode, Mode::Simple, "live mode flipped");
        assert_eq!(
            actions,
            vec![Action::PersistMode(Mode::Simple)],
            "the mode change is persisted",
        );
    }

    #[test]
    fn mode_command_via_one_shot_escape_persists_advanced() {
        // Reaching `mode advanced` from simple via the `:` one-shot
        // (ADR-0003) still emits the persist action for advanced.
        let mut app = App::new();
        assert_eq!(app.mode, Mode::Simple);
        type_str(&mut app, ":mode advanced");
        let actions = submit(&mut app);
        assert_eq!(app.mode, Mode::Advanced);
        assert!(
            actions.contains(&Action::PersistMode(Mode::Advanced)),
            "expected PersistMode(Advanced), got {actions:?}",
        );
    }

    // ---- copy to clipboard (ADR-0041, issue #11) ----

    /// Two output lines: a completed (`✓`) simple-mode echo and a
    /// `[system]` structure line — exercises the echo decoration and
    /// the tag prefix in `plain_text`.
    fn app_with_two_output_lines() -> App {
        let mut app = App::new();
        let mut echo = OutputLine::echo("create table T", Mode::Simple);
        echo.status = Some(EchoStatus::Ok);
        app.output.push_back(echo);
        app.output.push_back(OutputLine {
            text: "  T".to_string(),
            kind: OutputKind::System,
            mode_at_submission: Mode::Simple,
            styled_runs: None,
            status: None,
        });
        app
    }

    #[test]
    fn copy_all_emits_action_with_whole_panel_and_confirms() {
        let mut app = app_with_two_output_lines();
        let before = app.output.len();
        type_str(&mut app, "copy");
        let actions = submit(&mut app);

        // The payload is every line's rendered form, joined by \n —
        // tag + ✓ on the echo, tag on the system line.
        let expected = "[simple] create table T ✓\n[system]   T";
        assert_eq!(
            actions,
            vec![Action::CopyToClipboard(expected.to_string())],
            "copy emits the whole-panel payload",
        );
        // A confirmation [system] line is appended — and is NOT part
        // of the copied text (captured before the push).
        assert_eq!(app.output.len(), before + 1, "one confirmation line added");
        let note = app.output.back().unwrap();
        assert_eq!(note.kind, OutputKind::System);
        assert!(
            note.text.contains("clipboard"),
            "confirmation mentions the clipboard: {:?}",
            note.text,
        );
        assert!(
            !expected.contains("clipboard"),
            "the confirmation is never in the copied text",
        );
    }

    #[test]
    fn copy_bare_is_identical_to_copy_all() {
        let mut a1 = app_with_two_output_lines();
        type_str(&mut a1, "copy");
        let bare = submit(&mut a1);
        let mut a2 = app_with_two_output_lines();
        type_str(&mut a2, "copy all");
        let all = submit(&mut a2);
        assert_eq!(bare, all, "`copy` defaults to `copy all`");
    }

    #[test]
    fn copy_last_slices_from_the_most_recent_echo() {
        let mut app = app_with_two_output_lines();
        // An earlier, unrelated command + a fresh echo with a body.
        app.output.push_front(OutputLine {
            text: "earlier note".to_string(),
            kind: OutputKind::System,
            mode_at_submission: Mode::Simple,
            styled_runs: None,
            status: None,
        });
        let mut echo2 = OutputLine::echo("show data T", Mode::Simple);
        echo2.status = Some(EchoStatus::Ok);
        app.output.push_back(echo2);
        app.output.push_back(OutputLine {
            text: "(0 rows)".to_string(),
            kind: OutputKind::System,
            mode_at_submission: Mode::Simple,
            styled_runs: None,
            status: None,
        });

        type_str(&mut app, "copy last");
        let actions = submit(&mut app);
        // Only the LAST command (its echo + body), not the earlier one.
        let expected = "[simple] show data T ✓\n[system] (0 rows)";
        assert_eq!(actions, vec![Action::CopyToClipboard(expected.to_string())]);
    }

    #[test]
    fn copy_on_empty_panel_notes_nothing_and_emits_no_action() {
        let mut app = App::new();
        assert!(app.output.is_empty());
        type_str(&mut app, "copy");
        let actions = submit(&mut app);
        assert!(actions.is_empty(), "nothing to copy → no clipboard action");
        let note = app.output.back().expect("a 'nothing to copy' note");
        assert_eq!(note.kind, OutputKind::System);
        assert!(note.text.contains("nothing to copy"), "{:?}", note.text);
    }

    #[test]
    fn copy_last_without_an_echo_notes_nothing() {
        // A panel with only echo-less [system] lines has no "last
        // command" boundary → nothing to copy (ADR-0041 boundary note).
        let mut app = App::new();
        app.output.push_back(OutputLine {
            text: "just a note".to_string(),
            kind: OutputKind::System,
            mode_at_submission: Mode::Simple,
            styled_runs: None,
            status: None,
        });
        type_str(&mut app, "copy last");
        let actions = submit(&mut app);
        assert!(actions.is_empty());
        assert!(
            app.output.back().unwrap().text.contains("nothing to copy"),
            "no echo → nothing to copy",
        );
    }

    #[test]
    fn copy_with_unknown_target_is_a_friendly_error() {
        let mut app = app_with_two_output_lines();
        type_str(&mut app, "copy sideways");
        let actions = submit(&mut app);
        assert!(
            !actions
                .iter()
                .any(|a| matches!(a, Action::CopyToClipboard(_))),
            "an unknown target does not copy",
        );
        let rendered = app
            .output
            .iter()
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            rendered.contains("unknown copy target"),
            "friendly copy.unknown wording shown: {rendered}",
        );
    }

    #[test]
    fn project_switched_event_restores_the_stored_mode() {
        // A switch carries the target project's stored mode; the
        // App adopts it ("loading triggers the mode switch").
        let mut app = App::new();
        assert_eq!(app.mode, Mode::Simple);
        app.update(AppEvent::ProjectSwitched {
            display_name: "Other".to_string(),
            is_temp: false,
            history_entries: Vec::new(),
            mode: Mode::Advanced,
        });
        assert_eq!(app.mode, Mode::Advanced);
    }

    #[test]
    fn bare_create_table_emits_friendly_parse_error() {
        let mut app = App::new();
        type_str(&mut app, "create table Customers");
        let actions = submit(&mut app);
        // A definite parse error journals `err` (ADR-0034) and does
        // not dispatch a command to the worker.
        assert!(
            matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
            "expected only a JournalFailure, no dispatch; got {actions:?}",
        );
        // Parse-error rendering is now multi-line (ADR-0021):
        // caret + "parse error: …" + "usage: …" — the test
        // checks that some error line mentions `with pk`.
        let mentions_with_pk = app
            .output
            .iter()
            .any(|l| l.kind == OutputKind::Error && l.text.contains("with pk"));
        assert!(
            mentions_with_pk,
            "no error line mentions `with pk`; output:\n{}",
            error_lines(&app),
        );
    }

    #[test]
    fn invalid_dsl_in_simple_mode_produces_parse_error_in_output() {
        let mut app = App::new();
        type_str(&mut app, "frobulate widgets");
        let actions = submit(&mut app);
        assert!(
            matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
            "a definite parse error journals err without dispatching; got {actions:?}",
        );
        let has_parse_error = app
            .output
            .iter()
            .any(|l| l.kind == OutputKind::Error && l.text.starts_with("parse error"));
        assert!(
            has_parse_error,
            "no error line starts with `parse error`; output:\n{}",
            error_lines(&app),
        );
    }

    /// Helper: install a `Customers(id serial, Name text, Age int,
    /// SerNo serial)` schema cache on `app` for the Form-B education
    /// tests (issue #1).
    fn install_customers_schema_two_serials(app: &mut App) {
        use crate::completion::TableColumn;
        use crate::dsl::types::Type;
        let cols = [
            ("id", Type::Serial),
            ("Name", Type::Text),
            ("Age", Type::Int),
            ("SerNo", Type::Serial),
        ];
        app.schema_cache.tables.push("Customers".to_string());
        let tc: Vec<TableColumn> = cols
            .iter()
            .map(|(n, t)| TableColumn {
                name: (*n).to_string(),
                user_type: *t,
                not_null: false,
                has_default: false,
            })
            .collect();
        for c in &tc {
            app.schema_cache.columns.push(c.name.clone());
        }
        app.schema_cache
            .table_columns
            .insert("Customers".to_string(), tc);
    }

    #[test]
    fn simple_mode_submit_form_b_extra_value_shows_only_one_advice_line() {
        // Issue #1 sub-task 2 + sub-task 1 interaction: in the
        // 3-column Form B case (`Customers(id serial, Name, Email)` +
        // 3 values, e.g. `insert into Customers values (1, 'Alice',
        // 'a@b.c')`) the line IS valid in advanced mode, so the
        // cross-mode pointer fires. The sub-task 2 teaching note
        // ("list every column") would be parallel advice — both are
        // valid escape hatches — and showing both clutters the error.
        // The teaching note must defer to the cross-mode pointer.
        use crate::completion::TableColumn;
        use crate::dsl::types::Type;
        let mut app = App::new();
        let cols = [
            ("id", Type::Serial),
            ("Name", Type::Text),
            ("Email", Type::Text),
        ];
        app.schema_cache.tables.push("Customers".to_string());
        let tc: Vec<TableColumn> = cols
            .iter()
            .map(|(n, t)| TableColumn {
                name: (*n).to_string(),
                user_type: *t,
                not_null: false,
                has_default: false,
            })
            .collect();
        for c in &tc {
            app.schema_cache.columns.push(c.name.clone());
        }
        app.schema_cache
            .table_columns
            .insert("Customers".to_string(), tc);
        type_str(
            &mut app,
            "insert into Customers values (1, 'Alice', 'a@b.c')",
        );
        let _ = submit(&mut app);
        let out = error_lines(&app);
        // The cross-mode pointer (sub-task 1) fires — the line works
        // in advanced. Substring "mode advanced" is the durable
        // actionable fragment.
        assert!(
            out.contains("mode advanced"),
            "cross-mode pointer must fire for the 3-col line: {out}",
        );
        // The sub-task 2 teaching note is suppressed: no "list every
        // column" / "auto-generated and filled" prose alongside it.
        assert!(
            !out.contains("auto-generated and filled"),
            "sub-task 2 note must defer to the cross-mode pointer: {out}",
        );
    }

    #[test]
    fn advanced_mode_submit_of_form_b_value_count_mismatch_shows_preflight_note() {
        // Issue #1 sub-task 3: advanced-mode positional `INSERT INTO T
        // VALUES (…)` (no column list) requires every column. When the
        // value count doesn't match, today's flow lets the engine
        // produce a raw constraint or type error; we'd rather catch it
        // at dispatch time and surface a teaching note that names the
        // table's columns and shows the column-list override.
        let mut app = App::new();
        install_customers_schema_two_serials(&mut app);
        app.mode = Mode::Advanced;
        type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
        let actions = submit(&mut app);
        // The pre-flight rejected the line — no ExecuteDsl dispatch.
        assert!(
            !actions
                .iter()
                .any(|a| matches!(a, Action::ExecuteDsl { .. })),
            "advanced-mode pre-flight must suppress dispatch on Form-B count mismatch; got: {actions:?}",
        );
        let out = error_lines(&app);
        // The teaching note names the rule …
        assert!(
            out.contains("every column"),
            "missing the positional-VALUES rule in: {out}",
        );
        // … names the table's columns so the user can see what's needed …
        assert!(
            out.contains("Name")
                && out.contains("Age")
                && out.contains("id")
                && out.contains("SerNo"),
            "missing the column-name list in: {out}",
        );
        // … and shows the column-list override targeting the non-auto columns.
        assert!(
            out.contains("insert into Customers (Name, Age)"),
            "missing the column-list override example in: {out}",
        );
    }

    #[test]
    fn simple_mode_submit_of_form_b_extra_value_teaches_serial_skip() {
        // Issue #1 sub-task 2: when the user supplies too many positional
        // values in simple-mode Form B, the bare "expected `)`" parse
        // error doesn't explain why fewer values are expected. The
        // submit error gets a teaching line that names the columns
        // Form B fills automatically, names the columns it expects
        // values for, and points at the column-list (Form A) override.
        let mut app = App::new();
        install_customers_schema_two_serials(&mut app);
        type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
        let _ = submit(&mut app);
        let out = error_lines(&app);
        // The teaching line names the user-supplied columns …
        assert!(
            out.contains("Name") && out.contains("Age"),
            "missing non-auto column names in: {out}"
        );
        // … the auto-generated columns …
        assert!(
            out.contains("id") && out.contains("SerNo"),
            "missing auto column names in: {out}"
        );
        // … signals the contract …
        assert!(
            out.contains("auto-generated"),
            "missing the contract word in: {out}",
        );
        // … and shows the Form-A override path with every column listed.
        assert!(
            out.contains("insert into Customers (id, Name, Age, SerNo)"),
            "missing the Form-A override example in: {out}",
        );
        // Issue #1 sub-task 1 gate: the cross-mode pointer must be
        // suppressed for the user's reported case (count mismatches in
        // advanced too — switching modes wouldn't help). Without this
        // assertion, a regression of the gate would silently restore
        // the misleading pointer alongside the teaching note.
        assert!(
            !out.contains("mode advanced"),
            "cross-mode pointer must NOT fire for the 4-col mismatch case: {out}",
        );
    }

    #[test]
    fn simple_mode_submit_of_form_b_undersupply_teaches() {
        // Issue #1 siblings task: simple-mode under-supply
        // (`insert into Customers values ('Oli')` for a 4-col table
        // whose Form B expects 2 values for `Name`, `Age`). Today the
        // user sees a bare parse error; with the extended teaching
        // note they get the same forward-looking explanation as the
        // over-supply case — what's expected, what's auto-filled, how
        // to use the column-list form to override.
        let mut app = App::new();
        install_customers_schema_two_serials(&mut app);
        type_str(&mut app, "insert into Customers values ('Oli')");
        let _ = submit(&mut app);
        let out = error_lines(&app);
        assert!(
            out.contains("Name") && out.contains("Age"),
            "missing non-auto column names in: {out}",
        );
        assert!(
            out.contains("id") && out.contains("SerNo"),
            "missing auto column names in: {out}",
        );
        assert!(
            out.contains("auto-generated"),
            "missing contract word in: {out}",
        );
        assert!(
            out.contains("insert into Customers (id, Name, Age, SerNo)"),
            "missing column-list override in: {out}",
        );
    }

    #[test]
    fn simple_mode_submit_of_form_b_oversupply_beyond_total_teaches() {
        // Issue #1 siblings task: simple-mode over-supply *beyond* the
        // total column count (5 values for a 4-col table). The
        // teaching note still fires — the explanation of Form B's
        // contract applies regardless of how far past Form B's count
        // the user went.
        let mut app = App::new();
        install_customers_schema_two_serials(&mut app);
        type_str(
            &mut app,
            "insert into Customers values ('Oli', 52, 3, 13, 99)",
        );
        let _ = submit(&mut app);
        let out = error_lines(&app);
        assert!(
            out.contains("Name") && out.contains("Age"),
            "missing non-auto column names in: {out}",
        );
        assert!(
            out.contains("auto-generated"),
            "missing contract word in: {out}",
        );
        assert!(
            out.contains("insert into Customers (id, Name, Age, SerNo)"),
            "missing column-list override in: {out}",
        );
    }

    #[test]
    fn advanced_mode_submit_with_all_values_for_serials_succeeds_no_preflight() {
        // Issue #1 — the user explicitly confirmed that supplying every
        // column including the serials works in advanced mode
        // (`insert into Customers values (13, 'Oli', 42, 13)`). The
        // pre-flight must not interfere with that happy path: it fires
        // only on count mismatch, and the dispatch reaches the worker
        // normally. This locks down the bottom edge of the pre-flight
        // gate.
        let mut app = App::new();
        install_customers_schema_two_serials(&mut app);
        app.mode = Mode::Advanced;
        type_str(&mut app, "insert into Customers values (13, 'Oli', 42, 13)");
        let actions = submit(&mut app);
        assert!(
            actions
                .iter()
                .any(|a| matches!(a, Action::ExecuteDsl { .. })),
            "the line must dispatch normally; pre-flight must not fire when value count matches column count; got: {actions:?}",
        );
        // And no teaching note prose appears in the output …
        let out = error_lines(&app);
        assert!(
            !out.contains("requires a value for every column"),
            "pre-flight must stay silent when counts match: {out}",
        );
    }

    #[test]
    fn simple_mode_submit_of_form_b_count_mismatch_does_not_dispatch() {
        // Issue #17 EXECUTION SAFETY. Once simple-mode wrong-count Form B
        // tuples parse `Ok` (so the typing-time arity diagnostic can
        // fire), the submit path must still NOT dispatch the insert — a
        // wrong-count insert would otherwise reach the worker and fail
        // (or, worse, write the wrong row). The unified Ok-arm pre-flight
        // must block dispatch exactly as the old parse-error path did.
        let mut app = App::new();
        install_customers_schema_two_serials(&mut app);
        type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
        let actions = submit(&mut app);
        assert!(
            !actions
                .iter()
                .any(|a| matches!(a, Action::ExecuteDsl { .. })),
            "simple-mode Form B count mismatch must NOT dispatch; got: {actions:?}",
        );
    }

    #[test]
    fn simple_mode_submit_of_form_b_undersupply_does_not_dispatch() {
        // Companion to the above for under-supply.
        let mut app = App::new();
        install_customers_schema_two_serials(&mut app);
        type_str(&mut app, "insert into Customers values ('Oli')");
        let actions = submit(&mut app);
        assert!(
            !actions
                .iter()
                .any(|a| matches!(a, Action::ExecuteDsl { .. })),
            "simple-mode Form B under-supply must NOT dispatch; got: {actions:?}",
        );
    }

    #[test]
    fn simple_mode_submit_of_form_a_count_mismatch_does_not_dispatch() {
        // Form A (explicit column list) wrong count must also not
        // dispatch — it previously parse-errored; the unified pre-flight
        // must keep it blocked.
        let mut app = App::new();
        install_customers_schema_two_serials(&mut app);
        type_str(&mut app, "insert into Customers (Name, Age) values ('Oli')");
        let actions = submit(&mut app);
        assert!(
            !actions
                .iter()
                .any(|a| matches!(a, Action::ExecuteDsl { .. })),
            "simple-mode Form A count mismatch must NOT dispatch; got: {actions:?}",
        );
    }

    #[test]
    fn simple_mode_submit_of_sql_construct_appends_advanced_pointer() {
        // ADR-0033 Amendment 3 (+ Amendment 5): submitting a line in
        // simple mode that fails as DSL but would be valid in advanced
        // mode appends the `advanced_mode.also_valid_sql` pointer to
        // the parse error — keeping the DSL detail and pointing at
        // advanced mode. Multi-row VALUES is a definite DSL error and
        // valid SQL with a real schema (the validity verdict needs the
        // table to exist; an unknown-table diagnostic would correctly
        // suppress the pointer).
        use crate::completion::TableColumn;
        use crate::dsl::types::Type;
        let mut app = App::new();
        app.schema_cache.tables.push("T".to_string());
        let tc = vec![
            TableColumn {
                name: "a".to_string(),
                user_type: Type::Int,
                not_null: false,
                has_default: false,
            },
            TableColumn {
                name: "b".to_string(),
                user_type: Type::Int,
                not_null: false,
                has_default: false,
            },
        ];
        for c in &tc {
            app.schema_cache.columns.push(c.name.clone());
        }
        app.schema_cache.table_columns.insert("T".to_string(), tc);
        type_str(&mut app, "insert into T values (1, 2), (3, 4)");
        let actions = submit(&mut app);
        assert!(
            matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
            "the bad line journals err but must not dispatch; got {actions:?}",
        );
        let has_pointer = app
            .output
            .iter()
            .any(|l| l.kind == OutputKind::Error && l.text.contains("mode advanced"));
        assert!(
            has_pointer,
            "expected the advanced-mode pointer on submit; output:\n{}",
            error_lines(&app),
        );
    }

    #[test]
    fn form_b_insert_after_ddl_is_held_until_refresh_then_dispatched() {
        // Issue #39: a simple-mode Form-B insert (`insert into T values
        // (…)`, no column list) submitted faster than the post-DDL
        // schema-cache refresh must NOT be validated against the *stale*
        // cache — doing so wrongly rejects it as "trying to write SQL?".
        //
        // This reproduces, deterministically, the event ordering the async
        // runtime produces under fast input: a schema-mutating command is
        // dispatched (arming the gate while its cache refresh is in flight),
        // a follow-up insert is submitted before the refresh lands, and only
        // then does the `SchemaCacheRefreshed` event arrive. Pre-fix the
        // insert is validated against the pre-DDL schema and rejected; with
        // the gate it is held and dispatched against the fresh schema.
        use crate::completion::{SchemaCache, TableColumn};
        use crate::dsl::types::Type;

        // Pre-DDL schema: `Customers` has only the auto `id` (serial). This
        // is the stale cache a racing Form-B insert would otherwise see —
        // zero user-fillable columns, so `values ('Alice')` can't match.
        let mut app = App::new();
        let mut stale = SchemaCache::default();
        stale.tables.push("Customers".to_string());
        stale.columns.push("id".to_string());
        stale.table_columns.insert(
            "Customers".to_string(),
            vec![TableColumn {
                name: "id".to_string(),
                user_type: Type::Serial,
                not_null: true,
                has_default: false,
            }],
        );
        app.schema_cache = stale;

        // 1. Submit the DDL. It dispatches and arms the gate: a schema
        //    refresh is now (conceptually) in flight.
        type_str(&mut app, "add column to Customers: Name (text)");
        let ddl_actions = submit(&mut app);
        assert!(
            matches!(ddl_actions.as_slice(), [Action::ExecuteDsl { .. }]),
            "the DDL should dispatch; got {ddl_actions:?}",
        );

        // 2. Submit the Form-B insert *before* the refresh lands. It must be
        //    held — not dispatched, and crucially not rejected against the
        //    stale cache (no error note such as "trying to write SQL?").
        type_str(&mut app, "insert into Customers values ('Alice')");
        let held_actions = submit(&mut app);
        assert!(
            held_actions.is_empty(),
            "the insert must be held while the refresh is in flight, \
             not dispatched or rejected; got {held_actions:?}",
        );
        assert!(
            !app.output.iter().any(|l| l.kind == OutputKind::Error),
            "a held insert must produce no error note (e.g. the \
             'trying to write SQL?' pointer); errors:\n{}",
            error_lines(&app),
        );

        // 3. The DDL's schema refresh lands carrying the fresh schema
        //    (`Customers` now has `id` + `Name`). The held insert drains and
        //    dispatches, validated against the up-to-date cache.
        let mut fresh = SchemaCache::default();
        fresh.tables.push("Customers".to_string());
        fresh.columns.push("id".to_string());
        fresh.columns.push("Name".to_string());
        fresh.table_columns.insert(
            "Customers".to_string(),
            vec![
                TableColumn {
                    name: "id".to_string(),
                    user_type: Type::Serial,
                    not_null: true,
                    has_default: false,
                },
                TableColumn::new("Name", Type::Text),
            ],
        );
        let drained = app.update(AppEvent::SchemaCacheRefreshed(fresh));
        assert!(
            matches!(
                drained.as_slice(),
                [Action::ExecuteDsl {
                    command: Command::Insert { .. },
                    ..
                }]
            ),
            "the held insert must dispatch once the fresh schema lands; \
             got {drained:?}",
        );
    }

    #[test]
    fn simple_mode_submit_of_pure_dsl_error_has_no_advanced_pointer() {
        // A DSL error that is *not* valid SQL either (unknown command)
        // must not carry the advanced-mode pointer — there is nothing
        // to switch modes for.
        let mut app = App::new();
        type_str(&mut app, "frobulate widgets");
        let _ = submit(&mut app);
        // The pointer's current phrasing (`also_valid_sql`) is
        // "trying to write SQL? …"; an unknown command produces no
        // advanced-mode hint at all, so we look for any line carrying
        // the "mode advanced" actionable fragment that the pointer
        // always emits.
        let has_pointer = app.output.iter().any(|l| l.text.contains("mode advanced"));
        assert!(
            !has_pointer,
            "unknown command must not point at advanced mode"
        );
    }

    #[test]
    fn enter_in_advanced_mode_dispatches_select_with_advanced_tag() {
        // The pre-ADR-0030 placeholder echoed any advanced-mode
        // input back unexecuted; with the SQL surface live, a
        // `select` in advanced mode runs through `dispatch_dsl`
        // exactly like a DSL command, producing the standard
        // `running: …` echo and an `ExecuteDsl(Command::Select)`
        // action. The mode-tag invariant — that the echo carries
        // the submission's effective mode — is what this test
        // pins down.
        let mut app = App::new();
        app.mode = Mode::Advanced;
        type_str(&mut app, "select 1");
        let actions = submit(&mut app);
        let echoed = app
            .output
            .iter()
            .rfind(|l| l.kind == OutputKind::Echo)
            .unwrap();
        assert_eq!(echoed.mode_at_submission, Mode::Advanced);
        assert!(
            echoed.text.contains("select 1"),
            "echo line carries the input: {:?}",
            echoed.text,
        );
        assert!(
            matches!(
                actions.as_slice(),
                [Action::ExecuteDsl {
                    command: Command::Select { .. },
                    ..
                }],
            ),
            "advanced-mode `select` should produce ExecuteDsl(Select); got {actions:?}",
        );
    }

    #[test]
    fn submit_carries_the_three_way_effective_submission_mode() {
        // ADR-0037: ExecuteDsl carries the effective mode so the runtime
        // can gate the teaching echo (ADR-0038).
        let case = |mode: Mode, input: &str| -> EffectiveMode {
            let mut app = App::new();
            app.mode = mode;
            type_str(&mut app, input);
            match submit(&mut app).as_slice() {
                [
                    Action::ExecuteDsl {
                        submission_mode, ..
                    },
                ] => *submission_mode,
                other => panic!("expected one ExecuteDsl; got {other:?}"),
            }
        };
        assert_eq!(
            case(Mode::Advanced, "create table T with pk"),
            EffectiveMode::AdvancedPersistent
        );
        assert_eq!(
            case(Mode::Simple, ":create table T with pk"),
            EffectiveMode::AdvancedOneShot
        );
        assert_eq!(
            case(Mode::Simple, "create table T with pk"),
            EffectiveMode::Simple
        );
    }

    #[test]
    fn dsl_success_renders_the_teaching_echo_beneath_ok() {
        // ADR-0038: the echo carried on the success event renders as a
        // line immediately beneath the `[ok]` summary.
        let cmd = Command::CreateTable {
            name: "Other".to_string(),
            columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)],
            primary_key: vec!["id".to_string()],
        };
        let mut app = App::new();
        app.update(AppEvent::DslSucceeded {
            command: cmd.clone(),
            description: None,
            echo: Some(vec![
                "CREATE TABLE Other (id serial PRIMARY KEY)".to_string(),
            ]),
        });
        let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
        // ADR-0040: no `[ok]` summary; with no preceding `running:` echo
        // (event fired directly), the teaching echo leads.
        assert!(
            !texts.iter().any(|t| t.starts_with("[ok]")),
            "no [ok] summary line (ADR-0040): {texts:?}",
        );
        let echo_idx = texts
            .iter()
            .position(|t| t.contains("Executing SQL:"))
            .expect("an echo line");
        assert_eq!(echo_idx, 0, "teaching echo leads the output: {texts:?}");
        assert!(texts[echo_idx].contains("CREATE TABLE Other (id serial PRIMARY KEY)"));

        // No echo line when the event carries none (simple mode etc.).
        let mut app = App::new();
        app.update(AppEvent::DslSucceeded {
            command: cmd,
            description: None,
            echo: None,
        });
        assert!(
            !app.output.iter().any(|l| l.text.contains("Executing SQL:")),
            "no echo line when echo is None"
        );
    }

    #[test]
    fn bucket_a_success_events_render_the_teaching_echo_beneath_ok() {
        // ADR-0038 Phase 1: every Bucket A success event that gained an
        // `echo` field stashes it for `note_ok_summary` to render
        // immediately beneath `[ok]`. One case per event guards its
        // update() arm's stash + ordering — including the handlers that
        // render extra content (structure / notes), where the echo must
        // still sit directly beneath `[ok]`, above that content.
        use crate::db::{
            AddColumnResult, ChangeColumnTypeResult, DataResult, DeleteResult, DropColumnResult,
            UpdateResult,
        };
        use crate::dsl::command::{ChangeColumnMode, RowFilter};
        use crate::dsl::value::Value;

        fn assert_echo_beneath_ok(app: &App, expected: &str) {
            let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
            // ADR-0040: no `[ok]` summary line. These events are fired
            // without a preceding `running:` echo (they bypass
            // `dispatch_dsl`), so the teaching echo — pushed first by
            // `note_ok_summary` — leads the output.
            assert!(
                !texts.iter().any(|t| t.starts_with("[ok]")),
                "no [ok] summary line (ADR-0040): {texts:?}",
            );
            let echo_idx = texts
                .iter()
                .position(|t| t.contains("Executing SQL:"))
                .expect("an echo line");
            assert_eq!(echo_idx, 0, "teaching echo leads the output: {texts:?}");
            assert!(
                texts[echo_idx].contains(expected),
                "echo carries the SQL: {texts:?}"
            );
            // ADR-0038 §4 polish: every success arm now wires the echo as
            // `OutputKind::TeachingEcho` so `ui::render_output_line` fires
            // the dim-prefix + advanced-lex custom branch. Pinning this
            // here guards every event-arm wiring covered by this test —
            // a future refactor that regressed `push_teaching_echo` to
            // emit `System` would leave the text intact but break the
            // styling, and would be caught here.
            assert_eq!(
                app.output[echo_idx].kind,
                OutputKind::TeachingEcho,
                "echo line carries TeachingEcho kind: {:?}",
                app.output[echo_idx],
            );
        }

        fn empty_data() -> DataResult {
            DataResult {
                table_name: "T".to_string(),
                columns: Vec::new(),
                column_types: Vec::new(),
                rows: Vec::new(),
            }
        }

        // show data → DslDataSucceeded (the post-execution query path).
        let mut app = App::new();
        app.update(AppEvent::DslDataSucceeded {
            command: Command::ShowData {
                name: "T".to_string(),
                filter: None,
                limit: None,
            },
            data: empty_data(),
            echo: Some(vec!["SELECT * FROM T".to_string()]),
        });
        assert_echo_beneath_ok(&app, "SELECT * FROM T");

        // update … --all-rows → DslUpdateSucceeded.
        let mut app = App::new();
        app.update(AppEvent::DslUpdateSucceeded {
            command: Command::Update {
                table: "T".to_string(),
                assignments: vec![("v".to_string(), Value::Number("1".to_string()))],
                filter: RowFilter::AllRows,
            },
            result: UpdateResult {
                rows_affected: 1,
                data: empty_data(),
            },
            echo: Some(vec!["UPDATE T SET v = 1".to_string()]),
        });
        assert_echo_beneath_ok(&app, "UPDATE T SET v = 1");

        // delete … --all-rows → DslDeleteSucceeded.
        let mut app = App::new();
        app.update(AppEvent::DslDeleteSucceeded {
            command: Command::Delete {
                table: "T".to_string(),
                filter: RowFilter::AllRows,
            },
            result: DeleteResult {
                rows_affected: 1,
                cascade: Vec::new(),
                data: empty_data(),
            },
            echo: Some(vec!["DELETE FROM T".to_string()]),
        });
        assert_echo_beneath_ok(&app, "DELETE FROM T");

        // add column → DslAddColumnSucceeded (handler also renders structure).
        let mut app = App::new();
        app.update(AppEvent::DslAddColumnSucceeded {
            command: Command::AddColumn {
                table: "T".to_string(),
                column: "c".to_string(),
                ty: Type::Int,
                not_null: false,
                unique: false,
                default: None,
                check: None,
            },
            result: AddColumnResult {
                description: sample_description("T"),
                client_side_notes: Vec::new(),
            },
            echo: Some(vec!["ALTER TABLE T ADD COLUMN c int".to_string()]),
        });
        assert_echo_beneath_ok(&app, "ALTER TABLE T ADD COLUMN c int");

        // drop column → DslDropColumnSucceeded.
        let mut app = App::new();
        app.update(AppEvent::DslDropColumnSucceeded {
            command: Command::DropColumn {
                table: "T".to_string(),
                column: "c".to_string(),
                cascade: false,
            },
            result: DropColumnResult {
                description: sample_description("T"),
                dropped_indexes: Vec::new(),
            },
            echo: Some(vec!["ALTER TABLE T DROP COLUMN c".to_string()]),
        });
        assert_echo_beneath_ok(&app, "ALTER TABLE T DROP COLUMN c");

        // change column → DslChangeColumnSucceeded.
        let mut app = App::new();
        app.update(AppEvent::DslChangeColumnSucceeded {
            command: Command::ChangeColumnType {
                table: "T".to_string(),
                column: "c".to_string(),
                ty: Type::Text,
                mode: ChangeColumnMode::Default,
            },
            result: ChangeColumnTypeResult {
                description: sample_description("T"),
                client_side: None,
            },
            echo: Some(vec![
                "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string(),
            ]),
            dont_convert_caveat: false,
        });
        assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text");
    }

    #[test]
    fn add_column_client_side_notes_render_as_category_three_prose() {
        // ADR-0038 §4 polish (broader scope): the existing illuminating
        // `client_side_notes` on an `AddColumnResult` (e.g. the shortid
        // / serial auto-fill messages) now route through
        // `push_category_three_prose` — a System line with a whole-text
        // Hint span — so every category-3 prose line renders dim
        // consistently per §6, not just the new DontConvert caveat.
        //
        // The broader scope was user-confirmed in the polish session
        // (see ADR-0038 Status); this test pins that wiring so a
        // regression to plain `note_system` would be caught.
        use crate::db::{AddColumnResult, ColumnDescription};

        let mut app = App::new();
        app.update(AppEvent::DslAddColumnSucceeded {
            command: Command::AddColumn {
                table: "Customers".to_string(),
                column: "code".to_string(),
                ty: Type::ShortId,
                not_null: false,
                unique: false,
                default: None,
                check: None,
            },
            result: AddColumnResult {
                description: TableDescription {
                    name: "Customers".to_string(),
                    columns: vec![ColumnDescription {
                        name: "code".to_string(),
                        user_type: Some(Type::ShortId),
                        sqlite_type: "TEXT".to_string(),
                        notnull: false,
                        primary_key: false,
                        unique: false,
                        default: None,
                        check: None,
                    }],
                    outbound_relationships: Vec::new(),
                    inbound_relationships: Vec::new(),
                    indexes: Vec::new(),
                    unique_constraints: Vec::new(),
                    check_constraints: Vec::new(),
                },
                // A representative illuminating note — the wording is
                // the one the worker would emit via the
                // `client_side.auto_fill_add_shortid` i18n key.
                client_side_notes: vec![
                    "[client-side] 5 row(s) given auto-generated shortid values. In raw SQL this would need an explicit UPDATE to populate.".to_string(),
                ],
            },
            echo: None, // simple-mode submission, no echo
        });

        let note_line = app
            .output
            .iter()
            .find(|l| l.text.starts_with("[client-side]"))
            .expect("the illuminating client_side note");
        assert_eq!(note_line.kind, OutputKind::System);
        let runs = note_line
            .styled_runs
            .as_ref()
            .expect("client_side note carries a styled-runs payload after the polish");
        assert_eq!(runs.len(), 1);
        assert_eq!(runs[0].class, OutputStyleClass::Hint);
        assert_eq!(
            runs[0].byte_range,
            (0, note_line.text.len()),
            "the dim span covers the whole prose body — same shape as the caveat",
        );
    }

    #[test]
    fn polished_echo_carries_teaching_echo_kind_and_caveat_a_hint_span() {
        // ADR-0038 §4 styled-runs polish: the App-side wiring places
        // every echo line as an OutputKind::TeachingEcho (so
        // `ui::render_output_line`'s custom branch fires — dim prefix
        // + lexed SQL) and every category-3 prose line as a System
        // line with a single Hint span covering the whole text (so
        // the body renders dimmed via `output_span_style`).
        use crate::db::ChangeColumnTypeResult;
        let mut app = App::new();
        app.update(AppEvent::DslChangeColumnSucceeded {
            command: Command::ChangeColumnType {
                table: "T".to_string(),
                column: "c".to_string(),
                ty: Type::Int,
                mode: crate::dsl::ChangeColumnMode::DontConvert,
            },
            result: ChangeColumnTypeResult {
                description: sample_description("T"),
                client_side: None,
            },
            echo: Some(vec![
                "ALTER TABLE T ALTER COLUMN c SET DATA TYPE int".to_string(),
            ]),
            dont_convert_caveat: true,
        });

        // The echo line is a TeachingEcho.
        let echo_line = app
            .output
            .iter()
            .find(|l| l.text.contains("Executing SQL:"))
            .expect("an echo line");
        assert_eq!(
            echo_line.kind,
            OutputKind::TeachingEcho,
            "echo line carries TeachingEcho so ui.rs fires the dim-prefix + lex-rest branch",
        );
        // The echo line carries no styled_runs payload — the custom
        // ui.rs branch builds its own spans from the kind alone.
        assert!(
            echo_line.styled_runs.is_none(),
            "echo line uses kind-driven custom rendering, not styled-runs",
        );

        // The caveat is a System line with a single Hint span covering
        // the whole text — the whole prose body renders dim.
        let caveat_line = app
            .output
            .iter()
            .find(|l| l.text.contains("`--dont-convert` kept the stored values"))
            .expect("a caveat line");
        assert_eq!(caveat_line.kind, OutputKind::System);
        let runs = caveat_line
            .styled_runs
            .as_ref()
            .expect("caveat carries a styled-runs payload");
        assert_eq!(runs.len(), 1);
        assert_eq!(runs[0].class, OutputStyleClass::Hint);
        assert_eq!(
            runs[0].byte_range,
            (0, caveat_line.text.len()),
            "the dim span covers the entire prose body",
        );
    }

    #[test]
    fn change_column_dont_convert_renders_the_caveat_between_notes_and_structure() {
        // ADR-0038 §6 category 3 (Phase 3): when `change column …
        // --dont-convert` ran in an advanced effective mode, the runtime
        // sets `dont_convert_caveat = true`; the App emits the prose
        // caveat between the existing client-side notes (none here, since
        // --dont-convert skips that layer entirely) and the structure
        // render, so it reads alongside the echo. Simple mode ⇒
        // `dont_convert_caveat = false` ⇒ no caveat line.
        use crate::db::ChangeColumnTypeResult;

        use crate::dsl::ChangeColumnMode;

        // Advanced mode + DontConvert → caveat fires.
        let mut app = App::new();
        app.update(AppEvent::DslChangeColumnSucceeded {
            command: Command::ChangeColumnType {
                table: "T".to_string(),
                column: "c".to_string(),
                ty: Type::Int,
                mode: ChangeColumnMode::DontConvert,
            },
            result: ChangeColumnTypeResult {
                description: sample_description("T"),
                // --dont-convert skips the client-side layer → None.
                client_side: None,
            },
            echo: Some(vec![
                "ALTER TABLE T ALTER COLUMN c SET DATA TYPE int".to_string(),
            ]),
            dont_convert_caveat: true,
        });
        let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
        let echo_idx = texts
            .iter()
            .position(|t| t.contains("Executing SQL:"))
            .expect("echo line");
        let caveat_idx = texts
            .iter()
            .position(|t| t.contains("`--dont-convert` kept the stored values"))
            .expect("caveat line");
        assert!(
            caveat_idx > echo_idx,
            "caveat sits after the echo (reads alongside the line above): {texts:?}",
        );
        // And before the structure render (the structure shows column names).
        if let Some(structure_idx) = texts.iter().position(|t| t.contains("Columns")) {
            assert!(
                caveat_idx < structure_idx,
                "caveat sits before the structure render: {texts:?}",
            );
        }

        // Simple mode → no echo, no caveat.
        let mut app = App::new();
        app.update(AppEvent::DslChangeColumnSucceeded {
            command: Command::ChangeColumnType {
                table: "T".to_string(),
                column: "c".to_string(),
                ty: Type::Int,
                mode: ChangeColumnMode::DontConvert,
            },
            result: ChangeColumnTypeResult {
                description: sample_description("T"),
                client_side: None,
            },
            echo: None,
            dont_convert_caveat: false,
        });
        assert!(
            !app.output.iter().any(|l| l.text.contains("--dont-convert")),
            "no caveat in simple mode (no echo to refer to)",
        );
    }

    #[test]
    fn bucket_b_multi_line_echo_renders_one_line_per_statement_beneath_ok() {
        // ADR-0038 §6 category 2 / §4 / Phase 2 Slice 2b: a `drop column
        // --cascade` echo carries one `DROP INDEX <name>` line per
        // covering index plus the final `ALTER TABLE … DROP COLUMN …`.
        // The App renders each as its own `Executing SQL:` line beneath
        // `[ok]`, in order — the styled-runs polish refines the
        // presentation later, but ordering and one-per-statement are the
        // semantic invariants pinned here.
        use crate::db::DropColumnResult;

        let mut app = App::new();
        app.update(AppEvent::DslDropColumnSucceeded {
            command: Command::DropColumn {
                table: "Customers".to_string(),
                column: "Email".to_string(),
                cascade: true,
            },
            result: DropColumnResult {
                description: sample_description("Customers"),
                dropped_indexes: vec![
                    "Customers_Email_idx".to_string(),
                    "Customers_Email_Day_idx".to_string(),
                ],
            },
            echo: Some(vec![
                "DROP INDEX Customers_Email_idx".to_string(),
                "DROP INDEX Customers_Email_Day_idx".to_string(),
                "ALTER TABLE Customers DROP COLUMN Email".to_string(),
            ]),
        });
        let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
        // ADR-0040: no `[ok]` summary; the event is fired without a
        // preceding `running:` echo, so the three teaching echoes lead
        // the output at indices 0/1/2, in order.
        assert!(
            !texts.iter().any(|t| t.starts_with("[ok]")),
            "no [ok] summary line (ADR-0040): {texts:?}",
        );
        assert!(
            texts[0].contains("Executing SQL: DROP INDEX Customers_Email_idx"),
            "first echo line: {texts:?}",
        );
        assert!(
            texts[1].contains("Executing SQL: DROP INDEX Customers_Email_Day_idx"),
            "second echo line: {texts:?}",
        );
        // ADR-0038 §4 polish: every one of the multi-statement echo lines
        // carries TeachingEcho — the polish styling fires per line. A
        // regression that pushed the multi-line case as System would
        // leave the text intact but break the per-line styling.
        for (idx, label) in [(0, "first"), (1, "second"), (2, "third")] {
            assert_eq!(
                app.output[idx].kind,
                OutputKind::TeachingEcho,
                "{label} echo line carries TeachingEcho: {:?}",
                app.output[idx],
            );
        }
        assert!(
            texts[2].contains("Executing SQL: ALTER TABLE Customers DROP COLUMN Email"),
            "third echo line: {texts:?}",
        );
        // Pin the `Executing SQL:` prefix repeats once per statement
        // (the plain-rendering shape until the styled-runs polish lands).
        let exec_count = texts
            .iter()
            .filter(|t| t.contains("Executing SQL:"))
            .count();
        assert_eq!(exec_count, 3, "one Executing SQL: per statement: {texts:?}");
    }

    #[test]
    fn mode_command_switches_persistently() {
        let mut app = App::new();
        type_str(&mut app, "mode advanced");
        submit(&mut app);
        assert_eq!(app.mode, Mode::Advanced);
        type_str(&mut app, "mode simple");
        submit(&mut app);
        assert_eq!(app.mode, Mode::Simple);
    }

    #[test]
    fn mode_command_with_unknown_arg_errors() {
        let mut app = App::new();
        type_str(&mut app, "mode sideways");
        submit(&mut app);
        assert_eq!(app.mode, Mode::Simple);
        // The error surfaces somewhere in the output buffer
        // (could be the caret line, the parse-error detail
        // line, or the usage line). Scan for the friendly
        // "unknown mode" anchor phrase.
        let anywhere = app.output.iter().any(|l| l.text.contains("unknown mode"));
        assert!(
            anywhere,
            "expected 'unknown mode' somewhere in output: {:?}",
            app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
        );
        let any_error = app.output.iter().any(|l| l.kind == OutputKind::Error);
        assert!(any_error, "expected at least one Error line");
    }

    #[test]
    fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() {
        let mut app = App::new();
        type_str(&mut app, ":select 1");
        let actions = submit(&mut app);
        // The persistent mode is unchanged.
        assert_eq!(app.mode, Mode::Simple);
        let echoed = app
            .output
            .iter()
            .rfind(|l| l.kind == OutputKind::Echo)
            .unwrap();
        // The line ran under the one-shot effective mode, so
        // the echo carries the Advanced tag…
        assert_eq!(echoed.mode_at_submission, Mode::Advanced);
        // …and the `:` is stripped before dispatch (the SQL
        // executed is `select 1`, not `:select 1`).
        assert!(
            echoed.text.contains("select 1") && !echoed.text.contains(":select"),
            "echo carries the stripped input: {:?}",
            echoed.text,
        );
        // The one-shot dispatched the SELECT through the same
        // path as a persistent-advanced submission.
        assert!(
            matches!(
                actions.as_slice(),
                [Action::ExecuteDsl {
                    command: Command::Select { .. },
                    ..
                }],
            ),
            "`:select 1` should produce ExecuteDsl(Select); got {actions:?}",
        );
    }

    #[test]
    fn quit_command_returns_quit_action() {
        let mut app = App::new();
        type_str(&mut app, "quit");
        let actions = submit(&mut app);
        assert_eq!(actions, vec![Action::Quit]);
    }

    #[test]
    fn ctrl_c_returns_quit_action() {
        let mut app = App::new();
        let actions = app.update(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
        assert_eq!(actions, vec![Action::Quit]);
    }

    #[test]
    fn empty_submission_is_a_noop() {
        let mut app = App::new();
        let actions = submit(&mut app);
        assert!(actions.is_empty());
        assert!(app.output.is_empty());
    }

    #[test]
    fn output_buffer_is_capped() {
        let mut app = App::new();
        for i in 0..(OUTPUT_CAPACITY + 50) {
            app.note_system(format!("line{i}"));
        }
        assert_eq!(app.output.len(), OUTPUT_CAPACITY);
        // Oldest entries were dropped.
        assert!(app.output.front().unwrap().text.starts_with("line50"));
    }

    #[test]
    fn effective_mode_reflects_persistent_mode_when_no_input() {
        let mut app = App::new();
        assert_eq!(app.effective_mode(), EffectiveMode::Simple);
        app.mode = Mode::Advanced;
        assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent);
    }

    /// Build a two-table cache (`Orders(id, customer_id)` +
    /// `Customers(id, name)`) for the `:` one-shot SQL-feedback tests.
    fn install_join_schema(app: &mut App) {
        use crate::completion::TableColumn;
        use crate::dsl::types::Type;
        app.schema_cache.tables = vec!["Orders".into(), "Customers".into()];
        app.schema_cache.table_columns.insert(
            "Orders".into(),
            vec![
                TableColumn::new("id", Type::Serial),
                TableColumn::new("customer_id", Type::Int),
            ],
        );
        app.schema_cache.table_columns.insert(
            "Customers".into(),
            vec![
                TableColumn::new("id", Type::Serial),
                TableColumn::new("name", Type::Text),
            ],
        );
        for t in app.schema_cache.tables.clone() {
            for c in &app.schema_cache.table_columns[&t] {
                app.schema_cache.columns.push(c.name.clone());
            }
        }
    }

    #[test]
    fn colon_one_shot_gives_sql_completion_the_stripped_view() {
        // Bug (manual testing): the `:` one-shot escape (ADR-0003) left
        // the leading `:` in the buffer passed to the live SQL feedback,
        // so the walker bailed at `:` and Tab completed nothing — while
        // the identical line in full `mode advanced` completed. Now the
        // feedback view strips the `:`, so both behave the same.
        let body = "select c.name from Orders o join Customers c on c.id=o.cu";

        // Full advanced mode: completes `o.cu` → `o.customer_id`.
        let mut adv = App::new();
        adv.mode = Mode::Advanced;
        install_join_schema(&mut adv);
        type_str(&mut adv, body);
        adv.update(key(KeyCode::Tab));
        assert!(
            adv.input.ends_with("o.customer_id "),
            "full advanced should complete: {:?}",
            adv.input
        );

        // `:` one-shot from simple mode: must complete the same way, and
        // the `:` prefix must be preserved in the buffer.
        let mut one = App::new();
        one.mode = Mode::Simple;
        install_join_schema(&mut one);
        one.update(key(KeyCode::Char(':')));
        type_str(&mut one, body);
        assert_eq!(one.effective_mode(), EffectiveMode::AdvancedOneShot);
        one.update(key(KeyCode::Tab));
        assert!(
            one.input.trim_start().starts_with(':'),
            "the `:` prefix is kept: {:?}",
            one.input
        );
        assert!(
            one.input.ends_with("o.customer_id "),
            "`:` one-shot must complete the SQL column too: {:?}",
            one.input
        );
    }

    #[test]
    fn colon_one_shot_validity_is_clean_for_a_valid_query() {
        // A *valid* `:`-prefixed query must not light the `[ERR]`
        // indicator (the walker used to choke on the `:` and always
        // report Error).
        let mut app = App::new();
        install_join_schema(&mut app);
        app.update(key(KeyCode::Char(':')));
        type_str(&mut app, "select name from Customers");
        assert_eq!(
            app.input_validity_verdict(),
            None,
            "a valid one-shot query should verdict clean, got {:?}",
            app.input_validity_verdict(),
        );
    }

    #[test]
    fn effective_mode_flips_to_one_shot_when_colon_typed_in_simple_mode() {
        let mut app = App::new();
        type_str(&mut app, ":sel");
        assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
        while !app.input.is_empty() {
            app.update(key(KeyCode::Backspace));
        }
        assert_eq!(app.effective_mode(), EffectiveMode::Simple);
    }

    #[test]
    fn typing_colon_first_in_simple_mode_auto_inserts_a_space() {
        let mut app = App::new();
        type_str(&mut app, ":");
        assert_eq!(app.input, ": ");
        assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
    }

    #[test]
    fn typing_colon_after_other_chars_does_not_auto_insert_space() {
        let mut app = App::new();
        type_str(&mut app, "ab:");
        assert_eq!(app.input, "ab:");
    }

    #[test]
    fn typing_colon_in_advanced_mode_does_not_auto_insert_space() {
        let mut app = App::new();
        app.mode = Mode::Advanced;
        type_str(&mut app, ":");
        assert_eq!(app.input, ":");
    }

    #[test]
    fn auto_inserted_space_can_be_removed_with_backspace() {
        let mut app = App::new();
        type_str(&mut app, ":");
        assert_eq!(app.input, ": ");
        app.update(key(KeyCode::Backspace));
        assert_eq!(app.input, ":");
        assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
    }

    #[test]
    fn dsl_success_event_records_table_view_and_appends_summary() {
        let mut app = App::new();
        let cmd = Command::CreateTable {
            name: "Customers".to_string(),
            columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)],
            primary_key: vec!["id".to_string()],
        };
        let desc = sample_description("Customers");
        app.update(AppEvent::DslSucceeded {
            command: cmd,
            description: Some(desc.clone()),
            echo: None,
        });
        assert_eq!(app.current_table, Some(desc));
        // Some line in the output buffer is the structure
        // table row that contains `id` (followed by border
        // chars on either side).
        assert!(
            app.output.iter().any(|l| l.text.contains("id")),
            "expected `id` somewhere in structure output",
        );
        // ADR-0040: no `[ok]` summary line — success is signalled by
        // the echo's ✓ marker (no echo pushed in this direct-event
        // test) and the structure render itself.
        assert!(
            !app.output.iter().any(|l| l.text.starts_with("[ok]")),
            "no [ok] summary line (ADR-0040)",
        );
    }

    #[test]
    fn explain_success_event_renders_display_sql_and_plan_tree() {
        let mut app = App::new();
        let cmd = Command::Explain {
            query: Box::new(Command::ShowData {
                name: "Customers".to_string(),
                filter: None,
                limit: None,
            }),
        };
        let plan = crate::db::QueryPlan {
            display_sql: "SELECT \"id\" FROM \"Customers\"".to_string(),
            rows: vec![crate::db::ExplainRow {
                id: 2,
                parent: 0,
                detail: "SCAN Customers".to_string(),
            }],
        };
        app.update(AppEvent::DslExplainSucceeded { command: cmd, plan });
        // ADR-0040: no `[ok] explain …` header — the (no-echo here)
        // command's success shows via the marker; the plan output
        // itself carries the content.
        assert!(
            !app.output.iter().any(|l| l.text.starts_with("[ok]")),
            "no [ok] summary line (ADR-0040)",
        );
        // The display SQL and the plan node both reach output.
        assert!(
            app.output
                .iter()
                .any(|l| l.text.contains("SELECT \"id\" FROM \"Customers\"")),
            "expected the display SQL line",
        );
        assert!(
            app.output.iter().any(|l| l.text.contains("SCAN Customers")),
            "expected the plan-tree node",
        );
    }

    // ---- ADR-0040: completion marker on the echo line ----------

    #[test]
    fn mark_oldest_pending_echo_resolves_in_submission_order() {
        // Two echoes in flight; results arrive in submission order, so
        // the oldest pending echo is the correct target each time — a
        // finished command never leaves an earlier one stuck Pending.
        let mut app = App::new();
        app.output
            .push_back(OutputLine::echo("first", crate::mode::Mode::Advanced));
        app.output
            .push_back(OutputLine::echo("second", crate::mode::Mode::Advanced));
        app.mark_oldest_pending_echo(EchoStatus::Ok);
        app.mark_oldest_pending_echo(EchoStatus::Err);
        let echoes: Vec<_> = app
            .output
            .iter()
            .filter(|l| l.kind == OutputKind::Echo)
            .collect();
        assert_eq!(echoes[0].status, Some(EchoStatus::Ok), "first → Ok");
        assert_eq!(echoes[1].status, Some(EchoStatus::Err), "second → Err");
    }

    #[test]
    fn successful_command_resolves_its_echo_to_ok_with_no_summary_line() {
        // Full flow: dispatch pushes a Pending echo; the success event
        // resolves it to ✓ and emits no `[ok]` summary line (ADR-0040).
        let mut app = App::new();
        type_str(&mut app, "create table T with pk");
        submit(&mut app);
        let echo = app
            .output
            .iter()
            .find(|l| l.kind == OutputKind::Echo)
            .expect("dispatch pushed an echo");
        assert_eq!(
            echo.status,
            Some(EchoStatus::Pending),
            "pending before result"
        );
        app.update(AppEvent::DslSucceeded {
            command: Command::CreateTable {
                name: "T".to_string(),
                columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)],
                primary_key: vec!["id".to_string()],
            },
            description: None,
            echo: None,
        });
        let echo = app
            .output
            .iter()
            .find(|l| l.kind == OutputKind::Echo)
            .expect("echo still present");
        assert_eq!(echo.status, Some(EchoStatus::Ok), "resolved to Ok");
        assert!(
            !app.output.iter().any(|l| l.text.starts_with("[ok]")),
            "no [ok] summary line",
        );
    }

    #[test]
    fn parse_error_echo_stays_pending_and_keeps_running_prefix() {
        // ADR-0040 scope: a parse error never reaches the worker, so
        // its echo is not marker-tracked (status None) and keeps the
        // `running:` rendering + caret.
        let mut app = App::new();
        type_str(&mut app, "frobnicate widgets");
        submit(&mut app);
        let echo = app
            .output
            .iter()
            .find(|l| l.kind == OutputKind::Echo)
            .expect("parse error still echoes the input");
        assert_eq!(echo.status, None, "parse-error echo is not marker-tracked");
    }

    #[test]
    fn replay_command_dispatches_replay_action_not_execute_dsl() {
        // Submitting `replay <path>` must NOT produce an
        // `Action::ExecuteDsl` (otherwise the worker thread
        // would try to execute Replay, which has no semantics
        // there, and history.log would record the replay
        // invocation itself — see ADR-related runtime comments).
        let mut app = App::new();
        type_str(&mut app, "replay history.log");
        let actions = submit(&mut app);
        assert_eq!(actions.len(), 1);
        match &actions[0] {
            Action::Replay { path } => assert_eq!(path, "history.log"),
            other => panic!("expected Action::Replay, got {other:?}"),
        }
    }

    #[test]
    fn replay_completed_event_writes_ok_summary() {
        let mut app = App::new();
        app.update(AppEvent::ReplayCompleted {
            path: "seed.commands".to_string(),
            count: 4,
            warnings: Vec::new(),
        });
        let last = app.output.back().unwrap();
        assert_eq!(last.kind, OutputKind::System);
        assert!(last.text.starts_with("[ok] replay"), "{}", last.text);
        assert!(last.text.contains("4 command(s)"), "{}", last.text);
        assert!(last.text.contains("seed.commands"), "{}", last.text);
    }

    #[test]
    fn replay_completed_event_renders_skip_warnings() {
        // ADR-0034 Amendment 1: `[skip]` warnings (import / nested
        // replay) surface in the output after the summary line.
        let mut app = App::new();
        app.update(AppEvent::ReplayCompleted {
            path: "history.log".to_string(),
            count: 2,
            warnings: vec![
                "[skip] line 3: `import a.zip` — replay does not re-import".to_string(),
                "[skip] line 7: nested `replay x` — its commands were not replayed".to_string(),
            ],
        });
        let text: String = app
            .output
            .iter()
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");
        assert!(text.contains("[ok] replay"), "summary present:\n{text}");
        assert!(
            text.contains("import a.zip"),
            "import skip warning rendered:\n{text}"
        );
        assert!(
            text.contains("nested `replay x`"),
            "nested-replay skip warning rendered:\n{text}"
        );
    }

    #[test]
    fn replay_failed_event_renders_line_number_and_command_echo() {
        let mut app = App::new();
        app.update(AppEvent::ReplayFailed {
            path: "seed.commands".to_string(),
            line_number: 3,
            command: "this is not a command".to_string(),
            error: "parse error: …".to_string(),
        });
        // Two error lines emitted: header with line number,
        // then `  > <command>` echo for context.
        let lines: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
        assert!(
            lines.iter().any(|l| l.contains("at line 3")),
            "missing line-number header in {lines:?}"
        );
        assert!(
            lines.iter().any(|l| l.contains("> this is not a command")),
            "missing command echo in {lines:?}"
        );
    }

    #[test]
    fn replay_failed_with_line_zero_skips_command_echo() {
        // Line-number 0 is the runtime's signal that file-open
        // itself failed; there's no per-line command to echo.
        let mut app = App::new();
        app.update(AppEvent::ReplayFailed {
            path: "missing.commands".to_string(),
            line_number: 0,
            command: String::new(),
            error: "could not open `missing.commands`: not found".to_string(),
        });
        let lines: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
        assert!(
            lines.iter().any(|l| l.contains("could not open")),
            "missing error in {lines:?}"
        );
        assert!(
            !lines.iter().any(|l| l.contains("at line 0")),
            "should not render `at line 0` header in {lines:?}"
        );
    }

    #[test]
    fn dsl_failure_event_renders_through_friendly_translator() {
        // Synthetic DslFailed carries a structured DbError;
        // the App applies its current verbosity and routes the
        // payload through `friendly::translate_error` (ADR-0019).
        let mut app = App::new();
        let cmd = Command::DropTable {
            name: "Ghost".to_string(),
        };
        app.update(AppEvent::DslFailed {
            command: cmd,
            error: crate::db::DbError::Sqlite {
                message: "no such table: Ghost".to_string(),
                kind: crate::db::SqliteErrorKind::NoSuchTable,
            },
            facts: crate::friendly::FailureContext::default(),
            source: String::new(),
            advanced: false,
        });
        let last = app.output.back().unwrap();
        assert_eq!(last.kind, OutputKind::Error);
        // Anchor phrase + table name (ADR-0019 §10).
        assert!(last.text.contains("no such table"), "{}", last.text);
        assert!(last.text.contains("Ghost"), "{}", last.text);
    }

    // ── H2 / ADR-0053: contextual `hint` (Phase A skeleton) ──────

    fn f1(app: &mut App) -> Vec<Action> {
        app.update(key(KeyCode::F(1)))
    }

    fn no_such_table_failure() -> AppEvent {
        AppEvent::DslFailed {
            command: Command::DropTable {
                name: "Ghost".to_string(),
            },
            error: crate::db::DbError::Sqlite {
                message: "no such table: Ghost".to_string(),
                kind: crate::db::SqliteErrorKind::NoSuchTable,
            },
            facts: crate::friendly::FailureContext::default(),
            source: String::new(),
            advanced: false,
        }
    }

    #[test]
    fn hint_command_parses_to_app_hint() {
        use crate::dsl::{AppCommand, Command, parse_command};
        assert!(matches!(
            parse_command("hint"),
            Ok(Command::App(AppCommand::Hint))
        ));
    }

    // ── ADR-0054: in-app `version` command ──────────────────────────

    #[test]
    fn version_command_parses_to_app_version() {
        use crate::dsl::{AppCommand, Command, parse_command};
        assert!(matches!(
            parse_command("version"),
            Ok(Command::App(AppCommand::Version))
        ));
    }

    #[test]
    fn version_command_emits_the_cargo_version() {
        let mut app = App::new();
        type_str(&mut app, "version");
        submit(&mut app);
        assert!(
            output_contains(&app, env!("CARGO_PKG_VERSION")),
            "in-app `version` should print CARGO_PKG_VERSION: {}",
            error_lines(&app),
        );
    }

    #[test]
    fn hint_command_with_no_recent_error_shows_getting_started() {
        let mut app = App::new();
        type_str(&mut app, "hint");
        submit(&mut app);
        assert!(output_contains(&app, "press F1"), "{}", error_lines(&app));
    }

    #[test]
    fn f1_on_empty_input_with_no_error_shows_getting_started() {
        let mut app = App::new();
        let before = app.output.len();
        f1(&mut app);
        assert!(app.output.len() > before, "F1 must emit something");
        assert!(output_contains(&app, "press F1"));
    }

    #[test]
    fn f1_is_a_read_only_overlay() {
        let mut app = App::new();
        type_str(&mut app, "insert into T");
        let input = app.input.clone();
        let cursor = app.input_cursor;
        let before = app.output.len();
        f1(&mut app);
        assert_eq!(app.input, input, "F1 must not change the buffer");
        assert_eq!(app.input_cursor, cursor, "F1 must not move the cursor");
        assert!(app.output.len() > before, "F1 emits a hint line");
    }

    #[test]
    fn f1_preserves_the_completion_memo() {
        let mut app = App::new();
        type_str(&mut app, "show ");
        app.update(key(KeyCode::Tab));
        assert!(
            app.last_completion.is_some(),
            "precondition: Tab sets the memo"
        );
        let input = app.input.clone();
        f1(&mut app);
        assert!(app.last_completion.is_some(), "F1 must not clear the memo");
        assert_eq!(app.input, input, "F1 must not change the buffer");
    }

    // ── ADR-0047 amendment: Ctrl-G is a demo-mode alias for F1 ──────
    // The cast recorder (autocast) cannot emit F1 — an escape sequence —
    // but Ctrl-G is a single legacy control byte it can send. Demo-gated
    // so the real keymap stays F1-only, and badged as [F1] (see
    // `demo_badge_label`) so a recorded cast looks identical to a genuine
    // F1 press.

    fn ctrl_g() -> AppEvent {
        key_mod(KeyCode::Char('g'), KeyModifiers::CONTROL)
    }

    #[test]
    fn ctrl_g_in_demo_mode_aliases_f1_on_input() {
        let mut app = App::new();
        app.demo_mode = true;
        type_str(&mut app, "insert into T");
        let input = app.input.clone();
        let before = app.output.len();
        app.update(ctrl_g());
        assert_eq!(
            app.input, input,
            "Ctrl-G must not change the buffer (no `g` typed)"
        );
        assert!(
            app.output.len() > before,
            "Ctrl-G must emit the same hint F1 does"
        );
    }

    #[test]
    fn ctrl_g_in_demo_mode_aliases_f1_on_empty_input() {
        let mut app = App::new();
        app.demo_mode = true;
        let before = app.output.len();
        app.update(ctrl_g());
        assert!(
            app.output.len() > before,
            "Ctrl-G on empty input emits the getting-started hint"
        );
        assert!(output_contains(&app, "press F1"));
    }

    #[test]
    fn ctrl_g_outside_demo_mode_is_inert() {
        // Not in demo mode: Ctrl-G is neither the hint alias nor a typed
        // glyph (the `Char(c)` insert arm excludes CONTROL), so it falls
        // through to the inert catch-all — no `g`, no hint.
        let mut app = App::new();
        type_str(&mut app, "insert");
        let input = app.input.clone();
        let before = app.output.len();
        app.update(ctrl_g());
        assert_eq!(app.input, input, "Ctrl-G must not insert a `g`");
        assert_eq!(
            app.output.len(),
            before,
            "Ctrl-G does nothing when demo mode is off"
        );
    }

    #[test]
    fn dsl_failure_sets_hint_class_and_a_later_dsl_command_clears_it() {
        let mut app = App::new();
        app.update(no_such_table_failure());
        assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
        // A new DSL command supersedes the previous error.
        type_str(&mut app, "drop table Ghost");
        submit(&mut app);
        assert_eq!(app.last_error_hint_key, None);
    }

    #[test]
    fn app_command_does_not_clear_the_hint_class() {
        let mut app = App::new();
        app.update(no_such_table_failure());
        assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
        // `help` (an app command) leaves the last error intact, so a
        // following `hint` still expands on it.
        type_str(&mut app, "help");
        submit(&mut app);
        assert_eq!(
            app.last_error_hint_key.as_deref(),
            Some("not_found"),
            "an app command must not clear the last error's hint class"
        );
    }

    #[test]
    fn hint_after_error_emits_a_hint_without_panicking() {
        // Phase A: no tier-3 `hint.err.*` content exists yet, so the
        // error path falls back to the getting-started pointer. (Phase C
        // replaces this with the real error block.)
        let mut app = App::new();
        app.update(no_such_table_failure());
        let before = app.output.len();
        type_str(&mut app, "hint");
        submit(&mut app);
        assert!(app.output.len() > before, "hint must emit something");
    }

    #[test]
    fn help_list_includes_hint() {
        let mut app = App::new();
        type_str(&mut app, "help");
        submit(&mut app);
        assert!(
            output_contains(&app, "explain the most recent error"),
            "help list must advertise the hint command"
        );
    }

    #[test]
    fn help_hint_describes_the_hint_command() {
        let mut app = App::new();
        type_str(&mut app, "help hint");
        submit(&mut app);
        assert!(output_contains(&app, "explain the most recent error"));
    }

    // ── Phase B: tier-3 exemplar content renders ────────────────

    #[test]
    fn f1_on_insert_input_renders_the_insert_hint_block() {
        let mut app = App::new();
        type_str(&mut app, "insert into Customers ");
        f1(&mut app);
        assert!(
            output_contains(&app, "Add one or more rows to a table"),
            "expected the insert tier-3 block"
        );
    }

    /// Locks the rendered tier-3 block format (ADR-0053 D4): a `Hint`
    /// heading + aligned `What:` / `Example:` / `Concept:` lines.
    #[test]
    fn insert_hint_block_renders_in_the_labelled_format() {
        let mut app = App::new();
        type_str(&mut app, "insert into Customers ");
        f1(&mut app);
        let block = app
            .output
            .iter()
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");
        insta::assert_snapshot!("hint_block_insert", block);
    }

    #[test]
    fn f1_on_add_relationship_renders_the_relationship_block() {
        let mut app = App::new();
        type_str(
            &mut app,
            "add 1:n relationship from Customers.id to Orders.cust ",
        );
        f1(&mut app);
        assert!(
            output_contains(&app, "one parent, many children"),
            "expected the add-relationship tier-3 block"
        );
    }

    // ── Clause-concept hints (issue #37) ────────────────────────

    /// Move the input cursor left `n` times (into already-typed text).
    fn cursor_left_n(app: &mut App, n: usize) {
        for _ in 0..n {
            app.update(key(KeyCode::Left));
        }
    }

    #[test]
    fn f1_inside_on_delete_layers_the_referential_actions_concept() {
        let mut app = App::new();
        type_str(
            &mut app,
            "add 1:n relationship from Customers.id to Orders.cid on delete cascade",
        );
        // Park the cursor inside the already-typed `cascade` — the
        // cursor-inside case (#37), not merely the slot boundary.
        cursor_left_n(&mut app, 3);
        let buffer_before = app.input.clone();
        let cursor_before = app.input_cursor;
        f1(&mut app);
        // Per-form block still renders…
        assert!(
            output_contains(&app, "one parent, many children"),
            "the per-form relationship block must still render",
        );
        // …layered with the clause-concept block under its sub-heading.
        assert!(
            output_contains(&app, "About this clause"),
            "expected the clause-concept sub-heading",
        );
        assert!(
            output_contains(&app, "forbids orphans"),
            "expected the referential-actions concept block",
        );
        // F1 is a read-only overlay (ADR-0053 D1): buffer + cursor intact.
        assert_eq!(app.input, buffer_before, "F1 must not alter the buffer");
        assert_eq!(
            app.input_cursor, cursor_before,
            "F1 must not move the cursor"
        );
    }

    #[test]
    fn f1_concept_example_is_mode_correct() {
        // Advanced mode: the referential-actions concept is reachable
        // via `references … on delete`, and its example must be the
        // advanced (SQL) one, never the simple-mode `add 1:n …` line
        // (ADR-0053 D6 / clause-concept-hints D5).
        let mut app = App::new();
        app.mode = crate::mode::Mode::Advanced;
        type_str(
            &mut app,
            "create table Orders (cid int references Customers(id) on delete cascade)",
        );
        cursor_left_n(&mut app, 3); // inside `cascade`
        f1(&mut app);
        assert!(
            output_contains(&app, "references Customers(id) on delete cascade"),
            "expected the advanced-mode example",
        );
        assert!(
            !output_contains(&app, "add 1:n relationship"),
            "must NOT show the simple-mode example in advanced mode",
        );
    }

    #[test]
    fn f1_outside_a_concept_clause_shows_no_concept_block() {
        // Cursor at the end of a plain `insert` — not inside any
        // recognized clause. The per-form block renders; no concept
        // layer, no sub-heading.
        let mut app = App::new();
        type_str(&mut app, "insert into Customers ");
        f1(&mut app);
        assert!(
            output_contains(&app, "Add one or more rows to a table"),
            "the per-form block must render",
        );
        assert!(
            !output_contains(&app, "About this clause"),
            "no clause-concept block when the cursor is outside any clause",
        );
    }

    /// Locks the rendered shape of the per-form block + the layered
    /// clause-concept block (clause-concept-hints D6).
    #[test]
    fn hint_block_with_concept_renders_layered() {
        let mut app = App::new();
        type_str(
            &mut app,
            "add 1:n relationship from Customers.id to Orders.cid on delete cascade",
        );
        cursor_left_n(&mut app, 3);
        f1(&mut app);
        let block = app
            .output
            .iter()
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");
        insta::assert_snapshot!("hint_block_with_concept", block);
    }

    #[test]
    fn f1_on_add_column_does_not_render_the_relationship_block() {
        // Per-form disambiguation (ADR-0053 D3): `add column` resolves
        // to `add_column` (no tier-3 block yet → tier-2 fallback), NOT
        // the relationship block — proving the multi-form node keys
        // per form, not per node.
        let mut app = App::new();
        type_str(&mut app, "add column Note text to Customers");
        f1(&mut app);
        assert!(!output_contains(&app, "one parent, many children"));
        assert!(!output_contains(&app, "1:n"));
    }

    #[test]
    fn hint_renders_the_foreign_key_error_block() {
        let mut app = App::new();
        app.last_error_hint_key = Some("foreign_key.child_side".to_string());
        type_str(&mut app, "hint");
        submit(&mut app);
        assert!(
            output_contains(&app, "doesn't match any parent row"),
            "expected the FK child-side tier-3 block"
        );
    }

    // ── Phase C batch 1: app-command hints render ───────────────

    #[test]
    fn f1_on_an_app_command_renders_its_hint_block() {
        let mut app = App::new();
        type_str(&mut app, "mode advanced");
        f1(&mut app);
        assert!(
            output_contains(&app, "Switch between simple mode"),
            "expected the `mode` tier-3 block"
        );
    }

    // ── Phase C batch 2: DDL hints render (incl. multi-form DROP) ──

    #[test]
    fn f1_on_create_table_renders_its_hint_block() {
        let mut app = App::new();
        type_str(&mut app, "create table Customers with pk id(serial)");
        f1(&mut app);
        assert!(output_contains(&app, "Create a new table"));
    }

    #[test]
    fn f1_disambiguates_drop_forms() {
        let mut app = App::new();
        type_str(&mut app, "drop index idx_email");
        f1(&mut app);
        // Resolves drop_index, not drop_table/column/etc.
        assert!(output_contains(&app, "Remove an index by name"));
        assert!(!output_contains(&app, "Remove a table"));
    }

    // ── Phase C batch 3: DML hints render (incl. multi-form SHOW) ──

    #[test]
    fn f1_on_update_renders_its_hint_block() {
        let mut app = App::new();
        type_str(&mut app, "update Customers set email = 'x' ");
        f1(&mut app);
        assert!(output_contains(&app, "Change values in the rows"));
    }

    #[test]
    fn f1_disambiguates_show_forms() {
        let mut app = App::new();
        type_str(&mut app, "show relationships");
        f1(&mut app);
        assert!(output_contains(&app, "List all the relationships"));
        assert!(!output_contains(&app, "rows stored in a table"));
    }

    // ── Phase C batch 4: advanced-SQL hints (mode-aware) ────────

    #[test]
    fn f1_in_advanced_mode_renders_the_sql_insert_hint() {
        let mut app = App::new();
        app.mode = Mode::Advanced;
        type_str(&mut app, "insert into Customers (name) values ('x')");
        f1(&mut app);
        assert!(output_contains(&app, "Insert rows with SQL"));
        assert!(!output_contains(&app, "Add one or more rows"));
    }

    #[test]
    fn f1_on_select_renders_the_select_hint() {
        let mut app = App::new();
        app.mode = Mode::Advanced;
        type_str(&mut app, "select name from Customers");
        f1(&mut app);
        assert!(output_contains(&app, "heart of SQL"));
    }

    // ── Phase C batch 5: runtime error-class hints render ───────

    #[test]
    fn hint_renders_a_runtime_error_block() {
        let mut app = App::new();
        app.last_error_hint_key = Some("unique".to_string());
        type_str(&mut app, "hint");
        submit(&mut app);
        assert!(output_contains(&app, "must be unique"));
    }

    #[test]
    fn messages_command_toggles_verbosity_and_reports() {
        let mut app = App::new();
        // Default is verbose.
        type_str(&mut app, "messages");
        submit(&mut app);
        let last = app.output.back().unwrap();
        assert!(last.text.ends_with("verbose"), "{}", last.text);

        // Switch to short.
        type_str(&mut app, "messages short");
        submit(&mut app);
        assert_eq!(app.messages_verbosity, crate::friendly::Verbosity::Short);
        let last = app.output.back().unwrap();
        assert_eq!(last.text, "messages: short");

        // And back.
        type_str(&mut app, "messages verbose");
        submit(&mut app);
        assert_eq!(app.messages_verbosity, crate::friendly::Verbosity::Verbose);
    }

    #[test]
    fn dsl_failure_threads_facts_value_into_unique_insert_headline() {
        // The runtime resolves the user's attempted value into
        // `FailureContext::value` (Phase C). The App threads
        // it into `TranslateContext.value` and the catalog
        // headline gets the concrete substitution. Here we
        // simulate the runtime by populating `facts` directly.
        let mut app = App::new();
        let cmd = Command::Insert {
            table: "thing".to_string(),
            columns: None,
            values: vec![crate::dsl::Value::Number("1".to_string())],
        };
        let err = crate::db::DbError::Sqlite {
            message: "UNIQUE constraint failed: thing.id".to_string(),
            kind: crate::db::SqliteErrorKind::UniqueViolation,
        };
        let facts = crate::friendly::FailureContext {
            table: Some("thing".to_string()),
            column: Some("id".to_string()),
            value: Some("1".to_string()),
            ..crate::friendly::FailureContext::default()
        };
        app.update(AppEvent::DslFailed {
            command: cmd,
            error: err,
            facts,
            source: String::new(),
            advanced: false,
        });
        let body = app
            .output
            .iter()
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            body.contains("`1`"),
            "expected the attempted value `1` in headline:\n{body}"
        );
        assert!(
            !body.contains("{value}"),
            "{{value}} placeholder should have been substituted:\n{body}"
        );
    }

    #[test]
    fn dsl_failure_threads_facts_value_into_unique_update_headline() {
        // UPDATE: same threading as INSERT, just that the
        // runtime would have pulled `value` from the SET
        // assignment matching the offending column.
        let mut app = App::new();
        let cmd = Command::Update {
            table: "Customers".to_string(),
            assignments: vec![("id".to_string(), crate::dsl::Value::Number("7".to_string()))],
            filter: crate::dsl::RowFilter::eq("name", crate::dsl::Value::Text("Bob".to_string())),
        };
        let err = crate::db::DbError::Sqlite {
            message: "UNIQUE constraint failed: Customers.id".to_string(),
            kind: crate::db::SqliteErrorKind::UniqueViolation,
        };
        let facts = crate::friendly::FailureContext {
            table: Some("Customers".to_string()),
            column: Some("id".to_string()),
            value: Some("7".to_string()),
            ..crate::friendly::FailureContext::default()
        };
        app.update(AppEvent::DslFailed {
            command: cmd,
            error: err,
            facts,
            source: String::new(),
            advanced: false,
        });
        let body = app
            .output
            .iter()
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");
        assert!(body.contains("`7`"), "expected attempted id `7`:\n{body}");
    }

    #[test]
    fn messages_short_drops_the_hint_in_dsl_failure_render() {
        // Verbose mode → headline + hint. Short mode → headline only.
        // Use a UNIQUE-style violation since it has a meaty hint
        // worth measuring against.
        let mut app = App::new();
        let cmd = Command::Insert {
            table: "Customers".to_string(),
            columns: Some(vec!["id".to_string()]),
            values: vec![],
        };
        let err = || crate::db::DbError::Sqlite {
            message: "UNIQUE constraint failed: Customers.id".to_string(),
            kind: crate::db::SqliteErrorKind::UniqueViolation,
        };

        app.messages_verbosity = crate::friendly::Verbosity::Verbose;
        app.update(AppEvent::DslFailed {
            command: cmd.clone(),
            error: err(),
            facts: crate::friendly::FailureContext::default(),
            source: String::new(),
            advanced: false,
        });
        let verbose_text = app
            .output
            .iter()
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            verbose_text.contains("pick a different value"),
            "verbose mode missing hint: {verbose_text}"
        );

        // Reset and try short.
        let mut app = App::new();
        app.messages_verbosity = crate::friendly::Verbosity::Short;
        app.update(AppEvent::DslFailed {
            command: cmd,
            error: err(),
            facts: crate::friendly::FailureContext::default(),
            source: String::new(),
            advanced: false,
        });
        let short_text = app
            .output
            .iter()
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            short_text.contains("already has the value"),
            "short still has the headline: {short_text}"
        );
        assert!(
            !short_text.contains("pick a different value"),
            "short mode should not include the hint: {short_text}"
        );
    }

    #[test]
    fn tables_refreshed_event_replaces_cached_list() {
        let mut app = App::new();
        app.update(AppEvent::TablesRefreshed(vec![
            "A".to_string(),
            "B".to_string(),
        ]));
        assert_eq!(app.tables, vec!["A".to_string(), "B".to_string()]);
        app.update(AppEvent::TablesRefreshed(vec!["C".to_string()]));
        assert_eq!(app.tables, vec!["C".to_string()]);
    }

    #[test]
    fn add_column_command_with_unknown_type_reports_parse_error() {
        let mut app = App::new();
        type_str(&mut app, "add column to table T: c (varchar)");
        let actions = submit(&mut app);
        assert!(
            matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
            "expected only a JournalFailure, no dispatch; got {actions:?}",
        );
        let mentions_varchar = app
            .output
            .iter()
            .any(|l| l.kind == OutputKind::Error && l.text.contains("varchar"));
        assert!(
            mentions_varchar,
            "no error line mentions `varchar`; output:\n{}",
            error_lines(&app),
        );
    }

    #[test]
    fn drop_table_command_emits_execute_action() {
        let mut app = App::new();
        type_str(&mut app, "drop table T");
        let actions = submit(&mut app);
        assert_eq!(actions.len(), 1);
        let Action::ExecuteDsl { command, .. } = &actions[0] else {
            panic!("expected ExecuteDsl, got {:?}", actions[0]);
        };
        assert_eq!(
            command,
            &Command::DropTable {
                name: "T".to_string(),
            },
        );
    }

    #[test]
    fn typing_moves_cursor_to_end_of_input() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        assert_eq!(app.input, "hello");
        assert_eq!(app.input_cursor, 5);
    }

    #[test]
    fn left_arrow_moves_cursor_back_one_char() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.update(key(KeyCode::Left));
        assert_eq!(app.input_cursor, 4);
        app.update(key(KeyCode::Left));
        assert_eq!(app.input_cursor, 3);
    }

    #[test]
    fn left_arrow_at_zero_does_not_underflow() {
        let mut app = App::new();
        app.update(key(KeyCode::Left));
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn right_arrow_moves_cursor_forward() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.input_cursor = 0;
        app.update(key(KeyCode::Right));
        assert_eq!(app.input_cursor, 1);
    }

    #[test]
    fn home_and_end_jump_to_extremes() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.update(key(KeyCode::Home));
        assert_eq!(app.input_cursor, 0);
        app.update(key(KeyCode::End));
        assert_eq!(app.input_cursor, 5);
    }

    #[test]
    fn typing_inserts_at_cursor_position() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        // Cursor between 'h' and 'e'.
        app.input_cursor = 1;
        type_str(&mut app, "X");
        assert_eq!(app.input, "hXello");
        assert_eq!(app.input_cursor, 2);
    }

    #[test]
    fn backspace_removes_char_before_cursor() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        // Cursor at end.
        app.update(key(KeyCode::Backspace));
        assert_eq!(app.input, "hell");
        assert_eq!(app.input_cursor, 4);

        // Cursor in the middle.
        app.input_cursor = 2; // between 'e' and 'l'
        app.update(key(KeyCode::Backspace));
        assert_eq!(app.input, "hll");
        assert_eq!(app.input_cursor, 1);
    }

    #[test]
    fn backspace_at_start_is_a_noop() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.input_cursor = 0;
        app.update(key(KeyCode::Backspace));
        assert_eq!(app.input, "hello");
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn delete_removes_char_at_cursor() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.input_cursor = 1; // between 'h' and 'e'
        app.update(key(KeyCode::Delete));
        assert_eq!(app.input, "hllo");
        assert_eq!(app.input_cursor, 1);
    }

    #[test]
    fn delete_at_end_is_a_noop() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.update(key(KeyCode::Delete));
        assert_eq!(app.input, "hello");
        assert_eq!(app.input_cursor, 5);
    }

    #[test]
    fn cursor_handles_multibyte_chars() {
        let mut app = App::new();
        type_str(&mut app, "héllo"); // 'é' is 2 bytes
        // input length is 6 bytes, 5 chars
        assert_eq!(app.input.len(), 6);
        assert_eq!(app.input_cursor, 6);
        // Move left across the 2-byte char.
        app.update(key(KeyCode::Left));
        assert_eq!(app.input_cursor, 5);
        app.update(key(KeyCode::Left));
        assert_eq!(app.input_cursor, 4);
        app.update(key(KeyCode::Left));
        assert_eq!(app.input_cursor, 3);
        app.update(key(KeyCode::Left));
        // Now at the byte before 'é' — must skip the multi-byte char.
        assert_eq!(app.input_cursor, 1);
    }

    #[test]
    fn submit_resets_cursor_to_zero() {
        let mut app = App::new();
        type_str(&mut app, "drop table T");
        submit(&mut app);
        assert_eq!(app.input_cursor, 0);
    }

    // ---- ADR-0049 (issue #29): input-field readline keymap ----

    fn ctrl(c: char) -> AppEvent {
        key_mod(KeyCode::Char(c), KeyModifiers::CONTROL)
    }

    #[test]
    fn esc_clears_a_partly_typed_command() {
        let mut app = App::new();
        type_str(&mut app, "drop table T");
        app.update(key(KeyCode::Esc));
        assert_eq!(app.input, "");
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn esc_clear_resets_horizontal_scroll() {
        // A long line that has been horizontally scrolled must
        // reset its scroll offset on clear, exactly like submit.
        let mut app = App::new();
        type_str(&mut app, "drop table T");
        app.input_scroll_offset = 5;
        app.update(key(KeyCode::Esc));
        assert_eq!(app.input, "");
        assert_eq!(app.input_scroll_offset, 0);
    }

    #[test]
    fn esc_clear_cancels_history_navigation() {
        let mut app = App::new();
        type_str(&mut app, "drop table A");
        submit(&mut app);
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table A");
        app.update(key(KeyCode::Esc));
        assert_eq!(app.input, "");
        assert!(app.history_cursor.is_none());
    }

    #[test]
    fn esc_with_live_completion_memo_undoes_rather_than_clears() {
        // Precedence: while a multi-candidate Tab memo is alive, Esc
        // undoes the completion (restoring the original text), it does
        // NOT clear the whole input.
        let mut app = App::new();
        type_str(&mut app, "show ");
        app.update(key(KeyCode::Tab)); // → "show data", memo alive
        assert!(app.last_completion.is_some());
        app.update(key(KeyCode::Esc));
        assert_eq!(app.input, "show ");
    }

    #[test]
    fn ctrl_a_moves_cursor_to_start() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.update(ctrl('a'));
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn ctrl_e_moves_cursor_to_end() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.update(key(KeyCode::Home));
        assert_eq!(app.input_cursor, 0);
        app.update(ctrl('e'));
        assert_eq!(app.input_cursor, 5);
    }

    #[test]
    fn ctrl_w_deletes_the_previous_word() {
        let mut app = App::new();
        type_str(&mut app, "drop table T");
        app.update(ctrl('w'));
        assert_eq!(app.input, "drop table ");
        assert_eq!(app.input_cursor, "drop table ".len());
    }

    #[test]
    fn ctrl_w_eats_trailing_whitespace_then_the_word() {
        let mut app = App::new();
        type_str(&mut app, "foo bar ");
        app.update(ctrl('w'));
        assert_eq!(app.input, "foo ");
        assert_eq!(app.input_cursor, 4);
    }

    #[test]
    fn ctrl_w_at_start_is_a_noop() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.input_cursor = 0;
        app.update(ctrl('w'));
        assert_eq!(app.input, "hello");
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn ctrl_w_only_deletes_back_to_the_cursor() {
        // Mid-line: deletes the word before the cursor, leaving the
        // suffix untouched.
        let mut app = App::new();
        type_str(&mut app, "drop table T");
        app.input_cursor = "drop table".len(); // cursor right after "table"
        app.update(ctrl('w'));
        assert_eq!(app.input, "drop  T");
        assert_eq!(app.input_cursor, "drop ".len());
    }

    #[test]
    fn ctrl_w_handles_multibyte_words() {
        let mut app = App::new();
        type_str(&mut app, "héllo wörld");
        app.update(ctrl('w'));
        assert_eq!(app.input, "héllo ");
        assert_eq!(app.input_cursor, "héllo ".len());
    }

    #[test]
    fn ctrl_k_kills_to_end_of_line() {
        let mut app = App::new();
        type_str(&mut app, "hello world");
        app.input_cursor = 5; // after "hello"
        app.update(ctrl('k'));
        assert_eq!(app.input, "hello");
        assert_eq!(app.input_cursor, 5);
    }

    #[test]
    fn ctrl_u_kills_to_start_of_line() {
        let mut app = App::new();
        type_str(&mut app, "hello world");
        app.input_cursor = 6; // after "hello "
        app.update(ctrl('u'));
        assert_eq!(app.input, "world");
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn ctrl_u_cancels_history_navigation() {
        let mut app = App::new();
        type_str(&mut app, "drop table A");
        submit(&mut app);
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table A");
        app.update(ctrl('u')); // cursor is at end after recall → clears all
        assert_eq!(app.input, "");
        assert!(app.history_cursor.is_none());
    }

    #[test]
    fn ctrl_w_cancels_history_navigation() {
        let mut app = App::new();
        type_str(&mut app, "drop table A");
        submit(&mut app);
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table A");
        app.update(ctrl('w')); // deletes the recalled "A" word
        assert_eq!(app.input, "drop table ");
        assert!(app.history_cursor.is_none());
    }

    #[test]
    fn ctrl_w_on_whitespace_only_clears_it() {
        let mut app = App::new();
        type_str(&mut app, "   ");
        app.update(ctrl('w'));
        assert_eq!(app.input, "");
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn ctrl_k_at_end_of_line_is_a_noop() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        // Cursor at end.
        app.update(ctrl('k'));
        assert_eq!(app.input, "hello");
        assert_eq!(app.input_cursor, 5);
    }

    #[test]
    fn ctrl_k_at_start_kills_the_whole_line() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.input_cursor = 0;
        app.update(ctrl('k'));
        assert_eq!(app.input, "");
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn ctrl_u_at_start_of_line_is_a_noop() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        app.input_cursor = 0;
        app.update(ctrl('u'));
        assert_eq!(app.input, "hello");
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn ctrl_u_at_end_kills_the_whole_line() {
        let mut app = App::new();
        type_str(&mut app, "hello");
        // Cursor at end.
        app.update(ctrl('u'));
        assert_eq!(app.input, "");
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn esc_on_empty_input_is_harmless() {
        let mut app = App::new();
        app.update(key(KeyCode::Esc));
        assert_eq!(app.input, "");
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn esc_exiting_nav_mode_does_not_clear_the_input() {
        // ADR-0049 / ADR-0046 DC3: while a sidebar panel is focused
        // (Ctrl-O), Esc exits navigation mode — the nav handler
        // consumes it upstream of the input-field keymap, so the
        // partly-typed command is preserved, NOT cleared.
        let mut app = App::new();
        type_str(&mut app, "create table T");
        app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
        assert_eq!(app.nav_focus, NavFocus::SidebarTables);
        // The draft survives entering nav mode.
        assert_eq!(app.input, "create table T");
        app.update(key(KeyCode::Esc));
        // Esc returned focus to the input WITHOUT clearing it.
        assert_eq!(app.nav_focus, NavFocus::Input);
        assert_eq!(app.input, "create table T");
        assert_eq!(app.input_cursor, "create table T".len());
    }

    #[test]
    fn relationships_refreshed_event_updates_the_field() {
        // ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the
        // App stores it for the sidebar relationships panel to render.
        use crate::dsl::action::ReferentialAction;
        let mut app = App::new();
        assert!(app.relationships.is_empty());
        app.update(AppEvent::RelationshipsRefreshed(vec![
            crate::persistence::RelationshipSchema {
                name: "Customers_Orders".to_string(),
                parent_table: "Customers".to_string(),
                parent_columns: vec!["id".to_string()],
                child_table: "Orders".to_string(),
                child_columns: vec!["customer_id".to_string()],
                on_delete: ReferentialAction::Cascade,
                on_update: ReferentialAction::NoAction,
            },
        ]));
        assert_eq!(app.relationships.len(), 1);
        assert_eq!(app.relationships[0].name, "Customers_Orders");
    }

    #[test]
    fn ctrl_o_cycles_navigation_focus() {
        // ADR-0046 DC1: Input → Tables → Relationships → Input.
        let mut app = App::new();
        assert_eq!(app.nav_focus, NavFocus::Input);
        let ctrl_o = || key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL);
        app.update(ctrl_o());
        assert_eq!(app.nav_focus, NavFocus::SidebarTables);
        app.update(ctrl_o());
        assert_eq!(app.nav_focus, NavFocus::SidebarRelationships);
        app.update(ctrl_o());
        assert_eq!(app.nav_focus, NavFocus::Input);
    }

    #[test]
    fn esc_exits_navigation_mode() {
        let mut app = App::new();
        app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
        assert!(app.nav_focus.in_sidebar());
        app.update(key(KeyCode::Esc));
        assert_eq!(app.nav_focus, NavFocus::Input);
    }

    #[test]
    fn navigation_mode_ignores_input_keys() {
        // ADR-0046 DC4: the input is occluded; printable/Enter/Backspace
        // are inert while a sidebar panel is focused.
        let mut app = App::new();
        type_str(&mut app, "select");
        app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
        app.update(key(KeyCode::Char('x')));
        app.update(key(KeyCode::Backspace));
        let actions = app.update(key(KeyCode::Enter));
        assert_eq!(app.input, "select", "input untouched in navigation mode");
        assert!(
            actions.is_empty(),
            "Enter does not submit in navigation mode"
        );
    }

    #[test]
    fn nav_scroll_keys_move_only_the_focused_panel() {
        // ADR-0046 DC3: Up/Down line-scroll the focused sidebar panel.
        let mut app = App::new();
        app.nav_focus = NavFocus::SidebarTables;
        app.update(key(KeyCode::Down));
        app.update(key(KeyCode::Down));
        assert_eq!(app.tables_scroll, 2);
        assert_eq!(
            app.relationships_scroll, 0,
            "only the focused panel scrolls"
        );
        app.update(key(KeyCode::Up));
        assert_eq!(app.tables_scroll, 1);
        // Up saturates at the top.
        app.update(key(KeyCode::Up));
        app.update(key(KeyCode::Up));
        assert_eq!(app.tables_scroll, 0);
        // Switching focus moves the other panel instead.
        app.nav_focus = NavFocus::SidebarRelationships;
        app.update(key(KeyCode::Down));
        assert_eq!(app.relationships_scroll, 1);
        assert_eq!(app.tables_scroll, 0);
    }

    #[test]
    fn nav_page_scroll_uses_the_panels_visible_rows() {
        // ADR-0046 DC3: PageUp/PageDown move by the last reported
        // visible-row count.
        let mut app = App::new();
        app.nav_focus = NavFocus::SidebarTables;
        app.last_tables_visible = 10;
        app.update(key(KeyCode::PageDown));
        assert_eq!(app.tables_scroll, 10);
        app.update(key(KeyCode::PageUp));
        assert_eq!(app.tables_scroll, 0);
    }

    #[test]
    fn input_scroll_offset_resets_when_the_buffer_is_replaced() {
        // ADR-0046 DA3: the horizontal scroll offset must not leak from
        // one command to the next. Submitting and recalling from history
        // both replace the buffer wholesale, so both reset it.
        let mut app = App::new();
        type_str(&mut app, "a long command line that would have scrolled");
        app.input_scroll_offset = 25;
        submit(&mut app);
        assert_eq!(app.input_scroll_offset, 0, "submit resets the input scroll");

        // Recall the submitted line from history — also a reset.
        type_str(&mut app, "another draft line entirely");
        app.input_scroll_offset = 25;
        app.update(key(KeyCode::Up));
        assert_eq!(
            app.input_scroll_offset, 0,
            "history recall resets the input scroll"
        );
    }

    #[test]
    fn page_up_scrolls_output_back() {
        let mut app = App::new();
        for i in 0..30 {
            app.note_system(format!("line{i}"));
        }
        // Simulate a render establishing 10 visible / 30 wrapped.
        app.note_output_viewport(10, 30);
        assert_eq!(app.output_scroll, 0);
        app.update(key(KeyCode::PageUp));
        assert_eq!(app.output_scroll, super::PAGE_SCROLL_LINES);
    }

    #[test]
    fn page_down_scrolls_output_back_to_bottom() {
        let mut app = App::new();
        for i in 0..30 {
            app.note_system(format!("line{i}"));
        }
        app.note_output_viewport(10, 30);
        for _ in 0..3 {
            app.update(key(KeyCode::PageUp));
        }
        assert!(app.output_scroll > 0);
        for _ in 0..10 {
            app.update(key(KeyCode::PageDown));
        }
        assert_eq!(app.output_scroll, 0);
    }

    #[test]
    fn new_output_resets_scroll_to_zero() {
        let mut app = App::new();
        for i in 0..30 {
            app.note_system(format!("line{i}"));
        }
        app.note_output_viewport(10, 30);
        app.update(key(KeyCode::PageUp));
        assert!(app.output_scroll > 0);
        // Any new output line snaps the scroll back to bottom so
        // the user always sees the latest result after a command.
        app.note_system("fresh");
        assert_eq!(app.output_scroll, 0);
    }

    #[test]
    fn page_up_caps_at_top_of_buffer() {
        let mut app = App::new();
        app.note_system("only line");
        // Many PageUps in a row should not push past the buffer.
        for _ in 0..50 {
            app.update(key(KeyCode::PageUp));
        }
        // With 1 line in the buffer, the maximum scroll is 0
        // (since there's nothing older to reveal).
        assert_eq!(app.output_scroll, 0);
    }

    #[test]
    fn page_up_at_top_of_buffer_does_not_shrink_visible_window() {
        // Regression: extra PageUps past the top used to drift
        // `output_scroll` higher than `len - visible`, which
        // then made the rendered window slide off the top and
        // appeared to "eat" lines from the bottom.
        let mut app = App::new();
        for i in 0..30 {
            app.note_system(format!("line{i}"));
        }
        // Simulate a render reporting 10 visible rows over a
        // 30-row wrapped buffer (every line fits in one row in
        // this test).
        app.note_output_viewport(10, 30);
        // Page up many times — past the maximum useful scroll.
        for _ in 0..20 {
            app.update(key(KeyCode::PageUp));
        }
        // Cap should be at total_wrapped - visible = 30 - 10 = 20.
        assert_eq!(app.output_scroll, 20);
    }

    #[test]
    fn note_output_viewport_clamps_a_drifted_scroll_value() {
        // If the scroll value was set high while the viewport
        // was unknown (e.g. before the first render), the next
        // render's report should bring it back into range.
        let mut app = App::new();
        for i in 0..30 {
            app.note_system(format!("line{i}"));
        }
        app.output_scroll = 100;
        app.note_output_viewport(10, 30);
        assert_eq!(app.output_scroll, 20);
    }

    #[test]
    fn history_recall_places_cursor_at_end() {
        let mut app = App::new();
        type_str(&mut app, "drop table A");
        submit(&mut app);
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table A");
        assert_eq!(app.input_cursor, "drop table A".len());
    }

    #[test]
    fn history_records_submitted_lines() {
        let mut app = App::new();
        type_str(&mut app, "drop table A");
        submit(&mut app);
        type_str(&mut app, "drop table B");
        submit(&mut app);
        assert_eq!(
            app.history,
            vec!["drop table A".to_string(), "drop table B".to_string()]
        );
    }

    #[test]
    fn submitting_an_unparseable_line_emits_journal_failure() {
        // ADR-0034 §1/§2: a submitted line that fails to parse is
        // journalled `err` (recallable across sessions). The
        // pure-sync App emits the intent; the runtime does the I/O.
        let mut app = App::new();
        type_str(&mut app, "florp glorp");
        let actions = submit(&mut app);
        assert!(
            matches!(
                actions.as_slice(),
                [Action::JournalFailure { source, .. }] if source == "florp glorp"
            ),
            "expected JournalFailure for the typo'd line; got {actions:?}",
        );
    }

    #[test]
    fn dsl_failure_event_emits_journal_failure_carrying_the_source() {
        // ADR-0034 §1/§2: an execution failure (the worker rejected
        // a parsed command) is journalled `err` too. The runtime
        // forwards the source on `DslFailed`; the App turns it into
        // a `JournalFailure` action.
        let mut app = App::new();
        let actions = app.update(AppEvent::DslFailed {
            command: Command::DropTable {
                name: "Ghost".to_string(),
            },
            error: crate::db::DbError::Sqlite {
                message: "no such table: Ghost".to_string(),
                kind: crate::db::SqliteErrorKind::NoSuchTable,
            },
            facts: crate::friendly::FailureContext::default(),
            source: "drop table Ghost".to_string(),
            advanced: false,
        });
        assert!(
            matches!(
                actions.as_slice(),
                [Action::JournalFailure { source, .. }] if source == "drop table Ghost"
            ),
            "expected JournalFailure carrying the source; got {actions:?}",
        );
    }

    #[test]
    fn history_skips_consecutive_duplicates() {
        let mut app = App::new();
        type_str(&mut app, "drop table A");
        submit(&mut app);
        type_str(&mut app, "drop table A");
        submit(&mut app);
        type_str(&mut app, "drop table B");
        submit(&mut app);
        type_str(&mut app, "drop table A");
        submit(&mut app);
        assert_eq!(
            app.history,
            vec![
                "drop table A".to_string(),
                "drop table B".to_string(),
                "drop table A".to_string(),
            ]
        );
    }

    #[test]
    fn up_arrow_recalls_most_recent_history_entry() {
        let mut app = App::new();
        type_str(&mut app, "drop table A");
        submit(&mut app);
        type_str(&mut app, "drop table B");
        submit(&mut app);
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table B");
    }

    #[test]
    fn up_arrow_walks_backwards_through_history() {
        let mut app = App::new();
        for line in ["drop table A", "drop table B", "drop table C"] {
            type_str(&mut app, line);
            submit(&mut app);
        }
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table C");
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table B");
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table A");
        // Going past the oldest holds at the oldest.
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table A");
    }

    #[test]
    fn resubmitting_a_recalled_command_does_not_strand_the_cursor() {
        // Regression: a recalled command re-submitted unchanged
        // is a consecutive duplicate, so `push_history` skips the
        // append — but it must still reset the navigation cursor.
        // Otherwise the next Up steps backwards from the stranded
        // position instead of restarting at the newest entry.
        let mut app = App::new();
        type_str(&mut app, "show data Thing");
        submit(&mut app);
        type_str(&mut app, "insert into Thing values (1)");
        submit(&mut app);

        // Recall the insert and resubmit it unchanged, repeatedly.
        // Every fresh Up must restart at the newest entry.
        for round in 0..3 {
            app.update(key(KeyCode::Up));
            assert_eq!(
                app.input,
                "insert into Thing values (1)",
                "Up #{} should recall the newest entry",
                round + 1,
            );
            submit(&mut app);
        }
    }

    #[test]
    fn down_arrow_returns_through_history_to_the_draft() {
        let mut app = App::new();
        for line in ["drop table A", "drop table B"] {
            type_str(&mut app, line);
            submit(&mut app);
        }
        // Type a draft, then start navigating.
        type_str(&mut app, "in progress");
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table B");
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table A");
        app.update(key(KeyCode::Down));
        assert_eq!(app.input, "drop table B");
        app.update(key(KeyCode::Down));
        // Past the newest, restore the draft.
        assert_eq!(app.input, "in progress");
    }

    #[test]
    fn down_arrow_with_no_history_navigation_is_a_noop() {
        let mut app = App::new();
        type_str(&mut app, "draft");
        app.update(key(KeyCode::Down));
        assert_eq!(app.input, "draft");
    }

    #[test]
    fn editing_during_history_navigation_cancels_it() {
        let mut app = App::new();
        type_str(&mut app, "drop table A");
        submit(&mut app);
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table A");
        // Editing the recalled line cancels navigation: another
        // Up press should re-enter navigation from the new draft.
        type_str(&mut app, "X");
        assert_eq!(app.input, "drop table AX");
        app.update(key(KeyCode::Up));
        // Up brings the most recent history back, saving the
        // edited draft.
        assert_eq!(app.input, "drop table A");
        app.update(key(KeyCode::Down));
        assert_eq!(app.input, "drop table AX");
    }

    // ---- ADR-0052 (issue #30): mode-aware history recall ----

    #[test]
    fn one_shot_advanced_command_recalls_with_colon_in_simple_mode() {
        // The bug: a `:`-one-shot advanced command must recall WITH the
        // `:` so it re-runs in simple mode (in-session and, via the
        // `:`-prefixed ring form, across sessions too).
        let mut app = App::new();
        type_str(&mut app, ": select 1");
        submit(&mut app);
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, ": select 1");
    }

    #[test]
    fn persistent_advanced_command_recalls_with_colon_back_in_simple_mode() {
        // The feature: a command typed in *persistent* advanced mode
        // recalls into simple mode with a `:` so it stays runnable.
        let mut app = App::new();
        app.mode = Mode::Advanced;
        type_str(&mut app, "select 1");
        submit(&mut app);
        // Switch back to simple and recall.
        app.mode = Mode::Simple;
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, ": select 1");
    }

    #[test]
    fn advanced_command_recalls_bare_in_advanced_mode() {
        // In advanced mode the stored `:`-prefix is stripped so it runs
        // as bare SQL.
        let mut app = App::new();
        app.mode = Mode::Advanced;
        type_str(&mut app, "select 1");
        submit(&mut app);
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "select 1");
    }

    #[test]
    fn simple_command_recalls_bare_in_either_mode() {
        let mut app = App::new();
        type_str(&mut app, "drop table T");
        submit(&mut app);
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table T");
        app.mode = Mode::Advanced;
        app.update(key(KeyCode::Down)); // back to draft
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "drop table T");
    }

    #[test]
    fn app_command_recalls_bare_even_when_typed_with_colon() {
        // An app command runs in any mode, so it must NOT gain a `:` on
        // recall even when entered via the one-shot escape.
        let mut app = App::new();
        type_str(&mut app, ": mode advanced");
        submit(&mut app);
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "mode advanced");
    }

    #[test]
    fn a_bare_colon_is_not_recorded_in_history() {
        let mut app = App::new();
        type_str(&mut app, ":");
        submit(&mut app);
        // Nothing recallable.
        app.update(key(KeyCode::Up));
        assert_eq!(app.input, "");
    }

    #[test]
    fn add_column_with_text_type_emits_execute_action() {
        let mut app = App::new();
        type_str(&mut app, "add column to table T: Name (text)");
        let actions = submit(&mut app);
        assert_eq!(actions.len(), 1);
        let Action::ExecuteDsl { command, .. } = &actions[0] else {
            panic!("expected ExecuteDsl, got {:?}", actions[0]);
        };
        assert_eq!(
            command,
            &Command::AddColumn {
                table: "T".to_string(),
                column: "Name".to_string(),
                ty: Type::Text,
                not_null: false,
                unique: false,
                default: None,
                check: None,
            },
        );
    }

    // ---- Validity-indicator verdict (ADR-0027) ----------------

    #[test]
    fn input_validity_verdict_flags_a_broken_simple_command() {
        let mut app = App::new();
        app.input = "create table".to_string();
        assert_eq!(
            app.input_validity_verdict(),
            Some(crate::dsl::walker::Severity::Error),
        );
    }

    #[test]
    fn input_validity_verdict_is_none_for_clean_input() {
        let mut app = App::new();
        app.input = "quit".to_string();
        assert_eq!(app.input_validity_verdict(), None);
    }

    #[test]
    fn input_validity_verdict_fires_in_advanced_mode_for_incomplete_input() {
        // Updated per ADR-0032 §10.6 / §11.6 — Phase 2 wires
        // the SQL diagnostic surface (predicate warnings, etc.)
        // through to the validity indicator. Pre-Phase-2 the
        // verdict was silent in Advanced mode; now it reflects
        // the active-mode walker's verdict, mirroring Simple
        // mode's behaviour.
        let mut app = App::new();
        app.mode = Mode::Advanced;
        app.input = "create table".to_string();
        // Incomplete-at-EOF maps to Error (same as in Simple).
        assert_eq!(
            app.input_validity_verdict(),
            Some(crate::dsl::walker::Severity::Error),
        );
    }

    #[test]
    fn input_validity_verdict_fires_for_colon_one_shot() {
        // A `:`-prefixed line is a one-shot advanced escape;
        // the verdict reads the advanced walker view, same as
        // a persistent-advanced session.
        let mut app = App::new();
        app.input = ":create table".to_string();
        assert_eq!(
            app.input_validity_verdict(),
            Some(crate::dsl::walker::Severity::Error),
        );
    }

    #[test]
    fn input_validity_verdict_fires_warning_for_sql_predicate_in_advanced() {
        // ADR-0032 §11.6 — a SQL `LIKE`-on-numeric predicate
        // emits a Warning diagnostic. The validity indicator
        // now reflects that in Advanced mode.
        use crate::completion::TableColumn;
        use crate::dsl::types::Type;
        let mut app = App::new();
        app.mode = Mode::Advanced;
        app.schema_cache.tables.push("products".to_string());
        app.schema_cache.columns.push("price".to_string());
        app.schema_cache.table_columns.insert(
            "products".to_string(),
            vec![TableColumn {
                name: "price".to_string(),
                user_type: Type::Real,
                not_null: false,
                has_default: false,
            }],
        );
        app.input = "select * from products where price like 5".to_string();
        assert_eq!(
            app.input_validity_verdict(),
            Some(crate::dsl::walker::Severity::Warning),
        );
    }

    #[test]
    fn sql_update_success_shows_count_without_no_rows_band() {
        // ADR-0033 sub-phase 3e: a SQL UPDATE returns a column-less
        // result (precise rows are RETURNING, 3g). The render must
        // surface the affected-row count and NOT a misleading
        // "(no rows)" table band.
        let mut app = App::new();
        app.update(AppEvent::DslUpdateSucceeded {
            command: Command::SqlUpdate {
                sql: "update t set v = 1".to_string(),
                target_table: "t".to_string(),
                returning: false,
                set_literals: Vec::new(),
            },
            result: crate::db::UpdateResult {
                rows_affected: 2,
                data: crate::db::DataResult {
                    table_name: "t".to_string(),
                    columns: Vec::new(),
                    column_types: Vec::new(),
                    rows: Vec::new(),
                },
            },
            echo: None,
        });
        let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
        assert!(
            texts.iter().any(|t| t.contains("2 row(s) updated")),
            "affected-row count surfaced: {texts:?}",
        );
        assert!(
            !texts.iter().any(|t| t.contains("(no rows)")),
            "no misleading empty-table band: {texts:?}",
        );
    }

    #[test]
    fn update_success_with_columns_renders_the_table() {
        // The guard only suppresses a column-less result: a result
        // carrying columns (the DSL UPDATE path) still renders.
        let mut app = App::new();
        app.update(AppEvent::DslUpdateSucceeded {
            command: Command::SqlUpdate {
                sql: "update t set v = 1".to_string(),
                target_table: "t".to_string(),
                returning: false,
                set_literals: Vec::new(),
            },
            result: crate::db::UpdateResult {
                rows_affected: 1,
                data: crate::db::DataResult {
                    table_name: "t".to_string(),
                    columns: vec!["id".to_string(), "v".to_string()],
                    column_types: vec![Some(Type::Int), Some(Type::Int)],
                    rows: vec![vec![Some("1".to_string()), Some("9".to_string())]],
                },
            },
            echo: None,
        });
        let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
        assert!(
            texts.iter().any(|t| t.contains("id") && t.contains('v')),
            "header row rendered: {texts:?}",
        );
    }

    #[test]
    fn sql_delete_success_renders_count_and_cascade_summary() {
        // ADR-0033 sub-phase 3f: a SQL DELETE reuses the DSL delete
        // renderer (CommandOutcome::Delete -> handle_dsl_delete_
        // success). This pins that the SHARED renderer produces the
        // right user-facing strings for the SQL path — the row count
        // and the per-relationship cascade line. ADR-0040: success is
        // signalled by the echo's ✓ marker (no more `[ok]` summary), so
        // we push the `running:` echo first (as `dispatch_dsl` does)
        // and assert it resolves to `Ok`.
        use crate::dsl::ReferentialAction;
        let mut app = App::new();
        app.output.push_back(OutputLine::echo(
            "delete from Customers where id = 1",
            crate::mode::Mode::Advanced,
        ));
        app.update(AppEvent::DslDeleteSucceeded {
            command: Command::SqlDelete {
                sql: "delete from Customers where id = 1".to_string(),
                target_table: "Customers".to_string(),
                returning: false,
            },
            result: crate::db::DeleteResult {
                rows_affected: 1,
                cascade: vec![crate::db::CascadeEffect {
                    relationship_name: "places".to_string(),
                    child_table: "Orders".to_string(),
                    rows_changed: 2,
                    action: ReferentialAction::Cascade,
                }],
                data: crate::db::DataResult {
                    table_name: "Customers".to_string(),
                    columns: Vec::new(),
                    column_types: Vec::new(),
                    rows: Vec::new(),
                },
            },
            echo: None,
        });
        let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
        // ADR-0040: the echo resolves to ✓ (Ok); no `[ok]` summary line.
        assert!(
            app.output
                .iter()
                .any(|l| l.kind == OutputKind::Echo && l.status == Some(EchoStatus::Ok)),
            "the echo line resolves to Ok: {texts:?}",
        );
        assert!(
            !texts.iter().any(|t| t.starts_with("[ok]")),
            "no [ok] summary line (ADR-0040): {texts:?}",
        );
        assert!(
            texts.iter().any(|t| t.contains("1 row(s) deleted")),
            "directly-deleted count surfaced: {texts:?}",
        );
        assert!(
            texts
                .iter()
                .any(|t| t.contains("2 row(s) deleted in `Orders`")
                    && t.contains("relationship `places`")),
            "per-relationship cascade summary surfaced: {texts:?}",
        );
    }

    #[test]
    fn seed_success_renders_count_preview_and_advisory() {
        // ADR-0048: handle_dsl_seed_success renders the seeded-row count,
        // the preview table, and the enum/CHECK advisory.
        let mut app = App::new();
        app.output
            .push_back(OutputLine::echo("seed users 20", crate::mode::Mode::Simple));
        app.update(AppEvent::DslSeedSucceeded {
            command: Command::Seed {
                table: "users".to_string(),
                target_column: None,
                count: Some(20),
                overrides: Vec::new(),
                rng_seed: None,
            },
            result: crate::db::SeedResult {
                table: "users".to_string(),
                requested: 20,
                produced: 20,
                data: crate::db::DataResult {
                    table_name: "users".to_string(),
                    columns: vec!["name".to_string()],
                    column_types: vec![None],
                    rows: vec![vec![Some("Alice".to_string())]],
                },
                advisory_columns: vec!["status".to_string()],
            },
        });
        let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
        assert!(
            texts
                .iter()
                .any(|t| t.contains("20 row(s) seeded into users")),
            "seeded-row count surfaced: {texts:?}",
        );
        assert!(
            texts
                .iter()
                .any(|t| t.contains("status") && t.contains("generic text")),
            "the advisory names the enum-ish column: {texts:?}",
        );
    }

    #[test]
    fn seed_success_reports_a_cap() {
        // produced < requested → the cap note appears next to the count.
        let mut app = App::new();
        app.output
            .push_back(OutputLine::echo("seed J 10", crate::mode::Mode::Simple));
        app.update(AppEvent::DslSeedSucceeded {
            command: Command::Seed {
                table: "J".to_string(),
                target_column: None,
                count: Some(10),
                overrides: Vec::new(),
                rng_seed: None,
            },
            result: crate::db::SeedResult {
                table: "J".to_string(),
                requested: 10,
                produced: 4,
                data: crate::db::DataResult {
                    table_name: "J".to_string(),
                    columns: Vec::new(),
                    column_types: Vec::new(),
                    rows: Vec::new(),
                },
                advisory_columns: Vec::new(),
            },
        });
        let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
        assert!(
            texts
                .iter()
                .any(|t| t.contains("4 row(s) seeded into J") && t.contains("of 10 requested")),
            "the cap note surfaces requested vs produced: {texts:?}",
        );
    }

    #[test]
    fn sql_delete_returning_renders_cascade_and_result_table() {
        // ADR-0033 3g: a DELETE … RETURNING surfaces BOTH the cascade
        // summary AND the returned-rows table. Pins the render branch
        // that tabulates `result.data` when RETURNING populated it
        // (the column-less non-RETURNING path is skipped — see the
        // sibling test above).
        use crate::dsl::ReferentialAction;
        let mut app = App::new();
        app.update(AppEvent::DslDeleteSucceeded {
            command: Command::SqlDelete {
                sql: "delete from Customers where id = 1 returning *".to_string(),
                target_table: "Customers".to_string(),
                returning: true,
            },
            result: crate::db::DeleteResult {
                rows_affected: 1,
                cascade: vec![crate::db::CascadeEffect {
                    relationship_name: "places".to_string(),
                    child_table: "Orders".to_string(),
                    rows_changed: 2,
                    action: ReferentialAction::Cascade,
                }],
                data: crate::db::DataResult {
                    table_name: "Customers".to_string(),
                    columns: vec!["id".to_string(), "Name".to_string()],
                    column_types: vec![Some(Type::Int), Some(Type::Text)],
                    rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
                },
            },
            echo: None,
        });
        let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
        assert!(
            texts
                .iter()
                .any(|t| t.contains("2 row(s) deleted in `Orders`")),
            "cascade summary still surfaces alongside RETURNING: {texts:?}",
        );
        assert!(
            texts.iter().any(|t| t.contains("Name")) && texts.iter().any(|t| t.contains("Alice")),
            "the returned (deleted) row is tabulated: {texts:?}",
        );
    }
}
