From a8ad0c6cc3b32d86cecd12a83b065fd8fa9784df Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 11:26:45 +0000 Subject: [PATCH 01/25] feat(db): comprehensive logging across worker + executors (X1) Instrument db.rs to the CLAUDE.md "log liberally" bar (X1). 26 -> 67 tracing sites: - Entry-level debug! on all 34 do_* executors (DDL, DML, relationship, index, read paths), matching the existing do_sql_delete/do_run_select style -- so the route through delegating executors (e.g. add_column -> add_constrained_column_via_rebuild) is visible in the log sequence. - Decision-point logs: rebuild_table primitive (begin/commit; FK-check failure and foreign_keys re-enable failure as warn), do_insert autofill summary, do_delete cascade summary, do_create_table FK resolution. - Worker lifecycle (start/exit) raised debug! -> info! so it shows at the default level. Levels per the X1 discipline: debug for per-command detail (off by default, opt-in via RDBMS_PLAYGROUND_LOG=debug), info for lifecycle, warn for fallbacks. Loops log summary counts, never per-row. Tests: 2207 pass / 0 fail / 1 ignored (unchanged). Clippy clean. --- src/db.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/src/db.rs b/src/db.rs index cc77bfd..fc730d0 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1921,7 +1921,7 @@ fn worker_loop( snapshots: Option, mut rx: mpsc::Receiver, ) { - debug!("db worker started"); + info!("db worker started"); // `conn` must be mutable: restoring a snapshot (undo/redo) writes // into the live connection via the backup API (`&mut`). let mut conn = conn; @@ -1968,7 +1968,7 @@ fn worker_loop( other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other), } } - debug!("db worker exiting"); + info!("db worker exiting"); } /// Worker-side undo bracketing state for the request stream. @@ -3393,6 +3393,7 @@ fn do_create_table( check_constraints: &[String], foreign_keys: &[SqlForeignKey], ) -> Result { + debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table"); if columns.is_empty() { // SQLite requires at least one column. The DSL grammar // already prevents this, but defending here too keeps @@ -3407,6 +3408,9 @@ fn do_create_table( // §5, sub-phase 4b). Self-references validate against the columns // being defined; other parents must already exist. let resolved_fks = resolve_create_table_fks(conn, name, columns, primary_key, foreign_keys)?; + if !resolved_fks.is_empty() { + debug!(table = %name, fks = resolved_fks.len(), "create_table: foreign keys resolved + validated"); + } // Inline `PRIMARY KEY` on the column when the table has a single // primary-key column and it is the **first** column — the exact @@ -3568,6 +3572,7 @@ fn do_drop_table( source: Option<&str>, name: &str, ) -> Result<(), DbError> { + debug!(table = %name, "drop_table"); // Canonicalize the user-typed name to its stored case (and refuse a // non-existent / internal table), so the metadata DELETEs and the CSV // removal target the right name regardless of capitalization. @@ -3647,6 +3652,7 @@ fn do_add_column( table: &str, column: &ColumnSpec, ) -> Result { + debug!(table = %table, column = %column.name, "add_column"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); if matches!(column.ty, Type::Serial | Type::ShortId) { @@ -3700,6 +3706,7 @@ fn do_add_plain_column( table: &str, spec: &ColumnSpec, ) -> Result { + debug!(table = %table, column = %spec.name, "add_plain_column"); // The plain `ALTER TABLE ADD COLUMN` path. `do_add_column` // only routes here when the constraints are ALTER-expressible // (no UNIQUE; NOT NULL only alongside a default), so the @@ -3752,6 +3759,7 @@ fn do_add_auto_generated_column( table: &str, spec: &ColumnSpec, ) -> Result { + debug!(table = %table, column = %spec.name, "add_auto_generated_column"); use rusqlite::types::Value as RV; let ty = spec.ty; @@ -3883,6 +3891,7 @@ fn do_add_constrained_column_via_rebuild( table: &str, spec: &ColumnSpec, ) -> Result { + debug!(table = %table, column = %spec.name, "add_constrained_column_via_rebuild"); let old_schema = read_schema(conn, table)?; if old_schema.columns.iter().any(|c| c.name == spec.name) { return Err(DbError::Unsupported(format!( @@ -3984,6 +3993,7 @@ fn do_add_constraint( column: &str, constraint: &Constraint, ) -> Result { + debug!(table = %table, column = %column, "add_constraint"); // Canonicalize to the stored case (and refuse a non-existent / // internal `__rdbms_*` table as "no such table"), like the sibling // schema-mutation executors. Closes the simple `add constraint` @@ -4126,6 +4136,7 @@ fn do_drop_constraint( column: &str, kind: ConstraintKind, ) -> Result { + debug!(table = %table, column = %column, "drop_constraint"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; @@ -4228,6 +4239,7 @@ fn do_set_column_default( column: &str, default_sql: &str, ) -> Result { + debug!(table = %table, column = %column, "set_column_default"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; @@ -4617,6 +4629,7 @@ fn do_drop_column( column: &str, cascade: bool, ) -> Result { + debug!(table = %table, column = %column, cascade, "drop_column"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; @@ -4776,6 +4789,7 @@ fn do_rename_column( old: &str, new: &str, ) -> Result { + debug!(table = %table, old = %old, new = %new, "rename_column"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; @@ -4898,6 +4912,7 @@ fn do_rename_table( old: &str, new: &str, ) -> Result { + debug!(old = %old, new = %new, "rename_table"); reject_internal_table_name(new)?; // Canonicalize the source to its stored case (and refuse a // non-existent / internal source as "no such table") — so a @@ -5086,6 +5101,7 @@ fn do_change_column_type( ty: Type, mode: ChangeColumnMode, ) -> Result { + debug!(table = %table, column = %column, ty = %ty, mode = ?mode, "change_column_type"); // Canonicalize to the stored case (and refuse a non-existent / // internal `__rdbms_*` table as "no such table"), like the sibling // column executors. Closes the simple `change column` exposure and @@ -5888,6 +5904,7 @@ fn more_row(width: usize, more: usize) -> Vec { } fn do_list_tables(conn: &Connection) -> Result, DbError> { + debug!("list_tables"); let mut stmt = conn .prepare( "SELECT name FROM sqlite_schema \ @@ -5915,6 +5932,7 @@ fn do_show_relationship( conn: &Connection, name: &str, ) -> Result, DbError> { + debug!(name = %name, "show_relationship"); let Some(rel) = read_all_relationships(conn)? .into_iter() .find(|r| r.name == name) @@ -5937,6 +5955,7 @@ fn do_show_list( kind: crate::dsl::command::ShowListKind, name: Option<&str>, ) -> Result, DbError> { + debug!(kind = ?kind, name = ?name, "show_list"); use crate::dsl::command::ShowListKind; // V5a: a named item shows one relationship/index's detail. if let Some(name) = name { @@ -6024,6 +6043,7 @@ fn do_show_one( kind: crate::dsl::command::ShowListKind, name: &str, ) -> Result, DbError> { + debug!(kind = ?kind, name = %name, "show_one"); use crate::dsl::command::ShowListKind; let mut lines = Vec::new(); match kind { @@ -6802,6 +6822,7 @@ where C: FnOnce(&rusqlite::Transaction<'_>, &str, &str) -> Result<(), DbError>, M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>, { + debug!(table = %table, cols = new_schema.columns.len(), "rebuild_table: begin (foreign_keys OFF, temp-copy primitive)"); // foreign_keys=OFF must be set *outside* a transaction. conn.execute_batch("PRAGMA foreign_keys = OFF;") .map_err(DbError::from_rusqlite)?; @@ -6870,6 +6891,7 @@ where .map_err(DbError::from_rusqlite)?; let mut rows = check.query([]).map_err(DbError::from_rusqlite)?; if let Some(_row) = rows.next().map_err(DbError::from_rusqlite)? { + warn!(table = %table, "rebuild_table: foreign_key_check failed; existing data violates new constraint, rolling back"); return Err(DbError::Sqlite { message: format!( "foreign-key check failed after rebuild of `{table}`; \ @@ -6882,6 +6904,7 @@ where drop(check); tx.commit().map_err(DbError::from_rusqlite)?; + debug!(table = %table, indexes = captured_indexes.len(), "rebuild_table: committed (indexes recreated)"); Ok(()) })(); @@ -6889,6 +6912,9 @@ where let pragma_result = conn .execute_batch("PRAGMA foreign_keys = ON;") .map_err(DbError::from_rusqlite); + if let Err(e) = &pragma_result { + warn!(table = %table, error = %e, "rebuild_table: failed to re-enable foreign_keys after rebuild"); + } result.and(pragma_result) } @@ -7249,6 +7275,7 @@ fn do_add_relationship( on_update: ReferentialAction, create_fk: bool, ) -> Result { + debug!(name = ?name, parent = %parent_table, child = %child_table, "add_relationship"); // Canonicalize both endpoints to their stored case (and refuse a // non-existent / internal `__rdbms_*` table as "no such table"), like // the sibling schema-mutation executors — so the relationship metadata @@ -7409,6 +7436,7 @@ fn do_drop_relationship( source: Option<&str>, selector: &RelationshipSelector, ) -> Result, DbError> { + debug!(selector = ?selector, "drop_relationship"); // Resolve to a single relationship row. let resolved: Option<(String, String, String, String, String)> = match selector { RelationshipSelector::Named { name } => conn @@ -7488,6 +7516,7 @@ fn do_alter_add_table_check( name: Option<&str>, expr_sql: &str, ) -> Result { + debug!(table = %table, name = ?name, "alter_add_table_check"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; @@ -7593,6 +7622,7 @@ fn do_alter_add_unique( table: &str, columns: &[String], ) -> Result { + debug!(table = %table, cols = ?columns, "alter_add_unique"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; @@ -7660,6 +7690,7 @@ fn do_drop_constraint_by_name( table: &str, name: &str, ) -> Result, DbError> { + debug!(table = %table, name = %name, "drop_constraint_by_name"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); @@ -7781,6 +7812,7 @@ fn do_alter_add_foreign_key( name: Option<&str>, fk: &SqlForeignKey, ) -> Result { + debug!(child = %child_table, name = ?name, "alter_add_foreign_key"); reject_internal_table_name(child_table)?; reject_internal_table_name(&fk.parent_table)?; // Resolve the parent columns: explicit must be the full PK (F-A); @@ -7891,6 +7923,7 @@ fn do_add_index( columns: &[String], unique: bool, ) -> Result { + debug!(name = ?name, table = %table, cols = ?columns, unique, "add_index"); // 0. Canonicalize to the stored case (and refuse a non-existent / // internal `__rdbms_*` table) — both the simple `add index` and SQL // `CREATE INDEX` surfaces reach here, and the auto-index name embeds @@ -7979,6 +8012,7 @@ fn do_drop_index( source: Option<&str>, selector: &IndexSelector, ) -> Result { + debug!(selector = ?selector, "drop_index"); let (index_name, table_name) = match selector { IndexSelector::Named { name } => { let lookup = conn.query_row( @@ -8067,6 +8101,7 @@ fn do_describe_table_request( } fn do_describe_table(conn: &Connection, name: &str) -> Result { + debug!(name = %name, "describe_table"); // Column info — including the ADR-0029 constraints — comes // from `read_schema`, the single source of per-column truth // (it joins `pragma_table_info` with our type metadata and @@ -8422,6 +8457,7 @@ fn do_insert( user_columns: Option<&[String]>, user_values: &[Value], ) -> Result { + debug!(table = %table, "insert"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; @@ -8500,6 +8536,14 @@ fn do_insert( )); } + debug!( + table = %table, + user_cols = user_cols.len(), + total_cols = bindings.len(), + autofilled = bindings.len() - user_cols.len(), + "insert: column bindings resolved (serial/shortid auto-fill applied)" + ); + let cols_csv = bindings .iter() .map(|(c, _)| quote_ident(c)) @@ -8579,6 +8623,7 @@ fn do_update( assignments: &[(String, Value)], filter: &RowFilter, ) -> Result { + debug!(table = %table, assignments = assignments.len(), "update"); if assignments.is_empty() { return Err(DbError::InvalidValue( "UPDATE requires at least one assignment".to_string(), @@ -8678,6 +8723,7 @@ fn do_delete( table: &str, filter: &RowFilter, ) -> Result { + debug!(table = %table, "delete"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; @@ -8732,6 +8778,14 @@ fn do_delete( } } + debug!( + table = %table, + rows_affected, + cascaded_relationships = cascade.len(), + rewritten_tables = rewritten_tables.len(), + "delete: complete (cascade effects detected by child-count diff)" + ); + let changes = Changes { schema_dirty: false, rewritten_tables, @@ -9045,6 +9099,7 @@ fn do_sql_insert( returning: bool, literal_rows: &[Vec>], ) -> Result { + debug!(table = %target_table, returning, "sql_insert"); debug!(sql = %sql, table = %target_table, returning, "sql_insert"); let canonical_table = require_canonical_table(conn, target_table)?; let target_table = canonical_table.as_str(); @@ -9161,6 +9216,7 @@ fn do_sql_update( returning: bool, set_literals: &[(String, Option)], ) -> Result { + debug!(table = %target_table, returning, "sql_update"); debug!(sql = %sql, table = %target_table, returning, "sql_update"); let canonical_table = require_canonical_table(conn, target_table)?; let target_table = canonical_table.as_str(); @@ -9544,6 +9600,7 @@ fn do_query_data( filter: Option<&Expr>, limit: Option, ) -> Result { + debug!(table = %table, limit = ?limit, "query_data"); let schema = read_schema(conn, table)?; let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); let column_types: Vec> = @@ -9602,6 +9659,7 @@ fn format_cell(value: rusqlite::types::Value, ty: Option) -> Option Result { + debug!("explain_plan"); let (exec_sql, params) = match query { Command::ShowData { name, @@ -9855,6 +9913,7 @@ fn do_rebuild_from_text( source: Option<&str>, project_path: &Path, ) -> Result<(), DbError> { + debug!(path = %project_path.display(), "rebuild_from_text"); let yaml_path = project_path.join(PROJECT_YAML); let data_dir = project_path.join(DATA_DIR); From 0a7612efe2fec0f16b52ee31fe3f5a5db910382c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 11:38:22 +0000 Subject: [PATCH 02/25] feat: comprehensive logging across parser, app, persistence, runtime (X1) Completes the X1 full sweep started in a8ad0c6 (db.rs). Closes X1 -> [x]. - persistence/mod.rs: debug! on every yaml/CSV/history write -- the silent-failure-prone disk paths (write_schema, write_table_data incl. the empty->delete branch, append_history/_failure). - runtime.rs: debug! on execute_command_typed dispatch (one per executed command, complements the db.rs executor logs). - app.rs: debug! on submit (route + submission mode), dispatch_app_command, and the ADR-0044 diagram-vs-prose render-mode choice. - dsl/parser.rs: trace! on parse begin/outcome at the parse_command_inner choke point -- trace, not debug, because the live overlay/completion re-parse per keystroke (hot path). - logging.rs: documented level discipline (error/warn/info/debug/trace) so the convention survives across sessions. Levels verified end-to-end through the real worker thread + logging::init. ~75 -> 135 tracing sites total. Tests: 2207 pass / 0 fail / 1 ignored. Clippy clean. --- docs/requirements.md | 28 +++++++++++++++++++--------- src/app.rs | 9 +++++++++ src/dsl/parser.rs | 22 +++++++++++++++++++--- src/logging.rs | 32 ++++++++++++++++++++++++++++++++ src/persistence/mod.rs | 7 +++++++ src/runtime.rs | 1 + 6 files changed, 87 insertions(+), 12 deletions(-) diff --git a/docs/requirements.md b/docs/requirements.md index 144b459..fea72ef 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -804,17 +804,27 @@ since ADR-0027.) ## Cross-cutting -- [/] **X1** Comprehensive logging via the project's logging +- [x] **X1** Comprehensive logging via the project's logging infrastructure per `CLAUDE.md` (decision points, parameter values, fallback paths). - *(Partial, verified 2026-06-07: the logging **harness** is - wired — `src/logging.rs` sets up file-backed `tracing` with an - env filter — but instrumentation is **sparse**: ~25 `tracing::` - call sites across the tree, concentrated in `runtime.rs` and - `undo.rs` and mostly error/warning on failure paths. The - decision-point / parameter-value / fallback-path coverage the - `CLAUDE.md` "log liberally" standard calls for — especially in - `db.rs`, the parser, and the executors — is largely absent.)* + *(Done 2026-06-10 via a full-sweep instrumentation pass. The + prior state (verified 2026-06-07) was a wired harness + (`src/logging.rs`) but sparse instrumentation — failure-path + heavy, nothing in `db.rs`/parser/executors. The sweep brought + every layer to the "log liberally" bar under a documented level + discipline (see the `logging.rs` module doc): **`db.rs`** gained + entry-level `debug!` on all 34 `do_*` executors plus decision-point + logs (rebuild-table primitive, insert auto-fill, delete cascade, + FK resolution) — so the route through delegating executors is + visible in the log sequence; **persistence** logs every + yaml/CSV/history write (the silent-failure paths); **runtime** + logs `execute_command_typed` dispatch; **`app.rs`** logs + submit / app-command dispatch / render-mode choice; the **parser** + logs parse begin/outcome at `trace` (it is a per-keystroke hot + path). Levels: `debug` for per-command detail (off by default, + `RDBMS_PLAYGROUND_LOG=debug`), `info` for lifecycle, `warn` for + fallbacks, `trace` for hot paths. Emission verified end-to-end + through the real worker thread + `logging::init`. ~75 → ~135 sites.)* - [~] **X2** Language: English-only for v1; multi-language is an open question to revisit later. - [~] **X3** Accessibility: TUI screen-reader support is diff --git a/src/app.rs b/src/app.rs index 75ec9d9..00e7bae 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1311,6 +1311,13 @@ impl App { 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 now // share the chumsky parser (per the round-5 refactor). // App commands work in both modes — they're not gated by @@ -1342,6 +1349,7 @@ impl App { 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 } => { @@ -1700,6 +1708,7 @@ impl App { | 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, diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 2a1e650..9e260c8 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -12,6 +12,8 @@ //! synthetic "unknown command" error when the input's first //! identifier-shape token isn't a registered entry word. +use tracing::trace; + use crate::dsl::command::Command; use crate::mode::Mode; @@ -150,13 +152,27 @@ fn parse_command_inner( schema: Option<&crate::completion::SchemaCache>, mode: Mode, ) -> Result { + // `trace`, not `debug`: parsing is a hot path — the live overlay / + // completion (completion.rs) re-parse per keystroke, probing + // candidates in a loop, so a per-parse `debug` line would flood. The + // executed-command story lives at `debug` in db.rs (one per submit). + trace!( + len = input.len(), + mode = ?mode, + schema_aware = schema.is_some(), + "parse: begin" + ); if input.trim().is_empty() { + trace!("parse: empty input"); return Err(ParseError::Empty); } - if let Some(result) = try_walker_route(input, schema, mode) { - return result; + let result = + try_walker_route(input, schema, mode).unwrap_or_else(|| Err(unknown_command_error(input))); + match &result { + Ok(cmd) => trace!(command = cmd.verb(), "parse: ok"), + Err(e) => trace!(error = %e, "parse: rejected"), } - Err(unknown_command_error(input)) + result } /// Synthetic ParseError for inputs whose first identifier-shape diff --git a/src/logging.rs b/src/logging.rs index ad00703..6183e49 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -6,6 +6,38 @@ //! environment variable; if neither is set we default to //! `~/.rdbms-playground/playground.log` and create directories as //! needed. +//! +//! ## Level conventions (X1 — `requirements.md`) +//! +//! Instrumentation across the tree follows a consistent level +//! discipline so the default `info` filter stays quiet and +//! `RDBMS_PLAYGROUND_LOG=debug` (or `=trace`) is a rich, layered +//! diagnostic stream. The env filter (`RDBMS_PLAYGROUND_LOG`, +//! full `EnvFilter` syntax) controls this independently of the +//! file path above; the default is `info`. +//! +//! - **`error!`** — unrecoverable failure (fatal persistence, a +//! panic-equivalent). The process is going down or a command is +//! hard-failing. +//! - **`warn!`** — recoverable failure or a fallback taken (a +//! snapshot couldn't be staged, a `PRAGMA` couldn't be restored, +//! an integrity check rolled a rebuild back). +//! - **`info!`** — low-volume lifecycle, visible by default: db +//! worker start/exit, project create/open, "logging initialised". +//! - **`debug!`** — the bulk of instrumentation, one line per +//! *executed* command and the decision points within it (executor +//! entry with key params, autofill/cascade summaries, the +//! rebuild-table primitive, persistence writes, render-mode +//! choice). Off by default. +//! - **`trace!`** — hot paths only: per-keystroke parsing +//! (`dsl::parser`), per-key input handling (`app`), per-refresh +//! table reads. A firehose; never on except when debugging that +//! specific layer. +//! +//! Rule of thumb for new code: a loop logs a single summary count, +//! never per-iteration at `debug`/`info`. Logs are developer-facing, +//! so naming the engine (SQLite/PRAGMA) is fine here even though the +//! "no engine name" rule (ADR-0002) forbids it in user-facing strings. use std::fs::{File, OpenOptions, create_dir_all}; use std::path::{Path, PathBuf}; diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 5197b4f..96ccec7 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -19,6 +19,8 @@ use std::fs; use std::io::Write as _; use std::path::{Path, PathBuf}; +use tracing::debug; + use crate::dsl::action::ReferentialAction; use crate::dsl::types::Type; use crate::mode::Mode; @@ -338,6 +340,7 @@ impl Persistence { /// renames over the destination. pub fn write_schema(&self, schema: &SchemaSnapshot) -> Result<(), PersistenceError> { let body = yaml::serialize_schema(schema); + debug!(bytes = body.len(), "persist: write project.yaml (atomic)"); atomic_write(&self.project_path.join(PROJECT_YAML), body.as_bytes()) } @@ -355,8 +358,10 @@ impl Persistence { /// with files they didn't ask for. pub fn write_table_data(&self, table: &TableSnapshot) -> Result<(), PersistenceError> { if table.rows.is_empty() { + debug!(table = %table.name, "persist: table empty -> removing CSV (no data, no CSV)"); return self.delete_table_data(&table.name); } + debug!(table = %table.name, rows = table.rows.len(), "persist: write data/.csv (atomic)"); let data_dir = self.project_path.join(DATA_DIR); fs::create_dir_all(&data_dir).map_err(|source| PersistenceError::Io { operation: "create", @@ -394,6 +399,7 @@ impl Persistence { pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> { let path = self.project_path.join(HISTORY_LOG); let line = history::format_record(command_text, history::utc_iso8601_now()); + debug!(len = command_text.len(), "persist: append ok record to history.log"); history::append(&path, &line) } @@ -411,6 +417,7 @@ impl Persistence { history::utc_iso8601_now(), history::STATUS_ERR, ); + debug!(len = command_text.len(), "persist: append err record to history.log"); history::append(&path, &line) } diff --git a/src/runtime.rs b/src/runtime.rs index 9124b19..2ef09b4 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -2531,6 +2531,7 @@ async fn execute_command_typed( command: Command, source: String, ) -> Result { + debug!(verb = command.verb(), "execute command (routing to worker)"); let src = Some(source); match command { Command::CreateTable { From 6985a43f318b54deed12386515cc4f4d095d2d17 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 11:49:33 +0000 Subject: [PATCH 03/25] fix(fk): inline FK referencing a compound PK points at the table-level form ADR-0043 D4 residual: an inline column-level FK (`REFERENCES P(a,b)`) is single-column by construction, so referencing a parent's compound PK gave the generic arity error ("1 foreign-key column(s) on the child side, but `P`'s key has 2..."). It now points the user at the table-level form: "an inline column reference can only name one column ... Use the table-level form instead: FOREIGN KEY () REFERENCES P (a, b)". - Adds `inline: bool` to SqlForeignKey, set by the grammar's single shared builder consume_fk_reference (true for the inline path, false for the table-level and ALTER paths). - resolve_fk_parent_columns takes `inline` and tailors the arity-mismatch message when an inline FK meets a compound key. Tests: parse-layer (inline=true / table-level=false) + end-to-end worker refusal wording. 2209 pass / 0 fail / 1 ignored. Clippy clean. --- src/db.rs | 18 +++++++++ src/dsl/command.rs | 7 ++++ src/dsl/grammar/ddl.rs | 10 +++-- src/dsl/grammar/sql_create_table.rs | 10 +++++ tests/it/compound_fk.rs | 60 +++++++++++++++++++++++++++++ tests/it/sql_create_table.rs | 1 + tests/it/sql_drop_table.rs | 1 + 7 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/db.rs b/src/db.rs index fc730d0..df090fa 100644 --- a/src/db.rs +++ b/src/db.rs @@ -7110,6 +7110,7 @@ fn resolve_fk_parent_columns( parent_pk: &[String], explicit: Option<&[String]>, child_arity: usize, + inline: bool, ) -> Result, DbError> { if child_arity == 0 { return Err(DbError::Unsupported( @@ -7142,6 +7143,20 @@ fn resolve_fk_parent_columns( } }; if parent_columns.len() != child_arity { + // An inline column-level FK (`REFERENCES …`) can only carry + // the one column it sits on, so it can never satisfy a compound + // key — point the user at the table-level form rather than the + // generic arity message (ADR-0043 D4). + if inline && parent_columns.len() > 1 { + return Err(DbError::Unsupported(format!( + "an inline column reference can only name one column, but \ + `{parent_table}`'s key has {n}. Use the table-level form \ + instead: `FOREIGN KEY () REFERENCES \ + {parent_table} ({pk})`.", + n = parent_columns.len(), + pk = parent_columns.join(", "), + ))); + } return Err(DbError::Unsupported(format!( "{child_arity} foreign-key column(s) on the child side, but \ `{parent_table}`'s key has {n}. A foreign key references every \ @@ -7210,6 +7225,7 @@ fn resolve_create_table_fks( &parent_pk, fk.parent_columns.as_deref(), fk.child_columns.len(), + fk.inline, )?; // Each child column must be one of the columns being defined, @@ -7295,6 +7311,7 @@ fn do_add_relationship( &parent_schema.primary_key, Some(parent_columns), child_columns.len(), + false, // DSL `add relationship` is never an inline column FK )?; // 2. Read child schema; refuse missing columns unless --create-fk. @@ -7824,6 +7841,7 @@ fn do_alter_add_foreign_key( &parent_pk, fk.parent_columns.as_deref(), fk.child_columns.len(), + fk.inline, // false for `ALTER … ADD FOREIGN KEY` (table-level) )?; // Every child column must already exist for `ALTER … ADD FOREIGN // KEY` — there is no SQL spelling to auto-create one (`--create-fk` diff --git a/src/dsl/command.rs b/src/dsl/command.rs index a5b6b07..9378448 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -45,6 +45,13 @@ pub struct SqlForeignKey { pub parent_columns: Option>, pub on_delete: ReferentialAction, pub on_update: ReferentialAction, + /// `true` for an inline column-level FK (`REFERENCES …`), + /// `false` for the table-level `FOREIGN KEY (…)` and `ALTER …` + /// forms. An inline FK is single-column by construction, so when + /// it references a compound key the resolver points the user at + /// the table-level form rather than emitting the generic arity + /// error (ADR-0043 D4). + pub inline: bool, } /// A column at table-creation time: a name, a user-facing diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 35b15b5..022521c 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -1557,7 +1557,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result] foreign key () // references [()] [on …]` (ADR-0035 §5, 4b). @@ -1587,7 +1587,8 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result( items: &mut std::iter::Peekable, name: Option, child_columns: Vec, + inline: bool, ) -> SqlForeignKey where I: Iterator, @@ -1752,6 +1754,7 @@ where parent_columns, on_delete, on_update, + inline, } } @@ -2454,7 +2457,8 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) { items.next(); } - consume_fk_reference(&mut items, None, child_columns) + // `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form. + consume_fk_reference(&mut items, None, child_columns, false) } pub static SQL_ALTER_TABLE: CommandNode = CommandNode { diff --git a/src/dsl/grammar/sql_create_table.rs b/src/dsl/grammar/sql_create_table.rs index 1a09883..49415c3 100644 --- a/src/dsl/grammar/sql_create_table.rs +++ b/src/dsl/grammar/sql_create_table.rs @@ -1004,6 +1004,16 @@ mod builder_tests { assert_eq!(fk.parent_columns, Some(vec!["id".to_string()])); assert_eq!(fk.on_delete, ReferentialAction::NoAction); assert_eq!(fk.on_update, ReferentialAction::NoAction); + assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)"); + } + + #[test] + fn table_level_fk_is_not_inline() { + // The table-level `FOREIGN KEY (...)` form is not inline, so it can + // carry a multi-column reference and never triggers the inline + // "use the table-level form" hint (ADR-0043 D4). + let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))"); + assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline"); } #[test] diff --git a/tests/it/compound_fk.rs b/tests/it/compound_fk.rs index e8b539f..3ca22df 100644 --- a/tests/it/compound_fk.rs +++ b/tests/it/compound_fk.rs @@ -137,6 +137,7 @@ fn sql_create_table_compound_fk_executes_and_enforces() { parent_columns: Some(vec!["country".to_string(), "code".to_string()]), on_delete: ReferentialAction::NoAction, on_update: ReferentialAction::NoAction, + inline: false, }], false, None, @@ -363,6 +364,65 @@ fn compound_fk_arity_mismatch_is_refused() { }); } +#[test] +fn inline_fk_referencing_compound_pk_points_at_table_level_form() { + // ADR-0043 D4 residual: an *inline* single-column FK cannot express a + // multi-column reference, so referencing a parent's compound PK must + // refuse with a pointer to the table-level `FOREIGN KEY (...)` form — + // not the generic arity message. The grammar marks the FK `inline`. + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(async { + db.create_table( + "Region".to_string(), + vec![ + ColumnSpec::new("country", Type::Int), + ColumnSpec::new("code", Type::Int), + ], + vec!["country".to_string(), "code".to_string()], + None, + ) + .await + .expect("create Region"); + + // Parse the inline form so the `inline` flag is set by the grammar. + let cmd = parse_command( + "create table City (country int references Region(country, code))", + ) + .expect("parses"); + let Command::SqlCreateTable { + name, + columns, + primary_key, + unique_constraints, + check_constraints, + foreign_keys, + if_not_exists, + } = cmd + else { + panic!("expected SqlCreateTable"); + }; + let err = db + .sql_create_table( + name, + columns, + primary_key, + unique_constraints, + check_constraints, + foreign_keys, + if_not_exists, + None, + ) + .await + .expect_err("inline FK referencing a compound PK must be refused"); + let msg = format!("{err}"); + assert!( + msg.contains("FOREIGN KEY"), + "expected a pointer to the table-level `FOREIGN KEY (...)` form, got: {msg}" + ); + }); +} + #[test] fn compound_fk_type_mismatch_per_pair_is_refused() { let (_p, db, _dir) = open_project_db(); diff --git a/tests/it/sql_create_table.rs b/tests/it/sql_create_table.rs index 2d0b097..0d07f54 100644 --- a/tests/it/sql_create_table.rs +++ b/tests/it/sql_create_table.rs @@ -839,6 +839,7 @@ fn fk(child_column: &str, parent_table: &str, parent_column: Option<&str>) -> Sq parent_columns: parent_column.map(|c| vec![c.to_string()]), on_delete: ReferentialAction::NoAction, on_update: ReferentialAction::NoAction, + inline: false, } } diff --git a/tests/it/sql_drop_table.rs b/tests/it/sql_drop_table.rs index b71be7a..a94195f 100644 --- a/tests/it/sql_drop_table.rs +++ b/tests/it/sql_drop_table.rs @@ -109,6 +109,7 @@ fn dropping_a_referenced_parent_is_refused() { parent_columns: Some(vec!["id".to_string()]), on_delete: rdbms_playground::dsl::ReferentialAction::NoAction, on_update: rdbms_playground::dsl::ReferentialAction::NoAction, + inline: true, }], false, Some("create table child (id serial primary key, pid int references parent(id))".to_string()), From 5a33f2aeea74b8f2749cb0a28d542a729ecc768f Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 11:59:14 +0000 Subject: [PATCH 04/25] fix(fk): compound-FK violation message names every column pair ADR-0043 residual: a compound-FK violation's friendly error named only the first child->parent column pair (the ADR-0019 facts model is single-column). enrich_fk_violation now gathers all pairs of the matched relationship and carries them comma-joined in the existing single-column facts slots, so the headline reads e.g. "no parent row in `Region` has `country, code` = `7, 8`." instead of naming just `country`. Single-column behaviour is unchanged (a one-element join is the element itself). No facts-model or catalog change -- the joined strings flow through the existing `{parent_column}` / `{value}` placeholders. Tests: enrichment facts (compound names every pair, single-column regression) + translate rendering (headline names both columns). 2211 pass / 0 fail / 1 ignored. Clippy clean. --- src/friendly/translate.rs | 24 +++++++++++ src/runtime.rs | 37 ++++++++++------ tests/it/friendly_enrichment.rs | 75 +++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs index cbbe194..74cdb00 100644 --- a/src/friendly/translate.rs +++ b/src/friendly/translate.rs @@ -882,6 +882,30 @@ mod tests { assert!(f.headline.contains("`99`")); } + #[test] + fn fk_child_side_renders_every_column_of_a_compound_key() { + // ADR-0043 residual: a compound-FK violation carries the + // comma-joined column + value lists in the single-column facts + // slots, so the headline names every pair, not just the first. + let err = sqlite( + "FOREIGN KEY constraint failed", + SqliteErrorKind::UniqueViolation, + ); + let mut ctx = ctx_with(Operation::Insert); + ctx.parent_table = Some("Region".to_string()); + ctx.parent_column = Some("country, code".to_string()); + ctx.value = Some("7, 8".to_string()); + let f = translate(&err, &ctx); + assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline); + assert!(f.headline.contains("Region")); + assert!( + f.headline.contains("country, code"), + "both parent columns must appear: {}", + f.headline + ); + assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline); + } + #[test] fn fk_with_delete_op_renders_parent_side_wording() { let err = sqlite( diff --git a/src/runtime.rs b/src/runtime.rs index 2ef09b4..0ec720b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -2017,22 +2017,33 @@ async fn enrich_fk_violation( }; facts.table = Some(table.clone()); for rel in outbound { - // The friendly FK-error facts model is single-column - // (ADR-0019); for a compound FK (ADR-0043) we enrich - // from the first column pair — the error still surfaces, - // richer multi-column enrichment is a later refinement. - let Some(local_col) = rel.local_columns.first().cloned() else { + // Identify the violated FK by the first local column the + // user supplied a value for (SQLite names no column in the + // error). The single-column facts slots then carry the + // comma-joined lists so a compound FK (ADR-0043) names + // *every* child->parent column pair, not just the first. + let Some(first_local) = rel.local_columns.first().cloned() else { continue; }; - let value = - user_value_for_column_with_schema(database, command, table, &local_col).await; - if let Some(v) = value { - facts.column = Some(local_col); - facts.parent_table = Some(rel.other_table); - facts.parent_column = rel.other_columns.into_iter().next(); - facts.value = Some(v.to_string()); - break; + let Some(first_val) = + user_value_for_column_with_schema(database, command, table, &first_local).await + else { + continue; + }; + // Matched. Gather the remaining pairs' values in order. + let mut values = vec![first_val.to_string()]; + for local_col in rel.local_columns.iter().skip(1) { + if let Some(v) = + user_value_for_column_with_schema(database, command, table, local_col).await + { + values.push(v.to_string()); + } } + facts.column = Some(rel.local_columns.join(", ")); + facts.parent_table = Some(rel.other_table); + facts.parent_column = Some(rel.other_columns.join(", ")); + facts.value = Some(values.join(", ")); + break; } // For UPDATE, if no outbound match was found we may // be in the parent-side case (updating a column diff --git a/tests/it/friendly_enrichment.rs b/tests/it/friendly_enrichment.rs index ff289c9..30c46b0 100644 --- a/tests/it/friendly_enrichment.rs +++ b/tests/it/friendly_enrichment.rs @@ -464,6 +464,81 @@ fn enrich_fk_insert_resolves_parent_table_column_and_value() { }); } +#[test] +fn enrich_fk_insert_compound_names_every_column_pair() { + // ADR-0043 residual: a compound-FK violation must name *every* + // child->parent column pair, not just the first. The single-column + // facts slots carry the comma-joined lists. + let db = db(); + rt().block_on(async { + db.create_table( + "Region".to_string(), + vec![ + ColumnSpec::new("country".to_string(), Type::Int), + ColumnSpec::new("code".to_string(), Type::Int), + ], + vec!["country".to_string(), "code".to_string()], + None, + ) + .await + .unwrap(); + db.create_table( + "City".to_string(), + vec![ + ColumnSpec::new("country".to_string(), Type::Int), + ColumnSpec::new("region_code".to_string(), Type::Int), + ], + vec![], + None, + ) + .await + .unwrap(); + db.add_relationship( + None, + "Region".to_string(), + vec!["country".to_string(), "code".to_string()], + "City".to_string(), + vec!["country".to_string(), "region_code".to_string()], + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + None, + ) + .await + .unwrap(); + + // Insert a City whose (country, region_code) has no parent Region. + let cmd = Command::Insert { + table: "City".to_string(), + columns: Some(vec!["country".to_string(), "region_code".to_string()]), + values: vec![ + Value::Number("7".to_string()), + Value::Number("8".to_string()), + ], + }; + let err = db + .insert( + "City".to_string(), + Some(vec!["country".to_string(), "region_code".to_string()]), + vec![ + Value::Number("7".to_string()), + Value::Number("8".to_string()), + ], + None, + ) + .await + .unwrap_err(); + + let facts = enrich_dsl_failure(&db, &cmd, &err).await; + assert_eq!(facts.table.as_deref(), Some("City")); + assert_eq!(facts.parent_table.as_deref(), Some("Region")); + // Both pairs named, not just the first. + assert_eq!(facts.column.as_deref(), Some("country, region_code")); + assert_eq!(facts.parent_column.as_deref(), Some("country, code")); + assert_eq!(facts.value.as_deref(), Some("7, 8")); + }); +} + #[test] fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() { // Regression: `insert into Orders values (4, 11.99)` — From b8034682ab5b9efe6f4725a49d9bb59e3db7e723 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 12:24:01 +0000 Subject: [PATCH 05/25] =?UTF-8?q?docs:=20session=20handoff=2061=20?= =?UTF-8?q?=E2=80=94=20X1=20logging=20full=20sweep=20+=20T3=20residuals=20?= =?UTF-8?q?closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/handoff/20260610-handoff-61.md | 171 ++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/handoff/20260610-handoff-61.md diff --git a/docs/handoff/20260610-handoff-61.md b/docs/handoff/20260610-handoff-61.md new file mode 100644 index 0000000..22be510 --- /dev/null +++ b/docs/handoff/20260610-handoff-61.md @@ -0,0 +1,171 @@ +# Session handoff — 2026-06-10 (61) + +Sixty-first handover. Continues from handoff-60 (Gitea migration +cleanup + V1 relationship visualization, ADR-0044). This session was +a **list-trimming pass on "easy wins"**: it closed **X1** +(comprehensive logging, full sweep) and both **T3 residuals** (the two +ADR-0043 messaging-polish items). Four commits, all green, all +user-confirmed. + +## §1. State at handoff + +**Branch:** `main`. **HEAD `5a33f2a`.** 4 commits this session +(`a8ad0c6` → `5a33f2a`) on top of session-60's 5; push is the user's +step. + +**Tests: 2211 passing / 0 failing / 1 ignored** (lib 1588, it 431, +typing_surface_matrix 192; the 1 ignored is the long-standing +doc-test). **Clippy clean** (nursery, all targets). +4 over the +handoff-60 baseline of 2207 (one test per residual at each of the +enrichment + render layers, plus the two grammar/worker tests). + +This session's commits: +``` +5a33f2a fix(fk): compound-FK violation message names every column pair +6985a43 fix(fk): inline FK referencing a compound PK points at the table-level form +0a7612e feat: comprehensive logging across parser, app, persistence, runtime (X1) +a8ad0c6 feat(db): comprehensive logging across worker + executors (X1) +``` + +## §2. X1 — comprehensive logging (closed, `[x]`) + +The full-sweep instrumentation pass the "log liberally" standard +called for. **~75 → 135 `tracing` sites** under a documented level +discipline now living in the **`src/logging.rs` module doc** (read it +before adding logs — it is the durable convention). + +**Levels:** `error` = unrecoverable; `warn` = recoverable / fallback +taken; `info` = low-volume lifecycle (worker start/exit, project +open); `debug` = the bulk, one line per *executed* command + its +decision points (off by default, opt-in `RDBMS_PLAYGROUND_LOG=debug`); +`trace` = hot paths only (per-keystroke parse, per-key input). + +**Where logs go (was a point of confusion):** always a **file** +(stdout/stderr would corrupt the TUI). Path precedence: `--log-file` +> `RDBMS_PLAYGROUND_LOG_FILE` > default `~/.rdbms-playground/ +playground.log` (append mode). Level filter is the *separate* +`RDBMS_PLAYGROUND_LOG` env var, default `info`. + +**Coverage by commit:** +- `a8ad0c6` **db.rs** (26→67): entry-`debug!` on all 34 `do_*` + executors (DDL/DML/relationship/index/read), matching the existing + `do_sql_delete`/`do_run_select` style — so the route through + *delegating* executors (e.g. `add_column` → + `add_constrained_column_via_rebuild`) is visible in the log + *sequence*. Decision-point logs: `rebuild_table_with_copy` + begin/commit (+ FK-check-failure and `foreign_keys` re-enable + failure as `warn`), `do_insert` autofill summary, `do_delete` + cascade summary, `do_create_table` FK resolution. Worker + start/exit `debug!`→`info!`. +- `0a7612e` **rest**: `persistence/mod.rs` logs every yaml/CSV/history + write (the silent-failure disk paths); `runtime.rs` + `execute_command_typed` dispatch; `app.rs` submit / + `dispatch_app_command` / ADR-0044 diagram-vs-prose render choice; + `dsl/parser.rs` parse begin/outcome at **`trace`** (the + `parse_command_inner` choke point — `completion.rs` re-parses + per-keystroke, probing candidates in a loop, so `debug` would + flood). + +**Verification:** emission proven end-to-end through the *real* worker +thread + real `logging::init` via two throwaway smoke tests (db path +and persistence path), both since deleted. The DA-honest gap: a few +internal read-only helpers (`do_find_rows_matching`, +`do_read_relationships`, `do_list_names_for`) and the thin `*_request` +wrappers are not *individually* instrumented — the wrappers delegate +to logged executors (skipped to avoid double-logging), the helpers are +low-value. Effective coverage is complete via logged entry points; it +is not literally 44/44. + +## §3. T3 residuals — both closed (ADR-0043) + +Two messaging-only items carried since handoff-59 §4; FK +correctness/enforcement was never affected. + +**#1 — inline-FK arity wording (`6985a43`).** `col REFERENCES P(a,b)` +referencing a compound PK gave the generic arity error. An inline +column-level FK is single-column by construction, so it now points at +the table-level form: *"an inline column reference can only name one +column … Use the table-level form instead: `FOREIGN KEY () +REFERENCES P (a, b)`."* Mechanism: new **`inline: bool` on +`SqlForeignKey`**, set by the single shared grammar builder +`consume_fk_reference` (true for the inline path at `ddl.rs:1560`, +false for table-level `1590` and `build_alter_fk`); threaded into +`resolve_fk_parent_columns`, which tailors the arity-mismatch message +when `inline && parent_key.len() > 1`. 6 construction sites total (2 +grammar + 1 ALTER delegate + 3 test literals) — hand-edited, **not** +the scripted sweep handoff-59 §4 warned about. The bare inline form +(`col REFERENCES P`, no parens) hits the same arity branch, so it is +covered by the same code (tested via the explicit-parens form). + +**#2 — compound-FK violation names every pair (`5a33f2a`).** +`enrich_fk_violation` (`runtime.rs`) picked only `local_columns +.first()` / `other_columns.next()`. It now gathers all pairs of the +matched relationship and carries them **comma-joined in the existing +single-column facts slots** (`column`, `parent_column`, `value`), so +the headline reads *"no parent row in `Region` has `country, code` = +`7, 8`."* No facts-model or catalog change — joined strings flow +through the existing `{parent_column}`/`{value}` placeholders. +Single-column behaviour is byte-identical (a one-element join is the +element). **Known minor awkwardness:** the *verbose hint* interpolates +`{parent_table}.{parent_column}` → `Region.country, code`, which reads +a touch oddly; the headline is clean. A perfectly-formatted compound +hint would need catalog work, out of scope for a messaging-polish +residual — flagged, not fixed. + +## §4. Remaining open landscape (unchanged except X1) + +**Closed this session:** X1 → `[x]`; both T3 residuals (ADR-0043 fully +wrapped — no residuals left). + +**Still `[/]` / `[~]` / larger (design-first, own ADR):** +- **V2 / S3** multi-result tabs — output-model redesign. +- **V3** whole-DB ER export; **V4** scrollable journal + Markdown + (also the home for diagram live-reflow, ADR-0044 OOS-1). +- **A1** app-commands — blocked on `seed` (SD1) + `hint` (H2). +- **H1a** parse-error syntax help (partial; ADR-0021). +- **DOC1** reference docs. + +**`[ ]` not started:** H2 `hint`, SD1 `seed`, C4 m:n convenience, B3 +query-timeout, I1 multi-line input, I1b readline shortcuts, I5 +cancellation, **TT5 CI** (now Gitea Actions / Woodpecker — a fresh +decision tied to the migration + ADR-0001's reopened distribution +question), TT4 PTY (spec-only), D1–D3 distribution, NFR-1…7. + +**ADR-0044 OOS for later:** OOS-7 user-configurable relationship- +display setting (always-prose / always-diagram / auto-by-width). + +## §5. Next job — candidates (by readiness) + +No forced next step. Recommended order: +1. **TT5 CI** — test infra is solid (2211 green) and now there is real + logging to surface failures; no pipeline yet. A fresh **Gitea + Actions / Woodpecker** decision (earns a short ADR; ties into + ADR-0001's reopened distribution question). Highest leverage: + protects everything else. +2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1** + app-commands; both are net-new, self-contained features (each its + own ADR). +3. **C4 m:n convenience** — auto-generate a junction table; depends on + relationships, which are now solid (ADR-0043/0044 done). +4. **V2/S3 tabs** or **V4 journal** — larger output-model redesign; + design-first, own ADR. V4 also unlocks diagram live-reflow. + +## §6. How to take over + +1. Read handoffs 59 → 60 → 61, then `CLAUDE.md` (Gitea/`tea` section), + `docs/requirements.md` (X1 now `[x]`), `docs/adr/README.md`. +2. **Before adding any logging:** read the level-discipline block in + the `src/logging.rs` module doc (the X1 convention). +3. **For FK/relationship work:** ADR-0043 (compound FKs) + ADR-0044 + (visualization) are both fully landed; `SqlForeignKey` now carries + `inline`. +4. Codebase on `main` at `5a33f2a`, clean, 9 commits unpushed (5 from + session 60 + 4 this session). +5. Process pins that paid off: **verify log emission end-to-end, not + just that it compiles** (throwaway smoke tests through the real + worker thread caught nothing broken but proved the stack); + **hot-path logging belongs at `trace`, not `debug`** (the parser); + **test-first on both residuals** (red → green at every layer); + **hand-edit struct-field ripples, never script them** (handoff-59 + §4's scare avoided). Commits user-confirmed, append-only, no AI + attribution. From e44d2983ab872bc82ac9a106ed17dd01d6fafa80 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 13:18:07 +0000 Subject: [PATCH 06/25] test+docs: lock drop-PK-refused on advanced surface; document no-PK advanced mode (#19) Dropping a PK column was already refused in both modes via the shared do_drop_column guard; this adds end-to-end coverage on the advanced ALTER surface (single-column + compound PK, asserting refusal for the right reason) and documents the asymmetry that advanced-mode SQL can create a PK-less table (SQLite's implicit rowid keys it) while simple mode forbids it. See issue #19 comment for the full assessment. --- docs/simple-mode-limitations.md | 16 ++++++++++++ tests/it/sql_alter_table.rs | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/docs/simple-mode-limitations.md b/docs/simple-mode-limitations.md index 331b848..d89a1b1 100644 --- a/docs/simple-mode-limitations.md +++ b/docs/simple-mode-limitations.md @@ -41,6 +41,22 @@ entry names the ADR that drew the boundary. ## Table creation (ADR-0029) +- **A simple-mode table always has a primary key; an advanced-mode + table need not.** `create table … with pk …` is mandatory in simple + mode (ADR-0029) — the bare `with pk` even defaults to `id serial`. + Advanced-mode SQL follows standard SQL and permits a *PK-less* table: + `create table t (a int)` declares no primary key. This is **not** a + storage problem — every ordinary table (STRICT included) carries + SQLite's implicit `rowid`, which keys it internally; only a + `WITHOUT ROWID` table (which this app never creates) would lack one. + So the simple-mode requirement is a *pedagogical* boundary (teach that + tables should have a key), not an engine constraint. Consequences in a + PK-less table, all handled: `show data … limit` falls back to rowid + order (no stable user-facing key to order by); `update` / `delete` + still target the affected rows by rowid; and there is no "PK column" + to drop — dropping a *declared* PK column is refused in **both** modes + (the shared `do_drop_column` guard: *"cannot drop primary-key column + …"*). - **`create table` declares only primary-key columns.** `create table T with pk …` makes every listed column part of the primary key; there is no simple-mode syntax for a diff --git a/tests/it/sql_alter_table.rs b/tests/it/sql_alter_table.rs index 1d6f360..70c1a97 100644 --- a/tests/it/sql_alter_table.rs +++ b/tests/it/sql_alter_table.rs @@ -65,6 +65,50 @@ fn replay_is_refused(script: &str) -> bool { matches!(events.last(), Some(AppEvent::ReplayFailed { .. })) } +/// Like [`replay_is_refused`] but returns the failure message, so a test +/// can assert the command was refused *for the expected reason* rather +/// than e.g. a parse error. +fn replay_failure_message(script: &str) -> Option { + let (project, db, _d) = open(); + let r = rt(); + std::fs::write(project.path().join("conv.commands"), script).expect("write script"); + let events = r.block_on(run_replay(&db, project.path(), "conv.commands")); + match events.last() { + Some(AppEvent::ReplayFailed { error, .. }) => Some(error.clone()), + _ => None, + } +} + +#[test] +fn e2e_alter_drop_primary_key_column_is_refused() { + // Issue #19: dropping a PK column must be refused on the advanced + // ALTER surface too (it reaches the shared `do_drop_column` guard). + let msg = replay_failure_message( + "create table T (id int primary key, v text)\n\ + alter table T drop column id\n", + ) + .expect("dropping a PK column must be refused"); + assert!( + msg.to_lowercase().contains("primary"), + "refused for the wrong reason: {msg}" + ); +} + +#[test] +fn e2e_alter_drop_compound_primary_key_member_is_refused() { + // A member of a *compound* PK is still a PK column, so dropping it is + // refused identically (each member reports primary_key = true). + let msg = replay_failure_message( + "create table T (a int, b int, v text, primary key (a, b))\n\ + alter table T drop column a\n", + ) + .expect("dropping a compound-PK member must be refused"); + assert!( + msg.to_lowercase().contains("primary"), + "refused for the wrong reason: {msg}" + ); +} + /// The current user-facing type of column `name` in table `T`. fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option { r.block_on(db.describe_table("T".to_string(), None)) From e598008ecf4f8d4a06a88bc2e531dc5a40526c97 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 13:18:07 +0000 Subject: [PATCH 07/25] docs: ADR-0045 m:n convenience command (C4); accepted create m:n relationship from to [as ] generates a junction table (compound PK over the two FK column sets, CASCADE FKs) plus two 1:n relationships, in one do_create_table call = one undo step. Forks user-confirmed; /runda DA pass verified the reuse against code and the no-PK-tables-exist-in-advanced-mode fact (parent-PK guard retained). Self-referential m:n refused; FK cols named {table}_{pkcol}. --- docs/adr/0045-mn-convenience.md | 269 ++++++++++++++++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 270 insertions(+) create mode 100644 docs/adr/0045-mn-convenience.md diff --git a/docs/adr/0045-mn-convenience.md b/docs/adr/0045-mn-convenience.md new file mode 100644 index 0000000..971dd6e --- /dev/null +++ b/docs/adr/0045-mn-convenience.md @@ -0,0 +1,269 @@ +# ADR-0045: `create m:n relationship` convenience command (C4) + +## Status + +Accepted (2026-06-10). Closes `requirements.md` **C4**. All four +design forks were escalated and user-confirmed at the recommended +option (compound-over-FKs junction PK; `CASCADE` actions; auto-name + +optional `as`; both modes). Two follow-up points were also confirmed +in a `/runda` DA pass: **self-referential m:n is refused outright** +(user: "refuse — full stop"; it is a beginner-facing convenience, not +the place for directional-naming complexity), and the FK column naming +is **`{parent_table}_{pk_column}`**. The DA pass additionally +established — against the user's initial assumption — that **PK-less +tables *are* reachable** (advanced-mode SQL `create table t (a int)` +declares no PK; `sql_create_table.rs` asserts `pk.is_empty()`), so the +parent-PK guard (D7) is retained as a correctness check. + +Builds on ADR-0013 (named 1:n relationships, the relationship +metadata table, the rebuild-table primitive), ADR-0043 (compound, +list-based FK references — the junction may reference compound parent +PKs), ADR-0011 (`Type::fk_target_type()` for FK column typing), and +the existing `do_create_table` executor (which already accepts +`foreign_keys: Vec` and writes relationship metadata +per FK). Honours ADR-0003 (mode model), ADR-0009 (DSL conventions), +ADR-0002 (no engine name in user-facing strings), and ADR-0024 +(unified grammar / `CommandNode` registration, completion, hints, +help-id, usage-id wiring). + +## Context + +A many-to-many relationship is modelled in a relational database by a +**junction table** (a.k.a. associative / bridge table) that holds one +foreign key to each of the two parents. Today a learner can build this +by hand: `create table` the junction, then `add 1:n relationship` +twice. That is three commands and requires the learner to already know +the junction-table pattern — exactly the concept C4 is meant to +*teach*. + +C4 (`requirements.md`): *"`create m:n relationship from to ` +produces an auto-named junction table the user can rename; pulls +primary keys and FK definitions automatically."* + +The relationship machinery this builds on is freshly solid: ADR-0043 +made the relationship model list-based (compound-aware) across six +layers, and ADR-0044 gave relationships a visual representation. C4 is +the natural convenience layer on top. + +## Decision + +Add a DSL command: + +``` +create m:n relationship from to [as ] +``` + +It generates a **junction table** with one FK column per primary-key +column of each parent, a **compound primary key** over all of those FK +columns, and **two 1:n relationships** (junction → T1, junction → T2), +all in a single transaction (= one undo step). The junction is a +normal table: `rename table`, `drop table`, `show table`, `insert`, +etc. all work on it afterward. + +### D1 — Junction primary key: compound over the FK columns (fork, user-chosen) + +The junction's PK is the **combination of all its FK columns**. For +`create m:n relationship from Students to Courses` (both PK `id`): + +``` +Students_Courses + Students_id int ┐ PRIMARY KEY (Students_id, Courses_id) + Courses_id int ┘ + FOREIGN KEY (Students_id) REFERENCES Students(id) + FOREIGN KEY (Courses_id) REFERENCES Courses(id) +``` + +This is the textbook junction: the `(Students_id, Courses_id)` pair is +unique, so a student cannot be linked to the same course twice. It is +the most pedagogically correct model and needs no surrogate key. + +*Rejected:* a surrogate `serial` PK + `UNIQUE` over the FK pair (adds a +key the learner did not ask for); two FK columns with no PK (allows +duplicate links — wrong lesson). + +### D2 — Referential actions: `CASCADE` on delete and update (fork, user-chosen) + +Both generated FKs default to `ON DELETE CASCADE ON UPDATE CASCADE`. A +junction row is meaningless without both ends, so deleting a parent +(a Student or a Course) removes its link rows automatically — the +natural junction semantics, and a clean teaching demonstration of +cascade. There is no syntax in this command to override the actions; +the learner who wants different actions builds the junction by hand +(or drops + re-adds a relationship). This keeps the convenience +command convenient. + +### D3 — Naming: auto-name `_`, optional `as ` (fork, user-chosen) + +The junction table is auto-named `{T1}_{T2}` (e.g. `Students_Courses`). +An optional `as ` clause overrides it — consistent with +`add 1:n relationship [as ]` and saving a follow-up +`rename table`. The two generated relationships are auto-named by the +existing relationship-name resolver (e.g. +`Students_id_to_Students_Courses_Students_id`), exactly as `create +table` with inline FKs already names them. + +### D4 — FK column naming: `{parent_table}_{pk_column}` + +Each FK column is named `{parent_table}_{pk_column}` — one per PK +column of each parent. This disambiguates the common case where both +parents share a PK column name (both `id` → `Students_id`, +`Courses_id`), and generalises to compound parent PKs: a parent +`Sections(course_id, term)` contributes `Sections_course_id` and +`Sections_term`. Column types come from each parent PK column's +`Type::fk_target_type()` (ADR-0011): `serial → int`, `shortid → text`, +others identity — so the junction columns are plain storable types, +never auto-generating. + +### D5 — Mode availability: both simple and advanced + +`create m:n relationship` is a `CommandCategory::Simple` DSL command, +reachable in **both** input modes — the same posture as the sibling +relationship commands (`drop relationship … Mode::Advanced` is a tested +path today). There is no SQL spelling for it; an advanced-mode user who +prefers raw SQL still has `CREATE TABLE` + `FOREIGN KEY`. The command +is purely additive teaching sugar, so making it available everywhere is +harmless and consistent. + +### D6 — Implementation: one `do_create_table` call, not a batch + +The junction table and both relationships are created by **building a +single `Command`/worker request that `do_create_table` already +handles**: `do_create_table` accepts `columns`, `primary_key`, and +`foreign_keys: Vec`, and already inserts relationship +metadata for each FK. So the executor: + +1. Canonicalises `T1`, `T2` (`require_canonical_table`) and reads each + parent's PK (`read_schema().primary_key`). **D7 — parent-PK guard:** + if either parent's `primary_key` is empty, error with a friendly + "`
` has no primary key, so it cannot anchor an m:n + relationship" *before* building any FK. This is a real case — a + table created via advanced-mode SQL `create table t (a int)` has no + PK (`sql_create_table.rs` asserts `pk.is_empty()` for that form) — + not a theoretical one, so the guard is a correctness requirement, + not defensive padding. +2. Builds the junction `ColumnSpec`s (one per parent PK column, typed + via `fk_target_type`), the compound `primary_key` list, and two + `SqlForeignKey` values (`on_delete = on_update = Cascade`). +3. Calls the create-table path, which creates the table + both FKs + + all metadata in **one transaction** — naturally one undo step, no + `BeginBatch`/`EndBatch` bracketing needed. + +This reuses the most-tested machinery and inherits its persistence, +metadata, and FK-validation behaviour for free. + +A new typed command variant `Command::CreateM2nRelationship { t1, t2, +name }` carries the parsed form; the runtime/executor expands it to the +junction definition. (We do **not** lower it to `Command::CreateTable` +at parse time — keeping a distinct command preserves command identity +per the X5 "unique commands for every unique case" principle, and lets +the teaching echo speak in m:n terms.) + +## Grammar, AST, and cross-cutting wiring + +A new command is not done when it parses — it must light up every +surface a learner touches. Enumerated here so none is missed +(verification in **Testing**). + +- **Grammar node** `CREATE_M2N` (`ddl.rs`): a separate `CommandNode` + with `entry: create`, shape `m:n relationship [as ] from + to `, registered in `REGISTRY` as `CommandCategory::Simple`. A + separate node (not a branch inside `CREATE`) keeps the tested + create-table builder untouched; the walker already dispatches + multiple nodes per entry word. The `m:n` opener mirrors `1:n` + (`Word("m")`, `Punct(':')`, `Word("n")`). +- **AST builder** `build_create_m2n` → `Command::CreateM2nRelationship`. +- **Command + worker plumbing:** `Command::CreateM2nRelationship` + variant; `Request::CreateM2nRelationship`; runtime + `execute_command_typed` arm; `Database::create_m2n_relationship` + public API; executor `do_create_m2n_relationship` (per D6). +- **Completion:** add `("m", "m:n")` to `COMPOSITE_CANDIDATES` + (`completion.rs`) so Tab on `create m` offers `m:n` as one fluent + piece (exactly as `("1", "1:n")` does for `add`). Identifier slots + for ``/`` inherit table-name completion from the walker's + `IdentSource::Tables` automatically. +- **Hints:** set `HintMode`s on the `CREATE_M2N` nodes so the ambient + hint panel guides `from` / table / `to` / table / optional `as`, + matching the `add 1:n relationship` hinting. +- **Highlighting:** automatic — `walker/highlight.rs` is grammar-driven + with no per-command special-casing; verify the line highlights. +- **Help:** `help_id: Some("ddl.create_m2n")` → the command appears in + `help` automatically (REGISTRY iteration) and under `help create`; + add the `help.ddl.create_m2n` catalog string. +- **Usage / parse errors:** `usage_ids: &["parse.usage.create_m2n"]` + → a malformed `create m:n …` shows the form in the usage block; add + the `parse.usage.create_m2n` catalog string. Add the near-miss cases + to the `parse_error_pedagogy` matrix (ADR-0042) for the `create` + entry word. +- **Teaching echo** (`echo.rs` + `build_schema_echo`): a + `CreateM2nRelationship` arm that echoes the generated junction — + the `create table` it built plus the two `FOREIGN KEY` lines — so the + learner sees the pattern the convenience expanded to. +- **Structure render:** the executor returns the junction's + `TableDescription`; the ADR-0044 render path already draws its + relationships as diagrams on the create echo? No — incidental + `create table` echoes keep prose (ADR-0044 reach); the m:n echo shows + the junction structure with its outbound FKs in the standard prose + form. (A future enhancement could draw both relationships as + diagrams; out of scope here.) + +## Genuine forks (escalated, all resolved 2026-06-10) + +1. **Junction PK** — compound-over-FKs (chosen) vs surrogate serial + + UNIQUE vs no PK. → D1. +2. **Referential actions** — `CASCADE` (chosen) vs `NO ACTION` vs + `RESTRICT`. → D2. +3. **Naming** — auto-name + optional `as` (chosen) vs auto-name only. + → D3. +4. **Mode** — both (chosen by default, unobjected) vs simple-only. → D5. + +## Testing + +Integration (`tests/it/`), test-first: + +- **Functional:** `create m:n relationship from A to B` creates table + `A_B` with the two FK columns, compound PK over them, and two + enforced FKs; inserting a junction row with a non-existent parent is + refused; the `(fk1, fk2)` pair is unique (duplicate link refused). +- **`as `** overrides the junction name. +- **Compound parent PK:** a parent with a 2-column PK contributes two + FK columns; the junction PK spans all of them; FKs enforce per pair. +- **Cascade:** deleting a parent row removes its junction rows. +- **Undo:** one `create m:n` is exactly one undo step (table + both + relationships gone after `undo`). +- **Persistence round-trip:** the junction + both relationships survive + a save → rebuild-from-text. +- **Errors:** missing parent table; parent without a PK; junction-name + collision with an existing table; (self-m:n → OOS error, below). + +Cross-cutting (the surfaces a new command must light up): + +- **Completion:** Tab after `create ` surfaces `m:n relationship`; + table-name completion fires at the ``/`` slots. +- **Help:** `help` lists the command; `help create` includes the m:n + form; the catalog string renders. +- **Usage / parse pedagogy:** a bare/half `create m:n` shows the usage + block; near-miss matrix entries added (`parse_error_pedagogy`). +- **Hints + highlighting:** ambient hint progression through the form; + the line highlights (snapshot or assertion as the sibling commands + use). + +All tiers green, zero skips; clippy clean (nursery). + +## Out of scope + +- **Self-referential m:n** (`from T to T`) — **refused outright** + (user-confirmed, "full stop"): the two FK column sets would collide + on `{T}_{pkcol}`, and directional disambiguation (`from_*`/`to_*`) + is more complexity than this beginner-facing convenience warrants. + The executor detects `t1 == t2` (on the canonical names) and errors + with a friendly pointer ("an m:n relationship needs two different + tables — to link a table to itself, add the junction by hand"). + Not a deferred follow-up; a deliberate non-goal. +- **Per-relationship action overrides** in the command syntax (D2 fixes + `CASCADE`); use a hand-built junction for other actions. +- **Extra junction columns** (payload attributes on the link, e.g. an + enrolment date) — add them afterward with `add column`. +- **m:n visualization as diagrams** on the create echo (ADR-0044 reach + keeps incidental create echoes in prose). +- **Renaming the auto-generated *relationships*** (only the table is + `as`-nameable); drop + re-add covers it. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1f623e3..abba83d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -50,3 +50,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block - [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from

.(a, b) to .(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) +- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships From 8bd43ccadfa9d2032cd0a9db1e17f8e29a5e45ec Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 14:26:33 +0000 Subject: [PATCH 08/25] feat: create m:n relationship convenience command (C4, ADR-0045) `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column ({table}_{pkcol}, typed via fk_target_type), a compound PK over them, and two CASCADE 1:n relationships -- all in one do_create_table call = one undo step. Auto-named {T1}_{T2} (optional `as`), both modes, compound-parent PKs supported (ADR-0043). Self-referential m:n / PK-less parent / internal junction name / name collision all refused. Wired across every surface: grammar (separate CREATE_M2N node), worker executor, runtime dispatch, completion ("m:n" composite), hints, highlighting, help + usage catalog + disambiguator, and the advanced-mode DSL->SQL teaching echo (render_create_m2n, round-trips as valid SQL). Generalized/fixed framework assumptions the build + two /runda passes surfaced (all behaviour-preserving for existing commands): - simple-mode dispatch committed simple.first() unconditionally -> tries candidates, so `create table` no longer shadows `create m:n`. - the completion continuation-merge was advanced-only -> runs in simple mode too when an entry word has >1 DSL form (gated simple_count>1). - do_create_table now rejects internal `__rdbms_*` names (closes a pre-existing hole on the DSL create-table path too, not just m:n). - usage disambiguator now recognizes the `m:n` opener. Tests: 14 integration (tests/it/m2n.rs), 7 typing-surface matrix, echo / highlight / usage / internal-name units. Closes C4. 2237 pass / 0 fail / 1 ignored. Clippy clean. --- docs/adr/0045-mn-convenience.md | 33 +- docs/adr/README.md | 2 +- docs/requirements.md | 16 +- src/app.rs | 28 +- src/completion.rs | 14 +- src/db.rs | 171 +++++++ src/dsl/command.rs | 16 + src/dsl/grammar/ddl.rs | 69 +++ src/dsl/grammar/mod.rs | 14 + src/dsl/walker/highlight.rs | 15 + src/dsl/walker/mod.rs | 71 ++- src/echo.rs | 54 +++ src/friendly/keys.rs | 2 + src/friendly/strings/en-US.yaml | 4 + src/runtime.rs | 22 + tests/it/m2n.rs | 418 ++++++++++++++++++ tests/it/main.rs | 1 + tests/typing_surface/create_m2n.rs | 73 +++ tests/typing_surface/mod.rs | 2 + ...ter_as_keyword_is_incomplete@after_as.snap | 20 + ...ate_offers_table_and_m2n@after_create.snap | 52 +++ ...er_from_offers_table_names@after_from.snap | 52 +++ ..._after_to_offers_table_names@after_to.snap | 52 +++ ...__complete_create_m2n_parses@complete.snap | 20 + ..._m2n_with_as_name_parses@with_as_name.snap | 20 + ...incomplete@after_relationship_keyword.snap | 42 ++ ...ter_create_expects_table@after_create.snap | 11 + ...ble__after_with_expects_pk@after_with.snap | 5 +- 28 files changed, 1273 insertions(+), 26 deletions(-) create mode 100644 tests/it/m2n.rs create mode 100644 tests/typing_surface/create_m2n.rs create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap diff --git a/docs/adr/0045-mn-convenience.md b/docs/adr/0045-mn-convenience.md index 971dd6e..a1d3f01 100644 --- a/docs/adr/0045-mn-convenience.md +++ b/docs/adr/0045-mn-convenience.md @@ -2,7 +2,38 @@ ## Status -Accepted (2026-06-10). Closes `requirements.md` **C4**. All four +Accepted (2026-06-10); **implemented 2026-06-10**. Closes +`requirements.md` **C4**. + +**Implementation note — a corrected ADR premise.** The plan claimed +"the walker already dispatches multiple nodes per entry word" and used +that to justify a *separate* `CREATE_M2N` node. That is true only in +**advanced** mode. The build hit **two** places hard-coded to assume +**≤1 DSL form per entry word in simple mode**: (1) the dispatcher +(`decide`) committed `simple.first()` unconditionally, so `create +table` shadowed `create m:n`; (2) the completion continuation-merge was +gated `if mode == Advanced`, so simple mode never surfaced `m:n` as a +candidate. Both were generalized to support multiple DSL forms per +entry word — **behaviour-preserving for every existing single-form +command** (the dispatch reduces to the old single-candidate commit; the +completion merge is gated on `simple_count > 1`). Verified: zero ripple +beyond the new command's own surfaces. The teaching echo (advanced-mode +DSL→SQL, ADR-0038) was also wired: `render_create_m2n` emits the +generated `CREATE TABLE … FOREIGN KEY …` from the post-exec junction +description (round-trips as valid SQL). + +A second `/runda` DA pass (pre-commit) closed five coverage gaps +(highlighting, persistence round-trip, junction rename, name-collision, +missing-parent — the first two had been wrongly claimed verified) and +found two more issues: (a) `create m:n … as __rdbms_*` was accepted — +a hidden-orphan hole the new `as` slot **exposed**, but rooted in the +simple-mode `TABLE_NAME_NEW` slot having no internal-name guard (so +plain `create table __rdbms_*` had it too). Fixed at the **root** — +a `reject_internal_table_name` guard in the shared `do_create_table`, +closing every path (the advanced-SQL path already rejected at parse). +(b) the usage disambiguator (`usage_key_for_input`) handled the `1:n` +opener but not `m:n`, so `create m:n …` resolved to no usage form — +fixed with an explicit `m:n` branch. All four design forks were escalated and user-confirmed at the recommended option (compound-over-FKs junction PK; `CASCADE` actions; auto-name + optional `as`; both modes). Two follow-up points were also confirmed diff --git a/docs/adr/README.md b/docs/adr/README.md index abba83d..99b3a78 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -50,4 +50,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block - [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from

.(a, b) to .(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) -- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships +- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships diff --git a/docs/requirements.md b/docs/requirements.md index fea72ef..625ce85 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -276,9 +276,23 @@ since ADR-0027.) the same via drop + add today; one-step modify is a small follow-up using the existing rebuild-table machinery. ADR pending. -- [ ] **C4** Convenience: `create m:n relationship from to +- [x] **C4** Convenience: `create m:n relationship from to ` produces an auto-named junction table the user can rename; pulls primary keys and FK definitions automatically. + *(Done 2026-06-10 via **ADR-0045**. `create m:n relationship from + to [as ]` builds a junction table with one FK column + per parent PK column (`{table}_{pkcol}`, typed via `fk_target_type`), + a **compound PK** over them, and two **`CASCADE`** 1:n relationships + — all in one `do_create_table` call = one undo step. Auto-named + `{T1}_{T2}` (optional `as`), available in both modes, compound-parent + PKs supported (ADR-0043). Self-referential m:n refused; PK-less parent + refused. Wired across every surface — completion (`m:n` composite), + hints, highlighting, `help`/usage, and the advanced-mode DSL→SQL + teaching echo (the generated `CREATE TABLE … FOREIGN KEY …`). 9 + integration + 7 typing-surface + echo/parse unit tests. The build + surfaced — and fixed — two latent simple-mode dispatch/completion + assumptions ("≤1 DSL form per entry word"), now generalized + behaviour-preservingly.)* - [x] **C5** Data operations: insert / update / delete via DSL. *(ADR-0014. INSERT short and long forms, UPDATE/DELETE with required WHERE plus `--all-rows` opt-in, `show data `, diff --git a/src/app.rs b/src/app.rs index 00e7bae..1e6ff1c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2058,6 +2058,10 @@ impl App { // 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, @@ -2927,13 +2931,15 @@ mod tests { #[test] fn tab_at_word_boundary_inserts_next_expected_keyword() { - // `create ` → expects only `table`. Single candidate; - // insert "table " with space, no memo. + // `change ` → expects only `column`. Single candidate; + // insert "column " with space, no memo. (Uses `change`, not + // `create`: ADR-0045 made `create ` ambiguous — `table` vs + // `m:n` — so it is no longer a single-candidate boundary.) let mut app = App::new(); - type_str(&mut app, "create "); + type_str(&mut app, "change "); let actions = app.update(key(KeyCode::Tab)); assert!(actions.is_empty()); - assert_eq!(app.input, "create table "); + assert_eq!(app.input, "change column "); assert!(app.last_completion.is_none()); } @@ -3080,17 +3086,19 @@ mod tests { // Stage-8 follow-up #2 (testing-round-2): the // single-candidate-no-memo design lets the user chain // Tabs through unique completions without getting - // stuck. From "cr", Tab → "create ", Tab → "create - // table ". (Round 5 added the app-lifecycle commands — + // stuck. From "ch", Tab → "change ", Tab → "change + // column ". (Round 5 added the app-lifecycle commands — // single-letter prefixes like `i` are now ambiguous // (`insert` vs. `import`), so the test starts from a - // disambiguated two-letter prefix.) + // disambiguated two-letter prefix. `change` is used rather + // than `create`: ADR-0045 made `create ` ambiguous (`table` + // vs `m:n`), so it no longer chains as a unique completion.) let mut app = App::new(); - type_str(&mut app, "cr"); + type_str(&mut app, "ch"); app.update(key(KeyCode::Tab)); - assert_eq!(app.input, "create "); + assert_eq!(app.input, "change "); app.update(key(KeyCode::Tab)); - assert_eq!(app.input, "create table "); + assert_eq!(app.input, "change column "); assert!(app.last_completion.is_none()); } diff --git a/src/completion.rs b/src/completion.rs index ef74daa..5ca535a 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -31,6 +31,7 @@ use crate::mode::Mode; /// fragments the user thinks of as a single phrase: /// /// - `1:n` — the opener for `add 1:n relationship`. +/// - `m:n` — the opener for `create m:n relationship` (ADR-0045). /// - `double precision` — the lone two-word SQL type alias /// (ADR-0035 §6.3; the grammar has a dedicated branch so the per-word /// `Ident` validator never has to make sense of `double` alone). @@ -40,7 +41,7 @@ use crate::mode::Mode; /// composite replaces the bare opener rather than appearing /// alongside it. const COMPOSITE_CANDIDATES: &[(&str, &str)] = - &[("1", "1:n"), ("double", "double precision")]; + &[("1", "1:n"), ("m", "m:n"), ("double", "double precision")]; /// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D). /// @@ -1346,12 +1347,19 @@ mod tests { fn at_token_boundary_offers_next_expected_keyword() { // After `create ` advanced mode offers `table` (valid in both // modes) plus the SQL-only `unique` (`create unique index`) and - // `index` — the shared-entry-word merge (ADR-0035 §4i d). + // `index` — the shared-entry-word merge (ADR-0035 §4i d) — and + // `m:n` (`create m:n relationship`, ADR-0045), surfaced as the + // composite (the bare `m` opener is filtered). // `table` (Both) blocks before the Advanced-only `unique`/`index`. let cs = cands("create ", 7); assert_eq!( cs, - vec!["table".to_string(), "unique".to_string(), "index".to_string()] + vec![ + "table".to_string(), + "unique".to_string(), + "index".to_string(), + "m:n".to_string() + ] ); } diff --git a/src/db.rs b/src/db.rs index df090fa..48a85e0 100644 --- a/src/db.rs +++ b/src/db.rs @@ -605,6 +605,13 @@ enum Request { source: Option, reply: oneshot::Sender>, }, + CreateM2nRelationship { + t1: String, + t2: String, + name: Option, + source: Option, + reply: oneshot::Sender>, + }, DropRelationship { selector: RelationshipSelector, source: Option, @@ -1420,6 +1427,29 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// Generate a junction table for an m:n relationship between + /// `t1` and `t2` (ADR-0045 / C4). One worker request = one undo + /// step (the junction + both relationships are built in a single + /// `do_create_table`). + pub async fn create_m2n_relationship( + &self, + t1: String, + t2: String, + name: Option, + source: Option, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::CreateM2nRelationship { + t1, + t2, + name, + source, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + pub async fn drop_relationship( &self, selector: RelationshipSelector, @@ -2347,6 +2377,24 @@ fn handle_request( create_fk, )); } + Request::CreateM2nRelationship { + t1, + t2, + name, + source, + reply, + } => { + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_create_m2n_relationship( + conn, + persistence, + source.as_deref(), + &t1, + &t2, + name.as_deref(), + ) + }); + } Request::DropRelationship { selector, source, @@ -3394,6 +3442,14 @@ fn do_create_table( foreign_keys: &[SqlForeignKey], ) -> Result { debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table"); + // A new table may not take an internal `__rdbms_*` name (it would be + // filtered out of `list_tables` — a hidden orphan). The advanced-SQL + // create path rejects this at parse, but the simple-mode DSL + // `TABLE_NAME_NEW` slot has no validator, and `create m:n … as + // ` (ADR-0045) reaches here too — so the shared executor is the + // single place that closes every path (issue raised by the ADR-0045 + // /runda pass). + reject_internal_table_name(name)?; if columns.is_empty() { // SQLite requires at least one column. The DSL grammar // already prevents this, but defending here too keeps @@ -7277,6 +7333,101 @@ fn resolve_create_table_fks( Ok(out) } +/// Generate a junction table for an m:n relationship between `t1` and +/// `t2` (ADR-0045 / C4). Builds one FK column per parent PK column +/// (`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a +/// compound PK over all of them, and two `CASCADE` foreign keys, then +/// hands the whole thing to [`do_create_table`] — so the junction table +/// and both relationships are created in one transaction = one undo +/// step. Self-referential m:n is refused (column-name collision); a +/// PK-less parent is refused (nothing to reference). +fn do_create_m2n_relationship( + conn: &Connection, + persistence: Option<&Persistence>, + source: Option<&str>, + t1: &str, + t2: &str, + name: Option<&str>, +) -> Result { + debug!(t1 = %t1, t2 = %t2, name = ?name, "create_m2n_relationship"); + // Canonicalize both parents (refuse non-existent / internal tables). + let canon_t1 = require_canonical_table(conn, t1)?; + let t1 = canon_t1.as_str(); + let canon_t2 = require_canonical_table(conn, t2)?; + let t2 = canon_t2.as_str(); + + // Self-referential m:n is OOS (ADR-0045): the two FK column sets + // would collide on `{T}_{pkcol}`, needing directional names this + // beginner convenience deliberately avoids. + if t1.eq_ignore_ascii_case(t2) { + return Err(DbError::Unsupported(format!( + "an m:n relationship needs two different tables (got `{t1}` twice). \ + To link a table to itself, build the junction table by hand." + ))); + } + + let schema1 = read_schema(conn, t1)?; + let schema2 = read_schema(conn, t2)?; + + // Build one FK column per parent PK column (compound parents + // contribute one each, ADR-0043) + the compound PK + the two FKs. + let mut columns: Vec = Vec::new(); + let mut primary_key: Vec = Vec::new(); + let mut foreign_keys: Vec = Vec::new(); + for (tbl, schema) in [(t1, &schema1), (t2, &schema2)] { + // D7 parent-PK guard: advanced-mode SQL can create a PK-less + // table; it cannot anchor an m:n relationship. + if schema.primary_key.is_empty() { + return Err(DbError::Unsupported(format!( + "`{tbl}` has no primary key, so it cannot anchor an m:n relationship." + ))); + } + let mut child_columns: Vec = Vec::new(); + for pkcol in &schema.primary_key { + let pcol = schema + .columns + .iter() + .find(|c| &c.name == pkcol) + .ok_or_else(|| DbError::Sqlite { + message: format!("no such column: {tbl}.{pkcol}"), + kind: SqliteErrorKind::NoSuchColumn, + })?; + let pty = pcol.user_type.ok_or_else(|| { + DbError::Unsupported("primary-key column has no user type metadata".to_string()) + })?; + let col_name = format!("{tbl}_{pkcol}"); + columns.push(ColumnSpec::new(col_name.clone(), pty.fk_target_type())); + primary_key.push(col_name.clone()); + child_columns.push(col_name); + } + foreign_keys.push(SqlForeignKey { + name: None, + child_columns, + parent_table: tbl.to_string(), + parent_columns: Some(schema.primary_key.clone()), + on_delete: ReferentialAction::Cascade, + on_update: ReferentialAction::Cascade, + inline: false, + }); + } + + // Junction name: explicit `as ` or the auto-name `{t1}_{t2}`. + let junction = name.map_or_else(|| format!("{t1}_{t2}"), str::to_string); + debug!(junction = %junction, cols = columns.len(), "create_m2n_relationship: building junction table"); + + do_create_table( + conn, + persistence, + source, + &junction, + &columns, + &primary_key, + &[], + &[], + &foreign_keys, + ) +} + #[allow(clippy::too_many_arguments)] fn do_add_relationship( conn: &Connection, @@ -10397,6 +10548,26 @@ mod tests { assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } + #[tokio::test] + async fn create_table_rejects_an_internal_name() { + // A new table may not take an internal `__rdbms_*` name — it would + // be hidden from `list_tables`. The advanced-SQL path rejects this + // at parse; the shared executor guards every other path (the + // simple-mode DSL slot and `create m:n … as`, ADR-0045). + let db = db(); + let err = db + .create_table( + "__rdbms_sneaky".to_string(), + vec![col("id", Type::Int)], + vec!["id".to_string()], + None, + ) + .await + .unwrap_err(); + assert!(matches!(err, DbError::Sqlite { kind: SqliteErrorKind::NoSuchTable, .. }), "got {err:?}"); + assert!(db.list_tables().await.unwrap().is_empty()); + } + #[tokio::test] async fn drop_table_removes_it_from_list() { let db = db(); diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 9378448..68046e4 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -277,6 +277,18 @@ pub enum Command { on_update: ReferentialAction, create_fk: bool, }, + /// Convenience: generate a junction table for a many-to-many + /// relationship between `t1` and `t2` (ADR-0045 / C4). The + /// executor builds a table with one FK column per parent PK + /// column (named `{table}_{pkcol}`, typed via `fk_target_type`), + /// a compound PK over all of them, and two `CASCADE` 1:n + /// relationships — all in one `create table` (one undo step). + /// `name` overrides the auto-generated junction name `{t1}_{t2}`. + CreateM2nRelationship { + t1: String, + t2: String, + name: Option, + }, /// Drop a relationship by either user-given/auto-generated /// name, or by positional reference to the FK endpoints. DropRelationship { @@ -915,6 +927,7 @@ impl Command { Self::RenameColumn { .. } => "rename column", Self::ChangeColumnType { .. } => "change column", Self::AddRelationship { .. } => "add relationship", + Self::CreateM2nRelationship { .. } => "create m:n relationship", Self::DropRelationship { .. } => "drop relationship", Self::AddIndex { .. } => "add index", Self::DropIndex { .. } => "drop index", @@ -991,6 +1004,9 @@ impl Command { // table's "Referenced by" entry, which is what the // user looks at to confirm the relationship. Self::AddRelationship { parent_table, .. } => parent_table, + // For m:n we focus on the first table; the executor builds + // and returns the junction's structure regardless. + Self::CreateM2nRelationship { t1, .. } => t1, Self::DropRelationship { selector } => match selector { RelationshipSelector::Endpoints { parent_table, .. } => parent_table, // For a named drop we don't know the parent table diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 022521c..0167093 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -1362,6 +1362,75 @@ pub static CREATE: CommandNode = CommandNode { help_id: Some("ddl.create"), usage_ids: &["parse.usage.create_table"],}; +// ================================================================= +// create_m2n — `create m:n relationship from to [as ]` +// (ADR-0045 / C4). Generates an auto-named junction table with two FKs +// + two 1:n relationships. A *separate* `CommandNode` under the shared +// `create` entry word (the walker dispatches both); the `m` opener is a +// `Literal` (not a keyword) so it never shadows an identifier, mirroring +// the `1` in `add 1:n relationship`. +// ================================================================= + +const M2N_T1: Node = Node::Ident { + source: IdentSource::Tables, + role: "m2n_t1", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, +}; +const M2N_T2: Node = Node::Ident { + source: IdentSource::Tables, + role: "m2n_t2", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, +}; +// Optional `as ` — a *new* table name (the junction), +// so it reuses `TABLE_NAME_NEW` (role `table_name`, `NewName` source + +// hint). The only `table_name` role in this path, so the builder reads +// it directly as the junction name. +const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW]; +const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES)); + +const CREATE_M2N_NODES: &[Node] = &[ + Node::Literal("m"), + Node::Punct(':'), + Node::Word(Word::keyword("n")), + Node::Word(Word::keyword("relationship")), + Node::Word(Word::keyword("from")), + M2N_T1, + Node::Word(Word::keyword("to")), + M2N_T2, + M2N_AS_NAME_OPT, +]; +const CREATE_M2N_SHAPE: Node = Node::Seq(CREATE_M2N_NODES); + +fn build_create_m2n(path: &MatchedPath, _source: &str) -> Result { + Ok(Command::CreateM2nRelationship { + t1: require_ident(path, "m2n_t1")?, + t2: require_ident(path, "m2n_t2")?, + name: ident(path, "table_name").map(str::to_string), + }) +} + +pub static CREATE_M2N: CommandNode = CommandNode { + entry: Word::keyword("create"), + shape: CREATE_M2N_SHAPE, + ast_builder: build_create_m2n, + help_id: Some("ddl.create_m2n"), + usage_ids: &["parse.usage.create_m2n"], +}; + /// The friendly error for a column type without a preceding name — /// a structural impossibility given the grammar, defended anyway. fn sql_col_type_without_name() -> ValidationError { diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index d68fc3b..30a5b3b 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -657,6 +657,12 @@ pub fn usage_key_for_input_in_mode( if source.as_bytes().get(after).is_some_and(u8::is_ascii_digit) { return keys.iter().copied().find(|k| k.ends_with("relationship")); } + // The `create m:n relationship` form (ADR-0045) opens with `m:n` + // — a letter, so the digit branch misses it, and its usage key ends + // `…create_m2n` (not `relationship`). + if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) { + return keys.iter().copied().find(|k| k.ends_with("m2n")); + } // Otherwise the form word is an identifier — `column`, // `index`, `table`, `relationship` — matched against the // usage key's suffix. @@ -706,6 +712,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[ (&ddl::RENAME, CommandCategory::Simple), (&ddl::CHANGE, CommandCategory::Simple), (&ddl::CREATE, CommandCategory::Simple), + (&ddl::CREATE_M2N, CommandCategory::Simple), (&data::SHOW, CommandCategory::Simple), (&data::INSERT, CommandCategory::Simple), (&data::UPDATE, CommandCategory::Simple), @@ -852,6 +859,13 @@ mod usage_key_tests { ), ("show data T", "parse.usage.show_data"), ("show table T", "parse.usage.show_table"), + // `create` is multi-form (table vs m:n, ADR-0045): each typed + // form resolves to its own usage key. + ("create table T with pk id(int)", "parse.usage.create_table"), + ( + "create m:n relationship from A to B", + "parse.usage.create_m2n", + ), ]; for (input, expected) in cases { assert_eq!( diff --git a/src/dsl/walker/highlight.rs b/src/dsl/walker/highlight.rs index e5a4b9a..f2bd732 100644 --- a/src/dsl/walker/highlight.rs +++ b/src/dsl/walker/highlight.rs @@ -211,6 +211,21 @@ mod tests { assert_eq!(run("quit"), vec![(0, 4, HighlightClass::Keyword)]); } + #[test] + fn create_m2n_relationship_highlights_cleanly() { + // ADR-0045: a valid `create m:n relationship` line classifies + // with no Error runs; keywords are keywords and the table names + // are identifiers (the `m:n` opener is a Literal, keyword-classed). + let runs = run("create m:n relationship from A to B"); + assert!( + !runs.iter().any(|(_, _, c)| *c == HighlightClass::Error), + "no Error highlight on a valid m:n line: {runs:?}" + ); + let kinds: Vec = runs.iter().map(|(_, _, c)| *c).collect(); + assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}"); + assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}"); + } + #[test] fn keyword_plus_identifier_via_walker() { // `show data Customers` walks end-to-end. diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index d22e750..d3cf55b 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -406,13 +406,28 @@ pub fn completion_probe_in_mode( // Mismatch and is naturally skipped — the viability check is the // gate, not the cursor depth. let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()]; - if mode == crate::mode::Mode::Advanced { + { let s = skip_whitespace(source, 0); if let Some((kw_start, kw_end)) = consume_ident(source, s) { let entry = &source[kw_start..kw_end]; let candidates = grammar::commands_for_entry_word(entry); - if candidates.len() > 1 { - use crate::dsl::grammar::CommandCategory; + use crate::dsl::grammar::CommandCategory; + // Advanced mode merges DSL + SQL continuations across all + // candidate nodes; Simple mode merges only when an entry word + // has more than one DSL form (e.g. `create table` vs + // `create m:n relationship`, ADR-0045). With a single DSL form + // the committed node already carries every continuation, so + // that case is left untouched (its `Both` mode-class too) — + // keeping this zero-ripple for every existing command. + let simple_count = candidates + .iter() + .filter(|(_, _, c)| *c == CommandCategory::Simple) + .count(); + let run_merge = match mode { + crate::mode::Mode::Advanced => candidates.len() > 1, + crate::mode::Mode::Simple => simple_count > 1, + }; + if run_merge { // (continuation word, produced-by-simple, produced-by-advanced) let mut tally: Vec<(&'static str, bool, bool)> = Vec::new(); // Continuations that aren't keyword/literal-shaped @@ -422,6 +437,13 @@ pub fn completion_probe_in_mode( // for punctuation defaults to `Both`. let mut punct_tally: Vec = Vec::new(); for (_, node, category) in candidates { + // Simple mode never offers advanced SQL continuations + // (ADR-0030 §2); only DSL forms contribute. + if mode == crate::mode::Mode::Simple + && category == CommandCategory::Advanced + { + continue; + } let mut sctx = context::WalkContext::with_schema(schema); sctx.mode = mode; let (res, _) = @@ -2720,13 +2742,46 @@ fn decide( // appended at the rendering layer (see // `advanced_alternative_note`), combining the DSL fix with // the mode hint. - match simple.first() { - Some(&(sidx, snode)) => Decision::Commit { idx: sidx, node: snode }, - None => { - let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary); - Decision::ThisIsSql { primary } + if simple.is_empty() { + let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary); + return Decision::ThisIsSql { primary }; + } + // An entry word may register more than one DSL form + // (e.g. `create table` and `create m:n relationship`, + // ADR-0045). Commit the first that fully matches or is + // content-rejected (a `ValidationFailed` means the shape + // fits but the content is invalid — that error must + // surface), mirroring the advanced branch below. With a + // single DSL form this reduces to "commit it": a lone + // non-matching candidate falls through to the + // furthest-progress step and is committed anyway, so its + // positioned DSL error still surfaces (unchanged behaviour). + for &(idx, node) in &simple { + if matches!( + scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema), + WalkOutcome::Match { .. } | WalkOutcome::ValidationFailed { .. } + ) { + return Decision::Commit { idx, node }; } } + // None matched — commit the furthest-progress candidate + // (first on ties) so the surfaced DSL error is the most + // informative. + let mut best = simple[0]; + let mut best_progress = + scratch_progress(effective_source, kw_start, kw_end, best.1, mode, schema); + for &(idx, node) in &simple[1..] { + let progress = + scratch_progress(effective_source, kw_start, kw_end, node, mode, schema); + if progress > best_progress { + best = (idx, node); + best_progress = progress; + } + } + Decision::Commit { + idx: best.0, + node: best.1, + } } crate::mode::Mode::Advanced => { // Advanced candidates first, DSL as the fallback. diff --git a/src/echo.rs b/src/echo.rs index e320cb7..04461ca 100644 --- a/src/echo.rs +++ b/src/echo.rs @@ -15,6 +15,7 @@ use crate::app::EffectiveMode; use crate::dsl::ReferentialAction; +use crate::dsl::types::Type; use crate::dsl::Command; use crate::dsl::command::{ ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter, @@ -286,6 +287,31 @@ pub(crate) fn render_add_relationship( s } +/// The advanced-mode DSL→SQL teaching echo (ADR-0038) for `create m:n +/// relationship` (ADR-0045): the single `CREATE TABLE` the junction +/// expands to — every FK column, the compound primary key over them, +/// and the two `CASCADE` foreign keys (m:n always cascades, D2). Built +/// from the post-exec junction description (the resolved columns don't +/// exist on the command), so it shows exactly what was created. +pub(crate) fn render_create_m2n( + junction: &str, + columns: &[(String, Type)], + primary_key: &[String], + foreign_keys: &[(Vec, String, Vec)], +) -> String { + let mut parts: Vec = + columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect(); + parts.push(format!("PRIMARY KEY ({})", primary_key.join(", "))); + for (child_columns, parent_table, parent_columns) in foreign_keys { + parts.push(format!( + "FOREIGN KEY ({}) REFERENCES {parent_table} ({}) ON DELETE CASCADE ON UPDATE CASCADE", + child_columns.join(", "), + parent_columns.join(", "), + )); + } + format!("CREATE TABLE {junction} ({})", parts.join(", ")) +} + /// `ALTER TABLE DROP CONSTRAINT ` — the `drop relationship` /// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an /// `Endpoints` selector) and `child_table` (for a `Named` selector) **pre- @@ -1077,6 +1103,34 @@ mod tests { ); } + #[test] + fn create_m2n_echo_renders_junction_and_round_trips() { + // The advanced-mode teaching echo for `create m:n relationship` + // (ADR-0045): the single CREATE TABLE the junction expands to, + // compound PK + the two CASCADE FKs — and it is valid SQL. + let sql = render_create_m2n( + "Students_Courses", + &[ + ("Students_id".to_string(), Type::Int), + ("Courses_id".to_string(), Type::Int), + ], + &["Students_id".to_string(), "Courses_id".to_string()], + &[ + (vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]), + (vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]), + ], + ); + assert_eq!( + sql, + "CREATE TABLE Students_Courses (Students_id int, Courses_id int, \ + PRIMARY KEY (Students_id, Courses_id), \ + FOREIGN KEY (Students_id) REFERENCES Students (id) ON DELETE CASCADE ON UPDATE CASCADE, \ + FOREIGN KEY (Courses_id) REFERENCES Courses (id) ON DELETE CASCADE ON UPDATE CASCADE)" + ); + // The echoed SQL is valid advanced-mode SQL (round-trips). + assert!(matches!(reparse(&sql), Ok(Command::SqlCreateTable { .. }))); + } + // --- expr / literal rendering ------------------------------------ #[test] diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 4855855..f8b256c 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -190,6 +190,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("help.app.redo", &[]), ("help.app.copy", &[]), ("help.ddl.create", &[]), + ("help.ddl.create_m2n", &[]), ("help.ddl.sql_create_table", &[]), ("help.ddl.sql_drop_table", &[]), ("help.ddl.sql_create_index", &[]), @@ -277,6 +278,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.usage.add_relationship", &[]), ("parse.usage.change_column", &[]), ("parse.usage.create_table", &[]), + ("parse.usage.create_m2n", &[]), ("parse.usage.sql_create_table", &[]), ("parse.usage.sql_drop_table", &[]), ("parse.usage.sql_create_index", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 2f2e432..2ebed9e 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -279,6 +279,9 @@ help: ddl: create: |- create table with pk [

(), ...] — create a table + create_m2n: |- + create m:n relationship from to [as ] + — build a junction table linking two tables sql_create_table: |- create table [if not exists] ( [not null] [unique] [primary key] [default ] [check ()] [references

[(

)]], ... @@ -523,6 +526,7 @@ parse: # placeholders. ADR-0009's surface conventions apply. usage: create_table: "create table with pk [()[, ...]]" + create_m2n: "create m:n relationship from to [as ]" # Terse one-line synopsis (issue #12): the full grammar — every # column- and table-level constraint — lives in `help.ddl.sql_create_table`. sql_create_table: "create table [if not exists] ( [constraints], ...)" diff --git a/src/runtime.rs b/src/runtime.rs index 0ec720b..488020c 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1832,6 +1832,24 @@ fn build_schema_echo( .map(|(name, child_table)| { vec![crate::echo::render_drop_relationship(name, child_table)] }), + // `create m:n relationship` (ADR-0045): the resolved junction + // columns/FKs only exist on the post-exec description, so the + // teaching echo is rendered from it (not `command_to_sql`). + Command::CreateM2nRelationship { .. } => description.map(|desc| { + let columns: Vec<(String, crate::dsl::types::Type)> = desc + .columns + .iter() + .filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty))) + .collect(); + let primary_key: Vec = + desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect(); + let foreign_keys: Vec<(Vec, String, Vec)> = desc + .outbound_relationships + .iter() + .map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone())) + .collect(); + vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)] + }), // Everything else (Bucket A pure-Command, plus the no-echo Bucket C // variants like `Sql*` / `ShowTable`) routes through the existing // `echo::command_to_sql` — wrapping its `Option` to fit the @@ -2657,6 +2675,10 @@ async fn execute_command_typed( ) .await .map(|d| CommandOutcome::Schema(Some(d))), + Command::CreateM2nRelationship { t1, t2, name } => database + .create_m2n_relationship(t1, t2, name, src) + .await + .map(|d| CommandOutcome::Schema(Some(d))), Command::DropRelationship { selector } => database .drop_relationship(selector, src) .await diff --git a/tests/it/m2n.rs b/tests/it/m2n.rs new file mode 100644 index 0000000..df0b6d1 --- /dev/null +++ b/tests/it/m2n.rs @@ -0,0 +1,418 @@ +//! Integration tests for the m:n convenience command (C4 / ADR-0045): +//! `create m:n relationship from to [as ]`. +//! +//! Covers parse, junction generation (columns / compound PK / two +//! enforced FKs), the `as ` override, a compound-PK parent, +//! CASCADE delete, one-undo-step, self-m:n refusal, and the PK-less +//! parent guard. + +use rdbms_playground::db::Database; +use rdbms_playground::dsl::command::RowFilter; +use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value}; +use rdbms_playground::persistence::Persistence; +use rdbms_playground::project::{self, PLAYGROUND_DB}; + +fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio rt") +} + +fn open() -> (project::Project, Database, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let project = project::open_or_create(None, Some(dir.path())).expect("project"); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf())) + .expect("db"); + (project, db, dir) +} + +fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let project = project::open_or_create(None, Some(dir.path())).expect("project"); + let db = Database::open_with_persistence_and_undo( + project.db_path(), + Persistence::new(project.path().to_path_buf()), + true, + ) + .expect("db"); + (project, db, dir) +} + +/// A parent table `(id serial PK, label text)` — the `label` gives an +/// insertable non-PK column (a serial-PK-only table has nothing to put +/// in a short-form INSERT). +async fn serial_pk_table(db: &Database, name: &str) { + db.create_table( + name.to_string(), + vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)], + vec!["id".to_string()], + None, + ) + .await + .unwrap_or_else(|e| panic!("create {name}: {e}")); +} + +/// Insert one row into a `serial_pk_table`, returning its auto-assigned id. +async fn add_row(db: &Database, table: &str, label: &str) { + db.insert( + table.to_string(), + Some(vec!["label".to_string()]), + vec![Value::Text(label.to_string())], + None, + ) + .await + .unwrap_or_else(|e| panic!("insert into {table}: {e}")); +} + +// ---- parse layer ----------------------------------------------- + +#[test] +fn parses_to_create_m2n_relationship() { + match parse_command("create m:n relationship from Students to Courses").expect("parses") { + Command::CreateM2nRelationship { t1, t2, name } => { + assert_eq!(t1, "Students"); + assert_eq!(t2, "Courses"); + assert_eq!(name, None); + } + other => panic!("expected CreateM2nRelationship, got {other:?}"), + } +} + +#[test] +fn parses_with_as_name() { + match parse_command("create m:n relationship from Students to Courses as Enrollments") + .expect("parses") + { + Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")), + other => panic!("expected CreateM2nRelationship, got {other:?}"), + } +} + +// ---- junction generation --------------------------------------- + +#[test] +fn generates_junction_with_compound_pk_and_two_enforced_fks() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .expect("create m:n"); + + // Auto-named `Students_Courses` exists. + let tables = db.list_tables().await.unwrap(); + assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}"); + + // Two FK columns, both part of the compound PK. + let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap(); + let cols: Vec<(&str, bool)> = + desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect(); + assert_eq!( + cols, + vec![("Students_id", true), ("Courses_id", true)], + "expected two FK columns forming the compound PK" + ); + // Two outbound relationships (one per parent). + assert_eq!(desc.outbound_relationships.len(), 2, "expected two FKs"); + + // FK enforcement: a junction row needs existing parents. + add_row(&db, "Students", "s1").await; + add_row(&db, "Courses", "c1").await; + db.insert( + "Students_Courses".to_string(), + Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), + vec![Value::Number("1".to_string()), Value::Number("1".to_string())], + None, + ) + .await + .expect("valid link"); + // Duplicate link refused by the compound PK. + let dup = db + .insert( + "Students_Courses".to_string(), + Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), + vec![Value::Number("1".to_string()), Value::Number("1".to_string())], + None, + ) + .await; + assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused"); + // A link to a non-existent parent is refused by the FK. + let orphan = db + .insert( + "Students_Courses".to_string(), + Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), + vec![Value::Number("1".to_string()), Value::Number("99".to_string())], + None, + ) + .await; + assert!(orphan.is_err(), "link to a non-existent Course must be refused"); + }); +} + +#[test] +fn as_name_overrides_the_junction_table_name() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship( + "Students".to_string(), + "Courses".to_string(), + Some("Enrollments".to_string()), + None, + ) + .await + .expect("create m:n as Enrollments"); + let tables = db.list_tables().await.unwrap(); + assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}"); + assert!(!tables.contains(&"Students_Courses".to_string())); + }); +} + +#[test] +fn compound_parent_pk_contributes_one_fk_column_each() { + let (_p, db, _d) = open(); + rt().block_on(async { + // Sections has a 2-column PK (course_id, term). + db.create_table( + "Sections".to_string(), + vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)], + vec!["course_id".to_string(), "term".to_string()], + None, + ) + .await + .unwrap(); + serial_pk_table(&db, "Students").await; + + db.create_m2n_relationship("Students".to_string(), "Sections".to_string(), None, None) + .await + .expect("create m:n"); + + let desc = db.describe_table("Students_Sections".to_string(), None).await.unwrap(); + let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]); + // All three form the compound PK. + assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}"); + }); +} + +#[test] +fn deleting_a_parent_cascades_to_the_junction() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .unwrap(); + add_row(&db, "Students", "s1").await; + add_row(&db, "Courses", "c1").await; + db.insert( + "Students_Courses".to_string(), + Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), + vec![Value::Number("1".to_string()), Value::Number("1".to_string())], + None, + ) + .await + .unwrap(); + + // Deleting the student cascades to the junction (ON DELETE CASCADE). + db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap(); + let rows = db.query_data("Students_Courses".to_string(), None, None, None).await.unwrap(); + assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows); + }); +} + +#[test] +fn create_m2n_is_one_undo_step() { + let (_p, db, _d) = open_with_undo(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + // A real source makes the command undoable (a source-less call is + // treated as an internal, non-undoable op). + db.create_m2n_relationship( + "Students".to_string(), + "Courses".to_string(), + None, + Some("create m:n relationship from Students to Courses".to_string()), + ) + .await + .unwrap(); + assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string())); + + // One undo removes the junction table AND both relationships. + db.undo().await.unwrap(); + let tables = db.list_tables().await.unwrap(); + assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}"); + // The parents' relationships are gone too (the junction held them). + let students = db.describe_table("Students".to_string(), None).await.unwrap(); + assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo"); + }); +} + +// ---- guards ---------------------------------------------------- + +#[test] +fn self_referential_m2n_is_refused() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Users").await; + let err = db + .create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None) + .await + .expect_err("self m:n must be refused"); + assert!(format!("{err}").contains("two different tables"), "got: {err}"); + }); +} + +#[test] +fn missing_parent_table_is_refused() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + let err = db + .create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None) + .await + .expect_err("a missing parent table must be refused"); + // The standard "no such table" guard (require_canonical_table). + assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}"); + }); +} + +#[test] +fn junction_name_collision_is_refused() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .expect("first m:n"); + // A second identical m:n collides on the auto-name `Students_Courses`. + let err = db + .create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .expect_err("a junction-name collision must be refused"); + assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}"); + }); +} + +// ---- the junction is a normal table ---------------------------- + +#[test] +fn the_junction_can_be_renamed() { + // C4 requirement text: "an auto-named junction table the user can + // rename." It is a normal table, so `rename table` works. + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .unwrap(); + db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None) + .await + .expect("rename the junction"); + let tables = db.list_tables().await.unwrap(); + assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}"); + assert!(!tables.contains(&"Students_Courses".to_string())); + // Both relationships survive the rename (rebuild-preserving). + let desc = db.describe_table("Enrollments".to_string(), None).await.unwrap(); + assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename"); + }); +} + +#[test] +fn junction_survives_save_and_rebuild() { + // Persistence round-trip: the junction + both relationships are + // reconstructed from project.yaml after the .db is discarded. + let dir = tempfile::tempdir().expect("tempdir"); + let project_path = { + let project = project::open_or_create(None, Some(dir.path())).unwrap(); + let path = project.path().to_path_buf(); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())) + .unwrap(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship( + "Students".to_string(), + "Courses".to_string(), + None, + Some("create m:n relationship from Students to Courses".to_string()), + ) + .await + .unwrap(); + }); + drop(db); + drop(project); + path + }; + // Discard the derived .db so the next open rebuilds from text. + std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap(); + let project = project::Project::open(&project_path).unwrap(); + let db = + Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf())) + .unwrap(); + rt().block_on(async { + db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild"); + let tables = db.list_tables().await.unwrap(); + assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}"); + let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap(); + assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed"); + assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed"); + }); +} + +#[test] +fn as_an_internal_name_is_refused() { + // The junction must be a real, listable table — an `as __rdbms_*` + // name would be filtered out of `list_tables` (a hidden orphan). + // Guarded in the shared `do_create_table` (ADR-0045 /runda finding). + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + let err = db + .create_m2n_relationship( + "Students".to_string(), + "Courses".to_string(), + Some("__rdbms_evil".to_string()), + None, + ) + .await + .expect_err("an internal junction name must be refused"); + assert!(format!("{err}").contains("no such table"), "got: {err}"); + assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string())); + }); +} + +#[test] +fn pk_less_parent_is_refused() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + // A PK-less table via the advanced SQL path. + db.sql_create_table( + "Loose".to_string(), + vec![ColumnSpec::new("a", Type::Int)], + vec![], + vec![], + vec![], + vec![], + false, + None, + ) + .await + .unwrap(); + let err = db + .create_m2n_relationship("Students".to_string(), "Loose".to_string(), None, None) + .await + .expect_err("a PK-less parent must be refused"); + assert!(format!("{err}").contains("no primary key"), "got: {err}"); + }); +} diff --git a/tests/it/main.rs b/tests/it/main.rs index f2e9a57..a6d300d 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -19,6 +19,7 @@ mod iteration4a_rebuild_command; mod iteration4b_lifecycle_commands; mod iteration5_export_import; mod iteration6_resume_history; +mod m2n; mod parse_error_pedagogy; mod project_lifecycle; mod replay_command; diff --git a/tests/typing_surface/create_m2n.rs b/tests/typing_surface/create_m2n.rs new file mode 100644 index 0000000..10abc06 --- /dev/null +++ b/tests/typing_surface/create_m2n.rs @@ -0,0 +1,73 @@ +//! Matrix coverage for `create m:n relationship from to +//! [as ]` (C4 / ADR-0045). Exercises the full typing surface — +//! completion candidates, ambient hint, highlighting, and parse state — +//! at each stage, so a regression in any of those surfaces is caught. + +use crate::typing_surface::*; +use rdbms_playground::input_render::InputState; + +#[test] +fn after_create_offers_table_and_m2n() { + let schema = schema_multi_table(); + let a = assess_at_end("create ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + // `create` branches to `table` (create table) or the `m:n` composite. + assert_candidate_present(&a, &["table", "m:n"]); + crate::snap!("after_create", a); +} + +#[test] +fn m2n_relationship_keyword_sequence_is_incomplete() { + let schema = schema_multi_table(); + let a = assess_at_end("create m:n relationship ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + assert_candidate_present(&a, &["from"]); + crate::snap!("after_relationship_keyword", a); +} + +#[test] +fn after_from_offers_table_names() { + let schema = schema_multi_table(); + let a = assess_at_end("create m:n relationship from ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + assert_candidate_present(&a, &["Customers", "Orders"]); + crate::snap!("after_from", a); +} + +#[test] +fn after_to_offers_table_names() { + let schema = schema_multi_table(); + let a = assess_at_end("create m:n relationship from Customers to ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + assert_candidate_present(&a, &["Customers", "Orders"]); + crate::snap!("after_to", a); +} + +#[test] +fn complete_create_m2n_parses() { + let schema = schema_multi_table(); + let a = assess_at_end("create m:n relationship from Customers to Orders", &schema); + assert!(matches!(a.state, InputState::Valid)); + assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship")); + crate::snap!("complete", a); +} + +#[test] +fn create_m2n_with_as_name_parses() { + let schema = schema_multi_table(); + let a = assess_at_end( + "create m:n relationship from Customers to Orders as CustomerOrders", + &schema, + ); + assert!(matches!(a.state, InputState::Valid)); + assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship")); + crate::snap!("with_as_name", a); +} + +#[test] +fn after_as_keyword_is_incomplete() { + let schema = schema_multi_table(); + let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + crate::snap!("after_as", a); +} diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 4551501..c2d4307 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -35,6 +35,7 @@ pub mod create_table; pub mod drop_column; pub mod drop_relationship; pub mod add_relationship; +pub mod create_m2n; pub mod index_ops; pub mod constraints; pub mod rename_change_column; @@ -224,6 +225,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { RenameColumn { .. } => "RenameColumn".into(), ChangeColumnType { .. } => "ChangeColumnType".into(), AddRelationship { .. } => "AddRelationship".into(), + CreateM2nRelationship { .. } => "CreateM2nRelationship".into(), DropRelationship { .. } => "DropRelationship".into(), AddIndex { .. } => "AddIndex".into(), DropIndex { .. } => "DropIndex".into(), diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap new file mode 100644 index 0000000..11f0118 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap @@ -0,0 +1,20 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 72 +description: "input=\"create m:n relationship from Customers to Orders as \" cursor=52" +expression: "& a" +--- +Assessment { + input: "create m:n relationship from Customers to Orders as ", + cursor: 52, + state: IncompleteAtEof, + hint: Some( + Prose( + "Type a name", + ), + ), + completion: None, + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap new file mode 100644 index 0000000..4a0f4f9 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap @@ -0,0 +1,52 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 16 +description: "input=\"create \" cursor=7" +expression: "& a" +--- +Assessment { + input: "create ", + cursor: 7, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "table", + kind: Keyword, + mode: Simple, + }, + Candidate { + text: "m:n", + kind: Keyword, + mode: Both, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 7, + 7, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "table", + kind: Keyword, + mode: Simple, + }, + Candidate { + text: "m:n", + kind: Keyword, + mode: Both, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap new file mode 100644 index 0000000..a0c555b --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap @@ -0,0 +1,52 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 34 +description: "input=\"create m:n relationship from \" cursor=29" +expression: "& a" +--- +Assessment { + input: "create m:n relationship from ", + cursor: 29, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "Customers", + kind: Identifier, + mode: Both, + }, + Candidate { + text: "Orders", + kind: Identifier, + mode: Both, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 29, + 29, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "Customers", + kind: Identifier, + mode: Both, + }, + Candidate { + text: "Orders", + kind: Identifier, + mode: Both, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap new file mode 100644 index 0000000..4055c73 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap @@ -0,0 +1,52 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 43 +description: "input=\"create m:n relationship from Customers to \" cursor=42" +expression: "& a" +--- +Assessment { + input: "create m:n relationship from Customers to ", + cursor: 42, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "Customers", + kind: Identifier, + mode: Both, + }, + Candidate { + text: "Orders", + kind: Identifier, + mode: Both, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 42, + 42, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "Customers", + kind: Identifier, + mode: Both, + }, + Candidate { + text: "Orders", + kind: Identifier, + mode: Both, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap new file mode 100644 index 0000000..d4b1054 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap @@ -0,0 +1,20 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 52 +description: "input=\"create m:n relationship from Customers to Orders\" cursor=48" +expression: "& a" +--- +Assessment { + input: "create m:n relationship from Customers to Orders", + cursor: 48, + state: Valid, + hint: Some( + Prose( + "Submit with Enter", + ), + ), + completion: None, + parse_result: Ok( + "CreateM2nRelationship", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap new file mode 100644 index 0000000..05773bd --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap @@ -0,0 +1,20 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 64 +description: "input=\"create m:n relationship from Customers to Orders as CustomerOrders\" cursor=66" +expression: "& a" +--- +Assessment { + input: "create m:n relationship from Customers to Orders as CustomerOrders", + cursor: 66, + state: Valid, + hint: Some( + Prose( + "Type a name", + ), + ), + completion: None, + parse_result: Ok( + "CreateM2nRelationship", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap new file mode 100644 index 0000000..62fb479 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap @@ -0,0 +1,42 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 25 +description: "input=\"create m:n relationship \" cursor=24" +expression: "& a" +--- +Assessment { + input: "create m:n relationship ", + cursor: 24, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "from", + kind: Keyword, + mode: Simple, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 24, + 24, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "from", + kind: Keyword, + mode: Simple, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap index f2e64e4..39cca2e 100644 --- a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap @@ -1,5 +1,6 @@ --- source: tests/typing_surface/create_table.rs +assertion_line: 13 description: "input=\"create \" cursor=7" expression: "& a" --- @@ -13,6 +14,11 @@ Assessment { Candidate { text: "table", kind: Keyword, + mode: Simple, + }, + Candidate { + text: "m:n", + kind: Keyword, mode: Both, }, ], @@ -30,6 +36,11 @@ Assessment { Candidate { text: "table", kind: Keyword, + mode: Simple, + }, + Candidate { + text: "m:n", + kind: Keyword, mode: Both, }, ], diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap index 1da5358..600150c 100644 --- a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap @@ -1,5 +1,6 @@ --- source: tests/typing_surface/create_table.rs +assertion_line: 48 description: "input=\"create table Customers with \" cursor=28" expression: "& a" --- @@ -13,7 +14,7 @@ Assessment { Candidate { text: "pk", kind: Keyword, - mode: Both, + mode: Simple, }, ], selected: None, @@ -30,7 +31,7 @@ Assessment { Candidate { text: "pk", kind: Keyword, - mode: Both, + mode: Simple, }, ], }, From f88018b4be8c5dca09040775b26ed5186bf4f4a5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 14:28:50 +0000 Subject: [PATCH 09/25] =?UTF-8?q?docs:=20session=20handoff=2062=20?= =?UTF-8?q?=E2=80=94=20C4=20m:n=20convenience=20command=20+=20issue=20#19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/handoff/20260610-handoff-62.md | 185 ++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/handoff/20260610-handoff-62.md diff --git a/docs/handoff/20260610-handoff-62.md b/docs/handoff/20260610-handoff-62.md new file mode 100644 index 0000000..a73f864 --- /dev/null +++ b/docs/handoff/20260610-handoff-62.md @@ -0,0 +1,185 @@ +# Session handoff — 2026-06-10 (62) + +Sixty-second handover. Continues from handoff-61 (X1 logging full sweep ++ T3 residuals). This session was a **list-trimming + one-feature run**: +it closed **C4** (the `create m:n relationship` convenience command, +**ADR-0045**) and, in passing, resolved **Gitea issue #19** (drop-PK +guard). Handoff-61 itself was written mid-session, so the X1 / T3 work +it describes is also part of this session's commit range. + +## §1. State at handoff + +**Branch:** `main`. **HEAD `8bd43cc`.** Push is the user's step. + +**Tests: 2237 passing / 0 failing / 1 ignored** (the 1 ignored is the +long-standing doc-test). **Clippy clean** (nursery, all targets). +30 +over the handoff-60 baseline of 2207. + +**This session's commits** (8, on top of session-60's 5): +``` +8bd43cc feat: create m:n relationship convenience command (C4, ADR-0045) +e598008 docs: ADR-0045 m:n convenience command (C4); accepted +e44d298 test+docs: lock drop-PK-refused on advanced surface; document no-PK advanced mode (#19) +b803468 docs: session handoff 61 — X1 logging full sweep + T3 residuals closed +5a33f2a fix(fk): compound-FK violation message names every column pair +6985a43 fix(fk): inline FK referencing a compound PK points at the table-level form +0a7612e feat: comprehensive logging across parser, app, persistence, runtime (X1) +a8ad0c6 feat(db): comprehensive logging across worker + executors (X1) +``` + +**Requirements closed this session:** **X1** `[x]` (logging), **T3** +residuals (both ADR-0043 messaging items), **C4** `[x]` (m:n). Gitea +**#19 closed**. + +## §2. X1 — comprehensive logging (closed) — see handoff-61 §2 + +Full detail in handoff-61. In brief: ~75 → **137** `tracing` sites under +a documented level discipline (read the **`src/logging.rs` module doc** +before adding logs). Logs go to a **file** (`--log-file` > +`RDBMS_PLAYGROUND_LOG_FILE` > `~/.rdbms-playground/playground.log`); +level via the separate `RDBMS_PLAYGROUND_LOG` env (default `info`). +`debug` = per-command detail (off by default), `trace` = hot paths +(per-keystroke parse). + +## §3. T3 residuals (both closed) — see handoff-61 §3 + +`6985a43` inline-FK arity wording (points at the table-level form; +added `inline: bool` to `SqlForeignKey`). `5a33f2a` compound-FK +violation names every column pair (comma-joined in the single-column +facts slots; `enrich_fk_violation`). ADR-0043 now has no residuals. + +## §4. Issue #19 — drop-PK guard (closed, `e44d298`) + +A parallel check the user requested. **Finding: dropping a PK column is +already refused in both modes** via the shared `do_drop_column` guard +(*"cannot drop primary-key column …"*) — simple `drop column` and +advanced `ALTER … DROP COLUMN` both route through it. Added end-to-end +coverage (`tests/it/sql_alter_table.rs`: single + compound PK, refusal +for the right reason). **Corrected a long-standing misconception:** the +issue's premise ("we don't support creating a table with no PK") is true +only in **simple** mode — advanced SQL `create table t (a int)` makes a +real **PK-less** table (SQLite's implicit `rowid` keys it; only +`WITHOUT ROWID` lacks one, which this app never creates). The simple-mode +`with pk` requirement is **pedagogical** (ADR-0029), not an engine +constraint. Documented in `docs/simple-mode-limitations.md`. + +## §5. C4 — `create m:n relationship` (the feature, ADR-0045) + +`create m:n relationship from to [as ]` generates a +**junction table**: one FK column per parent PK column +(`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a **compound +PK** over all of them, and **two `CASCADE` 1:n relationships** — all in +**one `do_create_table` call = one undo step** (no batch needed; +`do_create_table` already takes `foreign_keys` + writes per-FK +relationship metadata). Auto-named `{T1}_{T2}` (optional `as`), available +in **both modes**, compound-parent PKs supported (ADR-0043). + +**Forks (all user-confirmed):** compound-over-FKs PK (vs surrogate / +none); `CASCADE` actions; auto-name + optional `as`; both modes; FK +columns `{table}_{pkcol}`. **Refused:** self-referential m:n (`from T to +T` — full stop, OOS); PK-less parent; internal `__rdbms_*` junction +name; name collision. + +**Where the code lives:** +- Grammar: a **separate `CREATE_M2N` `CommandNode`** in + `dsl/grammar/ddl.rs` (entry `create`, opener `Node::Literal("m")` — + not a keyword, so it never shadows an identifier), registered Simple + in `grammar/mod.rs` `REGISTRY`. `build_create_m2n` → + `Command::CreateM2nRelationship { t1, t2, name }`. +- Worker: `Request::CreateM2nRelationship`, + `Database::create_m2n_relationship`, executor + `do_create_m2n_relationship` (reads each PK, guards self-ref / + PK-less, builds columns + compound PK + 2 `SqlForeignKey`s, calls + `do_create_table`). +- Runtime: `execute_command_typed` arm. Echo: + `echo::render_create_m2n` (advanced-mode DSL→SQL teaching echo, ADR- + 0038 — the generated `CREATE TABLE … FOREIGN KEY …`, round-trips as + valid SQL), wired in `build_schema_echo`. +- Surfaces: completion `("m","m:n")` composite; `help.ddl.create_m2n` + + `parse.usage.create_m2n` catalog (+ `keys.rs` declarations); + highlighting is grammar-driven (automatic). + +**Tests:** 14 integration (`tests/it/m2n.rs`), 7 typing-surface matrix +(`tests/typing_surface/create_m2n.rs` — completion/hint/highlight/parse), +plus echo / highlight / usage-disambiguator / internal-name units. + +## §6. Framework fixes the C4 build + two `/runda` passes surfaced + +C4's "separate node" design rested on an ADR premise that proved **only +half true**: *"the walker already dispatches multiple nodes per entry +word"* held in **advanced** mode but not **simple**. Three latent +simple-mode assumptions ("≤1 DSL form per entry word") were generalized, +**all behaviour-preserving for existing single-form commands**: + +1. **Dispatch** (`walker/mod.rs` `decide`) committed `simple.first()` + unconditionally → now tries simple candidates (so `create table` no + longer shadows `create m:n`). Reduces to the old single-candidate + commit when there is one. +2. **Completion continuation-merge** (`walker/mod.rs`) was gated + `if mode == Advanced` → now runs in simple mode too, **gated on + `simple_count > 1`** so single-form entry words are untouched. +3. **Usage disambiguator** (`grammar/mod.rs` `usage_key_for_input`) + knew the `1:n` opener but not `m:n` → added an explicit branch. + +Plus a **root-cause bug fix** (user-chosen scope): `do_create_table` +now rejects internal `__rdbms_*` names. This closed both the C4 `as +__rdbms_*` hole **and a pre-existing hole** — simple-mode DSL `create +table __rdbms_*` was accepted at parse (the `TABLE_NAME_NEW` slot had no +guard; only the advanced-SQL path rejected internal names). The shared +executor is the single choke point; the SQL path still rejects earlier +at parse. + +**Process note:** the two `/runda` passes were worth it. The first +(pre-build) corrected the inverted "no PK-less tables" assumption and +confirmed the `do_create_table` reuse against code. The second +(pre-commit) closed **five** test-coverage gaps — two of which +(highlighting, persistence round-trip) had been **wrongly claimed +verified** (the typing-surface `Assessment` has no highlight field; +"transitively covered" was a hand-wave) — and found the two bugs above. +Lesson re-confirmed: verify a claimed-tested surface actually has an +assertion; "transitively covered" is a DA red flag. + +## §7. Remaining open landscape + +**Closed since handoff-60:** X1, both T3 residuals, C4, #19. ADR-0043 and +ADR-0045 fully landed. + +**Still open (by readiness, unchanged otherwise):** +1. **TT5 CI** — test infra solid (2237 green); no pipeline. **Gitea + Actions / Woodpecker** (a fresh decision tied to the migration + + ADR-0001's reopened distribution question). **Friction:** the + requirement is Linux/macOS/Windows on stable — self-hosted Gitea can + do Linux easily, but mac/Windows runners need machines that may not + exist; likely needs a Linux-first scope decision. +2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1** + app-commands; both net-new, own ADR (SD2 is the seed-generator design + ADR). SD1 should now seed **m:n junctions** too (valid FK refs from + parent rows) — C4 makes that concrete. +3. **V2/S3 multi-result tabs** or **V4 journal** — larger output-model + redesign, design-first, own ADR. V4 also unlocks diagram live-reflow. +4. **C3a modify relationship** — small follow-up (drop+add covers it + today; ADR pending). + +**ADR-0045 OOS for later:** self-referential m:n (deliberate non-goal); +per-relationship action overrides; extra junction payload columns; +m:n-as-diagram echo. **Pre-existing, now-fixed:** the internal-name hole +(§6) — no separate issue needed, it's closed. + +## §8. How to take over + +1. Read handoffs 60 → 61 → 62, then `CLAUDE.md`, `docs/requirements.md` + (X1/C4 now `[x]`), `docs/adr/README.md`. +2. **Before adding logging:** the level discipline in the + `src/logging.rs` module doc. +3. **For grammar/command work:** an entry word can now carry **multiple + DSL forms** in simple mode (C4 generalized the dispatch + completion + + usage paths). `create` is the first such entry word (table + m:n). +4. **For relationship/FK work:** ADR-0013/0043/0044/0045 are all landed; + `SqlForeignKey` carries `inline`; `do_create_table` now guards + internal names. +5. Codebase on `main` at `8bd43cc`, clean. Commits user-confirmed, + append-only, no AI attribution. Process pins that paid off: **two + `/runda` passes per feature** (design + pre-commit) — both found real + bugs and gaps every time; **verify a claimed-tested surface has an + actual assertion**; **escalate genuine forks** (every C4 design choice + + the internal-name fix scope was the user's). From 93266b99c9debf57150a3c1ca05d691713092590 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 16:57:46 +0000 Subject: [PATCH 10/25] docs: ADR-0046 UI sidebar nav-mode + responsive input/hint (#20/#21/#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepted; implementation pending, phased A→B→C. Treats the three coupled UI issues as one decision (shared width/height budget): - #20 hint jumpiness: hint height becomes a function of terminal geometry, fixed between resizes, so it no longer shoves the input/output panels. - #21 left column: kept but width-optional (hidden by default ≤90), with a new relationships sibling panel and a Ctrl-O navigation/focus mode (peek-reveal, expand-on-focus overlay, scroll). - #23 long input: single-logical-line horizontal scroll plus a 2-row display when tall, preserving the ADR-0027 indicator reserve. A pre-build /runda DA pass drove key corrections: Ctrl-B→Ctrl-O (Ctrl-B is the tmux prefix), an additive SchemaCache.relationship_details field (retyping would break completion), full nav-mode key disposition + modal gate, and Tier-2 snapshot coverage. Reconciles requirements S1 (evolved), S2 (overridden — separate relationships panel), and S4 (corrected — the stale "keyboard-toggleable" hint claim is struck; no toggle added). Updates docs/adr/README.md index and docs/requirements.md S1/S2/S4. --- ...ar-navigation-and-responsive-input-hint.md | 526 ++++++++++++++++++ docs/adr/README.md | 1 + docs/requirements.md | 24 +- 3 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md new file mode 100644 index 0000000..dd90d36 --- /dev/null +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -0,0 +1,526 @@ +# ADR-0046: Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20 / #21 / #23) + +## Status + +Accepted (2026-06-10); **implementation pending**, phased **A → B → C** +(see *Decision — phasing*). Closes Gitea issues **#20** (hint-panel +height jumpiness), **#21** (database-structure / left-column +improvements), and **#23** (long command input). Issue #23's own note +("handle after #21 is decided") is honoured: the input work is split so +the part that depends on the sidebar's width budget lands with it. + +Builds on and honours: **ADR-0003** (the persistent Simple/Advanced +mode model — navigation mode is *not* a third input mode, see DC1), +**ADR-0027** (the input validity indicator's reserved 6 right columns — +horizontal scroll and 2-line display preserve that reserve), +**ADR-0044** (relationship visualization — the relationships panel +renders the same `RelationshipSchema` data the `show relationship` +diagram already consumes), **ADR-0013 / ADR-0043** (the +`RelationshipSchema` model: name, parent/child tables, list-based +compound columns, referential actions), **ADR-0015** (project file +format — sidebar visibility is **session-only**, so the format is +untouched), and **ADR-0002** (no engine name in user-facing strings). +Preserves the **pure-render-from-`App`-state** invariant (CLAUDE.md): +visual changes here are driven either by new `App` *state* fields +(mutated in `update()`) or by pure *render-time* functions of the frame +geometry (see State section); `update()` stays pure-sync. + +**Requirements & issues touched (verified against `requirements.md`).** +Evolves **S1** (the always-present three-region layout — the left items +region becomes width-optional, DB1). **Overrides S2**, which planned +additional element kinds as *nested* items in the tables list; +relationships get their own panel instead (DB2/DB4 — see Genuine forks +§11). **Corrects S4**: its "keyboard-toggleable hint area" was never +implemented (no toggle keybinding exists in the code) and is not wanted +— the hint panel became indispensable once completion moved into it +(ADR-0022) — so the toggle phrase is struck from `requirements.md` and +no toggle is added here. Extends **I1a** (single-line cursor editing → +horizontal scroll, DA3) and honours **S6 / ADR-0027** (the 6-column +validity-indicator reserve, DA3/DA4). The PageUp/PageDown context-rebind +(DC3) does **not** regress **V4**'s output scroll, which stays live in +input mode. Adjacent but separate: Gitea **#22** (an in-app +overlay/annotation layer for casts and guided lessons — its own ADR) +shares the overlay-render and screencast context with DC2's `Clear` +overlay; the two are meant to coexist, not merge. + +## Context + +Three UI issues were raised together because they are coupled through +the terminal's width and height budget; treating them as one decision +avoids three conflicting partial fixes. + +**Current layout (verified in `src/ui.rs`).** `render()` splits +vertically into `Min(8)` main / `Length(1)` project label / `Length(1)` +status. The main area splits horizontally into a **fixed +`Length(28)`** left column (`render_items_panel`, a "Tables" list with +indented index names) and a `Min(20)` right column. The left column's +block has `Borders::ALL`, so its **usable inner width is 26 columns** +(28 − 2 borders). The right column splits vertically into output +(`Min(5)`), input (`Length(3)`), and hint (`Length(hint_content)`). + +**#20 — hint jumpiness.** `hint_content` is recomputed **every frame** +as `clamp(wrapped_lines, 1, MAX_HINT_ROWS=3) + 2`, i.e. 3–5 rows. As the +user types and hint strings appear, grow, and vanish, the hint panel +resizes and **shoves the input and output panels**, producing the +flicker visible in screencasts. The root cause is that height tracks +*content* rather than terminal *geometry*. + +The hint catalog (`src/friendly/strings/en-US.yaml`) was measured: the +two longest strings are `value_literal_slot` (106 chars) and +`create_table_element` (102); four more are 50–57; the rest ≤ 50. The +wrapping consequence is sharp: at a right-column inner width ≥ ~54 +columns the worst string needs **at most 2 lines**; a **3rd line is +only ever required when the right column is narrower than ~54** (a +sub-~83-column terminal *with the sidebar shown*, or a sub-~55-column +terminal). On the project's screencasts (90 columns wide, sidebar +hidden — see DB1) two lines are provably sufficient. + +**#21 — the left column.** A persistent 26-column Tables list is rarely +filled even half-way by a teaching database, yet it permanently costs +horizontal space the output and input panels want — acutely so on the +90-column screencasts. The pedagogical value of an *always-visible* +schema overview is real (CLAUDE.md "pedagogy wins ties"), so the column +is **kept but made optional and more useful**, not deleted. + +**#23 — long input.** The command input is a **single logical `String`** +rendered by a `Paragraph` with no wrap and no horizontal scroll +(`render_input_panel`); text past the panel width **clips silently**. +The cursor is a byte offset on a char boundary; Up/Down drive history. +The fix needs the width the sidebar's removal frees, hence the coupling. + +**Keybinding space (verified).** Taken: Tab/Shift-Tab (completion), +Enter (submit), Up/Down (history), Left/Right/Home/End (cursor), +PageUp/PageDown (output scroll), Backspace/Delete, Esc +(completion-undo / modal cancel), Ctrl-C (quit). Reserved-but-deferred +(I1b readline): Ctrl-A/E/W/K/U. Printable keys all route to the input. +Terminal-hijacked and therefore unusable: Ctrl-S/Q (flow control), +Ctrl-Z (suspend), Ctrl-H (backspace), Ctrl-I/M (Tab/Enter), Ctrl-G +(BEL). This leaves a narrow band of safe combinations for new controls. + +## Decision — phasing + +The work ships in three phases so the screencasts benefit from the +least-controversial part first and the riskiest part (the focus/scroll +model) is isolated: + +- **Phase A — input & hint (DA1–DA4):** self-contained, no sidebar + dependency; fixes #20 and the baseline of #23. +- **Phase B — optional, richer sidebar (DB1–DB3):** visibility model + + relationships panel + schema-cache enrichment. +- **Phase C — navigation mode (DC1–DC4):** the Ctrl-O focus/scroll/ + expand model that makes the sidebar browsable. + +Each phase is independently shippable and independently green. + +## Decision — Phase A: responsive input & hint heights + +### DA1 — Hint height is a function of terminal geometry, fixed between resizes + +The hint panel's height is **decoupled from hint content**. It is +computed from the terminal's width and height **once per resize** and +held constant as the user types. Because the panel no longer resizes on +every keystroke, it never shoves the input/output panels — the #20 jump +is eliminated at the source, not damped. Content that exceeds the fixed +height is ellipsized (the existing `clamp_wrapped` truncation), which is +now a rare, width-driven event rather than a per-keystroke one. + +### DA2 — Responsive height buckets + +Heights are chosen by terminal **height** (rows), with the hint's +optional 3rd line gated on right-column **width** (per the Context +measurement): + +| Terminal height | Input content rows | Hint content rows | +| --- | --- | --- | +| **Compact** (`H < 40` — covers the 25-row screencasts) | 1 (+ horizontal scroll, DA3) | 2 | +| **Comfortable** (`H ≥ 40` — fullscreen terminals) | 2 (soft-wrap, DA4) | 2 (→ 3 only if right-column inner < ~54) | + +A safety degradation protects tiny terminals: the output panel's +`Min(5)` is honoured first; if rows are insufficient, the hint shrinks +to 1, then the input to 1. The `40`-row threshold is a tunable constant. + +### DA3 — Input horizontal scroll (single logical line) + +The input keeps its **single-`String`** model (no embedded newlines — +this is explicitly *not* multi-line input, see Out of scope). A new +`App` field `input_scroll_offset: usize` tracks the first visible +column; the renderer shows a window of the line and keeps the cursor in +view, mirroring the candidate-line horizontal-scroll markers already in +`render_candidate_line`. The ADR-0027 6-column indicator reserve is +preserved (the scroll window is the text area = `inner.width − 6`, not +the full inner width). Because `update()` does not know the panel width, +the renderer feeds it back via a `note_input_viewport(text_width)` call +(the analogue of the existing `note_output_viewport`), against which the +offset is clamped to keep the cursor visible. `input_scroll_offset` +**resets to 0** whenever the buffer is replaced wholesale — on `submit`, +on history navigation (Up/Down), and on any clear. This is the baseline +#23 fix and is sufficient on its own for the compact (1-row) layout. + +### DA4 — Two-line input display when tall (`H ≥ 40`) + +On comfortable terminals the input renders across **2 visual rows** by +soft-wrapping the single logical line, with the cursor mapped to a +(row, col) within the two rows. Content longer than two rows scrolls +the two-row window horizontally (DA3) so the cursor stays visible. The +**ADR-0027 `[ERR]`/`[WRN]` indicator stays anchored to the right edge +of the *first* row** (its 6-column reserve applies to row 1; the soft- +wrap on row 1 stops 6 columns short, row 2 uses the full text width) — +S6 is preserved. + +This is display-only over the same single-`String` model — distinct +from the deferred true multi-line-input feature (I1, which adds +*multiple logical lines* with Enter-inserts-newline). **Forward-compat +note:** I1, when built, should reuse DA4's row-rendering and cursor +(row, col) mapping rather than introduce a parallel one — DA4 is the +substrate, not a competitor. + +## Decision — Phase B: optional, richer sidebar + +### DB1 — Width-derived visibility plus transient peek (session-only) + +Sidebar visibility is **derived, not stored**: the sidebar is visible +iff the terminal **width > 90** *or* navigation mode is currently +focused on a sidebar panel (the Ctrl-O peek, DC1). It is recomputed +every frame from terminal width and `NavFocus`; nothing persists to +`project.yaml` (ADR-0015 untouched), so it is session-only by +construction — and there is no stored visibility field to keep in sync. + +At ≤ 90 columns the sidebar is hidden by default — so the 90-column +screencasts never show it and the output panel gets the full width it +needs there — but `Ctrl-O` temporarily reveals it for the duration of a +browse and re-hides it on exit (DC1). + +**No persistent show/hide toggle (resolved 2026-06-10, user).** Issue +#21's original wording asked for "a keystroke to show and hide it"; the +Ctrl-O peek covers that need, so no separate toggle and no +force-shown/force-hidden override is added. Visibility stays a pure +function of `(terminal width, NavFocus)` — the simplest model that +satisfies the requirement. Should pinning ever prove necessary, a +persistent override is an additive follow-up (see Out of scope). + +### DB2 — Add a relationships panel; enrich the schema cache + +The left column gains a **second panel** below Tables: a list of the +project's relationships. This is a deliberate **override of S2**, whose +note proposed additional element kinds (relations, views) as *nested* +items inside the existing tables list. Relationships are *cross-table*, +not per-table, so nesting them under a single table reads wrong; a +sibling panel is the honest shape (user-confirmed 2026-06-10). S2's +"without restructuring" intent is still met — the items column simply +holds two stacked panels (DB4) instead of one. + +The panel needs the full `RelationshipSchema` (name, parent/child +tables, list-based columns, on-delete/on-update actions) that the `show +relationship` path already fetches. **`SchemaCache` is *extended*, not +retyped:** its existing `relationships: Vec` is left as-is +(`completion.rs` borrows it as `&Vec` via +`IdentSource::Relationships` for relationship-name completion, and +several test fixtures construct it) and a **new field +`relationship_details: Vec`** is added alongside, +populated by the same cache refresh that runs on schema change (the +refresh is taught to query relationship detail, which today it does not +— it only lists names). Retyping the existing field would break the +completion borrow and the fixtures; adding a field is the +zero-ripple change. + +The panel has **two display states** keyed off focus (DC2): + +- **Unfocused (26-col)** — an ambient glance. Per relationship: the + name (ellipsized past the inner width), and the endpoints broken at + the arrow to fit a narrow column: + + ``` + Customers_Orders + Customers.id -> + Orders.customer_id + ``` + +- **Focused + expanded (40–50 col, DC2)** — a browse view. At the wider + width the endpoints fit on one line + (`Customers.id -> Orders.customer_id`); the arrow-break is used only + when even the expanded width cannot hold a (possibly compound) + endpoint pair. The wider width minimises horizontal truncation so the + panel needs **mainly vertical scrolling** (DC3). + +### DB3 — Sidebar width unchanged when unfocused + +The unfocused sidebar keeps `Length(28)` / 26 inner columns. Widening +happens only on focus (DC2), as an overlay, so the unfocused layout and +the right-column reflow are unchanged from today. + +### DB4 — Vertical split of the two left-column panels + +The items column stacks **Tables (top)** and **Relationships (bottom)**. +The Relationships panel's height is content-driven within bounds, so it +stays small when there is little to show and never dominates the column +(user-chosen 2026-06-10): + +- **No relationships:** fixed at **5 rows** (3 content + 2 border), + rendering a single `None` line. This is the floor. +- **With relationships:** grows with content (`content_rows + 2`, where + the unfocused format is ~3 rows per relationship) up to a **cap of + 50 % of the column height**; beyond the cap the panel **scrolls** + (DC3). Formally `rel_h = clamp(content_rows + 2, 5, ⌊col_h / 2⌋)`. +- **Tables** takes the remainder (`col_h − rel_h`) and scrolls if it + overflows (it, too, is a focusable, scrollable panel — DC3). +- **Degradation:** on a column too short to honour the 5-row floor plus + a usable Tables panel (`col_h < ~10`), the floor yields first so + Tables keeps at least its border + one row; both panels stay + renderable. The `50 %` cap and `5`-row floor are tunable constants. + +Heights are a pure render-time function of the column height and the +cached relationship count, so they are unit-testable without a terminal +(see Testing). + +## Decision — Phase C: navigation mode + +### DC1 — `Ctrl-O` navigation mode: a focus cycle, not an input mode + +`Ctrl-O` enters a **navigation mode** that is orthogonal to the +Simple/Advanced input mode (ADR-0003) — it changes *where keystrokes +go*, not *how commands parse*. It drives a focus cycle: + +1. **Press 1 →** focus the **Tables** panel (revealing the sidebar if + it is currently hidden — a temporary peek). +2. **Press 2 →** focus the **Relationships** panel. +3. **Press 3 →** leave navigation mode: restore the sidebar width, + re-hide it if the peek revealed it, and return focus to the command + input. + +`Esc` exits navigation mode directly from any focused panel (a +short-cut for step 3); `Esc` is otherwise only completion-undo, which +does not apply while browsing. + +**Why `Ctrl-O` and not `Ctrl-B`.** `Ctrl-B` is the *default tmux prefix* +and `Ctrl-A` is *screen's* — a multiplexer eats them before the app +sees them, so either would make navigation mode unreachable for the many +students who run inside tmux/screen. `Ctrl-O` is not a multiplexer +prefix; in the raw mode the TUI sets, its legacy line-discipline meaning +(discard-output) is disabled, so it reaches the app. It is free in the +app today (the main key handler's catch-all, `app.rs:1001`). The +mnemonic is weak ("**O**utline"); reachability won over mnemonic. + +**Routing.** Navigation mode is handled inside the **main** key handler, +which runs only when no modal is open (`app.rs:919` gates on +`self.modal.is_some()`). So `Ctrl-O` and the nav keys are **inert while +a modal dialog is active** — modals keep full keyboard ownership. Within +the main handler, a `NavFocus != Input` branch precedes the normal +input-editing arms and routes keys per DC3/DC4. + +### DC2 — Expand-on-focus as an overlay + +A focused sidebar panel widens to **~40–50 columns**, rendered as an +**overlay**: the renderer draws a `Clear` over the affected right-column +region and paints the wide panel on top. The output/input/hint panels +underneath keep their exact layout — **unused and unchanging** while +browsing — and are restored by the next frame on exit. This is cheap +because the renderer is a pure function of `App` state: focus state +selects the width and the overlay path. (The input underneath is +inactive in navigation mode, so occluding it is harmless.) + +### DC3 — Scroll the focused panel; focus highlight + +While a sidebar panel is focused it scrolls, reusing the output panel's +proven mechanism (a `usize` offset clamped against a renderer-reported +viewport via a `note_*_viewport` call): + +- **Up / Down — line-by-line** scroll (the lazygit `j`/`k` feel; + user-chosen 2026-06-10). +- **PageUp / PageDown — page** scroll. + +This is a context-sensitive rebind: Up/Down drive *history* and +PageUp/PageDown scroll the *output* in input mode, whereas in navigation +mode they scroll the *focused sidebar panel*. The two contexts never +apply simultaneously (`NavFocus` selects which). The focused panel shows +an **accent border** so it is obvious where keys are going (lazygit +convention). + +### DC4 — Other keys are inert in navigation mode + +The command input is visibly occluded by the overlay while browsing, so +keys that have no navigation meaning are **inert** rather than acting on +the hidden input. Specifically, **only** `Ctrl-O` (advance focus), +Up/Down + PageUp/PageDown (scroll, DC3), and `Esc` (exit) are live; +printable characters, Enter, Tab, Backspace/Delete, Left/Right, and +Home/End all do nothing until navigation mode is exited. The occlusion +signals "not typing," so swallowing these is clearer than letting them +silently edit an invisible buffer. + +## State, keybindings, and cross-cutting wiring + +**Stored `App` state** (mutated in `update()`, read by the renderer): + +- `input_scroll_offset: usize` (DA3) — reset on submit / history-nav / + clear. +- `NavFocus { Input, SidebarTables, SidebarRelationships }` (DC1) — the + navigation-mode focus cursor; `Input` ≙ not in navigation mode. +- Per-panel scroll offsets for the Tables and Relationships panels, each + clamped against a renderer-reported viewport (DC3), mirroring + `output_scroll` / `note_output_viewport`. +- `SchemaCache` gains **`relationship_details: Vec`** + (DB2) — *additive*; the existing `relationships: Vec` (names, + used by `completion.rs` `IdentSource::Relationships`) is unchanged. The + cache refresh is extended to populate the new field. + +**Render-time derived** (pure functions of `frame.area()` + cached +counts — *not* stored fields; this keeps the pure-render invariant and +makes the geometry logic unit-testable without a terminal): + +- Sidebar visibility — `(width > 90) || NavFocus is a sidebar panel` + (DB1). +- Input/hint row counts — a pure helper `panel_heights(area) -> + (input_rows, hint_rows)` (DA1/DA2), the same helper the renderer and + the Tier-1 tests call. +- Left-column split `rel_h = clamp(content_rows + 2, 5, ⌊col_h/2⌋)` + (DB4). +- Input width fed back to `update()` via `note_input_viewport` + (DA3), since `update()` cannot read `frame.area()`. + +Keybindings introduced/affected: + +| Key | Input mode | Navigation mode | +| --- | --- | --- | +| `Ctrl-O` | enter nav mode, focus Tables (peek-reveal) | advance focus (Tables → Relationships → exit) | +| `Up` / `Down` | history (unchanged) | line-scroll focused panel | +| `PageUp` / `PageDown` | scroll output (unchanged) | page-scroll focused panel | +| `Esc` | completion-undo (unchanged) | exit nav mode directly | +| printable / Enter / Tab / Backspace / Left / Right / Home / End | edit/submit input (unchanged) | inert | + +All nav keys are inert while a modal is open (the main handler is gated +on `!modal.is_some()`, `app.rs:919`). + +Renderer changes (`src/ui.rs`): geometry-driven hint/input height +(DA1/DA2), input window + cursor windowing (DA3) and 2-row soft-wrap +with row-1 indicator (DA4), the relationships panel + two-panel split +(DB2/DB4), the focus accent border and expand-on-focus `Clear` overlay +(DC2/DC3); `note_input_viewport` feedback added alongside the existing +`note_output_viewport`. + +## Genuine forks (escalated, resolved 2026-06-10) + +1. **Left column fate** — remove entirely vs narrow vs **keep + make + optional and richer** (chosen, user). → DB1/DB2. +2. **Focus/scroll model** — a navigation mode (chosen, user) vs + modeless modifier-key scroll vs deferring scroll. → DC1. +3. **Navigation shortcut** — **`Ctrl-O`** (chosen, user); `Ctrl-B` + *rejected on review* (it is the default tmux prefix → unreachable + inside tmux); Ctrl-T also viable; terminal-hijacked combos excluded. + → DC1. +4. **Expand-on-focus rendering** — **overlay with `Clear`** (chosen, + keeps the right panels unchanging) vs re-splitting the layout (would + reflow output). → DC2. +5. **Navigation-mode printables** — **ignore** (chosen, user) vs + drop-to-input-and-type. → DC4. +6. **Hint anti-jump** — **fix height to terminal geometry** (chosen) + vs damping/hysteresis vs always-reserve-max. → DA1. +7. **Height thresholds** — `H < 40` compact / `H ≥ 40` comfortable, with + 1/2 and 2/2 splits (chosen, user). → DA2. +8. **Visibility persistence** — **session-only** (chosen, user) vs + per-project in `project.yaml`. → DB1. +9. **Persistent show/hide toggle** — **deferred** (chosen, user): the + Ctrl-O peek covers #21's "keystroke to show and hide", so visibility + stays width-derived with no override. → DB1. +10. **Nav-mode Up/Down** — **line-scroll the focused panel** (chosen, + user) vs leaving scroll to PageUp/PageDown only. → DC3. +11. **Relationships placement** — **a separate sibling panel** (chosen, + user — *overrides S2*) vs nesting relations inside the tables list + per S2's documented extension model. → DB2/DB4. +12. **Hint-area toggle (S4)** — **no toggle** (chosen, user): the hint + panel is indispensable since completion moved into it; S4's stale + "keyboard-toggleable" claim (never implemented) is struck from + `requirements.md`. → Status (Requirements & issues touched). + +## Testing + +Tier-1 (`app.rs` pure `update()` unit tests), **Tier-2 (`insta` +snapshots, `src/snapshots/`) for the visual surfaces** — this change is +heavily render-side, so the geometry/format/overlay assertions belong in +snapshots, not only behavioural tests — and Tier-3 integration. +Test-first per CLAUDE.md. The geometry helpers (`panel_heights`, the +DB4 split, visibility) are **pure functions** exercised directly in +Tier-1 without a terminal. + +Phase A: +- **Hint anti-jump:** `panel_heights(area)` is invariant under changing + hint content at a fixed terminal size (assert it does not change as + `app.hint` varies); it *does* change across the `H < 40` / `H ≥ 40` + boundary and the width-< 54 boundary. +- **Height buckets:** compact → input 1 row / hint 2; comfortable → + input 2 / hint 2 (3 only when right-column inner < ~54); tiny-terminal + degradation honours output `Min(5)`. +- **Input horizontal scroll:** a line longer than the panel keeps the + cursor visible while moving Left/Right/Home/End; ADR-0027's 6-column + reserve is intact; no characters are lost (buffer = full string); + `input_scroll_offset` resets on submit / history-nav / clear. +- **Two-line input:** at `H ≥ 40` a line wrapping to two rows renders + both rows with correct cursor (row, col), the `[ERR]`/`[WRN]` + indicator on row 1's right edge (Tier-2 snapshot); a longer line + scrolls. + +Phase B: +- **Schema-cache enrichment:** after each schema mutation the cache + carries full `relationship_details` (name, parent/child, columns, + actions) *and* the existing `relationships` names; `completion.rs` + `IdentSource::Relationships` still resolves names (the additive field + did not disturb it). +- **Relationships panel render (Tier-2):** empty → a single `None` line + at the 5-row floor; the unfocused narrow format (name + arrow-break, + ellipsis past inner width); a compound endpoint pair arrow-breaks + correctly. +- **Two-panel split (DB4):** `rel_h = clamp(content_rows + 2, 5, + ⌊col_h/2⌋)` — 5 when empty; grows with content; capped at 50 %; + Tables takes the remainder; degrades sanely at `col_h < 10`. +- **Width-derived visibility:** width ≤ 90 hides, > 90 shows, recomputed + on resize (the peek interaction is covered under Phase C). + +Phase C: +- **Focus cycle:** `Ctrl-O` cycles Input → Tables → Relationships → + Input; `Esc` exits directly; a peek-revealed sidebar re-hides on exit; + a width-shown (> 90) sidebar stays shown on exit; `Ctrl-O` is inert + while a modal is open. +- **Expand overlay (Tier-2):** focusing widens to the expanded width; + the underlying output/input/hint state is unchanged across enter/exit + (no reflow); the focus accent border marks the focused panel. +- **Scroll rebind:** in nav mode Up/Down line-scroll and PageUp/PageDown + page-scroll the focused panel (clamped to its viewport); in input mode + Up/Down still drive history and PageUp/PageDown still scroll output + (no V4 regression); inert keys (printable/Enter/Tab/Backspace) do + nothing in nav mode. + +All tiers green, zero skips; clippy clean (nursery). + +## Out of scope + +- **True multi-line input (I1)** — Enter-inserts-newline / Ctrl-Enter- + submits over a multi-logical-line buffer. DA3/DA4 keep a single + logical line; this remains a separate, deferred feature. +- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred; + not touched here. +- **Cross-session sidebar persistence** — visibility is session-only + (DB1); persisting it would amend ADR-0015. +- **The output panel as a third navigation focus target** — navigation + mode cycles the two sidebar panels only; output keeps its input-mode + PageUp/PageDown scroll. +- **Relationship search / filtering within the panel** — the panel is a + scrollable list; no query box. +- **Relationship rename / edit from the panel** — it is read-only; + mutation stays with the DSL/SQL commands. +- **A persistent show/hide toggle / force-shown override** (DB1, + resolved deferred) — visibility is width-derived + Ctrl-O peek; a + pin/force override is an additive follow-up if ever needed. +- **A hint-area toggle (old S4 wording)** — not implemented today and + not wanted (the hint panel is indispensable since completion moved in; + fork §12). The stale "keyboard-toggleable" phrase is removed from S4. +- **In-app overlay / keystroke-annotation layer (Gitea #22)** — a + separate feature with its own ADR; DC2's `Clear` overlay is built to + coexist with it, not to provide it. + +## Accepted consequences + +- **Width-threshold discontinuity.** Because `Auto` visibility flips at + width 90 and the sidebar costs 28 columns, widening a terminal across + the boundary (89 → 91) makes the *output* narrower (≈ 89 → 63 inner) + as the sidebar appears. This is inherent to any width-gated auto-hide + and is accepted: 90 is the screencast width, real terminals sit well + to one side of it, and `Ctrl-O` peek covers the in-between case. The + `90` threshold is a tunable constant. diff --git a/docs/adr/README.md b/docs/adr/README.md index 99b3a78..7d92890 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -51,3 +51,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from

.(a, b) to .(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) - [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships +- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted 2026-06-10; implementation pending, phased A→B→C** (closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). `SchemaCache` is **extended additively** with `relationship_details: Vec` (the existing names-only `relationships: Vec` is kept for completion); the two left panels split vertically with the relationships panel floored at 5 rows ("None" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) diff --git a/docs/requirements.md b/docs/requirements.md index 625ce85..68fa1fe 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -73,24 +73,38 @@ since ADR-0027.) panel (right), input field (bottom). *(Verified 2026-06-07: `ui.rs:26-58` lays out a horizontal split — items panel left, right column subdivided into output - panel / input field / hint panel; rendered every frame.)* + panel / input field / hint panel; rendered every frame. + **ADR-0046 evolves this:** the left items region becomes + width-optional — hidden by default at ≤ 90 columns, peek-revealed + via `Ctrl-O` navigation mode — so the three-region layout is the + wide-terminal default, not an invariant.)* - [x] **S2** Items list shows tables and per-table indexes; designed to extend to additional element kinds (relations, views, etc.) without restructuring. *(ADR-0025: the items panel renders a nested list — each table with its index names indented beneath it. The nested - model is the extension point for future element kinds.)* + model is the extension point for future element kinds. + **ADR-0046 overrides the nesting approach for relationships:** + because relationships are cross-table rather than per-table, they + get their own sibling panel stacked below the tables list, not + nested items within it — user-confirmed 2026-06-10.)* - [/] **S3** Output panel renders a visualization of the currently selected item and supports multiple tabs. *(Partial, verified 2026-06-07: single-element structure visualisation renders (`output_render.rs:82-180`); **multiple tabs are not implemented** — the output is one line buffer, no tab abstraction. Same multi-tab gap as V2.)* -- [x] **S4** Hint area below the input field; keyboard-toggleable - for inspecting hints about the current input or last error. +- [x] **S4** Hint area below the input field, showing hints about + the current input or last error. *(Verified 2026-06-07: `ui.rs:1088-1110` `render_hint_panel` / `resolve_hint_lines` — a dynamic 1–`MAX_HINT_ROWS` panel below - the input showing ambient hints, candidates, or the last error.)* + the input showing ambient hints, candidates, or the last error. + **Correction (2026-06-10, ADR-0046):** the original wording said + the area was "keyboard-toggleable"; that was never implemented and + is deliberately dropped — the panel became indispensable once + completion moved into it (ADR-0022), so it is always on. ADR-0046 + replaces its content-driven height with a geometry-driven one to + stop the resize jump (#20); no toggle is added.)* - [x] **S5** Mode label and distinct border style on the input field communicate the current input mode at all times. *(Verified 2026-06-07: `ui.rs:896-934` `render_input_panel` — From 9f5f76b05dc7dfbff965b05a2d0b1dcea92e291e Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 17:08:25 +0000 Subject: [PATCH 11/25] fix(ui): geometry-fixed hint-panel height kills the typing jump (#20, ADR-0046 DA1/DA2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint panel's height was recomputed every frame from the wrapped hint content (1–3 rows), so it resized as the user typed and shoved the input/output panels — the flicker visible in the screencasts. Make the height a pure function of terminal geometry (new hint_rows), fixed between resizes: 2 content rows on compact (<40-row) terminals, 3 only on comfortable terminals narrow enough (<54 inner cols) to wrap the longest catalog hint past two lines, degrading toward 1 on tiny terminals to protect the output Min(5). resolve_hint_lines clamps to that fixed budget (long hints ellipsize; short ones leave rows blank). This reverses issue #12's shrink-to-content "reclaim"; its two tests are replaced by an anti-jump invariant plus geometry-helper and third-row tests. Two layout snapshots regenerated. --- ...hlighted_input_all_token_classes_dark.snap | 6 +- ...nd__ui__tests__one_shot_advanced_dark.snap | 3 +- src/ui.rs | 171 ++++++++++++++---- 3 files changed, 137 insertions(+), 43 deletions(-) diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap index 5d836be..fa8dbd9 100644 --- a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 1583 +assertion_line: 1976 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -16,14 +16,14 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ +│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ │ ││insert into T values (1, 'hi', null) --all-r │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ │ ││after `insert into T values (1, 'hi', null)`, │ -│ ││expected end of input — usage: insert into │ -│ ││

[([, ...])] [values] ([, ...])│ +│ ││expected end of input — usage: insert into… │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap index cb62313..82e536b 100644 --- a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 1992 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -16,13 +17,13 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Advanced: ───────────────────────────────────────╮ │ ││: sel │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ │ ││select │ +│ ││ │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · Backspace cancel one-shot · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index cd60b95..9057cef 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -493,12 +493,58 @@ fn wrap_lines(s: &str, width: usize) -> Vec { lines } -/// Maximum content rows the Hint panel may grow to before its last -/// visible row is ellipsis-truncated (issue #12). The panel starts -/// at one row and grows only as far as a wrapped hint needs, up to -/// this cap, reclaiming the space when the hint is short. +/// Absolute ceiling on Hint-panel content rows. Per ADR-0046 (DA1/DA2) +/// the panel's height is no longer driven by the hint *content* — it is +/// a pure function of terminal geometry (`hint_rows`), fixed between +/// resizes, so it cannot resize mid-typing and shove the input/output +/// panels (#20). This is the most rows `hint_rows` will ever allocate; +/// a hint longer than the allocation is ellipsis-truncated (issue #12's +/// overflow signalling is retained — only the *sizing* changed). const MAX_HINT_ROWS: usize = 3; +/// Terminal heights below this are "compact" (covers the ~25-row +/// screencasts); at or above it the terminal is "comfortable" and can +/// afford taller panels (ADR-0046 DA2). Tunable. +const COMFORTABLE_MIN_HEIGHT: u16 = 40; + +/// A 3rd hint row is only ever needed when the hint column's inner +/// width is narrow enough to wrap the longest catalog hint past two +/// lines; at or above this width two rows always suffice (ADR-0046 DA2, +/// measured against `src/friendly/strings/en-US.yaml`). +const HINT_THIRD_ROW_MAX_INNER: u16 = 54; + +/// Hint-panel content-row count as a pure function of the right +/// column's geometry (ADR-0046 DA1/DA2) — NOT of the hint text. That is +/// the whole point of #20: a height fixed per terminal size cannot jump +/// as the user types. Add 2 for the panel borders. +/// +/// - Compact height (`< COMFORTABLE_MIN_HEIGHT`): 2 rows. +/// - Comfortable height: 2 rows, or 3 when the column is narrow enough +/// (`inner < HINT_THIRD_ROW_MAX_INNER`) that the longest hint needs a +/// third line. +/// - Degradation: on a terminal too short to honour the output panel's +/// `Min(5)` plus the fixed input (3) and hint panels, the hint +/// shrinks toward 1 so the output keeps its floor. +/// +/// (DA4 will fold the input panel's height in here too; today the input +/// is a fixed 3 rows, reflected in the degradation arithmetic.) +const fn hint_rows(area: Rect) -> u16 { + let inner_w = area.width.saturating_sub(2); + let mut hint_c: u16 = if area.height < COMFORTABLE_MIN_HEIGHT { + 2 + } else if inner_w < HINT_THIRD_ROW_MAX_INNER { + MAX_HINT_ROWS as u16 + } else { + 2 + }; + // Honour the output panel's Min(5) first on a very short terminal: + // 5 (output) + 3 (input panel) + (hint_c + 2) must fit in the column. + while 5 + 3 + (hint_c + 2) > area.height && hint_c > 1 { + hint_c -= 1; + } + hint_c +} + /// Word-wrap `text` to `width`, then clamp to at most `max_rows` /// rows. If wrapping produced more rows than the cap, the last kept /// row is truncated to end with an ellipsis so the overflow is @@ -551,21 +597,21 @@ fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: R } fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { - // Resolve the hint first so the layout can size the Hint panel to - // the wrapped hint (issue #12): one content row by default, - // growing up to MAX_HINT_ROWS, reclaiming the space when short. - // The hint panel spans the full column width, so `area.width` is - // its width too. - let hint_lines = resolve_hint_lines(app, theme, area.width); - let hint_content = - (hint_lines.len().clamp(1, MAX_HINT_ROWS) as u16).saturating_add(2); + // ADR-0046 DA1/DA2: the Hint panel's height is a pure function of + // the column geometry, fixed between resizes — it no longer tracks + // the hint content, so typing cannot make it resize and shove the + // input/output panels (#20). The hint is then clamped to that fixed + // row budget. The hint panel spans the full column width, so + // `area.width` is its width too. + let hint_c = hint_rows(area); + let hint_lines = resolve_hint_lines(app, theme, area.width, hint_c as usize); let rows = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(5), // Output panel - Constraint::Length(3), // Input panel - Constraint::Length(hint_content), // Hint panel (dynamic) + Constraint::Min(5), // Output panel + Constraint::Length(3), // Input panel (DA4 will size this) + Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed) ]) .split(area); @@ -1041,11 +1087,11 @@ fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) { } /// Resolve the Hint panel body into its rendered lines, pre-wrapped -/// to the panel's inner width and clamped to `MAX_HINT_ROWS` with an -/// ellipsis backstop (issue #12). The returned line count is the -/// content-row count `render_right_column` allocates for, so the -/// panel grows for a long hint and reclaims the space for a short -/// one. +/// to the panel's inner width and clamped to `max_rows` with an +/// ellipsis backstop (issue #12). `max_rows` is the geometry-fixed row +/// budget chosen by `hint_rows` (ADR-0046 DA1/DA2); the panel does not +/// resize to the hint, so a short hint simply leaves the spare rows +/// blank and a long one is ellipsized at the budget. /// /// Resolution order for the body: /// 1. An explicit app-set hint (e.g. modal contexts) wins. @@ -1064,11 +1110,16 @@ fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) { /// mode-aware walker (ADR-0030/0031/0032); the walker now speaks /// SQL, so `ambient_hint_in_mode` surfaces SQL slot hints + /// completion candidates in advanced mode too. -fn resolve_hint_lines(app: &App, theme: &Theme, area_width: u16) -> Vec> { +fn resolve_hint_lines( + app: &App, + theme: &Theme, + area_width: u16, + max_rows: usize, +) -> Vec> { let inner = area_width.saturating_sub(2) as usize; let muted = Style::default().fg(theme.muted); let prose = |text: &str| { - clamp_wrapped(text, inner, MAX_HINT_ROWS) + clamp_wrapped(text, inner, max_rows) .into_iter() .map(|l| Line::from(Span::styled(l, muted))) .collect::>>() @@ -1734,9 +1785,10 @@ mod tests { #[test] fn long_prose_hint_shows_tail_across_multiple_rows() { - // Before the fix the Hint panel was a fixed 1 content row, - // so this hint's useful tail was clipped. Now the panel - // grows (to MAX_HINT_ROWS) so the tail is visible. + // A multi-row hint panel (here 2 rows at a compact 80×20) shows + // the hint's useful tail rather than clipping it to one row. + // (Pre-#12 the panel was a fixed 1 row; ADR-0046 keeps it + // multi-row but now sizes by geometry, not content.) let mut app = App::new(); app.hint = Some(LONG_HINT.to_string()); let theme = Theme::dark(); @@ -1748,37 +1800,78 @@ mod tests { } #[test] - fn short_hint_keeps_panel_at_one_content_row() { - // Reclaim: a short hint must not inflate the panel. - let mut app = App::new(); - app.hint = Some("Type a command".to_string()); + fn hint_panel_height_is_fixed_by_geometry_not_content() { + // ADR-0046 DA1/DA2 (#20): the panel no longer shrinks to a + // short hint (the issue #12 "reclaim" behaviour is deliberately + // reversed). At a compact (height < 40) terminal it is a fixed + // 2 content rows whether the hint is short or long, so it never + // resizes mid-typing and shoves the input/output panels. let theme = Theme::dark(); - let out = render_to_string(&mut app, &theme, 80, 20); + + let mut short = App::new(); + short.hint = Some("Type a command".to_string()); + let short_out = render_to_string(&mut short, &theme, 80, 20); assert!( - out.lines().any(|l| l.contains("Type a command")), - "short hint visible:\n{out}" + short_out.lines().any(|l| l.contains("Type a command")), + "short hint visible:\n{short_out}" + ); + + let mut long = App::new(); + long.hint = Some(LONG_HINT.to_string()); + let long_out = render_to_string(&mut long, &theme, 80, 20); + + assert_eq!( + hint_content_rows(&short_out), + 2, + "compact terminal fixes the hint at 2 rows:\n{short_out}" ); assert_eq!( - hint_content_rows(&out), - 1, - "short hint should occupy exactly one content row:\n{out}" + hint_content_rows(&short_out), + hint_content_rows(&long_out), + "the hint panel height must not differ between a short and a \ + long hint at the same terminal size (#20 anti-jump):\n\ + short:\n{short_out}\nlong:\n{long_out}" ); } #[test] - fn long_hint_grows_panel_but_caps_at_max_rows() { + fn narrow_comfortable_terminal_allows_a_third_hint_row() { + // ADR-0046 DA2: a 3rd hint row appears only on a comfortable + // (height ≥ 40) terminal whose hint column is narrow enough + // (inner < 54) to wrap the longest hint past two lines; the + // ellipsis backstop still caps it at MAX_HINT_ROWS. (At a + // compact height the same hint is held to 2 rows.) let mut app = App::new(); app.hint = Some(LONG_HINT.to_string()); let theme = Theme::dark(); - // Narrow width forces more wrapped lines than the cap. - let out = render_to_string(&mut app, &theme, 44, 20); + let out = render_to_string(&mut app, &theme, 44, 50); assert_eq!( hint_content_rows(&out), MAX_HINT_ROWS, - "long hint caps at MAX_HINT_ROWS content rows:\n{out}" + "narrow + tall terminal caps the long hint at MAX_HINT_ROWS \ + content rows:\n{out}" ); } + #[test] + fn hint_rows_is_geometry_driven() { + // ADR-0046 DA1/DA2: the pure helper that the renderer and these + // tests share. Height picks the bucket; width gates the 3rd row; + // a tiny terminal degrades toward 1 to protect output `Min(5)`. + let at = |w: u16, h: u16| hint_rows(Rect::new(0, 0, w, h)); + // Compact height → always 2, regardless of width. + assert_eq!(at(90, 25), 2); + assert_eq!(at(40, 25), 2); + // Comfortable + wide (inner ≥ 54) → 2. + assert_eq!(at(90, 45), 2); + assert_eq!(at(56, 45), 2); // inner == 54 is "wide enough" + // Comfortable + narrow (inner < 54) → 3. + assert_eq!(at(55, 45), 3); // inner == 53 + assert_eq!(at(50, 45), 3); + // Very short terminal degrades the hint to protect output Min(5). + assert_eq!(at(90, 11), 1); + } + /// Count the content rows inside the Hint panel of a rendered /// screen: the rows between the `╭ Hint …` title border and the /// next `╰…╯` bottom border. From e0b9470febc3453aa43e5d88f3d91065f19d4e0c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 18:08:45 +0000 Subject: [PATCH 12/25] feat(ui): horizontal-scroll long input so the cursor stays visible (#23, ADR-0046 DA3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A command longer than the input field used to clip silently at the right edge, hiding the cursor and the command tail. Now the single logical input line scrolls horizontally to keep the cursor in view, with muted `<` / `>` markers at the reserved edge columns signalling hidden content on either side. The offset is a pure function of (line length, cursor column, field width, previous offset) — input_scroll_offset — so the view only moves when the cursor would leave the window, and one column is held on each side for the markers so a marker never hides the cursor. The stored App::input_scroll_offset resets when the buffer is replaced wholesale (submit, history recall). The ADR-0027 6-column indicator reserve is preserved. Tests: pure-offset cases, tail-visible + head-visible render checks, and the reset-on-submit/history check. One layout snapshot now shows a long command's tail instead of its clipped head. --- src/app.rs | 30 ++++ ...hlighted_input_all_token_classes_dark.snap | 4 +- src/ui.rs | 170 ++++++++++++++++-- 3 files changed, 189 insertions(+), 15 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1e6ff1c..189b367 100644 --- a/src/app.rs +++ b/src/app.rs @@ -237,6 +237,11 @@ pub struct App { /// 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, pub output: VecDeque, pub hint: Option, /// The validity indicator's currently-visible verdict @@ -439,6 +444,7 @@ impl App { messages_verbosity: crate::friendly::Verbosity::default(), input: String::new(), input_cursor: 0, + input_scroll_offset: 0, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: None, input_indicator: None, @@ -1232,6 +1238,7 @@ impl App { self.history_cursor = Some(next_index); self.input = self.history[next_index].clone(); self.input_cursor = self.input.len(); + self.input_scroll_offset = 0; } /// Move forwards in history (towards newer entries; eventually @@ -1250,6 +1257,7 @@ impl App { 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) { @@ -1284,6 +1292,7 @@ impl App { 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(); @@ -5089,6 +5098,27 @@ mod tests { assert_eq!(app.input_cursor, 0); } + #[test] + fn input_scroll_offset_resets_when_the_buffer_is_replaced() { + // ADR-0046 DA3: the horizontal scroll offset must not leak from + // one command to the next. Submitting and recalling from history + // both replace the buffer wholesale, so both reset it. + let mut app = App::new(); + type_str(&mut app, "a long command line that would have scrolled"); + app.input_scroll_offset = 25; + submit(&mut app); + assert_eq!(app.input_scroll_offset, 0, "submit resets the input scroll"); + + // Recall the submitted line from history — also a reset. + type_str(&mut app, "another draft line entirely"); + app.input_scroll_offset = 25; + app.update(key(KeyCode::Up)); + assert_eq!( + app.input_scroll_offset, 0, + "history recall resets the input scroll" + ); + } + #[test] fn page_up_scrolls_output_back() { let mut app = App::new(); diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap index fa8dbd9..ec4f4bf 100644 --- a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 1976 +assertion_line: 2063 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -19,7 +19,7 @@ expression: snapshot │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││insert into T values (1, 'hi', null) --all-r │ +│ ││(line: &'a OutputLine, theme: &Theme) -> Line<'a> { ]) } -fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { +/// Horizontal scroll offset (in display columns) for a single-line +/// input that may overflow its `text_width`-column viewport (ADR-0046 +/// DA3). Keeps the cursor visible; when the line overflows, reserves one +/// column on each side for the `<` / `>` overflow markers so a marker +/// never hides the cursor. `cursor_col` is the column of the cursor +/// cell (which can be `line_cols`, one past the last char, when the +/// cursor sits at the end). Returns the new offset given the previous +/// one, so the view only scrolls when the cursor would leave the window. +const fn input_scroll_offset( + line_cols: usize, + cursor_col: usize, + text_width: usize, + offset: usize, +) -> usize { + // The line (including the cursor-at-end cell) fits: no scroll. + if line_cols < text_width || text_width == 0 { + return 0; + } + // Reserve a column each side for the `<` / `>` markers. + let eff = if text_width > 2 { text_width - 2 } else { 1 }; + let mut off = offset; + if cursor_col < off { + off = cursor_col; + } else if cursor_col >= off + eff { + off = cursor_col + 1 - eff; + } + // Never scroll past the point where the cursor-at-end cell shows. + let max_off = (line_cols + 1).saturating_sub(eff); + if off > max_off { + off = max_off; + } + off +} + +fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let effective = app.effective_mode(); let (border_color, mode_color, label) = match effective { EffectiveMode::Simple => ( @@ -1004,6 +1038,36 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec // walker with the active mode so SQL keywords / operators / // CASE / function calls colour correctly in Advanced mode. let cursor = app.input_cursor.min(app.input.len()); + + // ADR-0027 §4: the rightmost six columns of the input row + // (a five-column label plus a one-column gap) are reserved + // unconditionally, so the text area is always + // `inner.width - 6` and the typed command never shifts + // sideways when the validity indicator appears or hides. + let inner = block.inner(area); + let text_area = Rect { + width: inner.width.saturating_sub(6), + ..inner + }; + + // ADR-0046 DA3: horizontally scroll a long line so the cursor stays + // visible, rather than clipping it off the right edge silently. + // Computed (and the offset stored) *before* the highlight spans + // borrow `app.input`, so the `&mut app` write does not clash. + let line_cols = app.input.chars().count(); + let cursor_col = app.input[..cursor].chars().count(); + let tw = text_area.width as usize; + let offset = input_scroll_offset(line_cols, cursor_col, tw, app.input_scroll_offset); + app.input_scroll_offset = offset; + + frame.render_widget(block, area); + + // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both modes — + // `render_input_runs_in_mode` runs the highlight walker with the + // active mode so SQL keywords / operators / CASE / function calls + // colour correctly in Advanced mode. The cursor cell is rendered + // inverted (an empty-range run) so it is visible without a real + // terminal cursor. let mode_for_render = match effective { EffectiveMode::Simple => crate::mode::Mode::Simple, EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => { @@ -1018,18 +1082,41 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec mode_for_render, ); let spans = runs_to_spans(&app.input, &runs); - // ADR-0027 §4: the rightmost six columns of the input row - // (a five-column label plus a one-column gap) are reserved - // unconditionally, so the text area is always - // `inner.width - 6` and the typed command never shifts - // sideways when the validity indicator appears or hides. - let inner = block.inner(area); - frame.render_widget(block, area); - let text_area = Rect { - width: inner.width.saturating_sub(6), - ..inner - }; - frame.render_widget(Paragraph::new(Line::from(spans)), text_area); + + if line_cols > tw || offset > 0 { + // Overflow: reserve one column each side for `<` / `>` markers, + // render the windowed text between them, then draw the markers + // for whichever side still has hidden content. + let eff = if tw > 2 { tw - 2 } else { 1 }; + let mid = Rect { + x: text_area.x + 1, + width: eff as u16, + ..text_area + }; + frame.render_widget( + Paragraph::new(Line::from(spans)).scroll((0, offset as u16)), + mid, + ); + let marker = Style::default().fg(theme.muted); + if offset > 0 { + frame.render_widget( + Paragraph::new(Span::styled("<", marker)), + Rect { width: 1, ..text_area }, + ); + } + if offset + eff < line_cols { + frame.render_widget( + Paragraph::new(Span::styled(">", marker)), + Rect { + x: text_area.x + text_area.width.saturating_sub(1), + width: 1, + ..text_area + }, + ); + } + } else { + frame.render_widget(Paragraph::new(Line::from(spans)), text_area); + } if let Some(severity) = app.input_indicator { let (indicator_label, color) = match severity { @@ -1872,6 +1959,63 @@ mod tests { assert_eq!(at(90, 11), 1); } + // ---- ADR-0046 DA3: input horizontal scroll ------------------- + + #[test] + fn input_scroll_offset_keeps_the_cursor_in_view() { + // Fits (line shorter than the viewport) → never scrolls. + assert_eq!(input_scroll_offset(10, 10, 20, 0), 0); + assert_eq!(input_scroll_offset(19, 19, 20, 5), 0); + // Overflow, cursor at end → window shows the tail, reserving the + // two marker columns (eff = tw - 2 = 18): 50 + 1 - 18 = 33. + assert_eq!(input_scroll_offset(50, 50, 20, 0), 33); + // Cursor jumped left of the window → scroll left to the cursor. + assert_eq!(input_scroll_offset(50, 5, 20, 33), 5); + // Cursor still inside the current window → stable, no change. + assert_eq!(input_scroll_offset(50, 40, 20, 33), 33); + // Never scroll past the cursor-at-end cell, even from a stale + // over-large offset. + assert_eq!(input_scroll_offset(50, 50, 20, 999), 33); + } + + const LONG_INPUT: &str = + "select * from Customers where id = 12345 and name = 'Alice Wonderland'"; + + #[test] + fn long_input_scrolls_to_keep_the_tail_and_cursor_visible() { + // #23: a command longer than the input field must not clip the + // cursor off the right edge — it scrolls so the tail is visible, + // with a `<` marker for the hidden head. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = app.input.len(); + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 24); + assert!( + out.contains("'Alice Wonderland'"), + "the tail around the cursor must be visible:\n{out}" + ); + assert!( + !out.lines().any(|l| l.contains("select * from Customers where")), + "the head must be scrolled off:\n{out}" + ); + assert!(out.contains('<'), "a left scroll marker signals the hidden head:\n{out}"); + } + + #[test] + fn input_at_home_shows_the_head_with_a_right_marker() { + // With the cursor at Home, the head is visible and a `>` marker + // signals the hidden tail. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = 0; + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 24); + assert!(out.contains("select * from"), "head visible at Home:\n{out}"); + assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}"); + assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}"); + } + /// Count the content rows inside the Hint panel of a rendered /// screen: the rows between the `╭ Hint …` title border and the /// next `╰…╯` bottom border. From 41bae99ab35666e999e7f08d5782f176604db988 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 18:19:15 +0000 Subject: [PATCH 13/25] feat(ui): two-row input display on tall terminals (#23, ADR-0046 DA4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a comfortable terminal (height >= 40) the input panel shows two rows: the single logical command soft-wraps across them — the first row stops 6 columns short for the ADR-0027 validity indicator, the second uses the full width — so a medium command is fully visible without horizontal scrolling. A line longer than both rows still scrolls (DA3-style, one column each side reserved for < / > markers) to keep the cursor visible. hint_rows generalises to panel_heights(area) -> (input_rows, hint_rows): compact (<40) stays input 1 / hint 2; comfortable becomes input 2, degrading hint-then-input on tiny terminals to protect the output Min(5). render_input_panel splits into render_input_one_row (the existing DA3 path, unchanged) and render_input_two_rows, with a new expand_runs_to_cells helper placing styled cells across the rows. Tests: panel_heights geometry, two-row wrap, overflow-scroll, the indicator-stays-on-the-first-row case, and a two-row layout snapshot. Compact one-row snapshots are byte-identical (that path is untouched). --- ...ground__ui__tests__two_row_input_dark.snap | 49 +++ src/ui.rs | 387 ++++++++++++++---- 2 files changed, 354 insertions(+), 82 deletions(-) create mode 100644 src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap diff --git a/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap new file mode 100644 index 0000000..91f7646 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap @@ -0,0 +1,49 @@ +--- +source: src/ui.rs +assertion_line: 2210 +expression: snapshot +--- +╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ +│(none yet) ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ SIMPLE ──────────────────────────────────────────╮ +│ ││select * from Customers where id = 12345 and │ +│ ││ name = 'Alice Wonderland' │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ Hint ────────────────────────────────────────────╮ +│ ││`select` is SQL — available in advanced mode. │ +│ ││Switch with `mode advanced`, or prefix the line │ +│ ││with `:` to run it once. │ +╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index e076712..23ece83 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -513,24 +513,25 @@ const COMFORTABLE_MIN_HEIGHT: u16 = 40; /// measured against `src/friendly/strings/en-US.yaml`). const HINT_THIRD_ROW_MAX_INNER: u16 = 54; -/// Hint-panel content-row count as a pure function of the right -/// column's geometry (ADR-0046 DA1/DA2) — NOT of the hint text. That is -/// the whole point of #20: a height fixed per terminal size cannot jump -/// as the user types. Add 2 for the panel borders. +/// Input- and hint-panel content-row counts as a pure function of the +/// right column's geometry (ADR-0046 DA1/DA2/DA4) — NOT of the hint or +/// input text. That is the point of #20: heights fixed per terminal +/// size cannot jump as the user types. Returns `(input_rows, +/// hint_rows)`; add 2 to each for the panel borders. /// -/// - Compact height (`< COMFORTABLE_MIN_HEIGHT`): 2 rows. -/// - Comfortable height: 2 rows, or 3 when the column is narrow enough -/// (`inner < HINT_THIRD_ROW_MAX_INNER`) that the longest hint needs a -/// third line. +/// - Compact height (`< COMFORTABLE_MIN_HEIGHT`): input 1 row, hint 2. +/// - Comfortable height: input 2 rows (DA4 two-row display); hint 2, or +/// 3 when the column is narrow enough (`inner < +/// HINT_THIRD_ROW_MAX_INNER`) that the longest hint needs a third +/// line. /// - Degradation: on a terminal too short to honour the output panel's -/// `Min(5)` plus the fixed input (3) and hint panels, the hint -/// shrinks toward 1 so the output keeps its floor. -/// -/// (DA4 will fold the input panel's height in here too; today the input -/// is a fixed 3 rows, reflected in the degradation arithmetic.) -const fn hint_rows(area: Rect) -> u16 { +/// `Min(5)` plus both panels, the hint shrinks first, then the input, +/// so the output keeps its floor. +const fn panel_heights(area: Rect) -> (u16, u16) { + let comfortable = area.height >= COMFORTABLE_MIN_HEIGHT; let inner_w = area.width.saturating_sub(2); - let mut hint_c: u16 = if area.height < COMFORTABLE_MIN_HEIGHT { + let mut input_c: u16 = if comfortable { 2 } else { 1 }; + let mut hint_c: u16 = if !comfortable { 2 } else if inner_w < HINT_THIRD_ROW_MAX_INNER { MAX_HINT_ROWS as u16 @@ -538,11 +539,18 @@ const fn hint_rows(area: Rect) -> u16 { 2 }; // Honour the output panel's Min(5) first on a very short terminal: - // 5 (output) + 3 (input panel) + (hint_c + 2) must fit in the column. - while 5 + 3 + (hint_c + 2) > area.height && hint_c > 1 { - hint_c -= 1; + // 5 (output) + (input_c + 2) + (hint_c + 2) must fit in the column. + // Shrink the hint first, then the input. + while 5 + (input_c + 2) + (hint_c + 2) > area.height { + if hint_c > 1 { + hint_c -= 1; + } else if input_c > 1 { + input_c -= 1; + } else { + break; + } } - hint_c + (input_c, hint_c) } /// Word-wrap `text` to `width`, then clamp to at most `max_rows` @@ -603,15 +611,15 @@ fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area // input/output panels (#20). The hint is then clamped to that fixed // row budget. The hint panel spans the full column width, so // `area.width` is its width too. - let hint_c = hint_rows(area); + let (input_c, hint_c) = panel_heights(area); let hint_lines = resolve_hint_lines(app, theme, area.width, hint_c as usize); let rows = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(5), // Output panel - Constraint::Length(3), // Input panel (DA4 will size this) - Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed) + Constraint::Min(5), // Output panel + Constraint::Length(input_c + 2), // Input panel (1 row, or 2 when tall) + Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed) ]) .split(area); @@ -1029,51 +1037,82 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: .title(title) .style(Style::default().bg(theme.bg).fg(theme.fg)); - // Cursor block: render the character at the cursor position - // inverted so the cursor is visible without enabling a real - // terminal cursor. - // - // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both - // modes — `render_input_runs_in_mode` runs the highlight - // walker with the active mode so SQL keywords / operators / - // CASE / function calls colour correctly in Advanced mode. let cursor = app.input_cursor.min(app.input.len()); // ADR-0027 §4: the rightmost six columns of the input row // (a five-column label plus a one-column gap) are reserved - // unconditionally, so the text area is always - // `inner.width - 6` and the typed command never shifts - // sideways when the validity indicator appears or hides. + // unconditionally, so the first row's text area is always + // `inner.width - 6` and the typed command never shifts sideways + // when the validity indicator appears or hides. A two-row input + // (DA4) lets the *second* row use the full width. let inner = block.inner(area); let text_area = Rect { width: inner.width.saturating_sub(6), ..inner }; - // ADR-0046 DA3: horizontally scroll a long line so the cursor stays - // visible, rather than clipping it off the right edge silently. - // Computed (and the offset stored) *before* the highlight spans - // borrow `app.input`, so the `&mut app` write does not clash. - let line_cols = app.input.chars().count(); - let cursor_col = app.input[..cursor].chars().count(); - let tw = text_area.width as usize; - let offset = input_scroll_offset(line_cols, cursor_col, tw, app.input_scroll_offset); - app.input_scroll_offset = offset; - - frame.render_widget(block, area); - // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both modes — - // `render_input_runs_in_mode` runs the highlight walker with the - // active mode so SQL keywords / operators / CASE / function calls - // colour correctly in Advanced mode. The cursor cell is rendered - // inverted (an empty-range run) so it is visible without a real - // terminal cursor. + // the highlight walker runs with the active mode so SQL keywords / + // operators / CASE / function calls colour correctly. The cursor + // cell is rendered inverted (an empty-range run) so it is visible + // without a real terminal cursor. let mode_for_render = match effective { EffectiveMode::Simple => crate::mode::Mode::Simple, EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => { crate::mode::Mode::Advanced } }; + + frame.render_widget(block, area); + + // ADR-0046 DA3/DA4: render the single logical line across one row + // (compact terminals) or two (comfortable, height ≥ 40), scrolling + // horizontally in either case so the cursor stays visible. + if inner.height >= 2 { + render_input_two_rows(app, theme, frame, inner, text_area, cursor, mode_for_render); + } else { + render_input_one_row(app, theme, frame, text_area, cursor, mode_for_render); + } + + if let Some(severity) = app.input_indicator { + let (indicator_label, color) = match severity { + crate::dsl::walker::Severity::Error => ("[ERR]", theme.error), + crate::dsl::walker::Severity::Warning => ("[WRN]", theme.warning), + }; + let label_area = Rect { + x: inner.x + inner.width.saturating_sub(5), + width: 5.min(inner.width), + ..inner + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + indicator_label, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ))), + label_area, + ); + } +} + +/// One-row input rendering (ADR-0046 DA3): the single logical line is +/// horizontally scrolled so the cursor stays visible, with `<` / `>` +/// markers (muted) at the reserved edge columns signalling hidden +/// content. The offset is stored *before* the highlight spans borrow +/// `app.input`, so the `&mut app` write does not clash. +fn render_input_one_row( + app: &mut App, + theme: &Theme, + frame: &mut Frame<'_>, + text_area: Rect, + cursor: usize, + mode_for_render: crate::mode::Mode, +) { + let line_cols = app.input.chars().count(); + let cursor_col = app.input[..cursor].chars().count(); + let tw = text_area.width as usize; + let offset = input_scroll_offset(line_cols, cursor_col, tw, app.input_scroll_offset); + app.input_scroll_offset = offset; + let runs = crate::input_render::render_input_runs_in_mode( &app.input, cursor, @@ -1117,25 +1156,126 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: } else { frame.render_widget(Paragraph::new(Line::from(spans)), text_area); } +} - if let Some(severity) = app.input_indicator { - let (indicator_label, color) = match severity { - crate::dsl::walker::Severity::Error => ("[ERR]", theme.error), - crate::dsl::walker::Severity::Warning => ("[WRN]", theme.warning), - }; - let label_area = Rect { - x: inner.x + inner.width.saturating_sub(5), - width: 5.min(inner.width), - ..inner - }; +/// Two-row input rendering (ADR-0046 DA4): on a comfortable terminal the +/// single logical line is soft-wrapped across two visual rows — the +/// first row stops 6 columns short (the ADR-0027 indicator reserve), the +/// second uses the full width. When the line overflows both rows it +/// scrolls horizontally (one column each side reserved for `<` / `>` +/// markers) so the cursor stays visible. `text_area` is the first +/// (narrower) row; `inner` spans both rows. +fn render_input_two_rows( + app: &mut App, + theme: &Theme, + frame: &mut Frame<'_>, + inner: Rect, + text_area: Rect, + cursor: usize, + mode_for_render: crate::mode::Mode, +) { + let row0_w = text_area.width as usize; // first row reserves the indicator + let row1_w = inner.width as usize; // second row uses the full width + let capacity = row0_w + row1_w; + let line_cols = app.input.chars().count(); + let cursor_col = app.input[..cursor].chars().count(); + let offset = input_scroll_offset(line_cols, cursor_col, capacity, app.input_scroll_offset); + app.input_scroll_offset = offset; + + let runs = crate::input_render::render_input_runs_in_mode( + &app.input, + cursor, + theme, + &app.schema_cache, + mode_for_render, + ); + let cells = expand_runs_to_cells(&app.input, &runs); + let len = cells.len(); + + // Overflowing both rows reserves a marker column on each row's + // outer edge; otherwise both rows use their full text width. + let overflow = line_cols >= capacity; + let row0_text_w = if overflow { row0_w.saturating_sub(1) } else { row0_w }; + let row1_text_w = if overflow { row1_w.saturating_sub(1) } else { row1_w }; + let eff_cap = row0_text_w + row1_text_w; + + let start = offset.min(len); + let end = (offset + eff_cap).min(len); + let window = &cells[start..end]; + let split = row0_text_w.min(window.len()); + let to_line = |cs: &[(String, Style)]| { + Line::from( + cs.iter() + .map(|(s, st)| Span::styled(s.clone(), *st)) + .collect::>(), + ) + }; + + let row0_x = if overflow { text_area.x + 1 } else { text_area.x }; + frame.render_widget( + Paragraph::new(to_line(&window[..split])), + Rect { + x: row0_x, + y: inner.y, + width: row0_text_w as u16, + height: 1, + }, + ); + frame.render_widget( + Paragraph::new(to_line(&window[split..])), + Rect { + x: inner.x, + y: inner.y + 1, + width: row1_text_w as u16, + height: 1, + }, + ); + + let marker = Style::default().fg(theme.muted); + if overflow && offset > 0 { frame.render_widget( - Paragraph::new(Line::from(Span::styled( - indicator_label, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ))), - label_area, + Paragraph::new(Span::styled("<", marker)), + Rect { + x: text_area.x, + y: inner.y, + width: 1, + height: 1, + }, ); } + if overflow && end < len { + frame.render_widget( + Paragraph::new(Span::styled(">", marker)), + Rect { + x: inner.x + inner.width.saturating_sub(1), + y: inner.y + 1, + width: 1, + height: 1, + }, + ); + } +} + +/// Expand styled runs into one owned `(grapheme, style)` cell per +/// display column, including the inverted cursor cell (ADR-0046 DA4). +/// The two-row renderer places cells across two visual rows and so +/// needs them individually rather than as byte-range spans. +fn expand_runs_to_cells( + input: &str, + runs: &[crate::input_render::StyledRun], +) -> Vec<(String, Style)> { + let mut cells = Vec::new(); + for r in runs { + if r.byte_range.0 == r.byte_range.1 { + // Cursor sentinel (empty range) → inverted space cell. + cells.push((" ".to_string(), r.style)); + } else { + for ch in input[r.byte_range.0..r.byte_range.1].chars() { + cells.push((ch.to_string(), r.style)); + } + } + } + cells } /// Convert `StyledRun`s into ratatui `Span`s borrowed from @@ -1941,22 +2081,24 @@ mod tests { } #[test] - fn hint_rows_is_geometry_driven() { - // ADR-0046 DA1/DA2: the pure helper that the renderer and these - // tests share. Height picks the bucket; width gates the 3rd row; - // a tiny terminal degrades toward 1 to protect output `Min(5)`. - let at = |w: u16, h: u16| hint_rows(Rect::new(0, 0, w, h)); - // Compact height → always 2, regardless of width. - assert_eq!(at(90, 25), 2); - assert_eq!(at(40, 25), 2); - // Comfortable + wide (inner ≥ 54) → 2. - assert_eq!(at(90, 45), 2); - assert_eq!(at(56, 45), 2); // inner == 54 is "wide enough" - // Comfortable + narrow (inner < 54) → 3. - assert_eq!(at(55, 45), 3); // inner == 53 - assert_eq!(at(50, 45), 3); - // Very short terminal degrades the hint to protect output Min(5). - assert_eq!(at(90, 11), 1); + fn panel_heights_are_geometry_driven() { + // ADR-0046 DA1/DA2/DA4: the pure helper the renderer and these + // tests share. Height picks the bucket (input 1→2, hint floor); + // width gates the hint's 3rd row; a tiny terminal degrades hint + // then input to protect output `Min(5)`. + let at = |w: u16, h: u16| panel_heights(Rect::new(0, 0, w, h)); + // Compact height → input 1, hint 2, regardless of width. + assert_eq!(at(90, 25), (1, 2)); + assert_eq!(at(40, 25), (1, 2)); + // Comfortable height → input 2; hint 2 when wide (inner ≥ 54). + assert_eq!(at(90, 45), (2, 2)); + assert_eq!(at(56, 45), (2, 2)); // inner == 54 is "wide enough" + // Comfortable + narrow (inner < 54) → hint 3. + assert_eq!(at(55, 45), (2, 3)); // inner == 53 + assert_eq!(at(50, 45), (2, 3)); + // Very short terminal degrades hint first, then input, to keep + // the output panel's Min(5). + assert_eq!(at(90, 11), (1, 1)); } // ---- ADR-0046 DA3: input horizontal scroll ------------------- @@ -2016,6 +2158,87 @@ mod tests { assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}"); } + // ---- ADR-0046 DA4: two-row input on tall terminals ----------- + + #[test] + fn comfortable_terminal_wraps_input_across_two_rows() { + // On a tall (height ≥ 40) terminal the input shows two rows, so + // a medium command wraps instead of scrolling — the whole + // command is visible at once, head above tail. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = app.input.len(); + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 44); + let head = out + .lines() + .position(|l| l.contains("select * from Customers")); + let tail = out.lines().position(|l| l.contains("'Alice Wonderland'")); + assert!( + head.is_some() && tail.is_some(), + "both head and tail are visible across two rows:\n{out}" + ); + assert!( + tail.unwrap() > head.unwrap(), + "the tail wraps onto a row below the head:\n{out}" + ); + } + + #[test] + fn two_row_input_scrolls_when_it_overflows_both_rows() { + // A narrow-but-tall terminal: two rows, but the line is longer + // than both can hold, so it scrolls to keep the tail/cursor + // visible with a `<` marker for the hidden head. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = app.input.len(); + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 50, 44); + assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}"); + assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}"); + } + + #[test] + fn two_row_input_keeps_the_indicator_on_the_first_row() { + // ADR-0046 DA4 / ADR-0027: the [ERR]/[WRN] indicator stays + // anchored to the *first* input row (whose 6-column reserve it + // occupies); the wrapped tail on the second row is untouched. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = app.input.len(); + app.input_indicator = Some(crate::dsl::walker::Severity::Error); + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 44); + let err_line = out + .lines() + .position(|l| l.contains("[ERR]")) + .expect("indicator visible"); + let head_line = out + .lines() + .position(|l| l.contains("select * from Customers")) + .expect("head visible"); + assert_eq!( + err_line, head_line, + "the indicator shares the first input row with the head:\n{out}" + ); + assert!( + out.contains("'Alice Wonderland'"), + "the wrapped tail on the second row is intact:\n{out}" + ); + } + + #[test] + fn two_row_input_snapshot() { + // Locks the DA4 two-row layout: head on the first (indicator- + // reserved) row, tail on the full-width second row. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = app.input.len(); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 80, 44); + insta::assert_snapshot!("two_row_input_dark", snapshot); + } + /// Count the content rows inside the Hint panel of a rendered /// screen: the rows between the `╭ Hint …` title border and the /// next `╰…╯` bottom border. From 386627a262eb3abcfc28c2dc8204d38c4eafedbe Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 18:28:57 +0000 Subject: [PATCH 14/25] =?UTF-8?q?feat(ui):=20width-derived=20sidebar=20vis?= =?UTF-8?q?ibility=20=E2=80=94=20hide=20at=20<=3D90=20cols=20(#21,=20ADR-0?= =?UTF-8?q?046=20DB1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema sidebar (the left Tables column) is now shown only when the terminal is wider than 90 columns; at or below that it is hidden and the output/input panels span the full width. This reclaims horizontal space on narrow terminals — notably the 90-column screencasts, where the sidebar added little and cost the output panel its width. Visibility is a pure function of terminal width (sidebar_visible); the Ctrl-O peek-reveal lands in Phase C. render() splits the layout conditionally — full-width right column when the sidebar is hidden. Snapshots/tests that rendered at 80 wide now reflect the hidden sidebar; those whose intent IS the sidebar (populated_with_table, the items-panel and drop-table integration checks) render at 110 so the Tables list is actually exercised — one masked-intent integration check (matched "Customers" in the output, not the panel) is corrected the same way. New tests cover the width gate and the show/hide boundary. --- ...und__ui__tests__default_advanced_dark.snap | 46 +++++----- ...round__ui__tests__default_simple_dark.snap | 46 +++++----- ...ound__ui__tests__default_simple_light.snap | 46 +++++----- ...hlighted_input_all_token_classes_dark.snap | 46 +++++----- ...nd__ui__tests__one_shot_advanced_dark.snap | 46 +++++----- ..._ui__tests__populated_with_table_dark.snap | 48 +++++----- ...ui__tests__rebuild_confirm_modal_dark.snap | 32 +++---- ...ground__ui__tests__two_row_input_dark.snap | 90 +++++++++---------- src/ui.rs | 82 +++++++++++++---- tests/it/walking_skeleton.rs | 6 +- 10 files changed, 271 insertions(+), 217 deletions(-) diff --git a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap index 8bc79f9..46a7503 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 1540 +assertion_line: 2326 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ ADVANCED ────────────────────────────────────────╮ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` │ -│ ││for a list │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ ADVANCED ────────────────────────────────────────────────────────────────────╮ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · mode simple switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap index 51a442a..c49a798 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 1523 +assertion_line: 2309 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` │ -│ ││for a list │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap index 7253e06..4a41ef7 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 1531 +assertion_line: 2317 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` │ -│ ││for a list │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap index ec4f4bf..34a6f6a 100644 --- a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 2063 +assertion_line: 2369 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││ [([, ...])] [values] ([, ...]) │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap index 82e536b..5afcd79 100644 --- a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 1992 +assertion_line: 2385 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Advanced: ───────────────────────────────────────╮ -│ ││: sel │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││select │ -│ ││ │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Advanced: ───────────────────────────────────────────────────────────────────╮ +│: sel │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│select │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · Backspace cancel one-shot · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap index 4536ba5..aac58e2 100644 --- a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 1841 +assertion_line: 2589 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│Customers ││[simple] create table Customers ✓ │ -│Orders ││[system] Customers │ -│ ││[system] id serial [PK] │ -│ ││[system] Name text │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` │ -│ ││for a list │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ -Project: Term Planner +╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ +│Customers ││[simple] create table Customers ✓ │ +│Orders ││[system] Customers │ +│ ││[system] id serial [PK] │ +│ ││[system] Name text │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ │╰────────────────────────────────────────────────────────────────────────────────╯ +│ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────╮ +│ ││ │ +│ │╰────────────────────────────────────────────────────────────────────────────────╯ +│ │╭ Hint ──────────────────────────────────────────────────────────────────────────╮ +│ ││Type a command — press Tab for options, `help` for a list │ +│ ││ │ +╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap index 8b3a591..2b36e30 100644 --- a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap @@ -1,15 +1,15 @@ --- source: src/ui.rs -assertion_line: 1613 +assertion_line: 2399 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ │ ╭ Rebuild project ─────────────────────────────────────────╮ │ │ │ │ │ │ │3 tables and 47 rows will be reconstructed; the existing │ │ @@ -17,13 +17,13 @@ expression: snapshot │ │ │ │ │ │Continue? │ │ │ │ │ │ -│ │[Y] Yes [N] No Esc cancel │─────────╯ -│ ╰──────────────────────────────────────────────────────────╯─────────╮ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` │ -│ ││for a list │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╰─────────│[Y] Yes [N] No Esc cancel │─────────╯ +╭ SIMPLE ─╰──────────────────────────────────────────────────────────╯─────────╮ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap index 91f7646..9168780 100644 --- a/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap @@ -1,49 +1,49 @@ --- source: src/ui.rs -assertion_line: 2210 +assertion_line: 2265 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││select * from Customers where id = 12345 and │ -│ ││ name = 'Alice Wonderland' │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││`select` is SQL — available in advanced mode. │ -│ ││Switch with `mode advanced`, or prefix the line │ -│ ││with `:` to run it once. │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ -Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +╭ Output ──────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────╯ +╭ SIMPLE ──────────────────────────────────────────────────╮ +│select * from Customers where id = 12345 and name = │ +│'Alice Wonderland' │ +╰──────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────╮ +│`select` is SQL — available in advanced mode. Switch │ +│with `mode advanced`, or prefix the line with `:` to run… │ +╰──────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · diff --git a/src/ui.rs b/src/ui.rs index 23ece83..5642d48 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -23,6 +23,19 @@ use crate::theme::Theme; /// computation — without that feedback, scrolling past the top /// of the buffer would slide the visible window off and /// "eat" lines from the bottom on subsequent renders. +/// Minimum terminal width at which the schema sidebar (the left items +/// column) is shown by default (ADR-0046 DB1). At or below this the +/// sidebar is hidden so the output/input panels get the full width — +/// notably the 90-column screencasts. Tunable. +const SIDEBAR_MIN_WIDTH: u16 = 90; + +/// Whether the schema sidebar is visible — a pure function of terminal +/// width (ADR-0046 DB1). Phase C will also reveal it while a sidebar +/// panel is focused via the Ctrl-O peek. +const fn sidebar_visible(total_width: u16) -> bool { + total_width > SIDEBAR_MIN_WIDTH +} + pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { let area = frame.area(); paint_background(theme, frame, area); @@ -39,13 +52,19 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { ]) .split(area); - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(28), Constraint::Min(20)]) - .split(outer[0]); - - render_items_panel(app, theme, frame, columns[0]); - render_right_column(app, theme, frame, columns[1]); + // ADR-0046 DB1: on a wide terminal the schema sidebar takes a fixed + // left column; at or below SIDEBAR_MIN_WIDTH it is hidden and the + // right column spans the full width. + if sidebar_visible(area.width) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(28), Constraint::Min(20)]) + .split(outer[0]); + render_items_panel(app, theme, frame, columns[0]); + render_right_column(app, theme, frame, columns[1]); + } else { + render_right_column(app, theme, frame, outer[0]); + } render_project_label(app, theme, frame, outer[1]); render_status_bar(app, theme, frame, outer[2]); @@ -2132,7 +2151,8 @@ mod tests { app.input.push_str(LONG_INPUT); app.input_cursor = app.input.len(); let theme = Theme::dark(); - let out = render_to_string(&mut app, &theme, 80, 24); + // Narrow (sidebar hidden, DB1) so the line overflows the field. + let out = render_to_string(&mut app, &theme, 60, 24); assert!( out.contains("'Alice Wonderland'"), "the tail around the cursor must be visible:\n{out}" @@ -2152,7 +2172,8 @@ mod tests { app.input.push_str(LONG_INPUT); app.input_cursor = 0; let theme = Theme::dark(); - let out = render_to_string(&mut app, &theme, 80, 24); + // Narrow (sidebar hidden, DB1) so the line overflows the field. + let out = render_to_string(&mut app, &theme, 60, 24); assert!(out.contains("select * from"), "head visible at Home:\n{out}"); assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}"); assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}"); @@ -2169,7 +2190,9 @@ mod tests { app.input.push_str(LONG_INPUT); app.input_cursor = app.input.len(); let theme = Theme::dark(); - let out = render_to_string(&mut app, &theme, 80, 44); + // Narrow (sidebar hidden, DB1) so the line wraps across the two + // rows rather than fitting on the first. + let out = render_to_string(&mut app, &theme, 60, 44); let head = out .lines() .position(|l| l.contains("select * from Customers")); @@ -2193,7 +2216,8 @@ mod tests { app.input.push_str(LONG_INPUT); app.input_cursor = app.input.len(); let theme = Theme::dark(); - let out = render_to_string(&mut app, &theme, 50, 44); + // Very narrow + tall: two rows, but the line exceeds both. + let out = render_to_string(&mut app, &theme, 38, 44); assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}"); assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}"); } @@ -2208,7 +2232,8 @@ mod tests { app.input_cursor = app.input.len(); app.input_indicator = Some(crate::dsl::walker::Severity::Error); let theme = Theme::dark(); - let out = render_to_string(&mut app, &theme, 80, 44); + // Narrow (sidebar hidden, DB1) so the line wraps across two rows. + let out = render_to_string(&mut app, &theme, 60, 44); let err_line = out .lines() .position(|l| l.contains("[ERR]")) @@ -2235,7 +2260,8 @@ mod tests { app.input.push_str(LONG_INPUT); app.input_cursor = app.input.len(); let theme = Theme::dark(); - let snapshot = render_to_string(&mut app, &theme, 80, 44); + // Narrow (sidebar hidden, DB1) so the command wraps across rows. + let snapshot = render_to_string(&mut app, &theme, 60, 44); insta::assert_snapshot!("two_row_input_dark", snapshot); } @@ -2557,7 +2583,9 @@ mod tests { }); let theme = Theme::dark(); - let snapshot = render_to_string(&mut app, &theme, 80, 24); + // Width > SIDEBAR_MIN_WIDTH so the sidebar (tables list) shows + // alongside the output panel (DB1). + let snapshot = render_to_string(&mut app, &theme, 110, 24); insta::assert_snapshot!("populated_with_table_dark", snapshot); } @@ -2577,10 +2605,34 @@ mod tests { ], ); let theme = Theme::dark(); - let out = render_to_string(&mut app, &theme, 80, 24); + // Width > SIDEBAR_MIN_WIDTH so the sidebar is shown (DB1). + let out = render_to_string(&mut app, &theme, 110, 24); assert!(out.contains("Customers"), "table listed:\n{out}"); assert!(out.contains("Orders"), "table listed:\n{out}"); assert!(out.contains("idx_email"), "index nested in panel:\n{out}"); assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}"); } + + #[test] + fn sidebar_visible_is_width_gated() { + // ADR-0046 DB1: shown above SIDEBAR_MIN_WIDTH, hidden at/below. + assert!(!sidebar_visible(80)); + assert!(!sidebar_visible(90)); // the 90-col screencast: hidden + assert!(sidebar_visible(91)); + assert!(sidebar_visible(120)); + } + + #[test] + fn sidebar_hidden_at_or_below_threshold_width() { + // The Tables panel disappears at a narrow width (the output + // panel then spans the full width) and returns when wide. + let mut app = App::new(); + app.tables = vec!["Customers".to_string()]; + let theme = Theme::dark(); + let narrow = render_to_string(&mut app, &theme, 80, 24); + assert!(!narrow.contains("Tables"), "sidebar hidden at 80 wide:\n{narrow}"); + let wide = render_to_string(&mut app, &theme, 110, 24); + assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}"); + assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}"); + } } diff --git a/tests/it/walking_skeleton.rs b/tests/it/walking_skeleton.rs index 24646e7..98b8400 100644 --- a/tests/it/walking_skeleton.rs +++ b/tests/it/walking_skeleton.rs @@ -301,7 +301,8 @@ fn create_table_flow_updates_tables_list_and_structure_view() { assert_eq!(app.tables, vec!["Customers".to_string()]); assert_eq!(app.current_table, Some(desc)); - let rendered = rendered_text(&mut app, &theme, 80, 24); + // Width > 90 so the sidebar (items panel) is shown (ADR-0046 DB1). + let rendered = rendered_text(&mut app, &theme, 110, 24); assert!( rendered.contains("Customers"), "items panel should list Customers:\n{rendered}" @@ -397,7 +398,8 @@ fn drop_table_flow_clears_items_list() { assert!(app.tables.is_empty()); assert!(app.current_table.is_none()); - let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); + // Width > 90 so the (now-empty) sidebar is shown (ADR-0046 DB1). + let rendered = rendered_text(&mut app, &Theme::dark(), 110, 24); assert!(rendered.contains("(none yet)")); // ADR-0040: `drop table` is content-less, so the echo's ✓ marker // is the entire success signal (replacing `[ok] drop table …`). From 94825d0f36bf7f58bff667757a4915907167d596 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 18:44:27 +0000 Subject: [PATCH 15/25] feat(ui): relationships sidebar panel + schema data (#21, ADR-0046 DB2/DB4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The left column now stacks a Tables panel over a Relationships panel. Each relationship renders as three narrow lines — its name, then the endpoints broken at the arrow (Customers.id -> / indented Orders.customer_id) — ellipsized past the inner width. The panel is content-sized within [5 rows ("(none)" when empty), half the column]; the Tables panel keeps the rest (>=3 rows). Phase C adds focus+scroll for content beyond the cap (clipped for now). Data path: a new worker Request::ReadAllRelationships + Database::read_all_relationships returns full RelationshipSchema records; the runtime posts them via a RelationshipsRefreshed event alongside the schema-cache refresh, and the App holds them in a new `relationships` field. ADR deviation (recorded in ADR-0046 DB2 + index): DB2 specified this data on SchemaCache; it lives on the App instead — SchemaCache is walker/completion-facing and needs only relationship names (untouched), while the full records are UI-only, so App is the cleaner home and it avoids editing ~23 SchemaCache literals. No behavioural difference. Tests: panel-height bounds, the three-line render, the empty "(none)" case, a snapshot, read_all_relationships end-to-end (real DB via the m:n junction), and the event->field handler. --- ...ar-navigation-and-responsive-input-hint.md | 52 +++--- docs/adr/README.md | 2 +- src/app.rs | 34 ++++ src/db.rs | 18 ++ src/event.rs | 4 + src/friendly/keys.rs | 2 + src/friendly/strings/en-US.yaml | 2 + src/runtime.rs | 7 + ..._ui__tests__populated_with_table_dark.snap | 8 +- ...__ui__tests__relationships_panel_dark.snap | 29 ++++ src/ui.rs | 155 +++++++++++++++++- tests/it/m2n.rs | 37 +++++ 12 files changed, 324 insertions(+), 26 deletions(-) create mode 100644 src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md index dd90d36..aed79a2 100644 --- a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -211,17 +211,28 @@ holds two stacked panels (DB4) instead of one. The panel needs the full `RelationshipSchema` (name, parent/child tables, list-based columns, on-delete/on-update actions) that the `show -relationship` path already fetches. **`SchemaCache` is *extended*, not -retyped:** its existing `relationships: Vec` is left as-is -(`completion.rs` borrows it as `&Vec` via -`IdentSource::Relationships` for relationship-name completion, and -several test fixtures construct it) and a **new field -`relationship_details: Vec`** is added alongside, -populated by the same cache refresh that runs on schema change (the -refresh is taught to query relationship detail, which today it does not -— it only lists names). Retyping the existing field would break the -completion borrow and the fixtures; adding a field is the -zero-ripple change. +relationship` path already fetches. + +**Data home — `App`, not `SchemaCache` (revised at implementation, +2026-06-10).** The design first proposed an additive +`SchemaCache.relationship_details: Vec` field. +Implementation revised this to a **parallel `App.relationships: +Vec`** field for two reasons: (1) `SchemaCache` is +*walker/completion-facing* — it needs only relationship **names** +(unchanged in `SchemaCache.relationships`, still borrowed as +`&Vec` by `IdentSource::Relationships`); the full records are +**UI-only**, so `App` is the architecturally correct home, mirroring +`app.tables` (which the items panel already reads alongside the cache). +(2) Adding a field to `SchemaCache` would force edits to ~23 full +struct literals across the test suite, whereas `App` gains one field. +The /runda guard it answered — *don't break completion by retyping +`relationships`* — is fully honoured either way. Delivery: a worker +`Request::ReadAllRelationships` (→ `Database::read_all_relationships`, +returning `Vec` via the existing +`read_all_relationships(conn)`); the runtime's `refresh_schema_cache` +posts a new `AppEvent::RelationshipsRefreshed` alongside +`SchemaCacheRefreshed`, and the `App` stores it. No behavioural +difference from the original design. The panel has **two display states** keyed off focus (DC2): @@ -357,10 +368,11 @@ silently edit an invisible buffer. - Per-panel scroll offsets for the Tables and Relationships panels, each clamped against a renderer-reported viewport (DC3), mirroring `output_scroll` / `note_output_viewport`. -- `SchemaCache` gains **`relationship_details: Vec`** - (DB2) — *additive*; the existing `relationships: Vec` (names, - used by `completion.rs` `IdentSource::Relationships`) is unchanged. The - cache refresh is extended to populate the new field. +- **`App.relationships: Vec`** (DB2) — the full + relationship records for the sidebar panel, delivered by + `AppEvent::RelationshipsRefreshed` from the runtime's schema refresh. + `SchemaCache.relationships: Vec` (names, for completion) is + unchanged. (See DB2 for why this lives on `App`, not `SchemaCache`.) **Render-time derived** (pure functions of `frame.area()` + cached counts — *not* stored fields; this keeps the pure-render invariant and @@ -458,11 +470,11 @@ Phase A: scrolls. Phase B: -- **Schema-cache enrichment:** after each schema mutation the cache - carries full `relationship_details` (name, parent/child, columns, - actions) *and* the existing `relationships` names; `completion.rs` - `IdentSource::Relationships` still resolves names (the additive field - did not disturb it). +- **Relationship data path:** `Database::read_all_relationships` + returns full records through the worker thread (integration test, real + DB via an m:n junction); `AppEvent::RelationshipsRefreshed` populates + `App.relationships`; `SchemaCache.relationships` names are undisturbed + (completion still resolves them). - **Relationships panel render (Tier-2):** empty → a single `None` line at the 5-row floor; the unfocused narrow format (name + arrow-break, ellipsis past inner width); a compound endpoint pair arrow-breaks diff --git a/docs/adr/README.md b/docs/adr/README.md index 7d92890..728eca5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -51,4 +51,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from

.(a, b) to .(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) - [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships -- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted 2026-06-10; implementation pending, phased A→B→C** (closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). `SchemaCache` is **extended additively** with `relationship_details: Vec` (the existing names-only `relationships: Vec` is kept for completion); the two left panels split vertically with the relationships panel floored at 5 rows ("None" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) +- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted 2026-06-10; implementation pending, phased A→B→C** (closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) diff --git a/src/app.rs b/src/app.rs index 189b367..2e8e6f0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -252,6 +252,12 @@ pub struct App { /// [`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, @@ -449,6 +455,7 @@ impl App { hint: None, input_indicator: None, tables: Vec::new(), + relationships: Vec::new(), current_table: None, history: Vec::new(), history_cursor: None, @@ -721,6 +728,11 @@ impl App { self.schema_cache = cache; Vec::new() } + AppEvent::RelationshipsRefreshed(relationships) => { + trace!(count = relationships.len(), "relationships refreshed"); + self.relationships = relationships; + Vec::new() + } AppEvent::PersistenceFatal { operation, path, @@ -5098,6 +5110,28 @@ mod tests { assert_eq!(app.input_cursor, 0); } + #[test] + fn relationships_refreshed_event_updates_the_field() { + // ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the + // App stores it for the sidebar relationships panel to render. + use crate::dsl::action::ReferentialAction; + let mut app = App::new(); + assert!(app.relationships.is_empty()); + app.update(AppEvent::RelationshipsRefreshed(vec![ + crate::persistence::RelationshipSchema { + name: "Customers_Orders".to_string(), + parent_table: "Customers".to_string(), + parent_columns: vec!["id".to_string()], + child_table: "Orders".to_string(), + child_columns: vec!["customer_id".to_string()], + on_delete: ReferentialAction::Cascade, + on_update: ReferentialAction::NoAction, + }, + ])); + assert_eq!(app.relationships.len(), 1); + assert_eq!(app.relationships[0].name, "Customers_Orders"); + } + #[test] fn input_scroll_offset_resets_when_the_buffer_is_replaced() { // ADR-0046 DA3: the horizontal scroll offset must not leak from diff --git a/src/db.rs b/src/db.rs index 48a85e0..562b8d8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -837,6 +837,13 @@ enum Request { source: crate::dsl::grammar::IdentSource, reply: oneshot::Sender, DbError>>, }, + /// All relationships as full schema records (name, parent/child + /// tables + columns, referential actions). Feeds the sidebar + /// relationships panel (ADR-0046 DB2); the walker only needs the + /// names, which `ListNamesFor` already provides. + ReadAllRelationships { + reply: oneshot::Sender, DbError>>, + }, /// Restore the most recent undo snapshot (ADR-0006 Amendment 1). /// Replies with the metadata of the command that was undone, or /// `None` if there is nothing to undo (or undo is disabled). @@ -1787,6 +1794,14 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// All relationships as full schema records, for the sidebar + /// relationships panel (ADR-0046 DB2). + pub async fn read_all_relationships(&self) -> Result, DbError> { + let (reply, recv) = oneshot::channel(); + self.send(Request::ReadAllRelationships { reply }).await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + /// Restore the most recent undo snapshot (ADR-0006 Amendment 1). /// `Ok(Some(meta))` reports the command that was undone; /// `Ok(None)` means nothing to undo (or undo is disabled). @@ -2774,6 +2789,9 @@ fn handle_request( let result = do_list_names_for(conn, source); let _ = reply.send(result); } + Request::ReadAllRelationships { reply } => { + let _ = reply.send(read_all_relationships(conn)); + } // Undo/redo/peek/batch are intercepted in `worker_loop` (they // need `&mut conn` or persistent batch state) and never reach // here. Listed explicitly so a new variant still forces a diff --git a/src/event.rs b/src/event.rs index 293dacf..51b2be2 100644 --- a/src/event.rs +++ b/src/event.rs @@ -165,6 +165,10 @@ pub enum AppEvent { /// posts this alongside `TablesRefreshed` after project /// load and after every successful DDL. SchemaCacheRefreshed(crate::completion::SchemaCache), + /// Refreshed list of relationships as full schema records, for the + /// sidebar relationships panel (ADR-0046 DB2). Posted by the runtime + /// alongside `SchemaCacheRefreshed` after every schema refresh. + RelationshipsRefreshed(Vec), /// A persistence failure occurred (ADR-0015 §8). The /// application surfaces a fatal banner and exits cleanly so /// the message remains above the shell prompt. diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index f8b256c..389a22a 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -443,6 +443,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("panel.hint_empty", &[]), ("panel.hint_title", &[]), ("panel.output_title", &[]), + ("panel.relationships_empty", &[]), + ("panel.relationships_title", &[]), ("panel.tables_empty", &[]), ("panel.tables_title", &[]), ("status.no_project", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 2ebed9e..c38402b 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -853,6 +853,8 @@ status: panel: tables_title: "Tables" tables_empty: "(none yet)" + relationships_title: "Relationships" + relationships_empty: "(none)" hint_empty: "Type a command — press Tab for options, `help` for a list" # Panel titles for the output and hint panels (rendered inside # the rounded border, hence the leading/trailing space). diff --git a/src/runtime.rs b/src/runtime.rs index 488020c..65d5f62 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1079,6 +1079,13 @@ async fn refresh_schema_cache( ) { let cache = build_schema_cache(database).await; let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await; + // ADR-0046 DB2: full relationship records for the sidebar panel. + // Best-effort — a failed read leaves the panel empty. + if let Ok(relationships) = database.read_all_relationships().await { + let _ = event_tx + .send(AppEvent::RelationshipsRefreshed(relationships)) + .await; + } } /// Build a `SchemaCache` snapshot from the live database. diff --git a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap index aac58e2..012b295 100644 --- a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2589 +assertion_line: 2679 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ @@ -19,9 +19,9 @@ expression: snapshot │ ││ │ │ │╰────────────────────────────────────────────────────────────────────────────────╯ │ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────╮ -│ ││ │ -│ │╰────────────────────────────────────────────────────────────────────────────────╯ -│ │╭ Hint ──────────────────────────────────────────────────────────────────────────╮ +╰──────────────────────────╯│ │ +╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯ +│(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮ │ ││Type a command — press Tab for options, `help` for a list │ │ ││ │ ╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap new file mode 100644 index 0000000..3840ae1 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap @@ -0,0 +1,29 @@ +--- +source: src/ui.rs +assertion_line: 2789 +expression: snapshot +--- +╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ +│Customers ││ │ +│Orders ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ │╰────────────────────────────────────────────────────────────────────────────────╯ +│ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────╮ +╰──────────────────────────╯│ │ +╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯ +│Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮ +│ Customers.id -> ││Type a command — press Tab for options, `help` for a list │ +│ Orders.customer_id ││ │ +╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index 5642d48..cdd6adc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -36,6 +36,24 @@ const fn sidebar_visible(total_width: u16) -> bool { total_width > SIDEBAR_MIN_WIDTH } +/// Height (including borders) of the Relationships sub-panel within the +/// left column (ADR-0046 DB4): floored at 5 rows (so an empty panel +/// shows "(none)"), grown with `content_rows` up to half the column, +/// and never so tall that the Tables panel above drops below 3 rows. +const fn relationships_panel_height(col_h: u16, content_rows: u16) -> u16 { + let want = content_rows + 2; // + top/bottom borders + let mut h = if want < 5 { 5 } else { want }; + let cap = col_h / 2; // never more than half the column + if h > cap { + h = cap; + } + let max_h = col_h.saturating_sub(3); // leave Tables at least 3 rows + if h > max_h { + h = max_h; + } + h +} + pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { let area = frame.area(); paint_background(theme, frame, area); @@ -60,7 +78,17 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { .direction(Direction::Horizontal) .constraints([Constraint::Length(28), Constraint::Min(20)]) .split(outer[0]); - render_items_panel(app, theme, frame, columns[0]); + // ADR-0046 DB4: the sidebar stacks Tables (top) over a + // Relationships panel (bottom), the latter content-sized within + // [5 rows, half the column]. + let rel_content = (app.relationships.len() as u16).saturating_mul(3); + let rel_h = relationships_panel_height(columns[0].height, rel_content); + let sidebar = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(rel_h)]) + .split(columns[0]); + render_items_panel(app, theme, frame, sidebar[0]); + render_relationships_panel(app, theme, frame, sidebar[1]); render_right_column(app, theme, frame, columns[1]); } else { render_right_column(app, theme, frame, outer[0]); @@ -710,6 +738,68 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec frame.render_widget(paragraph, area); } +/// The Relationships sub-panel below the Tables list (ADR-0046 DB2). In +/// the narrow (unfocused) column each relationship is three lines — its +/// name, then the endpoints broken at the arrow to fit — every line +/// ellipsized past the inner width. Phase C adds focus + scroll for the +/// overflow; for now content beyond the panel's height is clipped. +fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled( + format!(" {} ", crate::t!("panel.relationships_title")), + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(theme.bg).fg(theme.fg)); + + if app.relationships.is_empty() { + let placeholder = Paragraph::new(Line::from(Span::styled( + crate::t!("panel.relationships_empty"), + Style::default() + .fg(theme.muted) + .add_modifier(Modifier::ITALIC), + ))) + .block(block); + frame.render_widget(placeholder, area); + return; + } + + let inner_w = area.width.saturating_sub(2) as usize; + let name_style = Style::default().fg(theme.fg); + let detail_style = Style::default().fg(theme.muted); + let mut lines: Vec> = Vec::new(); + for rel in &app.relationships { + lines.push(Line::from(Span::styled( + ellipsize(&rel.name, inner_w), + name_style, + ))); + let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", ")); + lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style))); + let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", ")); + lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style))); + } + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); +} + +/// Truncate `s` to `width` display columns, appending an ellipsis when +/// it overflows (ADR-0046 DB2). Assumes one column per character. +fn ellipsize(s: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + if s.chars().count() <= width { + return s.to_string(); + } + let mut out: String = s.chars().take(width.saturating_sub(1)).collect(); + out.push('…'); + out +} + fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) @@ -2635,4 +2725,67 @@ mod tests { assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}"); assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}"); } + + #[test] + fn relationships_panel_height_is_content_sized_within_bounds() { + // ADR-0046 DB4: empty floors at 5; grows with content; capped at + // half the column; leaves the Tables panel at least 3 rows. + assert_eq!(relationships_panel_height(40, 0), 5); // empty floor + assert_eq!(relationships_panel_height(40, 6), 8); // 6 content + borders + assert_eq!(relationships_panel_height(40, 30), 20); // capped at half + assert_eq!(relationships_panel_height(7, 0), 3); // tiny: Tables keeps 3 + } + + fn one_relationship() -> crate::persistence::RelationshipSchema { + use crate::dsl::action::ReferentialAction; + crate::persistence::RelationshipSchema { + name: "Customers_Orders".to_string(), + parent_table: "Customers".to_string(), + parent_columns: vec!["id".to_string()], + child_table: "Orders".to_string(), + child_columns: vec!["customer_id".to_string()], + on_delete: ReferentialAction::Cascade, + on_update: ReferentialAction::Cascade, + } + } + + #[test] + fn relationships_panel_lists_each_relationship() { + // ADR-0046 DB2: name, then endpoints broken at the arrow. + let mut app = App::new(); + app.tables = vec!["Customers".to_string(), "Orders".to_string()]; + app.relationships = vec![one_relationship()]; + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 110, 24); + assert!(out.contains("Relationships"), "panel title present:\n{out}"); + assert!(out.contains("Customers_Orders"), "relationship name:\n{out}"); + assert!( + out.lines().any(|l| l.contains("Customers.id ->")), + "parent endpoint, broken at the arrow:\n{out}" + ); + assert!( + out.lines().any(|l| l.contains("Orders.customer_id")), + "child endpoint, indented:\n{out}" + ); + } + + #[test] + fn empty_relationships_panel_shows_none() { + let mut app = App::new(); + app.tables = vec!["Customers".to_string()]; + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 110, 24); + assert!(out.contains("Relationships"), "panel title present:\n{out}"); + assert!(out.contains("(none)"), "empty placeholder:\n{out}"); + } + + #[test] + fn relationships_panel_snapshot() { + let mut app = App::new(); + app.tables = vec!["Customers".to_string(), "Orders".to_string()]; + app.relationships = vec![one_relationship()]; + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 110, 24); + insta::assert_snapshot!("relationships_panel_dark", snapshot); + } } diff --git a/tests/it/m2n.rs b/tests/it/m2n.rs index df0b6d1..972a8b3 100644 --- a/tests/it/m2n.rs +++ b/tests/it/m2n.rs @@ -416,3 +416,40 @@ fn pk_less_parent_is_refused() { assert!(format!("{err}").contains("no primary key"), "got: {err}"); }); } + +/// ADR-0046 DB2: the worker's `read_all_relationships` returns full +/// schema records (name, parent/child tables + columns, actions) — the +/// data source for the sidebar relationships panel. Exercised through +/// the real worker thread after an m:n junction creates two of them. +#[test] +fn read_all_relationships_returns_the_junction_relationships() { + let (_project, db, _dir) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .expect("create m:n"); + + let rels = db + .read_all_relationships() + .await + .expect("read all relationships"); + assert_eq!( + rels.len(), + 2, + "the m:n junction creates two relationships: {rels:?}" + ); + // Both have the junction (Students_Courses) as their child. + for r in &rels { + assert_eq!(r.child_table, "Students_Courses", "child is the junction: {r:?}"); + } + // One points back to each parent. + let parents: std::collections::BTreeSet<&str> = + rels.iter().map(|r| r.parent_table.as_str()).collect(); + assert!( + parents.contains("Students") && parents.contains("Courses"), + "one relationship per parent: {rels:?}" + ); + }); +} From c9da6ff7852faf5041ecd544dd19ae9387fabbe6 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 18:56:39 +0000 Subject: [PATCH 16/25] =?UTF-8?q?feat(ui):=20Ctrl-O=20navigation=20mode=20?= =?UTF-8?q?=E2=80=94=20peek=20+=20expand=20the=20schema=20sidebar=20(#21,?= =?UTF-8?q?=20ADR-0046=20DC1/DC2/DC4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl-O enters a navigation mode orthogonal to the input mode, cycling focus Input -> Tables -> Relationships -> Input (Esc exits). While a sidebar panel is focused the sidebar is revealed (a peek, even when width-hidden) and drawn as an expanded 45-column overlay over a cleared main area, so the schema is browsable without the cramped 26-column unfocused width. The focused panel gets an accent border. Routing lives in the main key handler after the modal gate, so Ctrl-O and nav keys are inert while a modal is open; in nav mode every non-navigation key (printable/Enter/Tab/Backspace/...) is inert because the input is occluded. Scroll keys (Up/Down, PageUp/PageDown) are reserved for DC3 (next). New App state: NavFocus { Input, SidebarTables, SidebarRelationships }. Tests: the focus cycle, Esc exit, input-keys-inert, overlay reveal + expansion, the accent-border style, and an overlay snapshot. --- src/app.rs | 108 +++++++++++++++ ...av_overlay_relationships_focused_dark.snap | 29 ++++ src/ui.rs | 124 +++++++++++++++++- 3 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap diff --git a/src/app.rs b/src/app.rs index 2e8e6f0..db13064 100644 --- a/src/app.rs +++ b/src/app.rs @@ -226,6 +226,28 @@ impl EffectiveMode { } } +/// 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, @@ -242,6 +264,10 @@ pub struct App { /// 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, /// The validity indicator's currently-visible verdict @@ -451,6 +477,7 @@ impl App { input: String::new(), input_cursor: 0, input_scroll_offset: 0, + nav_focus: NavFocus::Input, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: None, input_indicator: None, @@ -922,6 +949,36 @@ impl App { } } + /// 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 { + if key.code == KeyCode::Esc { + self.nav_exit(); + } + Vec::new() + } + 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 @@ -938,6 +995,20 @@ impl App { 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); + } + // 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 @@ -5132,6 +5203,43 @@ mod tests { assert_eq!(app.relationships[0].name, "Customers_Orders"); } + #[test] + fn ctrl_o_cycles_navigation_focus() { + // ADR-0046 DC1: Input → Tables → Relationships → Input. + let mut app = App::new(); + assert_eq!(app.nav_focus, NavFocus::Input); + let ctrl_o = || key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL); + app.update(ctrl_o()); + assert_eq!(app.nav_focus, NavFocus::SidebarTables); + app.update(ctrl_o()); + assert_eq!(app.nav_focus, NavFocus::SidebarRelationships); + app.update(ctrl_o()); + assert_eq!(app.nav_focus, NavFocus::Input); + } + + #[test] + fn esc_exits_navigation_mode() { + let mut app = App::new(); + app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL)); + assert!(app.nav_focus.in_sidebar()); + app.update(key(KeyCode::Esc)); + assert_eq!(app.nav_focus, NavFocus::Input); + } + + #[test] + fn navigation_mode_ignores_input_keys() { + // ADR-0046 DC4: the input is occluded; printable/Enter/Backspace + // are inert while a sidebar panel is focused. + let mut app = App::new(); + type_str(&mut app, "select"); + app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL)); + app.update(key(KeyCode::Char('x'))); + app.update(key(KeyCode::Backspace)); + let actions = app.update(key(KeyCode::Enter)); + assert_eq!(app.input, "select", "input untouched in navigation mode"); + assert!(actions.is_empty(), "Enter does not submit in navigation mode"); + } + #[test] fn input_scroll_offset_resets_when_the_buffer_is_replaced() { // ADR-0046 DA3: the horizontal scroll offset must not leak from diff --git a/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap new file mode 100644 index 0000000..2009a79 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap @@ -0,0 +1,29 @@ +--- +source: src/ui.rs +assertion_line: 2895 +expression: snapshot +--- +╭ Tables ───────────────────────────────────╮ +│Customers │ +│Orders │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────╯ +╭ Relationships ────────────────────────────╮ +│Customers_Orders │ +│ Customers.id -> │ +│ Orders.customer_id │ +╰───────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index cdd6adc..428deea 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -12,7 +12,9 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap}; -use crate::app::{App, EchoStatus, EffectiveMode, OutputKind, OutputLine, OutputStyleClass}; +use crate::app::{ + App, EchoStatus, EffectiveMode, NavFocus, OutputKind, OutputLine, OutputStyleClass, +}; use crate::mode::Mode; use crate::theme::Theme; @@ -96,6 +98,15 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { render_project_label(app, theme, frame, outer[1]); render_status_bar(app, theme, frame, outer[2]); + // ADR-0046 DC2: in navigation mode, draw the focused sidebar as an + // expanded overlay over the (unchanged) base render — revealing it + // if it was hidden (peek) and widening it for browsing. Drawn below + // the modal layer; a modal can't open in navigation mode, but if one + // is somehow up it still wins. + if app.nav_focus.in_sidebar() { + render_nav_sidebar_overlay(app, theme, frame, outer[0]); + } + // Modal dialogs (rebuild confirm, save-as prompt, load // picker, …) are drawn last so they overlay the rest of // the frame. @@ -104,6 +115,54 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { } } +/// Width (columns) of the navigation-mode expanded sidebar overlay +/// (ADR-0046 DC2). Wide enough that most relationship endpoints fit on +/// one line, turning horizontal truncation into vertical scrolling. +const NAV_EXPANDED_WIDTH: u16 = 45; + +/// Draw the focused sidebar, expanded, as an overlay over the left of +/// the main content area (ADR-0046 DC2/DC3). `Clear` + a background +/// repaint hide the base render underneath; the two panels keep the +/// DB4 split. The focused panel is accent-bordered (DC3). +fn render_nav_sidebar_overlay(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + // Clear the whole main content region (the "affected right-column + // region", DC2) and repaint the background, so the base output / + // input / hint do not show through half-occluded. They are restored + // unchanged on the next frame when navigation mode exits. + frame.render_widget(ratatui::widgets::Clear, area); + paint_background(theme, frame, area); + + // Paint the expanded sidebar over the left; the rest stays blank + // background while browsing. + let width = NAV_EXPANDED_WIDTH.min(area.width); + let sidebar = Rect { + x: area.x, + y: area.y, + width, + height: area.height, + }; + let rel_content = (app.relationships.len() as u16).saturating_mul(3); + let rel_h = relationships_panel_height(sidebar.height, rel_content); + let parts = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(rel_h)]) + .split(sidebar); + render_items_panel(app, theme, frame, parts[0]); + render_relationships_panel(app, theme, frame, parts[1]); +} + +/// Border style for a sidebar panel: an accented, bold border when it +/// holds navigation focus (ADR-0046 DC3), the muted border otherwise. +fn panel_border_style(theme: &Theme, focused: bool) -> Style { + if focused { + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.border) + } +} + fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { use crate::app::Modal; match modal { @@ -684,7 +743,10 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.border)) + .border_style(panel_border_style( + theme, + app.nav_focus == NavFocus::SidebarTables, + )) .title(Span::styled( format!(" {} ", crate::t!("panel.tables_title")), Style::default() @@ -747,7 +809,10 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.border)) + .border_style(panel_border_style( + theme, + app.nav_focus == NavFocus::SidebarRelationships, + )) .title(Span::styled( format!(" {} ", crate::t!("panel.relationships_title")), Style::default() @@ -2788,4 +2853,57 @@ mod tests { let snapshot = render_to_string(&mut app, &theme, 110, 24); insta::assert_snapshot!("relationships_panel_dark", snapshot); } + + #[test] + fn navigation_mode_reveals_and_expands_the_sidebar() { + // ADR-0046 DC1/DC2: at a narrow width the sidebar is hidden, but + // focusing a sidebar panel peeks it open as an expanded overlay. + let mut app = App::new(); + app.tables = vec!["Customers".to_string()]; + app.relationships = vec![one_relationship()]; + let theme = Theme::dark(); + let normal = render_to_string(&mut app, &theme, 80, 24); + assert!( + !normal.contains("Tables"), + "sidebar hidden at 80 wide when not browsing:\n{normal}" + ); + + app.nav_focus = NavFocus::SidebarTables; + let focused = render_to_string(&mut app, &theme, 80, 24); + assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}"); + assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}"); + assert!( + focused.contains("Relationships"), + "relationships panel in the overlay:\n{focused}" + ); + assert!( + focused.contains("Customers_Orders"), + "relationship listed in the overlay:\n{focused}" + ); + } + + #[test] + fn focused_panel_gets_an_accent_border() { + // ADR-0046 DC3: the focused sidebar panel is accent-bordered. + let theme = Theme::dark(); + let focused = panel_border_style(&theme, true); + let normal = panel_border_style(&theme, false); + assert_eq!(focused.fg, Some(theme.fg)); + assert!(focused.add_modifier.contains(Modifier::BOLD)); + assert_eq!(normal.fg, Some(theme.border)); + assert!(!normal.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn navigation_overlay_snapshot() { + // The expanded overlay over a full-width base (sidebar hidden at + // 80), with the Relationships panel focused (accent border). + let mut app = App::new(); + app.tables = vec!["Customers".to_string(), "Orders".to_string()]; + app.relationships = vec![one_relationship()]; + app.nav_focus = NavFocus::SidebarRelationships; + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 80, 24); + insta::assert_snapshot!("nav_overlay_relationships_focused_dark", snapshot); + } } From 22bec61d11a946b96b959a38c24a5f901133e73b Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 21:27:13 +0000 Subject: [PATCH 17/25] feat(ui): scroll the focused sidebar panel + refine the nav overlay (#21, ADR-0046 DC3 + DC2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DC3 — navigation-mode scroll: the focused Tables / Relationships panel scrolls (Up/Down by a line, PageUp/PageDown by its visible-row count). Per-panel offsets are clamped to content at render time, and the renderer reports each panel's visible rows for paging — mirroring the output panel's scroll. render_items_panel / render_relationships_panel take &mut App, count their rows, and store+clamp the offset before building the borrowing lines. DC2 refinement: the expand-on-focus overlay now clears only the sidebar strip plus a one-column gutter, leaving the base output/input/hint visible (unchanged) to the right rather than blanking the whole area — truer to "underneath keeps its layout", with the gutter keeping the cut-off edge clean (chosen after eyeballing both variants). ADR DC2 and the overlay snapshot updated to match. Tests: line/page scroll move only the focused panel and clamp; the render clamps a past-the-end offset so the last row stays visible. --- ...ar-navigation-and-responsive-input-hint.md | 25 ++++-- src/app.rs | 79 ++++++++++++++++- ...av_overlay_relationships_focused_dark.snap | 46 +++++----- src/ui.rs | 88 ++++++++++++++++--- 4 files changed, 191 insertions(+), 47 deletions(-) diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md index aed79a2..cdc4ac6 100644 --- a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -320,14 +320,23 @@ input-editing arms and routes keys per DC3/DC4. ### DC2 — Expand-on-focus as an overlay -A focused sidebar panel widens to **~40–50 columns**, rendered as an -**overlay**: the renderer draws a `Clear` over the affected right-column -region and paints the wide panel on top. The output/input/hint panels -underneath keep their exact layout — **unused and unchanging** while -browsing — and are restored by the next frame on exit. This is cheap -because the renderer is a pure function of `App` state: focus state -selects the width and the overlay path. (The input underneath is -inactive in navigation mode, so occluding it is harmless.) +A focused sidebar panel widens to a **45-column** overlay +(`NAV_EXPANDED_WIDTH`): the renderer `Clear`s the strip the expanded +panel occupies **plus a one-column gutter** (`NAV_OVERLAY_GUTTER`) and +paints the wide panel on top. The output/input/hint panels underneath +keep their exact layout — **unused and unchanging** while browsing, +**still visible to the right** of the overlay (just partially occluded +on the left) — and are restored fully by the next frame on exit. The +gutter keeps them from butting against the expanded panel's border so +the overlay edge reads cleanly. This is cheap because the renderer is a +pure function of `App` state: focus state selects the width and the +overlay path. (The input underneath is inactive in navigation mode.) + +*Implementation note (2026-06-10):* a full-area clear (hiding the base +panels entirely during browse) was tried first and rejected — leaving +the base visible is truer to "underneath keep their layout," and the +one-column gutter resolves the only wrinkle (the panels' left edges +being cut by the overlay reading harshly without separation). ### DC3 — Scroll the focused panel; focus highlight diff --git a/src/app.rs b/src/app.rs index db13064..69d3c2e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -323,6 +323,14 @@ pub struct App { /// 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, + /// 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 @@ -491,6 +499,10 @@ impl App { last_output_visible: 0, last_output_total_wrapped: 0, last_output_width: 80, + tables_scroll: 0, + relationships_scroll: 0, + last_tables_visible: 0, + last_relationships_visible: 0, project_name: None, project_is_temp: false, fatal_message: None, @@ -973,12 +985,40 @@ impl App { /// (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 { - if key.code == KeyCode::Esc { - self.nav_exit(); + 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 @@ -5240,6 +5280,41 @@ mod tests { assert!(actions.is_empty(), "Enter does not submit in navigation mode"); } + #[test] + fn nav_scroll_keys_move_only_the_focused_panel() { + // ADR-0046 DC3: Up/Down line-scroll the focused sidebar panel. + let mut app = App::new(); + app.nav_focus = NavFocus::SidebarTables; + app.update(key(KeyCode::Down)); + app.update(key(KeyCode::Down)); + assert_eq!(app.tables_scroll, 2); + assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls"); + app.update(key(KeyCode::Up)); + assert_eq!(app.tables_scroll, 1); + // Up saturates at the top. + app.update(key(KeyCode::Up)); + app.update(key(KeyCode::Up)); + assert_eq!(app.tables_scroll, 0); + // Switching focus moves the other panel instead. + app.nav_focus = NavFocus::SidebarRelationships; + app.update(key(KeyCode::Down)); + assert_eq!(app.relationships_scroll, 1); + assert_eq!(app.tables_scroll, 0); + } + + #[test] + fn nav_page_scroll_uses_the_panels_visible_rows() { + // ADR-0046 DC3: PageUp/PageDown move by the last reported + // visible-row count. + let mut app = App::new(); + app.nav_focus = NavFocus::SidebarTables; + app.last_tables_visible = 10; + app.update(key(KeyCode::PageDown)); + assert_eq!(app.tables_scroll, 10); + app.update(key(KeyCode::PageUp)); + assert_eq!(app.tables_scroll, 0); + } + #[test] fn input_scroll_offset_resets_when_the_buffer_is_replaced() { // ADR-0046 DA3: the horizontal scroll offset must not leak from diff --git a/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap index 2009a79..57a76f8 100644 --- a/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 2895 +assertion_line: 2967 expression: snapshot --- -╭ Tables ───────────────────────────────────╮ -│Customers │ -│Orders │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -╰───────────────────────────────────────────╯ -╭ Relationships ────────────────────────────╮ -│Customers_Orders │ -│ Customers.id -> │ -│ Orders.customer_id │ -╰───────────────────────────────────────────╯ +╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮ +│Customers │ │ +│Orders │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ ─────────────────────────────────╯ +│ │ ─────────────────────────────────╮ +╰───────────────────────────────────────────╯ │ +╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯ +│Customers_Orders │ ─────────────────────────────────╮ +│ Customers.id -> │ ` for a list │ +│ Orders.customer_id │ │ +╰───────────────────────────────────────────╯ ─────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index 428deea..187936e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -120,21 +120,32 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { /// one line, turning horizontal truncation into vertical scrolling. const NAV_EXPANDED_WIDTH: u16 = 45; +/// Blank columns cleared to the right of the expanded sidebar overlay +/// (ADR-0046 DC2), separating it from the base panels left visible +/// behind it so the overlay's right edge reads cleanly. +const NAV_OVERLAY_GUTTER: u16 = 1; + /// Draw the focused sidebar, expanded, as an overlay over the left of /// the main content area (ADR-0046 DC2/DC3). `Clear` + a background /// repaint hide the base render underneath; the two panels keep the /// DB4 split. The focused panel is accent-bordered (DC3). -fn render_nav_sidebar_overlay(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { - // Clear the whole main content region (the "affected right-column - // region", DC2) and repaint the background, so the base output / - // input / hint do not show through half-occluded. They are restored - // unchanged on the next frame when navigation mode exits. - frame.render_widget(ratatui::widgets::Clear, area); - paint_background(theme, frame, area); - - // Paint the expanded sidebar over the left; the rest stays blank - // background while browsing. +fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + // ADR-0046 DC2: clear the sidebar strip plus a one-column gutter and + // paint the expanded sidebar over it. The base output / input / hint + // stay visible to the right — unchanged, just partially occluded — + // and the gutter keeps them from butting against the sidebar's + // border. They are restored fully on the next frame when navigation + // mode exits. let width = NAV_EXPANDED_WIDTH.min(area.width); + let cleared_w = (width + NAV_OVERLAY_GUTTER).min(area.width); + let cleared = Rect { + x: area.x, + y: area.y, + width: cleared_w, + height: area.height, + }; + frame.render_widget(ratatui::widgets::Clear, cleared); + paint_background(theme, frame, cleared); let sidebar = Rect { x: area.x, y: area.y, @@ -739,7 +750,7 @@ fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) { frame.render_widget(block, area); } -fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { +fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -755,6 +766,13 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec )) .style(Style::default().bg(theme.bg).fg(theme.fg)); + // ADR-0046 DC3: clamp + store the scroll before the (borrowing) + // lines are built. Visible rows and the content total are computed + // by counting (one row per table + one per index), so the `&mut + // app` writes finish before the immutable line borrows begin. + let visible = area.height.saturating_sub(2) as usize; + app.last_tables_visible = visible; + if app.tables.is_empty() { let placeholder = Paragraph::new(Line::from(Span::styled( crate::t!("panel.tables_empty"), @@ -767,6 +785,14 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec return; } + let total: usize = app + .tables + .iter() + .map(|t| 1 + app.schema_cache.table_indexes.get(t).map_or(0, Vec::len)) + .sum(); + let offset = app.tables_scroll.min(total.saturating_sub(visible)); + app.tables_scroll = offset; + let highlight = app .current_table .as_ref() @@ -796,7 +822,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec } } } - let paragraph = Paragraph::new(lines).block(block); + let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0)); frame.render_widget(paragraph, area); } @@ -805,7 +831,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec /// name, then the endpoints broken at the arrow to fit — every line /// ellipsized past the inner width. Phase C adds focus + scroll for the /// overflow; for now content beyond the panel's height is clipped. -fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { +fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -821,6 +847,11 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a )) .style(Style::default().bg(theme.bg).fg(theme.fg)); + // ADR-0046 DC3: clamp + store the scroll before the borrowing lines + // (three rows per relationship). + let visible = area.height.saturating_sub(2) as usize; + app.last_relationships_visible = visible; + if app.relationships.is_empty() { let placeholder = Paragraph::new(Line::from(Span::styled( crate::t!("panel.relationships_empty"), @@ -833,6 +864,10 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a return; } + let total = app.relationships.len() * 3; + let offset = app.relationships_scroll.min(total.saturating_sub(visible)); + app.relationships_scroll = offset; + let inner_w = area.width.saturating_sub(2) as usize; let name_style = Style::default().fg(theme.fg); let detail_style = Style::default().fg(theme.muted); @@ -847,7 +882,7 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", ")); lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style))); } - let paragraph = Paragraph::new(lines).block(block); + let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0)); frame.render_widget(paragraph, area); } @@ -2894,6 +2929,31 @@ mod tests { assert!(!normal.add_modifier.contains(Modifier::BOLD)); } + #[test] + fn focused_tables_panel_scrolls_and_clamps() { + // ADR-0046 DC3: more tables than fit → a large offset reveals the + // lower entries and clamps so it can't scroll past the end. + let mut app = App::new(); + app.tables = (0..30).map(|i| format!("Table{i:02}")).collect(); + app.nav_focus = NavFocus::SidebarTables; + app.tables_scroll = 1000; // way past the end + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 24); + assert!( + out.contains("Table29"), + "the last table is visible after the offset clamps:\n{out}" + ); + assert!( + !out.contains("Table00"), + "the top tables are scrolled off:\n{out}" + ); + assert!( + app.tables_scroll < 30, + "the stored offset was clamped to the content: {}", + app.tables_scroll + ); + } + #[test] fn navigation_overlay_snapshot() { // The expanded overlay over a full-width base (sidebar hidden at From 18303784a0b5b1b6f0de65c13d129ec4ef75740e Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 21:30:00 +0000 Subject: [PATCH 18/25] docs: session handoff 63 + ADR-0046 marked implemented (#20/#21/#23) ADR-0046 status -> Accepted + implemented (8 commits 9f5f76b..22bec61); README index updated; the two draft-divergent decisions recorded inline (App.relationships not SchemaCache; nav overlay partial-clear + gutter). Handoff 63 covers the full UI build across Phases A/B/C; issues #20/#21/#23 closed on Gitea. --- ...ar-navigation-and-responsive-input-hint.md | 21 ++- docs/adr/README.md | 2 +- docs/handoff/20260610-handoff-63.md | 159 ++++++++++++++++++ 3 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 docs/handoff/20260610-handoff-63.md diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md index cdc4ac6..3305a11 100644 --- a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -2,12 +2,21 @@ ## Status -Accepted (2026-06-10); **implementation pending**, phased **A → B → C** -(see *Decision — phasing*). Closes Gitea issues **#20** (hint-panel -height jumpiness), **#21** (database-structure / left-column -improvements), and **#23** (long command input). Issue #23's own note -("handle after #21 is decided") is honoured: the input work is split so -the part that depends on the sidebar's width budget lands with it. +Accepted (2026-06-10); **implemented 2026-06-10**, phased **A → B → C** +(see *Decision — phasing*) across commits `9f5f76b` (DA1/DA2) · `e0b9470` +(DA3) · `41bae99` (DA4) · `386627a` (DB1) · `94825d0` (DB2/DB4) · +`c9da6ff` (DC1/DC2/DC4) · `22bec61` (DC3 + DC2 refinement). Closes Gitea +issues **#20** (hint-panel height jumpiness), **#21** (database-structure +/ left-column improvements), and **#23** (long command input). Issue +#23's own note ("handle after #21 is decided") is honoured: the input +work is split so the part that depends on the sidebar's width budget +lands with it. + +Two decisions landed differently from the original draft and are +recorded inline: the relationship data lives on **`App`, not +`SchemaCache`** (DB2), and the navigation overlay clears **only the +sidebar strip + a one-column gutter** (panels stay visible behind), +not the whole area (DC2). Builds on and honours: **ADR-0003** (the persistent Simple/Advanced mode model — navigation mode is *not* a third input mode, see DC1), diff --git a/docs/adr/README.md b/docs/adr/README.md index 728eca5..b5f2cba 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -51,4 +51,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from

.(a, b) to .(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) - [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships -- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted 2026-06-10; implementation pending, phased A→B→C** (closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) +- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) diff --git a/docs/handoff/20260610-handoff-63.md b/docs/handoff/20260610-handoff-63.md new file mode 100644 index 0000000..33cb862 --- /dev/null +++ b/docs/handoff/20260610-handoff-63.md @@ -0,0 +1,159 @@ +# Session handoff — 2026-06-10 (63) + +Sixty-third handover. Continues from handoff-62 (C4 m:n + #19). This +was a **single-ADR, full-build session**: it designed and implemented +**ADR-0046** end to end — the UI work for the three sidebar/input +issues **#20 / #21 / #23**, all now **closed** on Gitea. + +## §1. State at handoff + +**Branch:** `main`. **HEAD `22bec61`** (plus an uncommitted docs +finalization — ADR status flip + this handoff — see §7). Push is the +user's step. + +**Tests: 2263 passing / 0 failing / 1 ignored** (the 1 ignored is the +long-standing doc-test). **Clippy clean** (nursery, all targets). +26 +over the handoff-62 baseline of 2237. + +**This session's commits** (8 + the docs finalization): +``` +22bec61 feat(ui): scroll the focused sidebar panel + refine the nav overlay (DC3 + DC2) +c9da6ff feat(ui): Ctrl-O navigation mode — peek + expand the schema sidebar (DC1/DC2/DC4) +94825d0 feat(ui): relationships sidebar panel + schema data (DB2/DB4) +386627a feat(ui): width-derived sidebar visibility — hide at <=90 cols (DB1) +41bae99 feat(ui): two-row input display on tall terminals (DA4) +e0b9470 feat(ui): horizontal-scroll long input so the cursor stays visible (DA3) +9f5f76b fix(ui): geometry-fixed hint-panel height kills the typing jump (DA1/DA2) +93266b9 docs: ADR-0046 UI sidebar nav-mode + responsive input/hint +``` + +**Issues closed:** **#20**, **#21**, **#23** (all via ADR-0046). +**#22** (in-app overlay/keystroke-annotation layer for casts/lessons) +remains **open** — its own future ADR; adjacent but out of scope here. + +## §2. What shipped — ADR-0046 (read it; it's the source of truth) + +Three coupled UI issues, treated as one decision because they share the +terminal width/height budget. Phased A → B → C. + +**Phase A — input & hint (#20, #23).** +- **DA1/DA2 (#20):** the Hint panel height is now a pure function of + terminal geometry (`hint_rows` → later `panel_heights`), **fixed + between resizes** — it no longer resizes as you type, killing the + jump. Compact (`<40` rows) = hint 2; comfortable = hint 2, or 3 only + when the column is narrow (`inner < 54`). This **reverses issue #12's** + shrink-to-content sizing (its two tests were replaced by an anti-jump + invariant). Long hints ellipsize at the fixed budget. +- **DA3 (#23):** long input **horizontally scrolls** to keep the cursor + visible (`input_scroll_offset`, pure `input_scroll_offset()` helper), + with muted `<` / `>` edge markers; resets on submit / history. + Preserves ADR-0027's 6-col indicator reserve. +- **DA4 (#23):** on a tall terminal (`>=40` rows) the input renders + across **two visual rows** (soft-wrap of the single logical line; + indicator stays on row 1). Distinct from deferred multi-line **I1**; + `expand_runs_to_cells` is the substrate I1 should reuse. + +**Phase B — the sidebar (#21).** +- **DB1:** the left column is **width-optional** — `sidebar_visible() = + width > 90`, so it's hidden at <=90 (the 90-col screencasts) and the + right column takes the full width. (Resize a terminal below ~90 to see + it; in a normal wide terminal it shows, by design.) +- **DB2/DB4:** a **Relationships panel** stacks below Tables — each + relationship is name + endpoints broken at the arrow + (`Customers.id ->` / indented `Orders.customer_id`), ellipsized. The + panel floors at 5 rows ("(none)") and grows to a 50%-of-column cap + (`relationships_panel_height`). **Overrides S2** (relations were to be + *nested* in the tables list; a sibling panel is the honest shape). + +**Phase C — navigation mode (#21).** +- **DC1/DC4:** **`Ctrl-O`** enters a navigation mode orthogonal to the + input mode, cycling focus **Input → Tables → Relationships → Input** + (`Esc` exits). It's routed in the main key handler *after* the modal + gate, so it's inert behind a modal; in nav mode every non-nav key is + inert (the input is occluded). `NavFocus` enum on `App`. +- **DC2:** the focused panel is revealed (peek, even when width-hidden) + and drawn as a **45-col expanded overlay**, clearing the sidebar strip + **+ a one-column gutter** and leaving the base output/input/hint + visible (unchanged) to the right. *(Two variants were eyeballed; this + partial-clear-with-gutter was chosen over a full-area clear.)* +- **DC3:** the focused panel **scrolls** — Up/Down by a line, + PageUp/PageDown by its visible rows; per-panel offsets clamped to + content at render time, mirroring the output-panel scroll. + +**`Ctrl-B` was rejected** for nav mode (it's the tmux prefix → +unreachable inside tmux); `Ctrl-O` is multiplexer-safe. + +## §3. Two decisions that landed differently from the draft + +Both recorded inline in the ADR (and called out in its Status): +1. **Relationship data on `App`, not `SchemaCache`** (DB2). `SchemaCache` + is walker/completion-facing and needs only relationship *names* + (untouched); the full records are UI-only, so `App.relationships` + mirrors `app.tables`, and it avoided editing ~23 `SchemaCache` + literals. Delivered via `Database::read_all_relationships` (new worker + request) + `AppEvent::RelationshipsRefreshed` from the runtime's + schema refresh. +2. **Nav overlay = partial clear + 1-col gutter** (DC2), not a full-area + clear — truer to "underneath keeps its layout." + +## §4. Process notes + +- **The pre-build `/runda` pass earned its keep again.** It caught the + `Ctrl-B`/tmux collision, a `SchemaCache` retype that would have broken + completion, the 2-row-input/indicator placement, the missing nav-mode + key disposition + modal gate, and **three unreferenced requirements** + (S1 evolved, S2 overridden, S4 corrected — `requirements.md` updated). +- **Snapshot discipline:** DB1's 90-col threshold collided with the + test-suite's 80-col convention — many snapshots/tests were retuned + (sidebar-dependent ones now render at 110; input tests at narrower + widths so the now-wider input still overflows). One masked-intent + integration check (matched "Customers" in output, not the panel) was + corrected. +- Each phase was committed green + clippy-clean, user-confirmed message, + no AI attribution, append-only. + +## §5. Requirements / S-items touched + +`requirements.md` annotated: **S1** (three-region layout → left region +width-optional), **S2** (*overridden* — relationships get a sibling +panel, not nested), **S4** (*corrected* — the "keyboard-toggleable" hint +claim was never implemented and is struck; the panel is always-on). + +## §6. Remaining open landscape (unchanged from handoff-62, minus the closed items) + +1. **TT5 CI** — test infra solid (2263 green); no pipeline. Gitea Actions + / Woodpecker decision + likely a Linux-first scope call. +2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1** + app-commands; both net-new, own ADR. SD1 should seed m:n junctions. +3. **V2/S3 multi-result tabs** or **V4 journal** — larger output-model + redesign, design-first, own ADR. +4. **C3a modify relationship** — small follow-up (drop+add covers it). +5. **#22 overlay/annotation layer** — own ADR; shares the cast + overlay + space with DC2 (designed to coexist). +6. **Tutorial/lesson system** — acknowledged in scope; needs its own ADR. + +**ADR-0046 OOS (deferred):** true multi-line input (I1); readline +shortcuts (I1b); cross-session sidebar persistence; a persistent +show/hide toggle (Ctrl-O peek covers it); output as a third nav focus; +relationship search/edit from the panel; a hint-area toggle. + +## §7. How to take over + +1. Read handoffs 61 → 62 → 63, then `CLAUDE.md`, `docs/requirements.md`, + `docs/adr/README.md`, and **ADR-0046** (fully landed). +2. **Pending:** an uncommitted docs finalization (ADR-0046 status → + *implemented*; README index status; this handoff). Commit it as + `docs: session handoff 63` (the user confirms commit messages). +3. **For UI/layout work:** `src/ui.rs` now has `panel_heights`, + `sidebar_visible`, `relationships_panel_height`, the nav overlay, and + `&mut App` sidebar panels (they report scroll viewports). `App` gained + `input_scroll_offset`, `nav_focus`, `relationships`, and the + `tables_scroll` / `relationships_scroll` (+ `last_*_visible`) fields. +4. **For relationship/schema-cache work:** relationship *names* are in + `SchemaCache.relationships` (completion); full records are on + `App.relationships` via `Database::read_all_relationships` + + `RelationshipsRefreshed`. +5. **Eyeball reminder honoured:** the user reviewed the nav overlay + appearance and chose the partial-clear + 1-col-gutter variant. +6. Run a `cargo sweep` at some point — `target/` has grown across this + build-heavy session. From 638b4c96642696c445ba700883df0593dcd7e3e3 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 21:36:18 +0000 Subject: [PATCH 19/25] feat(app): vi-style j/k/g/G navigation in the load picker (#24) Add j (down), k (up), g (first) and G (last) to the load picker's list sub-mode, alongside the existing arrow keys. Typeable keys keep the picker drivable by autocast in the website's documentation casts, which cannot emit arrow keys. Footer hint left unchanged. --- src/app.rs | 18 ++++- tests/it/iteration4b_lifecycle_commands.rs | 85 ++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index 69d3c2e..6747172 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2488,20 +2488,34 @@ impl App { self.note_system(crate::t!("modal.load_cancelled")); Vec::new() } - KeyCode::Up => { + // `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() } - KeyCode::Down => { + // `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; diff --git a/tests/it/iteration4b_lifecycle_commands.rs b/tests/it/iteration4b_lifecycle_commands.rs index a323799..bcd15dd 100644 --- a/tests/it/iteration4b_lifecycle_commands.rs +++ b/tests/it/iteration4b_lifecycle_commands.rs @@ -252,6 +252,91 @@ fn load_picker_renders_entries_and_navigates() { assert_eq!(source, "load"); } +/// Build a load picker with three entries for the vi-navigation tests. +fn three_entry_picker() -> App { + let mut app = App::new(); + app.update(AppEvent::LoadPickerReady { + entries: vec![ + LoadPickerEntry { + display_name: "First".to_string(), + modified: "2026-05-07 14:30".to_string(), + path: std::path::PathBuf::from("/tmp/first"), + is_temp: true, + }, + LoadPickerEntry { + display_name: "Second".to_string(), + modified: "2026-05-05 10:00".to_string(), + path: std::path::PathBuf::from("/tmp/second"), + is_temp: false, + }, + LoadPickerEntry { + display_name: "Third".to_string(), + modified: "2026-05-01 09:15".to_string(), + path: std::path::PathBuf::from("/tmp/third"), + is_temp: false, + }, + ], + }); + app +} + +fn picker_selected(app: &App) -> usize { + let Some(Modal::LoadPicker(picker)) = app.modal.as_ref() else { + panic!("expected LoadPicker modal"); + }; + picker.selected +} + +#[test] +fn load_picker_jk_navigates_like_arrows() { + // vi-style j/k mirror Down/Up so autocast (typeable keys only) can drive + // the load picker in documentation casts (#24). + let mut app = three_entry_picker(); + assert_eq!(picker_selected(&app), 0); + + // j moves the selection down. + app.update(key(KeyCode::Char('j'))); + assert_eq!(picker_selected(&app), 1); + app.update(key(KeyCode::Char('j'))); + assert_eq!(picker_selected(&app), 2); + + // j at the last entry does not wrap past the end. + app.update(key(KeyCode::Char('j'))); + assert_eq!(picker_selected(&app), 2); + + // k moves the selection up. + app.update(key(KeyCode::Char('k'))); + assert_eq!(picker_selected(&app), 1); + + // k at the first entry does not wrap past the start. + app.update(key(KeyCode::Char('k'))); + assert_eq!(picker_selected(&app), 0); + app.update(key(KeyCode::Char('k'))); + assert_eq!(picker_selected(&app), 0); +} + +#[test] +fn load_picker_g_jumps_to_first_and_last() { + // g → first entry, G → last entry (vi convention). + let mut app = three_entry_picker(); + + // G jumps to the last entry from the top. + app.update(key(KeyCode::Char('G'))); + assert_eq!(picker_selected(&app), 2); + + // G again is idempotent at the end. + app.update(key(KeyCode::Char('G'))); + assert_eq!(picker_selected(&app), 2); + + // g jumps back to the first entry. + app.update(key(KeyCode::Char('g'))); + assert_eq!(picker_selected(&app), 0); + + // g again is idempotent at the start. + app.update(key(KeyCode::Char('g'))); + assert_eq!(picker_selected(&app), 0); +} + #[test] fn load_picker_b_enters_path_entry_submode() { let mut app = App::new(); From e9eb1b177ef1a3d2d9d46e6bbd39019dbf576f89 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 22:16:44 +0000 Subject: [PATCH 20/25] =?UTF-8?q?docs:=20ADR-0047=20=E2=80=94=20demonstrat?= =?UTF-8?q?ion=20overlay=20layer=20for=20casts/teaching=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepted decision record for the in-app demo overlay: a --demo mode that shows automatic keystroke badges ([TAB], [ENTER], …) and a stealth Ctrl+]-delimited step-caption buffer, both as floating black-on-yellow boxes at the output panel's bottom-right. All forks user-confirmed; a /runda pass contributed 10 tightening findings. Indexed in docs/adr/README.md. --- docs/adr/0047-demonstration-overlay-layer.md | 379 +++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 380 insertions(+) create mode 100644 docs/adr/0047-demonstration-overlay-layer.md diff --git a/docs/adr/0047-demonstration-overlay-layer.md b/docs/adr/0047-demonstration-overlay-layer.md new file mode 100644 index 0000000..9d4e78f --- /dev/null +++ b/docs/adr/0047-demonstration-overlay-layer.md @@ -0,0 +1,379 @@ +# ADR-0047: Demonstration overlay layer — keystroke badges and step captions + +## Status + +Accepted (2026-06-10). Addresses Gitea **#22**. Builds the in-app +overlay/annotation primitive that screencast recording (ADR-website-001 +§2, the `autocast` pipeline) and a future guided-lesson system both +need. Adjacent to ADR-0046 (the nav-mode sidebar overlay it must +coexist with) and unblocks the polished version of the assistive-editor +and projects (`#24`) casts. + +All primary forks and the visual placement were **user-confirmed** — +including the two follow-ups settled after the first draft: the trigger +key (**`Ctrl+]`**, the maximally-obscure valid single-byte code, over +`Ctrl+!` which autocast cannot send) and caption sizing (**wrap to 3 +lines**). A `/runda` pass over this ADR ran before implementation and +tightened it — its findings are folded in below (caption/badge +interception placement, in-capture key disposition, badge suppression +during capture, the timer arm-condition, box clamping, the new +output-rect field, and the control-code decode note). + +**Requirements traceability.** There is **no `requirements.md` item** +for this work — verified by sweep. It is tracked as Gitea issue **#22** +plus this ADR, consistent with the project's convention ("issues are +the lightweight tracker; ADRs are the decisions"). The website-side cast +scope lives in **ADR-website-001** (website branch), not main's +`requirements.md`. + +## Context + +The website records its demos as asciinema `.cast` files driven by +**`autocast`** (ADR-website-001 §2; STYLE.md): source step-lists in +`casts-src/casts.mjs` (`type` / `wait` / `key`) expand to **one key per +character, Enter = `^M`**, recorded against the real `target/debug` +binary. The hard constraint — the same one that drove `#24` — is that +autocast can only emit **typeable characters, ASCII control codes +(`^X`), and waits**. It cannot send arrow keys, function keys, or any +multi-byte escape sequence. + +Two classes of on-screen event are therefore invisible or +unexplained in a cast: + +1. **Keystrokes that cause a visible change but render no glyph of + their own** — most acutely **Tab** completion: the command line + jumps from `show data bo` to `show data books` with no sign a key + was pressed. Enter, the arrows, Ctrl-O, Esc are the same. +2. **Step structure / "what just happened" narration** — a cast is a + silent moving picture; there is no channel to separate or explain + steps for a visual learner. + +asciinema-player has no inline keystroke overlay, and a website-side +HTML overlay layered on the player would be fragile (its timings would +have to track every recording and break on each re-record). The robust +place to solve this is **in the app**: if the app renders the overlay, +the cast captures it natively and it re-records for free. The same +primitive is exactly what a future **guided-lesson** system needs to +point at things and narrate steps — so it is built as a general +capability, not a cast-only hack (the issue's "pays off twice"). It is +also directly useful for a **teacher demonstrating the playground +live** — pressing Tab in front of a class has the same +invisible-keystroke problem as a cast. + +The app's renderer is a pure function of `App` state and already draws +two kinds of last-pass overlay over the base render with **no layout +reflow**: modals and the ADR-0046 nav-mode sidebar overlay. The event +loop already **time-boxes `event_rx.recv()`** with a `tokio` timeout +(the ADR-0027 `IndicatorDebounce`) and redraws when the timer elapses — +the exact mechanism a self-expiring badge needs. These two existing +seams make the feature cheap. + +## Decisions + +### D1 — Activation: a `--demo` flag (+ env var), off by default + +Demonstration mode is entered with a **`--demo`** CLI flag, or +equivalently the **`RDBMS_PLAYGROUND_DEMO`** environment variable (set +truthy) — mirroring the existing `--log-file` / `RDBMS_PLAYGROUND_LOG_FILE` +pair. It combines freely with every other flag (`--resume`, `--mode`, a +positional path); there are no exclusions. + +When the flag is **off** (the default), none of the key handling or +rendering below is active and the app behaves exactly as today — **zero +footprint for real users** (R8). `autocast` sets the flag when it +launches the binary; a teacher sets it on their own command line. + +It is framed as a general **demonstration mode**, not "cast mode" — the +honest name for what it does, and it reads sensibly in `--help`. The +flag is documented in the CLI banner (one line); obscurity is not a +security property here and a harmless opt-in flag is better surfaced +than hidden. What stays "low-profile" (per #22) is that there is **no +normal in-app command** for it and **no persistent on-screen indicator** +(see D7) — so a cast frame is never polluted by a `[DEMO]` marker. + +### D2 — Keystroke badges: automatic, app-detected + +In demo mode the app shows a transient badge **automatically** whenever +it handles one of a curated set of *otherwise-invisible* keys. The cast +does nothing special — it presses the key it was going to press anyway, +and the badge re-records for free. The set: + +| Key | Badge | | Key | Badge | +|-----|-------|-|-----|-------| +| Tab | `[TAB]` | | Home | `[HOME]` | +| Shift-Tab | `[SHIFT-TAB]` | | End | `[END]` | +| Enter | `[ENTER]` | | PageUp | `[PGUP]` | +| Esc | `[ESC]` | | PageDown | `[PGDN]` | +| ↑ | `[UP]` | | Backspace | `[BKSP]` | +| ↓ | `[DOWN]` | | Delete | `[DEL]` | +| ← | `[LEFT]` | | Ctrl-O | `[CTRL-O]` | +| → | `[RIGHT]` | | | | + +Plain character keys render a glyph on the input line already, so they +produce **no** badge (that is the definition of the set — "invisible" +keys). The badge fires on **key press**, regardless of whether the key +had an effect in the current state (e.g. `↑` with no history still shows +`[UP]`): simpler, and the demo author controls the script. Badge text is +bracketed ASCII (`[TAB]`) per the user's preference — renders on every +terminal and is cast-safe, unlike the `⇥` glyph mocked earlier. + +The label mapping is a **pure function** `demo_badge_label(&KeyEvent) -> +Option<&'static str>` (Tier-1 testable). The badge **auto-expires on a +timer** (D5). + +### D3 — Step captions: a stealth, control-code-delimited input buffer + +Caption text must arrive through typeable input only (R4). A **single +toggle control code — `Ctrl+]`** (byte `0x1D`) drives a **stealth +capture buffer**. `Ctrl+]` was chosen (over the bound `Ctrl-O`/`Ctrl-C`, +the readline-reserve letters Ctrl-A/E/W/K/U, the tmux-prefix Ctrl-B, the +signal/flow-control codes Ctrl-\\=SIGQUIT and Ctrl-S/Q=XON/XOFF, and a +plain letter chord like Ctrl-G) because it is **maximally non-obvious** +— the classic telnet escape, almost never pressed by accident — while +still being a single ASCII control byte autocast can emit. It has **no +signal or flow-control baggage** and is **multiplexer-safe**. Note +collision risk is already near-zero in casts (a fresh `--demo` binary +sees only scripted keys); the obscurity mainly protects a live teacher +from a stray trigger. + +- First `Ctrl+]` **opens** capture. The command input line and the + output are untouched. If a caption is already visible, opening clears + it (you are starting a new annotation). +- Subsequent typed characters **accumulate into the caption buffer + invisibly** — they do **not** appear on the prompt, do not execute, + and do not enter history. **`Backspace`** deletes the last buffered + character. **Every other key while capturing — Enter, the arrows, + Tab, … — is inert** (swallowed, no effect): only typing and `Ctrl+]` + do anything. +- A second `Ctrl+]` **commits** the buffer to the caption box (D4). + An **empty** commit (toggle-toggle with nothing typed) clears any + visible caption — the author's explicit dismiss. + +Because nothing about the capture shows on the prompt, the caption +"pops" into its box with no ugly typing artifact, while the caption text +still lives **inline in `casts.mjs`** at the right spot (one source of +truth, no separate notes file to keep ordered). + +This is all keyboard-stream interpretation, so it lives in the +pure-sync `App::update()` (Tier-1 testable) and is **only active in demo +mode** — when off, `Ctrl+]` is inert and characters reach the input +line normally. + +**Placement in `handle_key` — before the modal gate (runda finding).** +The capture interception (`Ctrl+]` and the accumulating characters) +**and** the "clear a visible caption on the next keystroke" check sit at +the **very top of `handle_key`, before the `self.modal.is_some()` +gate** — *not* alongside the `Ctrl-O` handler, which is gated behind it. +This is required so captions can be authored **while a modal is open** — +specifically the load-picker, which is exactly the **projects / `#24` +cast** (annotating "press j/k to move", with an `[ENTER]` badge as the +selection is made). While capturing, the modal is frozen (capture +swallows keys), which is the intended behaviour. `App` exposes +`demo_capturing` so the runtime can read it (see D5). + +The control-code path is sound end to end, verified against our +crossterm (0.29, `event/sys/unix/parse.rs:110-113`): `autocast` emits +`^]` = byte `0x1D`; crossterm decodes `0x1C..=0x1F` → +`KeyCode::Char('4'..='7') + CONTROL`, so **`Ctrl+]` (0x1D) arrives in +the app as `KeyCode::Char('5') + KeyModifiers::CONTROL`** — that is the +pattern `handle_key` matches. (The same routine decodes `0x09`/`0x0D`/ +`0x1B`/`0x7F` to the named `Tab`/`Enter`/`Esc`/`Backspace` keys and +`0x01..=0x1A` to `Ctrl+a..z`, so `0x1D` is unambiguously distinct.) The +canonical way to produce it is **Ctrl+]**; on some layouts `Ctrl+5` +yields the same byte. *(This is the Unix/Linux decode path — the +cast-recording platform; crossterm's separate Windows backend would be +confirmed by test if live `--demo` on Windows is exercised.)* + +### D4 — Both overlays are floating boxes at the output panel's inner bottom-right + +The badge and the caption both render as **floating, bordered boxes +anchored to the inside of the output panel's bottom-right corner** +(inset one cell from the panel's inner edge), drawn **last over the base +render** — after modals, so they remain visible while the load-picker +(the `#24` cast) or any modal is up, and with **no layout reflow** +(consistent with the modal / nav-overlay precedent; honours R8). + +The top-level `render()` does not currently know the output-panel rect +(it is computed inside `render_right_column`), so a **new field +`App.last_output_area: Rect`** is set in `render_output_panel` and read +at the top-level draw pass to anchor the overlay — the established +"renderer reports metrics back to `App`" pattern (sibling to +`note_output_viewport`, which stores row counts, not a rect). + +When **both** are present, the **keystroke badge stacks directly above +the caption box** (both right-aligned in the corner) so they never +overlap. + +**Styling — deliberately high-contrast:** **black text on a yellow +background**, bold, bordered — hard to overlook, identical in light and +dark themes (a fixed high-contrast pair centralised in `theme.rs`, not +theme-derived). + +**Caption sizing (user-confirmed).** The caption is **word-wrapped to at +most 3 lines** within a content width of `min(40, output_inner_width − +6)` columns, ellipsised beyond the third line. So the caption box is +**3–5 rows** tall (1–3 text rows + 2 border), its height varying with +the text — a full sentence fits without forcing the author to split it, +while the 3-line cap keeps it corner-sized. The **badge** box is always +a single short token (`[TAB]` … `[SHIFT-TAB]`), so it is a fixed **3 +rows** (1 text + 2 border), narrow. + +**Clamping (runda finding).** Stacked, the two boxes are up to 8 rows +(5 caption + 3 badge); the output panel's inner height is only `Min(5)`, +so on a short terminal they could exceed it. Both boxes are **clamped to +the output inner area**: width to `output_inner_width`, the caption's +wrap-line count reduced so the stack fits the available height (badge +first — it is the time-critical one), and if a box cannot fit at all +(pathologically small terminal) it is **not drawn** rather than +overflowing. Cast geometry (90×26) leaves ~18 output rows — ample; the +guard only protects a real user who runs `--demo` in a tiny window. + +### D5 — Timing: badges expire on a ~1.5 s timer; captions persist until the next keystroke + +- **Keystroke badge:** auto-expires on a **time-based TTL**, default + **1.5 s** (a single tunable constant; the user asked for 1–2 s). This + matters for both media: in a cast the badge fades on its own so a + trailing `wait` ends on a clean frame, and in live teaching the badge + clears without the presenter needing another key. A new badge replaces + the current one and resets the timer. +- **Caption:** persists **until the next keystroke**, which clears it + and is then processed normally (or until an explicit empty-`Ctrl+]` + dismiss, or replacement by a new caption). + +The timer reuses the runtime's existing time-boxed-`recv` pattern: the +loop already arms a `tokio::time::timeout` for the indicator debounce. + +**Arm-condition extension (runda finding).** Today the loop time-boxes +`recv` **only while `debounce.is_armed()`** — and the debounce settles +at `INDICATOR_DEBOUNCE` (1000 ms), shorter than the 1500 ms badge TTL. +So the arm condition becomes **`debounce.is_armed() || badge_pending`**, +and the loop waits on the **nearest deadline** of the two. On a wake it +checks each independently: at the 1000 ms debounce deadline it settles +the indicator **without clearing the badge**; at the 1500 ms badge +deadline it clears the badge; then redraws. The pure "nearest deadline" +computation is unit-testable on its own. + +The badge's expiry `Instant` lives in the **runtime** (so `App` stays +clock-free and Tier-1-pure, exactly as `IndicatorDebounce` keeps timing +out of `App`); `App.demo_badge: Option<&'static str>` is the render +mirror, **set by the runtime** on a significant key and cleared on timer +elapse. + +**Badge suppression during capture (runda finding).** Because the +runtime sets badges from the raw key independently of `App` state, it +must **not** badge a key that capture swallowed (e.g. an inert `Tab` +while a caption is being typed would otherwise flash `[TAB]` for a +no-op). The runtime sets a badge only when **`!app.demo_capturing`**. + +**Ownership note.** `demo_caption` is mutated inside `update()` +(input-driven) while `demo_badge` is mutated by the runtime +(timing-driven). This split is deliberate and mirrors the existing +`input` (set in `update()`) vs `input_indicator` (set by the runtime +from `IndicatorDebounce`) pair — not an inconsistency. + +### D6 — Help text and strings + +The CLI banner (`help.cli_banner` in `en-US.yaml`) gains a `--demo` +line. User-facing wording obeys the house rules (no engine name, no +"DSL"): *"Demonstration mode — show on-screen badges for otherwise- +invisible keys (Tab, Enter, …) and enable scripted step captions, for +screencasts and live teaching."* Badge labels and the `[…]` chrome are +fixed ASCII, not localised; caption content is author-supplied free +text and likewise not a catalog string. + +## Alternatives considered + +- **Scripted badges** (cast pushes each badge explicitly) — rejected: + the app already sees every key, so automatic detection (D2) is more + robust and re-records for free. *(User-confirmed.)* +- **Typed hidden command for captions** (a secret-prefixed line) — + rejected: the command is briefly visible being typed on the prompt. + **Preloaded notes file + advance key** — rejected: a separate file + that must stay ordered/in-sync with the cast. The **stealth buffer** + (D3) is self-contained in the cast script *and* leaves the prompt + clean. *(User-confirmed.)* +- **Fixed-corner HUD badge / badge by the input line** — rejected in + favour of a floating box at the output panel's bottom-right; **top + banner / subtitle band** for captions — rejected in favour of the + matching floating box. *(User-confirmed via mockups.)* +- **A persistent `[DEMO]` status-bar marker** — rejected: it would show + in every cast frame. Demo mode is silent except for the transient + overlays (D7). +- **Caption persists for a fixed time** (instead of until next + keystroke) — noted as a one-constant change if the next-keystroke rule + proves too eager in practice; the user chose next-keystroke. +- **Trigger via `Ctrl+!` / a Kitty-protocol chord** — rejected: not + representable as a single ASCII control byte, so autocast cannot send + it (fails R4, the same wall as arrow keys). **`Ctrl+G` / a letter + chord** — workable but less non-obvious; the user chose the + maximally-obscure `Ctrl+]` from the valid single-byte set. +- **Single-line ellipsised caption** — rejected in favour of wrap-to-3- + lines so a full sentence fits. *(User-confirmed via mockups.)* + +## Consequences + +- A general overlay primitive exists that the cast pipeline uses now and + the guided-lesson system can reuse later (`App.demo_caption` and the + badge channel are the seam). +- `autocast` casts gain a real Tab-completion moment, key indicators for + the projects/`#24` round-trip, and step captions — all by adding + `key: ^G` / `type:` / `key: ^G` and ordinary keys to `casts.mjs`, then + re-running `pnpm casts`. No website-side overlay machinery. +- Teachers get the same affordance live via `--demo`. +- One new control-code binding (`Ctrl+]`) is consumed, but only inside + demo mode — normal sessions are unaffected, so it does not encroach on + the reserved readline chords (I1b). +- The renderer must expose the output-panel rect to `App`; a small, + pattern-consistent addition. + +## Scope / non-goals (OOS) + +- **Manual/scripted badge push** and **badges for plain character + keys** — out; badges are automatic over the fixed invisible-key set. +- **Configurable overlay styling or placement** — out; fixed + black-on-yellow boxes at the output panel's bottom-right. +- **The guided-lesson / tutorial system itself** — out (its own ADR); + this ADR only builds the primitive it will reuse. +- **Persisting demo mode across project switches / sessions** — out; + it is a per-run flag. +- **Localising caption content** — out; captions are author-supplied + free text. +- **Output-pane scroll-in-casts** and other arrow-only interactions — + out (separate enhancement; same autocast limitation as noted in #24). + +## Testing + +Per ADR-0008 and the project's test discipline (test-first; green, no +skips): + +- **Tier 1 (`app.rs` units):** `demo_badge_label` mapping over the full + key set **and** the no-badge cases (plain chars, `Ctrl+]`, `Ctrl-C`); + the stealth-caption state machine — open on `Ctrl+]`; characters + accumulate with the **input line unchanged**; `Backspace` edits the + buffer; **non-typing keys inert while capturing**; commit sets the + caption; empty commit clears; opening over a visible caption clears + it; next keystroke clears a visible caption **then processes + normally**; capture works **with a modal open** (caption set while the + load-picker modal is up, picker state untouched); the **demo-off + gate** (`Ctrl+]` inert, characters reach the input, no caption/badge + state ever set); the pure "nearest deadline" helper. +- **Tier 2 (insta snapshots, `ui.rs`):** badge box, caption box, both + stacked, at 90×26 in light and dark — verifying the bottom-right + anchor, the stack order, and the black-on-yellow styling; plus a + short-terminal case exercising the clamp/skip guard. +- **Tier 3 (integration):** `--demo` plumbs `app.demo_mode`; a + significant-key event sets `app.demo_badge` and a swallowed key during + capture does **not**; a `Ctrl+]` / type / `Ctrl+]` sequence sets + `app.demo_caption` without touching `app.input`. +- **CLI (`cli.rs` units):** `--demo` parses (mirrors `--no-undo`); the + `RDBMS_PLAYGROUND_DEMO` env fallback; default-off. + +**Honest coverage limit.** The badge **timer-expiry wiring** runs inside +`run_loop` (terminal + db worker), which is not unit-testable in +isolation; it is a thin reuse of the already-proven `IndicatorDebounce` +time-boxed-`recv` path. We therefore test the **pure pieces** +exhaustively (label fn, capture state machine, nearest-deadline helper) +and assert plumbing via Tier-3, rather than over-claiming an integration +test of the `tokio` timeout itself. + + diff --git a/docs/adr/README.md b/docs/adr/README.md index b5f2cba..9157c3f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,3 +52,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) - [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships - [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) +- [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10** (Gitea **#22**; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a `/runda` pass that produced 10 tightening findings). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating black-on-yellow boxes at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) From f879d54721d08ff5b510d2aa84a7704e3d2776f2 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 22:22:12 +0000 Subject: [PATCH 21/25] feat(cli): --demo demonstration mode flag + app plumbing (#22, ADR-0047 D1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `--demo` (and the RDBMS_PLAYGROUND_DEMO env fallback) to enter demonstration mode, threaded onto App.demo_mode through run_loop — mirrors the --no-undo plumbing. Off by default, zero footprint when off. The --help line advertises only the visible keystroke badges; the Ctrl+] caption trigger is kept low-profile (ADR-0047 D6 updated). Phase A of ADR-0047; behaviour (badges/captions) lands in B and C. --- docs/adr/0047-demonstration-overlay-layer.md | 13 +++- src/app.rs | 11 +++ src/cli.rs | 77 ++++++++++++++++++++ src/friendly/strings/en-US.yaml | 3 + src/runtime.rs | 7 ++ 5 files changed, 107 insertions(+), 4 deletions(-) diff --git a/docs/adr/0047-demonstration-overlay-layer.md b/docs/adr/0047-demonstration-overlay-layer.md index 9d4e78f..754bb03 100644 --- a/docs/adr/0047-demonstration-overlay-layer.md +++ b/docs/adr/0047-demonstration-overlay-layer.md @@ -276,10 +276,15 @@ from `IndicatorDebounce`) pair — not an inconsistency. The CLI banner (`help.cli_banner` in `en-US.yaml`) gains a `--demo` line. User-facing wording obeys the house rules (no engine name, no "DSL"): *"Demonstration mode — show on-screen badges for otherwise- -invisible keys (Tab, Enter, …) and enable scripted step captions, for -screencasts and live teaching."* Badge labels and the `[…]` chrome are -fixed ASCII, not localised; caption content is author-supplied free -text and likewise not a catalog string. +invisible keys (Tab, Enter, …), for screencasts and live teaching."* + +The help text **deliberately mentions only the visible badges, not the +`Ctrl+]` step-caption mechanism** (user decision): the caption trigger +stays low-profile, true to #22's "secret trigger" framing — a cast +author or lesson script knows it; a casual `--help` reader is not +pointed at it. Badge labels and the `[…]` chrome are fixed ASCII, not +localised; caption content is author-supplied free text and likewise +not a catalog string. ## Alternatives considered diff --git a/src/app.rs b/src/app.rs index 6747172..fd1f2eb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -368,6 +368,14 @@ pub struct App { /// 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 (set by the runtime, see + /// `demo_badge`) and `Ctrl+]` drives the stealth step-caption + /// buffer (`demo_caption` / `demo_capturing`). + pub demo_mode: bool, /// 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 @@ -512,6 +520,9 @@ impl App { // 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, pending_echo: None, } } diff --git a/src/cli.rs b/src/cli.rs index 29c4311..57f9a23 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -42,6 +42,13 @@ pub struct Args { /// mode > the default (`simple`). Combines with `--resume` and /// a positional path; on collision the flag wins. pub mode: Option, + /// `--demo` (or `RDBMS_PLAYGROUND_DEMO` set truthy): enter + /// **demonstration mode** (ADR-0047, issue #22). Off by default, + /// zero footprint when off. When on, the app shows transient + /// on-screen badges for otherwise-invisible keys (Tab, Enter, …) + /// and enables the `Ctrl+]` stealth step-caption buffer — for + /// screencasts and live teaching. The flag wins over the env var. + pub demo: bool, } /// Usage banner printed by `--help`. @@ -124,6 +131,12 @@ impl Args { let mut help = false; let mut no_undo = false; let mut mode: Option = None; + // Demonstration mode (ADR-0047): the env var is the default, + // the `--demo` flag overrides it to on. Mirrors the + // env-then-flag layering used for the log file above. + let mut demo = env::var("RDBMS_PLAYGROUND_DEMO") + .ok() + .is_some_and(|v| demo_value_is_truthy(&v)); let mut iter = iter.into_iter().map(Into::into); while let Some(arg) = iter.next() { match arg.as_str() { @@ -136,6 +149,9 @@ impl Args { "--no-undo" => { no_undo = true; } + "--demo" => { + demo = true; + } "--theme" => { let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?; theme = match value.as_str() { @@ -194,10 +210,25 @@ impl Args { help, no_undo, mode, + demo, }) } } +/// Whether a `RDBMS_PLAYGROUND_DEMO` value enables demo mode. +/// +/// Truthy for any value except the conventional "off" set +/// (`0`/`false`/`no`/`off`, case-insensitively, and the empty +/// string). So `RDBMS_PLAYGROUND_DEMO=1` and `=true` enable, while +/// `=0` / `=false` explicitly disable — letting a value of `0` turn +/// it off even if something upstream exported the variable. +fn demo_value_is_truthy(value: &str) -> bool { + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "0" | "false" | "no" | "off" + ) +} + fn default_theme() -> Theme { // NFR-7: support both backgrounds. For the walking skeleton we // honour an explicit `--theme` flag and the COLORFGBG env var @@ -391,6 +422,52 @@ mod tests { ); } + // ---- ADR-0047 (issue #22): --demo demonstration mode ---- + + #[test] + fn demo_flag_parses() { + let args = Args::parse(["--demo"]).unwrap(); + assert!(args.demo); + } + + #[test] + fn demo_defaults_off() { + // Absent `--demo` (and absent env var in the test runner), + // demo mode is off — zero footprint for real users. + let args = Args::parse(std::iter::empty::<&str>()).unwrap(); + assert!(!args.demo, "demo is off unless --demo or the env var is given"); + } + + #[test] + fn demo_flag_coexists_with_positional_path() { + let args = Args::parse(["--demo", "/home/me/MyProject"]).unwrap(); + assert!(args.demo); + assert_eq!( + args.project_path.as_deref(), + Some(std::path::Path::new("/home/me/MyProject")) + ); + } + + #[test] + fn demo_flag_combines_with_resume_and_mode() { + let args = Args::parse(["--resume", "--demo", "--mode", "advanced"]).unwrap(); + assert!(args.demo); + assert!(args.resume); + assert_eq!(args.mode, Some(Mode::Advanced)); + } + + #[test] + fn demo_env_value_truthiness() { + // Enabling values. + for v in ["1", "true", "TRUE", "yes", "on", "anything", " 1 "] { + assert!(demo_value_is_truthy(v), "{v:?} should enable demo mode"); + } + // Disabling values. + for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] { + assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode"); + } + } + #[test] fn unknown_double_dash_flag_errors_even_with_positional() { // Make sure the path-vs-flag distinction is robust: diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index c38402b..88778ee 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -204,6 +204,9 @@ help: project's stored mode. Without it, the project's last-used mode is restored (default: simple). + --demo Demonstration mode: show on-screen badges + for otherwise-invisible keys (Tab, Enter, + ...) — for screencasts and live teaching. App-level commands (typed inside the app, available in both modes): quit Exit cleanly. diff --git a/src/runtime.rs b/src/runtime.rs index 65d5f62..ee1b75b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -216,6 +216,9 @@ pub async fn run(args: Args) -> Result<()> { let db_existed = db_path.exists(); // Undo is on unless `--no-undo` (ADR-0006 Amendment 1). let undo_enabled = !args.no_undo; + // Demonstration mode under `--demo` / `RDBMS_PLAYGROUND_DEMO` + // (ADR-0047). Off by default; threaded onto the `App` in run_loop. + let demo_mode = args.demo; let database = Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled) .context("open database")?; @@ -273,6 +276,7 @@ pub async fn run(args: Args) -> Result<()> { initial_events, undo_enabled, resolved_mode, + demo_mode, ) .await; if let Err(e) = teardown_terminal(&mut terminal) { @@ -331,6 +335,7 @@ async fn run_loop( initial_events: Vec, undo_enabled: bool, initial_mode: crate::mode::Mode, + demo_mode: bool, ) -> Result> { let (event_tx, mut event_rx) = mpsc::channel::(EVENT_CHANNEL_CAPACITY); let reader_handle = spawn_event_reader(event_tx.clone()); @@ -339,6 +344,8 @@ async fn run_loop( app.project_name = Some(project_display_name); app.project_is_temp = project_is_temp; app.undo_enabled = undo_enabled; + // ADR-0047: enable the demo overlays for this session under `--demo`. + app.demo_mode = demo_mode; // Start in the resolved input mode (ADR-0015 mode-restore // amendment, issue #14): `--mode` > stored project mode > // default. `Persistence` already carries the same value, so the From 2584e76b22f4e480ba4bc75f0c71e84851e3979b Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 11 Jun 2026 07:02:23 +0000 Subject: [PATCH 22/25] feat(ui): demo-mode keystroke badges (#22, ADR-0047 D2/D4/D5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In --demo mode, an otherwise-invisible key (Tab, Enter, arrows, Ctrl-O, …) raises a transient [LABEL] badge — a floating black-on-yellow box inset at the output panel's bottom-right. Set in App::update before the modal gate (so it shows over the load picker, the #24 cast); pure demo_badge_label maps the key set. The runtime expires it on a ~1.5s timer via a new nearest_deadline helper that extends the existing time-boxed-recv arm condition without disturbing the ADR-0027 indicator debounce. New App.last_output_area lets the top-level draw anchor the overlay; overlay colours centralised in theme.rs. Tier 1 (label fn, badge set/seq, over-modal), Tier 2 (dark/light snapshots, black-on-yellow style, too-small clamp), runtime unit (nearest_deadline). Phase B of ADR-0047; captions land in C. --- src/app.rs | 161 +++++++++++++++++- src/runtime.rs | 111 +++++++++--- ...__tests__demo_badge_enter_light_90x26.snap | 30 ++++ ..._ui__tests__demo_badge_tab_dark_90x26.snap | 30 ++++ src/theme.rs | 10 ++ src/ui.rs | 142 +++++++++++++++ 6 files changed, 462 insertions(+), 22 deletions(-) create mode 100644 src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap create mode 100644 src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap diff --git a/src/app.rs b/src/app.rs index fd1f2eb..acdde9c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,7 @@ use std::collections::VecDeque; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::layout::Rect; use tracing::{debug, trace, warn}; use crate::action::Action; @@ -323,6 +324,12 @@ pub struct App { /// 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 @@ -372,10 +379,21 @@ pub struct App { /// 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 (set by the runtime, see - /// `demo_badge`) and `Ctrl+]` drives the stealth step-caption - /// buffer (`demo_caption` / `demo_capturing`). + /// 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 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 @@ -478,6 +496,36 @@ 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::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() @@ -507,6 +555,7 @@ impl App { 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, @@ -523,6 +572,8 @@ impl App { // 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, pending_echo: None, } } @@ -1039,6 +1090,19 @@ impl App { } trace!(?key, "handle_key"); + // ADR-0047 D2: in demo mode raise a transient badge for an + // otherwise-invisible key. Done before every gate below so it + // fires even while a modal is open (the `#24` projects cast) or + // in navigation mode. The runtime times its expiry (D5). (Phase + // C inserts the caption-capture gate *above* this, so captured + // keystrokes return early and never raise a badge.) + 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. @@ -2934,6 +2998,97 @@ mod tests { AppEvent::Key(KeyEvent::new(code, mods)) } + // ---- ADR-0047 (issue #22): demo-mode keystroke badges ---- + + fn ke(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, mods) + } + + #[test] + fn demo_badge_label_maps_the_invisible_keys() { + let none = KeyModifiers::NONE; + assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]")); + assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Down, none)), Some("[DOWN]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Left, none)), Some("[LEFT]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Right, none)), Some("[RIGHT]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]")); + assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]")); + assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]")); + assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]")); + assert_eq!( + demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)), + Some("[CTRL-O]") + ); + } + + #[test] + fn demo_badge_label_none_for_glyphs_and_excluded_chords() { + // Plain characters render their own glyph — no badge. + assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None); + assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None); + // Quit and the (Phase C) caption toggle are deliberately excluded. + assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None); + // Ctrl+] decodes to Char('5')+CONTROL — must not badge. + assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None); + } + + #[test] + fn demo_mode_off_never_sets_a_badge() { + let mut app = App::new(); + assert!(!app.demo_mode); + app.update(key(KeyCode::Tab)); + assert_eq!(app.demo_badge, None); + assert_eq!(app.demo_badge_seq, 0); + } + + #[test] + fn demo_mode_on_sets_badge_and_bumps_seq() { + let mut app = App::new(); + app.demo_mode = true; + + app.update(key(KeyCode::Tab)); + assert_eq!(app.demo_badge, Some("[TAB]")); + assert_eq!(app.demo_badge_seq, 1); + + app.update(key(KeyCode::Enter)); + assert_eq!(app.demo_badge, Some("[ENTER]")); + assert_eq!(app.demo_badge_seq, 2); + + // The same label twice still bumps the seq so the runtime + // restarts the expiry timer. + app.update(key(KeyCode::Enter)); + assert_eq!(app.demo_badge, Some("[ENTER]")); + assert_eq!(app.demo_badge_seq, 3); + + // A glyph key leaves the badge (and seq) untouched — the + // runtime's timer is what clears it, not the next key. + app.update(key(KeyCode::Char('x'))); + assert_eq!(app.demo_badge, Some("[ENTER]")); + assert_eq!(app.demo_badge_seq, 3); + } + + #[test] + fn demo_badge_fires_over_an_open_modal() { + // Badges are set before the modal gate, so the `#24` projects + // cast can show [ENTER]/[DOWN] while the load picker is up. + let mut app = App::new(); + app.demo_mode = true; + app.modal = Some(Modal::LoadPicker(LoadPickerModal { + entries: Vec::new(), + selected: 0, + sub_mode: LoadPickerSubMode::List, + })); + app.update(key(KeyCode::Down)); + assert_eq!(app.demo_badge, Some("[DOWN]")); + assert_eq!(app.demo_badge_seq, 1); + } + fn type_str(app: &mut App, s: &str) { for c in s.chars() { app.update(key(KeyCode::Char(c))); diff --git a/src/runtime.rs b/src/runtime.rs index ee1b75b..ba9c056 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -11,7 +11,7 @@ use std::io; use std::path::PathBuf; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use crossterm::event::{Event as CtEvent, EventStream}; @@ -53,6 +53,24 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100); /// reappears once typing stops (ADR-0027 §3). const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000); +/// How long a demo-mode keystroke badge stays on screen before it +/// fades on its own (ADR-0047 D5). Long enough to read in a screencast +/// or in front of a class; short enough that a trailing `wait` in a +/// cast ends on a clean frame. +const DEMO_BADGE_TTL: Duration = Duration::from_millis(1500); + +/// The nearest (soonest) of two optional deadlines (ADR-0047 D5) — the +/// instant the event loop should next wake to service a timer. `None` +/// when neither is set (the loop then blocks on `recv`). Pure, so the +/// scheduling decision is unit-testable without the loop. +fn nearest_deadline(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, b) => b, + } +} + /// The input-validity indicator's debounce state machine /// (ADR-0027 §3, step E). /// @@ -383,6 +401,17 @@ async fn run_loop( // no wake-ups. See `IndicatorDebounce` for the decision // logic; `app.input_indicator` mirrors it for the renderer. let mut debounce = IndicatorDebounce::default(); + // ADR-0027 §3 + ADR-0047 D5: absolute deadlines for the two timed + // wake-ups — the indicator debounce and the demo keystroke-badge + // expiry. The loop time-boxes `recv` on the *nearest* of them and, + // on elapse, services whichever actually fired. Tracking them as + // `Instant`s (rather than one fixed `timeout` duration) lets the + // shorter badge timer fire without prematurely settling the longer + // debounce, and vice-versa. Both `None` ⇒ block on `recv` (no idle + // wake-ups). + let mut debounce_deadline: Option = None; + let mut badge_deadline: Option = None; + let mut last_badge_seq: u64 = app.demo_badge_seq; // Long-lived native clipboard for the `copy` command (ADR-0041). // Created lazily on first copy (so an OSC-52-only session never // opens an X11 connection) and kept alive for the session — the @@ -390,25 +419,36 @@ async fn run_loop( // handle, so it must outlive each write. let mut native_clipboard = crate::clipboard::SystemClipboard::new(); loop { - let event = if debounce.is_armed() { - match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await { - Ok(Some(event)) => event, - Ok(None) => break, - Err(_elapsed) => { - // Typing has been quiet for the debounce - // interval — settle the indicator. - debounce.settle(app.input_validity_verdict()); - app.input_indicator = debounce.visible(); - terminal - .draw(|f| ui::render(&mut app, &theme, f)) - .context("redraw")?; - continue; - } - } - } else { - match event_rx.recv().await { + let event = match nearest_deadline(debounce_deadline, badge_deadline) { + None => match event_rx.recv().await { Some(event) => event, None => break, + }, + Some(deadline) => { + let wait = deadline.saturating_duration_since(Instant::now()); + match tokio::time::timeout(wait, event_rx.recv()).await { + Ok(Some(event)) => event, + Ok(None) => break, + Err(_elapsed) => { + let now = Instant::now(); + // ADR-0047 D5: the keystroke badge has aged out. + if badge_deadline.is_some_and(|d| d <= now) { + app.demo_badge = None; + badge_deadline = None; + } + // ADR-0027 §3: typing has paused for the debounce + // interval — settle the validity indicator. + if debounce_deadline.is_some_and(|d| d <= now) { + debounce.settle(app.input_validity_verdict()); + app.input_indicator = debounce.visible(); + debounce_deadline = None; + } + terminal + .draw(|f| ui::render(&mut app, &theme, f)) + .context("redraw")?; + continue; + } + } } }; let is_key = matches!(event, AppEvent::Key(_)); @@ -591,6 +631,23 @@ async fn run_loop( // pauses; non-key events leave it untouched. debounce.note_event(is_key); app.input_indicator = debounce.visible(); + // Keep the debounce deadline in lock-step with `is_armed()`, + // restarting it on every event while armed (preserving the prior + // behaviour) and clearing it once the indicator is visible again. + debounce_deadline = debounce + .is_armed() + .then(|| Instant::now() + INDICATOR_DEBOUNCE); + // ADR-0047 D5: (re)arm the badge timer whenever `update()` set a + // fresh badge. `demo_badge_seq` bumps even for the same label + // twice, so a repeated key restarts the timer rather than letting + // a stale deadline expire it early. + if app.demo_badge_seq != last_badge_seq { + last_badge_seq = app.demo_badge_seq; + badge_deadline = app + .demo_badge + .is_some() + .then(|| Instant::now() + DEMO_BADGE_TTL); + } terminal .draw(|f| ui::render(&mut app, &theme, f)) .context("redraw")?; @@ -3012,8 +3069,24 @@ fn teardown_terminal( #[cfg(test)] mod tests { - use super::IndicatorDebounce; + use super::{IndicatorDebounce, nearest_deadline}; use crate::dsl::walker::Severity; + use std::time::{Duration, Instant}; + + #[test] + fn nearest_deadline_picks_the_soonest_or_none() { + let now = Instant::now(); + let soon = now + Duration::from_millis(100); + let later = now + Duration::from_millis(500); + // Neither armed ⇒ block (None). + assert_eq!(nearest_deadline(None, None), None); + // One armed ⇒ that one, regardless of order. + assert_eq!(nearest_deadline(Some(soon), None), Some(soon)); + assert_eq!(nearest_deadline(None, Some(soon)), Some(soon)); + // Both armed ⇒ the soonest, regardless of order. + assert_eq!(nearest_deadline(Some(soon), Some(later)), Some(soon)); + assert_eq!(nearest_deadline(Some(later), Some(soon)), Some(soon)); + } #[test] fn starts_hidden_and_disarmed() { diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap new file mode 100644 index 0000000..290d0cb --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭─────────╮ │ +│ │ [ENTER] │ │ +│ ╰─────────╯ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap new file mode 100644 index 0000000..ce45304 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭───────╮ │ +│ │ [TAB] │ │ +│ ╰───────╯ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/theme.rs b/src/theme.rs index 925e48d..ad2a424 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -20,6 +20,16 @@ use ratatui::style::Color; use crate::dsl::grammar::HighlightClass; +/// Foreground of the demonstration-mode overlays (ADR-0047 D4). +/// +/// Deliberately a fixed, theme-independent high-contrast pair — black +/// on yellow — so the badge / caption boxes are hard to overlook in a +/// screencast on any background. +pub const DEMO_OVERLAY_FG: Color = Color::Black; +/// Background of the demonstration-mode overlays (ADR-0047 D4); see +/// [`DEMO_OVERLAY_FG`]. +pub const DEMO_OVERLAY_BG: Color = Color::Rgb(0xFF, 0xD7, 0x00); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Background { Light, diff --git a/src/ui.rs b/src/ui.rs index 187936e..0392f92 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -113,6 +113,55 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { if let Some(modal) = app.modal.as_ref() { render_modal(modal, theme, frame, area); } + + // ADR-0047 D4: the demo overlays draw last of all — over modals — so + // a keystroke badge (and, in Phase C, a step caption) stays visible + // while the load picker (the #24 cast) or any modal is up. + if app.demo_mode { + render_demo_overlays(app, frame); + } +} + +/// Draw the demonstration-mode overlays anchored to the output panel's +/// inner bottom-right corner (ADR-0047 D4). Phase B renders the +/// keystroke badge; the step-caption box joins it in Phase C. +fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) { + let area = app.last_output_area; + if area.width == 0 || area.height == 0 { + return; // not measured yet + } + if let Some(label) = app.demo_badge { + render_badge_box(label, area, frame); + } +} + +/// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset +/// one cell from the bottom-right of `area` (ADR-0047 D2/D4). Skipped +/// rather than overflowing if the output area is too small to host it. +fn render_badge_box(label: &str, area: Rect, frame: &mut Frame<'_>) { + let style = Style::default() + .bg(crate::theme::DEMO_OVERLAY_BG) + .fg(crate::theme::DEMO_OVERLAY_FG) + .add_modifier(Modifier::BOLD); + // ` [LABEL] ` (one pad each side) inside a rounded border. + let box_w = label.chars().count() as u16 + 4; + let box_h = 3; + if box_w + 1 > area.width || box_h + 1 > area.height { + return; + } + let rect = Rect { + x: area.x + area.width - box_w - 1, + y: area.y + area.height - box_h - 1, + width: box_w, + height: box_h, + }; + frame.render_widget(ratatui::widgets::Clear, rect); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(style); + let para = Paragraph::new(format!(" {label} ")).style(style).block(block); + frame.render_widget(para, rect); } /// Width (columns) of the navigation-mode expanded sidebar overlay @@ -933,6 +982,9 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area // ADR-0044 §3: record the panel width so a later `show relationship` // diagram (rendered App-side) can choose side-by-side vs vertical. app.last_output_width = inner.width; + // ADR-0047 D4: record the full inner area so the top-level draw can + // anchor the demo overlays to the output panel's bottom-right corner. + app.last_output_area = inner; let lines: Vec> = app .output @@ -2966,4 +3018,94 @@ mod tests { let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("nav_overlay_relationships_focused_dark", snapshot); } + + // ---- ADR-0047 (issue #22): demo-mode keystroke badge ---- + + /// Render to a `TestBackend` buffer (for cell-level style checks the + /// text-only `render_to_string` cannot make). + fn render_to_buffer( + app: &mut App, + theme: &Theme, + width: u16, + height: u16, + ) -> ratatui::buffer::Buffer { + if app.project_name.is_none() { + app.project_name = Some("Term Planner".to_string()); + } + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("create terminal"); + terminal.draw(|f| render(app, theme, f)).expect("draw frame"); + terminal.backend().buffer().clone() + } + + #[test] + fn demo_badge_box_renders_at_output_bottom_right() { + // At the 90×26 cast geometry the sidebar is hidden and the badge + // box sits inset in the output panel's bottom-right corner. + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[TAB]"); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_badge_tab_dark_90x26", snapshot); + } + + #[test] + fn demo_badge_box_renders_in_light_theme() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[ENTER]"); + let theme = Theme::light(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_badge_enter_light_90x26", snapshot); + } + + #[test] + fn demo_badge_box_is_black_on_yellow() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[TAB]"); + let theme = Theme::dark(); + let buffer = render_to_buffer(&mut app, &theme, 90, 26); + // Collect the badge cells (the only ones painted with the fixed + // overlay background) and confirm the high-contrast pairing. + let mut badge_cells = 0; + let mut row_text: std::collections::BTreeMap = Default::default(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + let cell = &buffer[(x, y)]; + if cell.bg == crate::theme::DEMO_OVERLAY_BG { + badge_cells += 1; + assert_eq!( + cell.fg, + crate::theme::DEMO_OVERLAY_FG, + "badge cell at ({x},{y}) must be black-on-yellow" + ); + row_text.entry(y).or_default().push_str(cell.symbol()); + } + } + } + assert!(badge_cells > 0, "expected a yellow badge box to be drawn"); + // The label appears on the box's middle (text) row. + assert!( + row_text.values().any(|line| line.contains("[TAB]")), + "badge text not found among styled rows: {row_text:?}" + ); + } + + #[test] + fn demo_badge_box_skipped_when_area_too_small() { + // ADR-0047 D4 clamp guard: a box that cannot fit the given area + // is not drawn rather than overflowing. + let backend = TestBackend::new(40, 10); + let mut terminal = Terminal::new(backend).expect("create terminal"); + terminal + .draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), f)) + .expect("draw frame"); + let buffer = terminal.backend().buffer(); + let drew_badge = (0..buffer.area.height).any(|y| { + (0..buffer.area.width).any(|x| buffer[(x, y)].bg == crate::theme::DEMO_OVERLAY_BG) + }); + assert!(!drew_badge, "badge must be skipped when it cannot fit"); + } } From 241f60c50366b4872176ac4fe0fcc56a0a149405 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 11 Jun 2026 08:32:16 +0000 Subject: [PATCH 23/25] feat(ui): demo-mode step-caption stealth buffer (#22, ADR-0047 D3/D4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+] (decodes to Char('5')+CONTROL) toggles an invisible capture buffer: typed characters accumulate without touching the input/output, Backspace edits, every other key is inert, and a second Ctrl+] commits the text to a caption box (empty commit dismisses). Handled at the top of handle_key — before the badge and modal gates — so captions can be authored over the load picker (the #24 cast); an ordinary keystroke clears a visible caption. The caption renders as a floating black-on-yellow box at the output panel's bottom-right, wrapped to <=3 lines (then ellipsised), with the keystroke badge stacked directly above it when both are present. Tier 1: capture/commit, invisible accumulation, backspace, inert keys (incl. no badge), empty-commit dismiss, next-key clear, over-modal, demo-off inert. Tier 2: caption / stacked / wrapped snapshots. Phase C of ADR-0047 — feature complete. --- src/app.rs | 204 +++++++++++++++++- ..._demo_badge_and_caption_stacked_90x26.snap | 30 +++ ...d__ui__tests__demo_caption_dark_90x26.snap | 30 +++ ...ui__tests__demo_caption_wrapped_90x26.snap | 30 +++ src/ui.rs | 134 ++++++++++-- 5 files changed, 408 insertions(+), 20 deletions(-) create mode 100644 src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap create mode 100644 src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap create mode 100644 src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap diff --git a/src/app.rs b/src/app.rs index acdde9c..56fc7fc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -394,6 +394,19 @@ pub struct App { /// (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 @@ -574,6 +587,9 @@ impl App { 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, } } @@ -1090,12 +1106,25 @@ impl App { } 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 every gate below so it - // fires even while a modal is open (the `#24` projects cast) or - // in navigation mode. The runtime times its expiry (D5). (Phase - // C inserts the caption-capture gate *above* this, so captured - // keystrokes return early and never raise a badge.) + // 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) { @@ -1206,6 +1235,59 @@ impl App { } } + /// 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 { @@ -3089,6 +3171,118 @@ mod tests { assert_eq!(app.demo_badge_seq, 1); } + // ---- ADR-0047 (issue #22): demo-mode step-caption stealth buffer ---- + + /// `Ctrl+]` — the caption toggle (decodes to Char('5')+CONTROL). + fn caption_toggle() -> AppEvent { + key_mod(KeyCode::Char('5'), KeyModifiers::CONTROL) + } + + #[test] + fn demo_caption_toggle_captures_then_commits() { + let mut app = App::new(); + app.demo_mode = true; + + app.update(caption_toggle()); + assert!(app.demo_caption_capturing, "first Ctrl+] opens capture"); + assert_eq!(app.demo_caption, None); + + type_str(&mut app, "Press Tab"); + // The text accumulates invisibly — nothing on the input line. + assert_eq!(app.input, ""); + assert_eq!(app.demo_caption_buffer, "Press Tab"); + assert_eq!(app.demo_caption, None, "not shown until committed"); + + app.update(caption_toggle()); + assert!(!app.demo_caption_capturing, "second Ctrl+] commits"); + assert_eq!(app.demo_caption.as_deref(), Some("Press Tab")); + assert_eq!(app.demo_caption_buffer, "", "buffer drained on commit"); + assert_eq!(app.input, "", "input never touched"); + } + + #[test] + fn demo_caption_backspace_edits_the_buffer() { + let mut app = App::new(); + app.demo_mode = true; + app.update(caption_toggle()); + type_str(&mut app, "Helloo"); + app.update(key(KeyCode::Backspace)); + assert_eq!(app.demo_caption_buffer, "Hello"); + assert_eq!(app.input, ""); + } + + #[test] + fn demo_caption_other_keys_are_inert_while_capturing() { + let mut app = App::new(); + app.demo_mode = true; + app.update(caption_toggle()); + type_str(&mut app, "note"); + // Enter must not submit, Tab must not complete, arrows do nothing. + let a1 = app.update(key(KeyCode::Enter)); + let a2 = app.update(key(KeyCode::Tab)); + let a3 = app.update(key(KeyCode::Up)); + assert!(a1.is_empty() && a2.is_empty() && a3.is_empty()); + assert!(app.demo_caption_capturing, "still capturing"); + assert_eq!(app.demo_caption_buffer, "note"); + assert_eq!(app.input, ""); + assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing"); + } + + #[test] + fn demo_caption_empty_commit_dismisses() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_caption = Some("old".to_string()); + // Open (clears the visible caption) then commit empty. + app.update(caption_toggle()); + assert_eq!(app.demo_caption, None, "opening clears the visible caption"); + app.update(caption_toggle()); + assert_eq!(app.demo_caption, None, "empty commit leaves nothing"); + assert!(!app.demo_caption_capturing); + } + + #[test] + fn demo_caption_cleared_by_next_ordinary_keystroke() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_caption = Some("step 1".to_string()); + // An ordinary key clears the caption, then is processed normally. + app.update(key(KeyCode::Char('a'))); + assert_eq!(app.demo_caption, None); + assert_eq!(app.input, "a", "the key still reaches the input"); + } + + #[test] + fn demo_caption_captures_over_an_open_modal() { + // The stealth buffer sits before the modal gate, so captions can + // be authored while the load picker is up (the `#24` cast). + let mut app = App::new(); + app.demo_mode = true; + app.modal = Some(Modal::LoadPicker(LoadPickerModal { + entries: Vec::new(), + selected: 0, + sub_mode: LoadPickerSubMode::List, + })); + app.update(caption_toggle()); + type_str(&mut app, "pick one"); + app.update(caption_toggle()); + assert_eq!(app.demo_caption.as_deref(), Some("pick one")); + // The modal is untouched by the capture. + assert!(matches!(app.modal, Some(Modal::LoadPicker(_)))); + } + + #[test] + fn demo_mode_off_makes_ctrl_rbracket_inert() { + let mut app = App::new(); + assert!(!app.demo_mode); + app.update(caption_toggle()); + type_str(&mut app, "x"); + assert!(!app.demo_caption_capturing); + assert_eq!(app.demo_caption, None); + // Ctrl+] did nothing; the later 'x' is an ordinary character. + assert_eq!(app.input, "x"); + } + fn type_str(app: &mut App, s: &str) { for c in s.chars() { app.update(key(KeyCode::Char(c))); diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap new file mode 100644 index 0000000..f1de558 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭───────╮ │ +│ │ [TAB] │ │ +│ ╰───────╯ │ +│ ╭─────────────────────╮ │ +│ │ Completing the name │ │ +│ ╰─────────────────────╯ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap new file mode 100644 index 0000000..1907566 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭──────────────────────────────────────────╮ │ +│ │ Now press Tab to complete the table name │ │ +│ ╰──────────────────────────────────────────╯ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap new file mode 100644 index 0000000..7863da9 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭──────────────────────────────────────────╮ │ +│ │ This is a deliberately long step caption │ │ +│ │ that must wrap onto several lines and │ │ +│ │ then be clipped to three with an… │ │ +│ ╰──────────────────────────────────────────╯ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index 0392f92..f2596a0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -122,46 +122,114 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { } } +/// The fixed high-contrast style for every demo overlay (ADR-0047 D4): +/// bold black text on a yellow background. +fn demo_overlay_style() -> Style { + Style::default() + .bg(crate::theme::DEMO_OVERLAY_BG) + .fg(crate::theme::DEMO_OVERLAY_FG) + .add_modifier(Modifier::BOLD) +} + /// Draw the demonstration-mode overlays anchored to the output panel's -/// inner bottom-right corner (ADR-0047 D4). Phase B renders the -/// keystroke badge; the step-caption box joins it in Phase C. +/// inner bottom-right corner (ADR-0047 D4): the step caption (if any) at +/// the bottom, the keystroke badge stacked directly above it (or at the +/// bottom when there is no caption). Both are inset one cell and skipped +/// rather than overflowing when the area is too small. fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) { let area = app.last_output_area; if area.width == 0 || area.height == 0 { return; // not measured yet } + // Caption first — it owns the bottom-right corner. The badge then + // stacks above whatever the caption actually occupied. + let caption_rect = app + .demo_caption + .as_deref() + .and_then(|text| render_caption_box(text, area, frame)); if let Some(label) = app.demo_badge { - render_badge_box(label, area, frame); + render_badge_box(label, area, caption_rect, frame); } } /// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset -/// one cell from the bottom-right of `area` (ADR-0047 D2/D4). Skipped -/// rather than overflowing if the output area is too small to host it. -fn render_badge_box(label: &str, area: Rect, frame: &mut Frame<'_>) { - let style = Style::default() - .bg(crate::theme::DEMO_OVERLAY_BG) - .fg(crate::theme::DEMO_OVERLAY_FG) - .add_modifier(Modifier::BOLD); +/// one cell from the bottom-right of `area` (ADR-0047 D2/D4). When a +/// caption box is present (`above`), the badge sits directly on top of +/// it, right-aligned; otherwise it takes the bottom-right corner. +/// Skipped rather than overflowing if it cannot fit. +fn render_badge_box(label: &str, area: Rect, above: Option, frame: &mut Frame<'_>) { // ` [LABEL] ` (one pad each side) inside a rounded border. let box_w = label.chars().count() as u16 + 4; let box_h = 3; - if box_w + 1 > area.width || box_h + 1 > area.height { + if box_w + 1 > area.width { return; } + let x = area.x + area.width - box_w - 1; + let y = match above { + // Directly above the caption, right edges aligned. + Some(c) => { + if c.y < area.y + box_h { + return; // no room above the caption + } + c.y - box_h + } + None => { + if box_h + 1 > area.height { + return; + } + area.y + area.height - box_h - 1 + } + }; + let rect = Rect { x, y, width: box_w, height: box_h }; + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(demo_overlay_style()); + let para = Paragraph::new(format!(" {label} ")) + .style(demo_overlay_style()) + .block(block); + frame.render_widget(ratatui::widgets::Clear, rect); + frame.render_widget(para, rect); +} + +/// A step-caption box inset one cell from the bottom-right of `area` +/// (ADR-0047 D3/D4): the text word-wrapped to at most 3 lines within a +/// corner-sized width, bold black on yellow. Returns the rect it drew, +/// or `None` if it was too small to place (so the badge can fall back to +/// the bottom-right corner). +fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option { + // Content width capped so the box stays corner-sized; the caption + // wraps to ≤ 3 lines and ellipsises beyond (D4). + let content_w = 40.min(area.width.saturating_sub(6)) as usize; + if content_w < 4 { + return None; // output too narrow for a useful caption + } + let lines = clamp_wrapped(text, content_w, 3); + let inner_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); + let box_w = inner_w as u16 + 4; // 2 border + 1 pad each side + let box_h = lines.len() as u16 + 2; // 2 border + if box_w + 1 > area.width || box_h + 1 > area.height { + return None; + } let rect = Rect { x: area.x + area.width - box_w - 1, y: area.y + area.height - box_h - 1, width: box_w, height: box_h, }; - frame.render_widget(ratatui::widgets::Clear, rect); + let body = lines + .iter() + .map(|l| format!(" {l}")) + .collect::>() + .join("\n"); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .style(style); - let para = Paragraph::new(format!(" {label} ")).style(style).block(block); + .style(demo_overlay_style()); + let para = Paragraph::new(body).style(demo_overlay_style()).block(block); + frame.render_widget(ratatui::widgets::Clear, rect); frame.render_widget(para, rect); + Some(rect) } /// Width (columns) of the navigation-mode expanded sidebar overlay @@ -3093,6 +3161,42 @@ mod tests { ); } + #[test] + fn demo_caption_box_renders_at_output_bottom_right() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_caption = Some("Now press Tab to complete the table name".to_string()); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_caption_dark_90x26", snapshot); + } + + #[test] + fn demo_badge_stacks_above_caption() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[TAB]"); + app.demo_caption = Some("Completing the name".to_string()); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_badge_and_caption_stacked_90x26", snapshot); + } + + #[test] + fn demo_caption_wraps_to_three_lines_and_ellipsises() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_caption = Some( + "This is a deliberately long step caption that must wrap onto \ + several lines and then be clipped to three with an ellipsis \ + so the corner box never grows without bound." + .to_string(), + ); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_caption_wrapped_90x26", snapshot); + } + #[test] fn demo_badge_box_skipped_when_area_too_small() { // ADR-0047 D4 clamp guard: a box that cannot fit the given area @@ -3100,7 +3204,7 @@ mod tests { let backend = TestBackend::new(40, 10); let mut terminal = Terminal::new(backend).expect("create terminal"); terminal - .draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), f)) + .draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), None, f)) .expect("draw frame"); let buffer = terminal.backend().buffer(); let drew_badge = (0..buffer.area.height).any(|y| { From 2d0f4b2958394cb4360951045b5111a3f490fa72 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 11 Jun 2026 08:40:07 +0000 Subject: [PATCH 24/25] feat(ui): flat filled rectangles for demo overlays (#22, ADR-0047 D4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the keystroke badge and step caption as a solid yellow rectangle with no border glyphs and a one-cell text margin, instead of a rounded-border box — deliberately unlike the app's bordered panels so the demo overlays read as a distinct, eye-catching callout. Shared fill_overlay_rect helper (borderless Block fill + inset Paragraph). Snapshots regenerated; ADR-0047 D4 wording updated. --- docs/adr/0047-demonstration-overlay-layer.md | 41 +++++++----- ..._demo_badge_and_caption_stacked_90x26.snap | 12 ++-- ...__tests__demo_badge_enter_light_90x26.snap | 6 +- ..._ui__tests__demo_badge_tab_dark_90x26.snap | 6 +- ...d__ui__tests__demo_caption_dark_90x26.snap | 6 +- ...ui__tests__demo_caption_wrapped_90x26.snap | 10 +-- src/ui.rs | 66 +++++++++---------- 7 files changed, 76 insertions(+), 71 deletions(-) diff --git a/docs/adr/0047-demonstration-overlay-layer.md b/docs/adr/0047-demonstration-overlay-layer.md index 754bb03..3d7eba9 100644 --- a/docs/adr/0047-demonstration-overlay-layer.md +++ b/docs/adr/0047-demonstration-overlay-layer.md @@ -186,12 +186,21 @@ confirmed by test if live `--demo` on Windows is exercised.)* ### D4 — Both overlays are floating boxes at the output panel's inner bottom-right -The badge and the caption both render as **floating, bordered boxes -anchored to the inside of the output panel's bottom-right corner** -(inset one cell from the panel's inner edge), drawn **last over the base -render** — after modals, so they remain visible while the load-picker -(the `#24` cast) or any modal is up, and with **no layout reflow** -(consistent with the modal / nav-overlay precedent; honours R8). +The badge and the caption both render as **floating, flat filled +rectangles anchored to the inside of the output panel's bottom-right +corner** (inset one cell from the panel's inner edge), drawn **last over +the base render** — after modals, so they remain visible while the +load-picker (the `#24` cast) or any modal is up, and with **no layout +reflow** (consistent with the modal / nav-overlay precedent; honours +R8). + +**Flat rectangle, not a bordered box (user decision, post-build).** The +overlays draw as a **solid yellow rectangle with no border glyphs** and +a one-cell margin around the text — deliberately *unlike* the app's +rounded-border panels, so they read as a distinct callout that "stands +out nicely" rather than as another panel. Implemented with a borderless +`Block` fill (the `paint_background` mechanism) plus a `Paragraph` inset +into a one-cell `Margin`. The top-level `render()` does not currently know the output-panel rect (it is computed inside `render_right_column`), so a **new field @@ -204,19 +213,19 @@ When **both** are present, the **keystroke badge stacks directly above the caption box** (both right-aligned in the corner) so they never overlap. -**Styling — deliberately high-contrast:** **black text on a yellow -background**, bold, bordered — hard to overlook, identical in light and -dark themes (a fixed high-contrast pair centralised in `theme.rs`, not -theme-derived). +**Styling — deliberately high-contrast:** **bold black text on a yellow +fill** — hard to overlook, identical in light and dark themes (a fixed +high-contrast pair centralised in `theme.rs`, not theme-derived). **Caption sizing (user-confirmed).** The caption is **word-wrapped to at most 3 lines** within a content width of `min(40, output_inner_width − -6)` columns, ellipsised beyond the third line. So the caption box is -**3–5 rows** tall (1–3 text rows + 2 border), its height varying with -the text — a full sentence fits without forcing the author to split it, -while the 3-line cap keeps it corner-sized. The **badge** box is always -a single short token (`[TAB]` … `[SHIFT-TAB]`), so it is a fixed **3 -rows** (1 text + 2 border), narrow. +4)` columns, ellipsised beyond the third line. So the caption rectangle +is **3–5 rows** tall (1–3 text rows + a one-cell margin top and bottom), +its height varying with the text — a full sentence fits without forcing +the author to split it, while the 3-line cap keeps it corner-sized. The +**badge** rectangle is always a single short token (`[TAB]` … +`[SHIFT-TAB]`), so it is a fixed **3 rows** (1 text row + the margin), +narrow. **Clamping (runda finding).** Stacked, the two boxes are up to 8 rows (5 caption + 3 badge); the output panel's inner height is only `Min(5)`, diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap index f1de558..7f82289 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap @@ -11,12 +11,12 @@ expression: snapshot │ │ │ │ │ │ -│ ╭───────╮ │ -│ │ [TAB] │ │ -│ ╰───────╯ │ -│ ╭─────────────────────╮ │ -│ │ Completing the name │ │ -│ ╰─────────────────────╯ │ +│ │ +│ [TAB] │ +│ │ +│ │ +│ Completing the name │ +│ │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap index 290d0cb..7120bbd 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap @@ -14,9 +14,9 @@ expression: snapshot │ │ │ │ │ │ -│ ╭─────────╮ │ -│ │ [ENTER] │ │ -│ ╰─────────╯ │ +│ │ +│ [ENTER] │ +│ │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap index ce45304..d6358c1 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap @@ -14,9 +14,9 @@ expression: snapshot │ │ │ │ │ │ -│ ╭───────╮ │ -│ │ [TAB] │ │ -│ ╰───────╯ │ +│ │ +│ [TAB] │ +│ │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap index 1907566..b132bbd 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap @@ -14,9 +14,9 @@ expression: snapshot │ │ │ │ │ │ -│ ╭──────────────────────────────────────────╮ │ -│ │ Now press Tab to complete the table name │ │ -│ ╰──────────────────────────────────────────╯ │ +│ │ +│ Now press Tab to complete the table name │ +│ │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap index 7863da9..9d2184d 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap @@ -12,11 +12,11 @@ expression: snapshot │ │ │ │ │ │ -│ ╭──────────────────────────────────────────╮ │ -│ │ This is a deliberately long step caption │ │ -│ │ that must wrap onto several lines and │ │ -│ │ then be clipped to three with an… │ │ -│ ╰──────────────────────────────────────────╯ │ +│ │ +│ This is a deliberately long step caption │ +│ that must wrap onto several lines and │ +│ then be clipped to three with an… │ +│ │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ diff --git a/src/ui.rs b/src/ui.rs index f2596a0..e4df268 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -152,15 +152,31 @@ fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) { } } +/// Paint a flat filled overlay rectangle — a solid yellow block with no +/// border glyphs (ADR-0047 D4) — and lay `body` inside a one-cell +/// margin. The borderless solid block is deliberately *unlike* the app's +/// bordered panels, so the demo overlays read as a distinct callout. +fn fill_overlay_rect(rect: Rect, body: String, frame: &mut Frame<'_>) { + frame.render_widget(ratatui::widgets::Clear, rect); + // `Block` with no borders fills the whole rect with the overlay + // background (same mechanism as `paint_background`). + frame.render_widget(Block::default().style(demo_overlay_style()), rect); + let inner = rect.inner(Margin { + horizontal: 1, + vertical: 1, + }); + frame.render_widget(Paragraph::new(body).style(demo_overlay_style()), inner); +} + /// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset -/// one cell from the bottom-right of `area` (ADR-0047 D2/D4). When a -/// caption box is present (`above`), the badge sits directly on top of -/// it, right-aligned; otherwise it takes the bottom-right corner. -/// Skipped rather than overflowing if it cannot fit. +/// one cell from the bottom-right of `area` (ADR-0047 D2/D4) — the label +/// on a flat yellow rectangle with a one-cell margin. When a caption box +/// is present (`above`), the badge sits directly on top of it, right +/// edges aligned; otherwise it takes the bottom-right corner. Skipped +/// rather than overflowing if it cannot fit. fn render_badge_box(label: &str, area: Rect, above: Option, frame: &mut Frame<'_>) { - // ` [LABEL] ` (one pad each side) inside a rounded border. - let box_w = label.chars().count() as u16 + 4; - let box_h = 3; + let box_w = label.chars().count() as u16 + 2; // one-cell margin each side + let box_h = 3; // text row + a margin row above and below if box_w + 1 > area.width { return; } @@ -180,34 +196,25 @@ fn render_badge_box(label: &str, area: Rect, above: Option, frame: &mut Fr area.y + area.height - box_h - 1 } }; - let rect = Rect { x, y, width: box_w, height: box_h }; - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .style(demo_overlay_style()); - let para = Paragraph::new(format!(" {label} ")) - .style(demo_overlay_style()) - .block(block); - frame.render_widget(ratatui::widgets::Clear, rect); - frame.render_widget(para, rect); + fill_overlay_rect(Rect { x, y, width: box_w, height: box_h }, label.to_string(), frame); } /// A step-caption box inset one cell from the bottom-right of `area` /// (ADR-0047 D3/D4): the text word-wrapped to at most 3 lines within a -/// corner-sized width, bold black on yellow. Returns the rect it drew, -/// or `None` if it was too small to place (so the badge can fall back to -/// the bottom-right corner). +/// corner-sized width, bold black on a flat yellow rectangle. Returns +/// the rect it drew, or `None` if it was too small to place (so the +/// badge can fall back to the bottom-right corner). fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option { // Content width capped so the box stays corner-sized; the caption // wraps to ≤ 3 lines and ellipsises beyond (D4). - let content_w = 40.min(area.width.saturating_sub(6)) as usize; + let content_w = 40.min(area.width.saturating_sub(4)) as usize; if content_w < 4 { return None; // output too narrow for a useful caption } let lines = clamp_wrapped(text, content_w, 3); let inner_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); - let box_w = inner_w as u16 + 4; // 2 border + 1 pad each side - let box_h = lines.len() as u16 + 2; // 2 border + let box_w = inner_w as u16 + 2; // one-cell margin each side + let box_h = lines.len() as u16 + 2; // text rows + a margin row above and below if box_w + 1 > area.width || box_h + 1 > area.height { return None; } @@ -217,18 +224,7 @@ fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option>() - .join("\n"); - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .style(demo_overlay_style()); - let para = Paragraph::new(body).style(demo_overlay_style()).block(block); - frame.render_widget(ratatui::widgets::Clear, rect); - frame.render_widget(para, rect); + fill_overlay_rect(rect, lines.join("\n"), frame); Some(rect) } From f0afec38126fb645e1cb707462ac1a46293be0c5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 11 Jun 2026 09:59:51 +0000 Subject: [PATCH 25/25] docs: session handoff 64 + ADR-0047 implemented (#22/#24) Flip ADR-0047 Status -> implemented (commits f879d54..2d0f4b2, phased A->B->C + flat-rectangle restyle); update the README index entry to match (implemented, flat black-on-yellow rectangles, final 2290-green tally, website-cast follow-up noted). Add session handoff 64 covering #24 (vi load-picker nav) and #22 (ADR-0047 demo overlay layer). --- docs/adr/0047-demonstration-overlay-layer.md | 27 +++- docs/adr/README.md | 2 +- docs/handoff/20260611-handoff-64.md | 140 +++++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 docs/handoff/20260611-handoff-64.md diff --git a/docs/adr/0047-demonstration-overlay-layer.md b/docs/adr/0047-demonstration-overlay-layer.md index 3d7eba9..4258e95 100644 --- a/docs/adr/0047-demonstration-overlay-layer.md +++ b/docs/adr/0047-demonstration-overlay-layer.md @@ -2,13 +2,38 @@ ## Status -Accepted (2026-06-10). Addresses Gitea **#22**. Builds the in-app +Accepted (2026-06-10); **implemented 2026-06-11**, phased A→B→C (closes +Gitea **#22**). Addresses Gitea **#22**. Builds the in-app overlay/annotation primitive that screencast recording (ADR-website-001 §2, the `autocast` pipeline) and a future guided-lesson system both need. Adjacent to ADR-0046 (the nav-mode sidebar overlay it must coexist with) and unblocks the polished version of the assistive-editor and projects (`#24`) casts. +**Implementation (commits `f879d54` → `2d0f4b2`).** Phase A +(`f879d54`): `--demo` flag + `RDBMS_PLAYGROUND_DEMO` env → +`App.demo_mode`, mirroring the `--no-undo` plumbing; help text mentions +only the visible badges (the `Ctrl+]` caption trigger stays +low-profile, D6). Phase B (`2584e76`): automatic keystroke badges — pure +`demo_badge_label`, set in `App::update` before the modal gate, expired +by a ~1.5 s runtime timer via the new `nearest_deadline` helper that +extends the time-boxed-`recv` arm condition **without** regressing the +ADR-0027 indicator debounce (the rewrite tracks `Instant` deadlines; +verified equivalent). Phase C (`241f60c`): the stealth `Ctrl+]` +caption buffer in `App::update`, intercepted before the modal gate so +captions work over the load picker. Post-build (`2d0f4b2`, user +decision): the overlays render as **flat filled yellow rectangles** (no +border glyphs, one-cell text margin) to read as a distinct callout. A +whole-implementation `/runda` pass returned **PASS** with no blockers; +the only untested wiring is the `run_loop` badge timer (not unit-testable +in isolation — same posture as the existing `IndicatorDebounce`; the +pure pieces are all tested). One intentional, user-acknowledged +behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, +by spec; exit capture with `Ctrl+]`). Tests: 2290 passing / 0 failing / +0 skipped (Tier-1 label fn + caption FSM + `nearest_deadline`, Tier-2 +dark/light/stacked/wrapped/clamp snapshots + black-on-yellow style, +CLI parse/env); clippy clean. + All primary forks and the visual placement were **user-confirmed** — including the two follow-ups settled after the first draft: the trigger key (**`Ctrl+]`**, the maximally-obscure valid single-byte code, over diff --git a/docs/adr/README.md b/docs/adr/README.md index 9157c3f..f73b802 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,4 +52,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) - [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships - [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) -- [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10** (Gitea **#22**; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a `/runda` pass that produced 10 tightening findings). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating black-on-yellow boxes at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) +- [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle diff --git a/docs/handoff/20260611-handoff-64.md b/docs/handoff/20260611-handoff-64.md new file mode 100644 index 0000000..e5dbbe2 --- /dev/null +++ b/docs/handoff/20260611-handoff-64.md @@ -0,0 +1,140 @@ +# Session handoff — 2026-06-11 (64) + +Sixty-fourth handover. Continues from handoff-63 (ADR-0046 sidebar/nav). +This session closed **two unrelated, website-screencast-enabling Gitea +issues**: **#24** (vi-style load-picker navigation) and **#22** +(in-app demonstration overlay layer — its own **ADR-0047**, built end +to end across three phases + a restyle). + +## §1. State at handoff + +**Branch:** `main`. **HEAD `2d0f4b2`** plus an **uncommitted docs +finalization** (ADR-0047 status → implemented, README index, this +handoff — see §6). Push is the user's step. + +**Tests: 2290 passing / 0 failing / 0 skipped / 1 ignored** (the 1 +ignored is the long-standing `friendly` doctest). **Clippy clean** +(nursery, all targets). +27 over the handoff-63 baseline of 2263. + +**This session's commits:** +``` +2d0f4b2 feat(ui): flat filled rectangles for demo overlays (#22, ADR-0047 D4) +241f60c feat(ui): demo-mode step-caption stealth buffer (#22, ADR-0047 D3/D4) +2584e76 feat(ui): demo-mode keystroke badges (#22, ADR-0047 D2/D4/D5) +f879d54 feat(cli): --demo demonstration mode flag + app plumbing (#22, ADR-0047 D1) +e9eb1b1 docs: ADR-0047 — demonstration overlay layer for casts/teaching (#22) +638b4c9 feat(app): vi-style j/k/g/G navigation in the load picker (#24) +``` + +**Issues closed:** **#24** (vi nav) and **#22** (demo overlays) — close +#22 once the docs finalization commit lands. + +## §2. #24 — vi-style load-picker navigation (commit `638b4c9`) + +Purely additive to the ADR-0015 load picker (`handle_load_picker_key`, +`LoadPickerSubMode::List`): **`j`/`k`** mirror Down/Up (bounds- +respecting, no wrap), **`g`/`G`** jump to first/last. Existing keys +(`↑↓`/`Enter`/`Esc`/`b`) unchanged; the footer hint is **left as-is** at +the user's request (the new keys are not advertised). No ADR (additive). +Motivation: `autocast` (the website cast driver) can only send typeable +characters — not arrow keys — so the projects demo needs `j`/`k` to +drive the picker. Tests: `load_picker_jk_navigates_like_arrows`, +`load_picker_g_jumps_to_first_and_last` (test-first). + +## §3. #22 — ADR-0047 demonstration overlay layer (read the ADR) + +An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` +env, **off by default, zero footprint when off**) that renders two +transient overlays so `autocast` screencasts — and live teaching, and a +future guided-lesson system — can show otherwise-invisible interactions. + +- **Phase A (`f879d54`):** `--demo` + env → `App.demo_mode`, threaded + through `run_loop` like `--no-undo`. `--help` line mentions **only the + visible badges**; the `Ctrl+]` caption trigger is kept low-profile + (user decision, D6). +- **Phase B (`2584e76`):** **automatic keystroke badges** + (`[TAB]`/`[ENTER]`/`[UP]`/…) over a fixed set of glyph-less keys — + pure `demo_badge_label(&KeyEvent)`, set in `App::update` **before the + modal gate** (so they fire over the load picker), expired by a **~1.5 s + runtime timer**. The timer extends the event loop's time-boxed-`recv` + via a new pure `nearest_deadline` helper; the rewrite tracks `Instant` + deadlines and was **verified not to regress the ADR-0027 indicator + debounce**. New `App.last_output_area: Rect` (set in + `render_output_panel`) anchors the overlays. +- **Phase C (`241f60c`):** the **stealth `Ctrl+]` caption buffer** — + `Ctrl+]` (byte `0x1D` → `Char('5')+CONTROL`, verified vs crossterm + 0.29) toggles an invisible buffer; typed chars accumulate without + touching input/output, `Backspace` edits, other keys inert, a second + `Ctrl+]` commits (empty commit dismisses). In pure-sync `App::update`, + intercepted **before the modal gate**; an ordinary keystroke clears a + visible caption. +- **Restyle (`2d0f4b2`):** the overlays render as **flat filled yellow + rectangles** (no border glyphs, one-cell text margin) — user decision, + deliberately unlike the bordered panels so they pop. Shared + `fill_overlay_rect` (borderless `Block` fill + inset `Paragraph`). + +**Placement:** both float at the output panel's inner bottom-right, +drawn **last over modals**, badge **stacked directly above** the caption +when both show; caption **wraps to ≤ 3 lines** then ellipsises; clamp/ +skip guard for tiny terminals. + +**Process:** ADR-first (user chose), pre-build `/runda` (10 findings, +folded in) + whole-implementation `/runda` (**PASS, no blockers**). Every +fork user-confirmed via mockups/questions, incl. the two post-draft +follow-ups: `Ctrl+]` trigger (over `Ctrl+!`, which `autocast` cannot +send — not a single ASCII byte) and wrap-to-3-line captions. + +## §4. Two things to know about the implementation + +1. **Ownership split (intentional, mirrors `input`/`input_indicator`):** + `demo_caption`/`demo_caption_capturing`/`demo_caption_buffer` are + driven by `App::update` (input); `demo_badge` is **set** by + `App::update` but its expiry is **timed by the runtime** + (`demo_badge_seq` bumps so a repeated key restarts the timer). +2. **`Ctrl-C` is inert while capturing** — by spec ("every other key is + inert"); exit capture with `Ctrl+]`. User-acknowledged; flagged in + the ADR. The only behaviour worth a second look if it ever annoys. + +## §5. Honest coverage note + +Everything *testable* is tested (label fn, full caption FSM incl. +over-modal + demo-off, `nearest_deadline`, all rendering, CLI parse/env). +The **only** untested wiring is inside `run_loop` (the badge-timer +arm/clear and `app.demo_mode = demo_mode`) — `run_loop` is not +unit-testable (terminal + DB + channels), exactly the posture the +existing `IndicatorDebounce` already takes. A future Tier-4 PTY harness +(ADR-0008 TT4, still unwired) would close it. + +## §6. How to take over + +1. Read handoffs 62 → 63 → 64, `CLAUDE.md`, `docs/requirements.md`, + `docs/adr/README.md`, and **ADR-0047** (fully landed). +2. **Pending:** the docs finalization commit (ADR-0047 status → + implemented; README index; this handoff). Commit as + `docs: session handoff 64 + ADR-0047 implemented (#22/#24)` (the user + confirms commit messages). Then close **#22** on Gitea. +3. **For demo-overlay work:** `App` has `demo_mode`, `demo_badge`, + `demo_badge_seq`, `demo_caption`, `demo_caption_capturing`, + `demo_caption_buffer`, `last_output_area`. Rendering: + `render_demo_overlays` / `render_badge_box` / `render_caption_box` / + `fill_overlay_rect` in `ui.rs`; colours `DEMO_OVERLAY_FG/BG` in + `theme.rs`; key handling `handle_demo_caption_key` + the top-of- + `handle_key` gate; timer in `runtime.rs` (`nearest_deadline`, + `DEMO_BADGE_TTL`). + +## §7. Remaining open landscape (from handoff-63, minus the closed items) + +1. **Wire the overlays into the website casts** — `casts.mjs` on the + `website` branch can now emit `^]`/text/`^]` for captions and rely on + automatic badges. Website-branch follow-up (OOS for #22's app scope). +2. **TT5 CI** — 2290 green, no pipeline yet. +3. **SD1 `seed`** then **H2 `hint`** — the unblockers for **A1** + app-commands; own ADRs. +4. **V2/S3 multi-result tabs** / **V4 journal** — larger output-model + redesign, own ADR. +5. **C3a modify relationship** — small (drop+add covers it). +6. **Tutorial/lesson system** — acknowledged in scope; needs its own + ADR; ADR-0047's overlay primitive is what it will reuse. + +Run a `cargo sweep` at some point — `target/` grew across this +build-heavy session.