# 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 `/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. (No "expected next" line — the always-on tier-2 ambient panel already shows it live; tier-2 owns position-awareness.) |
| **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 forms** —
  `add` (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:

```yaml
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 the `App::note_hint*` family (sibling of
`note_help`/`note_help_topic`, `src/app.rs`) via `emit_tier3_block`,
emitting into the `output` buffer as `OutputKind::System`: a **`Hint`
heading** followed by aligned **`What:` / `Example:` / `Concept:`** lines
(labels + heading from `hint.block.*`). The `concept` line is muted
(`OutputStyleClass::Hint`); the rest are plain. 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. Its rendered shape is locked by an `insta` snapshot
(`hint_block_insert`). The bottom keybinding strip (ADR-0051) advertises
F1 in the editing (leading) and default states.

### 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 classes** — `unique`, `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_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:

1. This ADR carries **2–3 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; shipped as the rendered format)

**Command (F1 live-input), `insert`** (the rendered shape, locked by the
`hint_block_insert` snapshot — a `Hint` heading + aligned labels, no
`Next:` line since tier-2 owns position-awareness):

```
Hint
  What:    Add one or more rows to a table.
  Example: insert into Customers values ('Ann', 'ann@example.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.
```

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

```
Hint
  What:    The value you gave for the child column doesn't match any
           parent row, so the foreign key has nothing to point at.
  Example: First insert the parent (insert into Customers …), then the
           child that references it.
  Concept: A foreign key is a promise that every child points at a real
           parent, so 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
  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).
