docs(adr): ADR-0053 — contextual hint command + F1 keybinding (H2)
Settles the `hint` slot ADR-0003 left pending; closes the last open piece of A1. Two surfaces (F1 → live-input hint; `hint` command → last-error expansion), no topic arg, and a new tier-3 teaching corpus keyed on a new CommandNode `hint_id` so advanced-SQL forms get distinct mode-correct content. Comprehensive content for v1, authored exemplars-first. Refines ADR-0003; references ADR-0019/0021/0022/0049/ 0051. Files #36 for the parallel help-side gap.
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
# ADR-0053: Contextual `hint` — F1 live-input keybinding + `hint` command, with a tier-3 teaching corpus (H2)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — implementation pending. Revised after a `/runda` review
|
||||
(2026-06-14): corrected the verbosity-default fact; re-keyed tier-3
|
||||
content on a new `hint_id` (not `help_id`) so every command form — simple
|
||||
and advanced-SQL — gets distinct, mode-correct content; split the
|
||||
pre-submit-diagnostic and runtime-error paths; added a comprehensiveness
|
||||
coverage test. The parallel question of whether the in-app `help` command
|
||||
should likewise distinguish advanced-SQL forms is tracked **separately**
|
||||
as Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour).
|
||||
|
||||
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.
|
||||
|
||||
**Two error sources, one namespace.** Errors come in two kinds and reach
|
||||
`hint` by different routes:
|
||||
|
||||
- **Pre-submit diagnostics** (the ~33 `diagnostic.*` classes — arity,
|
||||
type, unknown table/column) are computed *while typing* by the walker.
|
||||
The **F1 live-input path** reads the current under-cursor diagnostic
|
||||
directly from the walker (the same source the ambient panel uses) and
|
||||
renders its `hint.err.<class>` block — no stored state needed.
|
||||
- **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).
|
||||
|
||||
Both render from the same `hint.err.*` namespace. **`:`-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_id: Option<&'static str>`** field added to `CommandNode`
|
||||
(`src/dsl/grammar/mod.rs:512`, parallel to the existing `help_id` /
|
||||
`usage_ids`). The F1 live-input path resolves the current input to its
|
||||
command node and looks up `hint.cmd.<node.hint_id>`.
|
||||
|
||||
**Why a new field, not `help_id`:** `help_id` is **not** 1:1 with
|
||||
command forms. The 7 advanced-mode SQL nodes (`SELECT`, `WITH`,
|
||||
`SQL_INSERT/UPDATE/DELETE`, `EXPLAIN_SQL`) carry `help_id: None` *purely
|
||||
to dedup the `help` command's printed list* (they share an entry word
|
||||
with a simple sibling — see `grammar/mod.rs:915-918`), not because they
|
||||
lack distinct content. Their SQL syntax differs from the simple-DSL
|
||||
sibling's, so they **must get their own tier-3 block**. A dedicated
|
||||
`hint_id` gives every one of the ~37 REGISTRY nodes — simple and
|
||||
advanced-SQL alike — its own key and its own mode-correct example, with
|
||||
no sharing or deferral. (The analogous gap in the `help` command is out
|
||||
of scope here — issue #36.)
|
||||
- **`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 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) needs no
|
||||
stored state: it reads the current diagnostic from the walker at F1 time
|
||||
(D2). This is the cleaner split the `/runda` pass surfaced — typing-time
|
||||
diagnostics and post-submit runtime errors are genuinely different
|
||||
sources and should not be funnelled through one stored key.
|
||||
|
||||
### D6 — Content scope: comprehensive for v1
|
||||
|
||||
v1 ships tier-3 content for the **whole inventory**, not a subset (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` (×4 sides),
|
||||
`not_null`, `check`, `type_mismatch`, `not_found`, `already_exists`,
|
||||
`generic`, `invalid_value` — each gets a `hint.err.*` block.
|
||||
- **~33 `diagnostic.*` pre-submit classes** — arity, type, unknown
|
||||
table/column, etc. — each gets a `hint.err.*` block.
|
||||
|
||||
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 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_id` field on `CommandNode`** (parallel to `help_id`), 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 large, durable content corpus** (~37 command blocks + ~42 error/
|
||||
diagnostic blocks ≈ 80) 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).
|
||||
- **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 pre-submit-diagnostic vs runtime-error routing; 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 has a `hint_id` resolving to a `hint.cmd.*` block,
|
||||
and every runtime-error/diagnostic class has 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 "comprehensive"
|
||||
enforceable rather than aspirational.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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`) and per `diagnostic.*` pre-submit class.
|
||||
Reference in New Issue
Block a user