Completes H2: - comprehensiveness coverage tests: every REGISTRY command form has a hint_id resolving to a hint.cmd.* block, and every runtime error class resolves to a hint.err.* block (enforces ADR-0053 D6) - ADR-0051 keybinding strip advertises F1 in the editing (leads) and default states; +shortcut.hint label; 12 full-panel snapshots re-accepted (status-bar line only) - flip ADR-0053 -> implemented, requirements H2 + A1 -> [x], README 2498 pass / 1 ignored, clippy clean.
22 KiB
ADR-0053: Contextual hint — F1 live-input keybinding + hint command, with a tier-3 teaching corpus (H2)
Status
Accepted — implemented 2026-06-15 (plan:
docs/plans/20260614-adr-0053-contextual-hint-H2.md; the F1 keybinding +
hint command, the hint_ids per-form keying + hint_key_for_input_in_mode,
last_error_hint_key + friendly::error_hint_class, the note_hint*
renderers, and the hint.cmd.*/hint.err.* corpus for every command form
- the 9 runtime error classes, with the comprehensiveness coverage test
and the ADR-0051 strip advertising F1). Closes A1 + requirements
H2. Deferred: the pre-submit-diagnostic route +
diagnostic.*blocks (#38), clause-concept hints (#37). Revised after a/rundareview (2026-06-14): corrected the verbosity-default fact; re-keyed tier-3 content offhelp_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 ahint_ids: &[&str]array mirroringusage_ids— and clause-concept hints are recorded as a deferred extension (issue #37). During Phase C the pre-submit-diagnostic route + the ~33diagnostic.*blocks were deferred (issue #38) —Diagnosticdoesn'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-apphelpcommand 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 /
expectedset, shown live while typing (ADR-0022, cataloguehint.ambient_*/hint.value_slot_*); and the errorhint:field — which, becauseVerbosity::Verboseis the default (src/friendly/translate.rs:46), is shown by default beneath every error headline (messages shortis the opt-out, notmessages verbosethe 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:
- 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").
hintcommand → most recent error. Submittinghintrenders 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 newhint_ids: &'static [&'static str]field onCommandNode(src/dsl/grammar/mod.rs:512), mirroring the existingusage_ids. The F1 live-input path resolves the current input to its form's hint key viahint_key_for_input_in_mode, which reuses the same form-word disambiguation asusage_key_for_input_in_mode.Why an array mirroring
usage_ids, not a per-nodehint_id(/runda/implementation revision, 2026-06-15): a single per-node key is too coarse. Several entry words are one node spanning many forms —add(column/relationship/index/constraint),drop(table/column/ relationship/index),show(data/table/tables/relationships/indexes),create(table/index). A live-input hint foradd 1:n relationshipis 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_idsis a per-form array, disambiguated by the form word), sohint_idsmirrors it. Single-form nodes carry one entry; multi-form nodes carry one per form. This also covers the advanced-SQL forms whoseusage_idsare empty (SQL_INSERT/UPDATE/DELETE,EXPLAIN_SQL) — they get their ownhint_idsdirectly, independent of usage, with mode-correct SQL examples. (Thehelp-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), thecreate tableconstraint slots (primary/unique/check/foreign),with pk,1:n/m:ncardinality. 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 laterhint.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
REGISTRYgets its ownhint.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 classes —
unique,foreign_key(child/parent side),not_null,check,type_mismatch,not_found,already_exists,generic,invalid_value— each gets ahint.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_table↔not_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:
- This ADR carries 2–3 worked exemplars (below) as the canonical
style reference. The
/rundareview of this ADR is where the voice and depth are approved. - 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 ininput_render.rs/tests are scenario labels, not the key, and ADR-0022 uses noF1requirement label). No collision with the ADR-0049 readline keys,Ctrl-O(ADR-0046),Esc-clear, or the reservedCtrl-Ccancel (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
hintregistered 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 onCommandNode(mirroringusage_ids) + ahint_key_for_input_in_modelookup (reusing theusage_key_for_input_in_modeform-disambiguation), one new field ofAppstate (last_error_hint_key), and one new renderer family (note_hint*); theAppCommandenum gainsHint, the grammar aHINTnode, 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 bykeys.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;
hintwith/without a recent error;last_error_hint_keyset on thetranslate_errorsites and cleared on success; the mode-aware form resolution; the:strip), the command-identification logic, and the tier-2 fallback; Tier-2instasnapshots 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 ahint_idsentry resolves to ahint.cmd.*block, and every runtime error class resolves to ahint.err.*block —keys.rsonly 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). hintfor 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): ahint.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 onDiagnosticthreaded 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 distinctREGISTRYnode (~37), each with its ownhint_idand 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, rawSELECT/WITH) each get their own block with SQL syntax — they do not reuse their simple sibling's (this is the/rundacorrection; the parallelhelp-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). Thediagnostic.*pre-submit classes are deferred (D6 / issue #38).