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