Files
rdbms-playground/docs/adr/0053-contextual-hint-command-and-keybinding.md
T
claude@clouddev1 417cbc8df9 docs(hint): defer pre-submit-diagnostic route + diagnostic.* blocks (ADR-0053)
Phase C scope decision: Diagnostic carries no class key, so the F1
diagnostic route would need a class field threaded through every
diagnostic site (broad change) for marginal value — tier-2 already
surfaces diagnostics and many duplicate the runtime error classes. Defer
the route + the ~33 diagnostic.* tier-3 blocks to issue #38. v1 ships
command-form hints + 9 runtime error-class hints (comprehensive for
those). Updates ADR-0053 D2/D6/Status/OOS + README.
2026-06-15 16:28:54 +00:00

22 KiB
Raw Blame History

ADR-0053: Contextual hint — F1 live-input keybinding + hint command, with a tier-3 teaching corpus (H2)

Status

Accepted — implementation in progress. Revised after a /runda review (2026-06-14): corrected the verbosity-default fact; re-keyed tier-3 content off help_id; split the pre-submit-diagnostic and runtime-error paths; added a comprehensiveness coverage test. Revised again during Phase B implementation (2026-06-15): the first exemplar showed per-node keying is too coarse for multi-form commands (add/drop/show/ create), so D3 now keys tier-3 content per form via a hint_ids: &[&str] array mirroring usage_ids — and clause-concept hints are recorded as a deferred extension (issue #37). During Phase C the pre-submit-diagnostic route + the ~33 diagnostic.* blocks were deferred (issue #38) — Diagnostic doesn't carry its class key, so the route needs a broad change for marginal value (D6). v1 therefore ships command-form hints + the 9 runtime error-class hints. The parallel question of whether the in-app help command should likewise distinguish advanced-SQL forms is tracked separately as Gitea issue #36.

Decided in conversation 2026-06-14. Closes the last open piece of A1 (the canonical app-command set, ADR-0003): every app command is implemented except hint, which ADR-0003's command table listed as "Request a hint for the current input (ADR pending)." This ADR is that pending decision. Tracked as H2 in docs/requirements.md.

References ADR-0003 (app-command set + the : escape), ADR-0019 (the friendly error layer / H1), ADR-0021 (per-command usage templates / H1a), ADR-0022 (ambient typing assistance — colour + hint panel + completion), ADR-0027 (input validity indicator), ADR-0046 (sidebar navigation + responsive input hint), ADR-0049 (input-field readline keymap), and ADR-0051 (context/state-aware keybinding strip).

Context

hint is the only unbuilt app command. The naive reading — "show a hint" — hides a real subtlety, and a real cost.

The subtlety: a submitted hint command cannot see live input. App commands are submitted with Enter, which empties the input buffer. By the time hint dispatches, the partial command it was meant to help with is gone. So "a hint for the current input" cannot be served by a submitted command alone — it needs a keybinding that acts on the live buffer without submitting. ADR-0003 said "current input"; requirements.md broadened it to "current input or the most recent error." Both are wanted; they map to two different trigger surfaces.

The cost: the value of hint is content, not plumbing. The app already carries two tiers of contextual text:

  • Tier 1 — terse, always-on: syntax colour (ADR-0022); the error headline alone (ADR-0019, when messages_verbosity: Short).
  • Tier 2 — short contextual lines: the ambient typing prose / expected set, shown live while typing (ADR-0022, catalogue hint.ambient_* / hint.value_slot_*); and the error hint: field — which, because Verbosity::Verbose is the default (src/friendly/translate.rs:46), is shown by default beneath every error headline (messages short is the opt-out, not messages verbose the opt-in).

So the verbose error hint is already on screen by default. If hint merely re-showed it, it would duplicate what the user can already see (and the ambient panel). To justify itself, hint must add a tier 3: a genuinely deeper, teaching-grade explanation — what the command/error means, a worked example, and the underlying relational concept. That corpus does not exist yet, and authoring it (to the standard of a teaching tool, where "pedagogy wins ties") is the bulk of the work.

The mechanism is small and reuses everything already present: the command REGISTRY (src/dsl/grammar/mod.rs), the AppCommand enum (src/dsl/command.rs), key dispatch (App::handle_key, src/app.rs:1155), the note_help/note_help_topic renderers (src/app.rs:2982/3021), the parser/walker expected-set (ParseError.expected, WalkResult.tail_expected), the friendly catalogue + t! macro + keys.rs validation, and the output styling vocabulary (OutputStyleClass::Hint).

Decision

D1 — Two surfaces, no topic argument

hint is delivered through two complementary surfaces:

  1. F1 keybinding → live input. Pressing F1 while typing renders a tier-3 hint for the command currently in the buffer, into the output panel, without submitting or altering the buffer. This is the primary, most-valuable path (it serves the literal "current input").
  2. hint command → most recent error. Submitting hint renders the tier-3 expansion of the most recent error. This is why the command exists despite the empty-buffer problem: the thing it helps with is the last thing you tried, not the now-empty buffer.

hint takes no topic argument. Explicit per-command reference is already help <topic> (H3); hint is purely contextual, which keeps the two cleanly distinct (hint = "help me with what I'm doing right now"; help insert = "show me the insert reference").

F1 is a read-only overlay: it never alters the input buffer, the cursor, or the live completion memo (ADR-0022) — it only emits a block into the output journal. (It must therefore be handled in handle_key before the "any other key clears the memo" fall-through.)

D2 — Trigger matrix

Trigger Buffer / state Result
F1 non-empty input tier-3 hint for the command being typed, plus the live "expected next" (from the walker's tail_expected / parser expected)
F1 empty input, a recent error exists tier-3 expansion of that error
F1 empty input, no recent error a short "getting started" pointer (press F1 while typing a command; help for the full list)
hint (submitted) a recent error exists tier-3 expansion of that error (primary use)
hint (submitted) no recent error the same "getting started" pointer

F1 is inert behind a modal and while a sidebar panel holds navigation focus (consistent with the existing handle_key gates, ADR-0046); it is active in the input context in both Simple and Advanced mode.

Error routes. Runtime errors (the 9 translate_error classes) occur after submit; the hint command / empty-input F1 path reads them via the stored last_error_hint_key (D5) and renders their hint.err.<class> block. (A second route for pre-submit diagnostics on the F1 live-input path was specified but is deferred — D6 / issue #38; with a diagnostic present, F1 shows the command block and tier-2 shows the diagnostic.) :-prefix handling: on the simple-mode one-shot escape (: SELECT …), command identification for the F1 path strips the leading : first, so the advanced form is matched.

D3 — The tier-3 content model

Tier-3 blocks live in the friendly catalogue under the existing hint: top-level namespace (where tier-2 ambient strings already live), in two new sub-namespaces:

  • hint.cmd.<hint_id> — one per command form, keyed by a new hint_ids: &'static [&'static str] field on CommandNode (src/dsl/grammar/mod.rs:512), mirroring the existing usage_ids. The F1 live-input path resolves the current input to its form's hint key via hint_key_for_input_in_mode, which reuses the same form-word disambiguation as usage_key_for_input_in_mode.

    Why an array mirroring usage_ids, not a per-node hint_id (/runda/implementation revision, 2026-06-15): a single per-node key is too coarse. Several entry words are one node spanning many formsadd (column/relationship/index/constraint), drop (table/column/ relationship/index), show (data/table/tables/relationships/indexes), create (table/index). A live-input hint for add 1:n relationship is only useful if it is specific to relationships, so the content must be per form, not per node. The project already solved exactly this for usage templates (usage_ids is a per-form array, disambiguated by the form word), so hint_ids mirrors it. Single-form nodes carry one entry; multi-form nodes carry one per form. This also covers the advanced-SQL forms whose usage_ids are empty (SQL_INSERT/UPDATE/DELETE, EXPLAIN_SQL) — they get their own hint_ids directly, independent of usage, with mode-correct SQL examples. (The help-list collapse of advanced-SQL forms is a separate gap — issue #36.)

    Deferred extension — clause-concept hints (issue #37): per-form is the right granularity for tier-3 teaching (position-awareness within a form is owned by tier-2 ambient + the live Next: line, D4). But some concepts live inside a clause, not a form — … on delete ⟨cascade| set null|restrict⟩ (referential actions), the create table constraint slots (primary/unique/check/foreign), with pk, 1:n/m:n cardinality. A learner parked in such a clause may want teaching deeper than tier-2's candidate list but narrower than the whole-form block. v1 does not build this (it would multiply content for points whose value we can't yet measure, and we don't expect to accumulate usage statistics to drive it empirically — it will be tackled as a deliberate follow-up job). The keying does not lock it out: a later hint.concept.<topic> namespace can be surfaced when the cursor sits in a recognized clause, layered on top of the per-form block.

  • hint.err.<class> — one per error/diagnostic class, keyed by the friendly error/diagnostic key (e.g. hint.err.foreign_key.child_side, hint.err.type_mismatch, hint.err.insert_arity_mismatch). Used by both error routes (D2).

Each tier-3 block is a structured entry with three labelled parts, so the voice stays consistent and the renderer can style them uniformly:

hint.cmd.dsl.insert:
  what: "Add one or more rows to a table."
  example: "insert into Customers values ('Ann', 'ann@x.io')"
  concept: "A row is one record; each value lines up with a column, in
    order. Columns typed `serial`/`shortid` fill themselves — leave them out."
  • what — one or two plain sentences: what this command does / what this error means.
  • example — a single concrete, copyable line (rendered neutral, not muted, so it stands out as runnable).
  • concept — the underlying relational idea, in teaching voice; the part that makes this tier-3 rather than tier-2.

concept is optional where there is genuinely no concept beyond the mechanics (e.g. quit); what + example are always present.

D4 — Rendering

Both surfaces render through one new renderer, App::note_hint* (sibling of note_help/note_help_topic, src/app.rs), emitting a small framed block into the output buffer as OutputKind::System with OutputStyleClass::Hint on the what/concept prose and Neutral on the example line. The block is persistent (scrolls in the journal), unlike the transient ambient panel — pressing F1 is an explicit request to keep the deeper guidance on screen. The bottom keybinding strip (ADR-0051) advertises F1 in the editing/typing state.

D5 — "Most recent (runtime) error" state

The runtime-error route (submitted hint, and empty-input F1) needs to map the last runtime error back to its hint.err.<class> key. Runtime errors today live only as rendered text in the output buffer. We add a single small piece of App state — last_error_hint_key: Option<String> — set at the translate_error call sites (runtime.rs:2615, app.rs:2424) when a friendly error is rendered, cleared when a later command succeeds. Absent → the "getting started" pointer.

The pre-submit-diagnostic route (the F1 live-input path reading the under-cursor diagnostic) is deferred — see the scope note in D6.

D6 — Content scope for v1

v1 ships tier-3 content for the command forms and runtime error classes — comprehensive for those (the graceful tier-2 fallback below is a safety net, not the plan):

  • ~37 command forms — every distinct node in REGISTRY gets its own hint.cmd.<hint_id> block (app + DSL + DDL + advanced-mode SQL forms), each with a mode-correct example (the advanced-SQL forms show SQL syntax, their simple siblings show DSL — no sharing).
  • 9 runtime error classesunique, foreign_key (child/parent side), not_null, check, type_mismatch, not_found, already_exists, generic, invalid_value — each gets a hint.err.* block.

Deferred — the ~33 diagnostic.* pre-submit classes and the F1 diagnostic route (Phase C scope decision, 2026-06-15; issue #38). The original "comprehensive" scope included them, but implementation revealed Diagnostic (walker/outcome.rs) carries only its rendered message, not its class key — so a live diagnostic can't be mapped to hint.err.<class> without adding a class field threaded through every diagnostic-creation site (a broad change). Weighed against the value, it isn't worth it for v1: pre-submit diagnostics are already surfaced by tier-2 (ambient message + validity indicator, ADR-0027); F1 still shows the useful command block when a diagnostic is present; and many diagnostic classes duplicate runtime classes already covered (type_mismatch, unknown_tablenot_found, arity↔invalid_value). Deferred to issue #38, additively (the keying doesn't lock it out).

The full enumerated checklist is the implementation plan's tracking artifact (see Content inventory, below).

Fallback (safety net): if a tier-3 key is ever missing at runtime, the surface degrades to tier 2 — the ambient prose for the command path, or the verbose error hint: for the error path — never to a blank or an error. The keys.rs build-time validation keeps the corpus honest, so a missing key is caught in tests, not in front of a student.

D7 — Authoring process: exemplars-first

Because the corpus is large and its voice is a pedagogical decision the maintainer owns, content is produced in two stages:

  1. This ADR carries 23 worked exemplars (below) as the canonical style reference. The /runda review of this ADR is where the voice and depth are approved.
  2. Once approved, the remaining blocks are authored to that template in reviewable batches (grouped by area: DDL, DML, app commands, error classes), not one monolithic drop.

Exemplars (the style reference to approve)

Command (F1 live-input), insert:

Hint — insert
  What:    Add one or more rows to a table.
  Example: insert into Customers values ('Ann', 'ann@x.io')
  Concept: A row is one record; each value lines up with a column, in
           order. Columns typed serial/shortid fill themselves — leave
           them out.
  Next:    a value list `(...)`, or `(col, ...) values (...)` to name columns

(The "Next:" line is the live expected-set from the walker, shown only on the non-empty-input F1 path.)

Error (hint command), foreign-key child-side violation:

Hint — no parent row to point at
  What:    The value you inserted into Orders.customer_id doesn't match
           any Customers row, so the foreign key has nothing to point at.
  Example: First   insert into Customers values ('Ann', ...)
           Then    insert into Orders values (..., 'Ann')
  Concept: A foreign key is a promise that every child points at a real
           parent. The parent must exist first. To allow orphans on
           delete instead, set the relationship's `on delete` to
           `set null` or `cascade`.

Command (F1 live-input), add 1:n relationship:

Hint — add relationship
  What:    Link two tables so a parent row can own many child rows.
  Example: add 1:n relationship from Customers.id to Orders.customer_id
  Concept: The "1:n" means one parent, many children. The child column
           holds the foreign key; `--create-fk` adds it for you if it
           doesn't exist yet.

Forks (all user-chosen, 2026-06-14)

  • Trigger model: both a keybinding (live input) and a submitted command (last error), rather than command-only or keybinding-only — the live-input path is the most useful, but the command completes the A1 slot and serves the error case.
  • Keybinding = F1: the universal help convention; the key is genuinely free (no KeyCode::F(1) binding exists today — the "F1" strings in input_render.rs/tests are scenario labels, not the key, and ADR-0022 uses no F1 requirement label). No collision with the ADR-0049 readline keys, Ctrl-O (ADR-0046), Esc-clear, or the reserved Ctrl-C cancel (I5). Rejected: ? (a typeable character — fiddly position-dependent handling) and a Ctrl/Alt chord (less discoverable, no advantage).
  • No topic argument: contextual only; help <topic> already owns explicit reference lookup.
  • Comprehensive content for v1: the full inventory, not a starter subset.
  • Exemplars-first authoring: lock the voice on a few blocks, then mass-author to template.

Consequences

  • A1 closes. With hint registered and built, all 15 canonical app-level commands exist in both modes.
  • A third contextual tier exists. Students get on-demand, teaching- grade guidance that is deeper than the always-on colour, the headline, the ambient one-liner, and the verbose error hint — without cluttering those terse defaults.
  • One new keybinding (F1) joins the keymap and the ADR-0051 strip.
  • A new hint_ids: &[&str] field on CommandNode (mirroring usage_ids) + a hint_key_for_input_in_mode lookup (reusing the usage_key_for_input_in_mode form-disambiguation), one new field of App state (last_error_hint_key), and one new renderer family (note_hint*); the AppCommand enum gains Hint, the grammar a HINT node, the REGISTRY one entry.
  • A durable content corpus (~37 command blocks + 10 runtime error-class blocks) enters the catalogue under hint.cmd.* / hint.err.*, validated by keys.rs. This is ongoing surface area: new commands/error classes should ship with their tier-3 hint (a checklist item for future feature ADRs). (Diagnostic-class blocks deferred — #38.)
  • Testing: Tier-1 unit tests for the trigger matrix (F1 with empty/non-empty input; hint with/without a recent error; last_error_hint_key set on the translate_error sites and cleared on success; the mode-aware form resolution; the : strip), the command-identification logic, and the tier-2 fallback; Tier-2 insta snapshots for a representative rendered hint block; Tier-3 integration tests for the end-to-end flows (type a partial command → F1 → block appears, buffer and completion memo untouched; run a failing command → hint → error expansion). A comprehensiveness coverage test (enforces D6): iterate the REGISTRY and assert every node with a hint_ids entry resolves to a hint.cmd.* block, and every runtime error class resolves to a hint.err.* block — keys.rs only checks that referenced keys resolve, not that every command/error has one, so this test is what makes the scope enforceable rather than aspirational. (Diagnostic classes are out of this scope — D6 / #38.)

Out of scope

  • Per-topic hint <topic> — OOS (rejected): help <topic> already serves explicit lookup; a topic arg would overlap it and double the content-authoring surface.
  • Re-showing tier-3 inline as the always-on ambient hint — OOS (rejected): the ambient panel stays terse by design (ADR-0022); tier-3 is on-demand. Promoting it would defeat the tiering.
  • Localised tier-3 content beyond en-US — OOS (deferred): the catalogue is structured for i18n (ADR-0019), but additional locales follow the project's English-only-for-v1 stance (requirements X2).
  • hint for a successful command's deeper teaching (e.g. "you just created a table — here's what an index would add") — OOS (deferred): a plausible future tier-3 use, but v1 scopes the command path to errors and the F1 path to in-progress input.
  • Clause-concept hints (… on delete ⟨action⟩, constraint slots, with pk, cardinality) — OOS (deferred, issue #37): a hint.concept.<topic> layer surfaced when the cursor sits in a recognized clause, deeper than tier-2's candidate list but narrower than the per-form block. Per-form keying (D3) does not lock it out. To be tackled as a deliberate follow-up job, not gated on usage statistics.
  • Pre-submit-diagnostic route + diagnostic.* tier-3 blocks — OOS (deferred, issue #38): needs a class field on Diagnostic threaded through every creation site (broad change) for marginal value, since tier-2 already surfaces diagnostics and many duplicate runtime classes (D6).

Content inventory (implementation tracking)

The implementation plan enumerates and checks off every block:

  • hint.cmd.<hint_id> — one per distinct REGISTRY node (~37), each with its own hint_id and a mode-correct example: app (save, save as, load, new, rebuild, export, import, replay, undo, redo, mode, messages, copy, help, hint, quit); DDL (create table, create m:n, add column/relationship/index, drop, rename, change column); DML (insert, update, delete, show, seed, explain, select/with). The 7 advanced-mode SQL forms (SQL CREATE TABLE, ALTER TABLE, CREATE/DROP INDEX, DROP TABLE, SQL INSERT/UPDATE/DELETE, EXPLAIN SQL, raw SELECT/WITH) each get their own block with SQL syntax — they do not reuse their simple sibling's (this is the /runda correction; the parallel help-side gap is issue #36).
  • hint.err.* — one per runtime error class (unique, foreign_key.{child,parent}_side, not_null, check, type_mismatch, not_found, already_exists, generic, invalid_value). The diagnostic.* pre-submit classes are deferred (D6 / issue #38).