# ADR-0022: Ambient typing assistance — colour, hint panel, completion (I3 + I4)

## Status

Accepted.

Subsumes the originally-planned I4 (syntax highlighting) and
I3 (tab completion) into a single coherent feature: ambient
typing assistance. Builds on ADR-0020 (the lexer + parser-over-tokens
that this ADR consumes) and ADR-0021 (per-command usage
templates, which this ADR promotes from on-submit-only to live
ambient feedback). Cross-references ADR-0001 (theme conventions),
ADR-0010 (worker-thread access for schema queries), ADR-0019
(catalog conventions for any new strings), and ADR-0009 (the
closed grammar surface that defines what each token class means).

## Context

ADR-0019, 0020, and 0021 closed the on-submit feedback gap: a
parse error now renders with a caret, a structural / custom
message, and a per-command usage template. Once the user
hits Enter, they get a thorough explanation.

What's missing is *during* typing. Today the input field
renders as plain foreground colour with one inverted character
at the cursor. There is no signal for any of:

- "is `crete` a keyword or a typo?";
- "what comes after `create table Customers`?";
- "is `Customers` actually a table that exists?";
- "did I open this string and forget to close it?";
- "does Tab do anything here?".

Three separate mechanisms are commonly built to fix this:
**syntax highlighting** (colour), **tab completion** (Tab
key), and a **hint / status surface** (in our case the
already-present hint panel, which has been empty most of the
time). Each is correct on its own but they answer the same
underlying question — *what does the user need to know mid-typing?*
— and planning them separately produces three loose pieces
that drift apart in voice, mechanism, and what they cover.

The framing this ADR adopts:

- Colour is the **silent always-on** layer.
- The hint panel is the **verbose always-visible** layer.
- Tab is the **accept-this-suggestion action**.

These layer cleanly, with each one taking the load the other
two cannot bear:

- Colour catches lexer-level garbage and signals "this
  token is in error" instantly, but cannot explain why.
- The hint panel always says what comes next in prose, but
  cannot tell the user "the third character you typed is
  wrong" — colour does that.
- Tab does nothing on its own; the hint panel tells the
  user when Tab would help.

The hint panel is the central design move: it converts the
"Tab opens a popup over your text" anti-pattern into "the
already-visible panel becomes the chooser when Tab activates
it", which keeps screen real estate stable and removes any
modal floating UI.

## Decision

### 1. Three mid-typing states

Every keystroke produces one of three classifications, derived
from `lex(input)` followed by `parse_tokens(...)`:

1. **Valid-so-far.** Lex produces zero `Error` tokens AND
   parse succeeds OR parse fails at end-of-input (more input
   would make it valid).
2. **Definite error.** Lex produces an `Error` token, OR
   parse fails at a position before end-of-input (a token
   appears where no production accepts it).
3. **Incomplete-but-plausible.** A subset of valid-so-far:
   parse fails at end-of-input. The user's input is on
   track but not done.

The three states drive what colour, hint, and tab all do.

### 2. Layered feedback channels

Four channels operate independently, layered:

| Channel              | Layer        | Always on | Driver              |
|----------------------|--------------|-----------|---------------------|
| Token-class colour   | Silent       | Yes       | Lexer               |
| Error overlay        | Silent       | When applies | Parse position    |
| Hint panel ambient   | Verbose      | Yes       | Parse expected-set + state |
| Hint panel completion| Verbose      | Tab-triggered | User action       |

Each channel knows its layer's job and stays in it.

### 3. Token-class colour (the original I4 scope)

`Theme` gains seven token-class colour fields:

```rust
pub struct Theme {
    // ... existing fields ...
    pub tok_keyword: Color,
    pub tok_identifier: Color,
    pub tok_number: Color,
    pub tok_string: Color,
    pub tok_punct: Color,
    pub tok_flag: Color,
    pub tok_error: Color,
}
```

Both `Theme::dark()` and `Theme::light()` populate all seven
with WCAG-AA-contrasting values. Where two classes can
plausibly share `theme.fg`, the field still exists so a
future palette refresh can distinguish them without code
churn.

`ui::render_input_panel` runs `lex(&app.input)` per render.
Each token contributes a styled `Span` using its class
colour; whitespace gaps are preserved as `theme.fg` spans.
The cursor reverse-styling is injected by splitting the span
that contains the cursor's byte position into
(before, under, after) sub-spans, marking `under` REVERSED
while preserving its colour.

Per-render lex is microsecond-cheap. No caching.

### 4. Error overlay (parse-error highlighting on the failing token)

Render-time parse, in addition to lex. Then:

- If parse succeeds, no overlay.
- If parse fails at end-of-input (incomplete-but-plausible),
  no overlay.
- If parse fails at a token position before end-of-input,
  the token at that position renders with `theme.tok_error`
  overlay (foreground only, no underline / reverse — see
  reasoning in §6 of the previous draft).
- Lex `Error` tokens always render with `theme.tok_error`
  regardless of parse outcome.

Subsequent tokens after the error position render in their
normal lex-class colours. Rationale: the user fixes one
thing at a time; cascading red across the rest of the input
is visually overwhelming and rarely informative.

### 5. Echo lines: same colour treatment for simple-mode echoes

`render_output_line` for `OutputKind::Echo + Mode::Simple`
peels the catalog-bound `running: ` prefix, lexes the rest,
and renders the tokens identically to the input panel.
Advanced-mode echoes stay plain. Lift the
`dsl::ECHO_PREFIX = "running: "` constant + a unit test
asserting `t!("dsl.running", input = "")` matches it (so a
translator changing the prefix breaks the test, not the
render).

### 6. Hint panel ambient mode (always visible)

The hint panel currently shows `panel.hint_empty` when
`app.hint` is `None`. This ADR repurposes it as the verbose
typing-assistance surface. Three sub-states, one per
mid-typing state:

- **Valid-so-far + complete** (parse succeeds, all tokens
  consumed): "submit with Enter" — short and unobtrusive.
- **Valid-so-far + incomplete-but-plausible** (parse fails at
  EOF): "expected: <oxford-joined expected set>". Pulls
  from chumsky's expected-set at the failure point. For
  multi-entry families ("`add` family expects `column` or
  `1`") this is one line of natural prose.
- **Definite error**: "<short description of error> — usage:
  <template>". The usage template is the same `parse.usage.*`
  catalog content rendered on submit (ADR-0021), now also
  surfaced live. Multi-entry families render the matching
  family member only, picked by the same `usage::matched_entry`
  logic. Keeps the panel one-or-two-lines-tall.

Empty input keeps the existing `panel.hint_empty` content —
the hint panel only takes over when the user starts typing.

The catalog gains a small set of templates for these states:

```yaml
hint:
  ambient_complete: "submit with Enter"
  ambient_expected: "expected: {expected}"
  ambient_error_with_usage: "{message} — usage: {usage}"
```

### 7. Hint panel completion mode (Tab-triggered)

When the user presses Tab on a slot with multiple candidates,
the hint panel switches into completion mode. Visually the
panel shows the candidate list with one highlighted; the
input field is unchanged.

Mechanics:

- **Single candidate** → Tab inserts it immediately (with a
  trailing space if the next token would expect one). No
  mode change.
- **Multiple candidates** → Tab opens completion mode. The
  hint panel shows the candidates, one per line (or
  comma-joined if they fit), with the first highlighted.
- **Zero candidates** → Tab is a no-op.

While in completion mode, the input event flow shifts:

| Key                 | Behaviour                                  |
|---------------------|--------------------------------------------|
| Tab / Down          | Move highlight to next candidate           |
| Shift+Tab / Up      | Move highlight to previous candidate       |
| Enter               | Accept highlighted candidate               |
| Esc                 | Close completion mode without accepting   |
| Letter / digit / `_`| Append to input AND narrow candidate list  |
| Backspace           | Delete from input AND widen candidate list (or close mode if empty) |
| Other (cursor moves, paste, etc.) | Close completion mode, then process key normally |

Completion mode is a sticky state on `App` (`completion: Option<CompletionState>`).
It survives until accepted, cancelled, or the input changes
in a way that no candidate matches anymore (close + return
to ambient).

### 8. Identifier slot taxonomy

Identifier completion needs to know *what kind of identifier*
fits at the cursor. The parser is the only thing that knows
this — every `ident()` call has a semantic role. We make
that role explicit by replacing `ident()` with a tagged
variant at each call site:

```rust
pub enum IdentSlot {
    /// A name the user is inventing — no completion candidates.
    /// Examples: new table name, new column name, new relationship
    /// alias.
    NewName,
    /// An existing table.
    TableName,
    /// An existing column in a specific table. The table is bound
    /// by an earlier-consumed token in the same command.
    ColumnIn(TableRef),
    /// An existing relationship.
    RelationshipName,
}

pub enum TableRef {
    /// The table identifier is the Nth `ident()` call in this
    /// command's parser combinator chain.
    Earlier(usize),
}
```

`ident_ctx(slot)` is a small wrapper around the existing
`ident()` combinator that records the slot type alongside
the parsed identifier in the parser's `extra` data. The AST
itself is unchanged — slot tags are render-time concerns,
not AST concerns.

Concretely, every `ident()` call in `dsl/parser.rs` is
audited and becomes one of:

```rust
ident_ctx(IdentSlot::NewName)            // create table <Name>
ident_ctx(IdentSlot::TableName)          // drop table <T>
ident_ctx(IdentSlot::ColumnIn(Earlier(0)))  // drop column <T>: <Col>
// etc.
```

A unit test asserts every parser combinator references
`ident_ctx`, not bare `ident()`, so future commands can't
forget to tag.

The taxonomy is intentionally minimal for v1. Future
extensions (e.g. relationship endpoints needing both
parent-table and child-table awareness) are amendments to
this enum.

### 9. Schema query plumbing

The completion engine needs current schema data to enumerate
candidates for `TableName`, `ColumnIn`, and `RelationshipName`
slots. Per ADR-0010, all database access goes through the
worker thread.

Decision: a single new request:

```rust
Request::ListNamesFor(IdentSlot) → NameList
pub struct NameList { pub names: Vec<String> }
```

The worker computes from cached metadata; reply is small
and cheap. The completion engine fires this on Tab when
the slot is identifier-typed; until the reply arrives the
hint panel shows "(loading…)". For a teaching tool with a
small schema, the reply is effectively instantaneous; we
do not pre-cache or invalidate-on-write.

If schema mutates between Tab and Enter (the user pressed
Tab, then a background event changed the schema, then
Enter), the worst case is the user accepts a stale name —
caught by the next parse on submit. Acceptable.

### 10. Tab on keyword slots

Keyword candidates come from the parser's expected-token-set
at the cursor. The expected-set is the same data that
already drives the structural error wording (ADR-0020 §9
hook). For a slot with one keyword candidate, Tab inserts
it directly; for multiple, Tab opens completion mode with
the keyword list.

Punctuation that the parser expects (`:`, `(`, `,`) is
*not* offered by Tab — punctuation is too short to benefit
from completion, and inserting it without a following
identifier is an awkward halfway state. Tab on a slot whose
expected-set is purely punctuation is a no-op; the hint
panel's ambient prose still names what's expected.

### 11. Cursor position interpretation

The completion engine needs to know what slot the cursor is
at. Two cases:

- **Cursor at end of input**: the slot is whatever comes
  next after the last consumed token. Easiest case.
- **Cursor inside the input**: split the input at the
  cursor, lex the prefix, parse the prefix, and treat the
  failure-at-EOF state as the cursor slot. The suffix (the
  text after the cursor) is ignored for completion purposes.

This means tab completion in the middle of a typed-out
command works the same way as at the end — the completion
engine pretends the input ends at the cursor.

### 12. Mode interaction (simple vs. advanced; persistent vs. one-shot)

Ambient typing assistance is a **simple-mode** feature.
Advanced mode (persistent or `:`-one-shot) renders input
plain, has no hint panel ambient content beyond
`panel.hint_empty`, and Tab is a no-op. Justification: the
DSL lexer + parser don't speak SQL; using them for SQL
input would mark almost everything as Identifier/Error and
mislead. Future SQL-subset ADR (Q4) decides whether to
extend this.

The mode-banner colour (red for advanced, blue for simple)
already gives the user a strong "you are in a different
mode" signal; the absence of typing assistance reinforces
it.

### 13. Performance posture

Per render: lex + parse on the current input. For typical
input sizes (~80 chars, ~10 tokens), this is well under
100 µs total — orders of magnitude under ratatui's frame
budget. Schema queries on Tab incur one worker round-trip
(~1 ms in practice); the hint panel shows `(loading…)`
between Tab and reply. No caching, no debouncing in v1.

If profiling later shows render-loop pressure, options are
(a) cache the parse result keyed on input-string identity,
(b) lex+parse only on input change, not every render. Both
are local optimisations; not part of this ADR.

## Amendment 1 — Advanced-mode ambient assistance re-enabled (2026-05-21)

This amendment **supersedes §12's carve-out** that ambient
typing assistance is a simple-mode-only feature.

### The obsolete premise

§12 disabled ambient assistance in advanced mode because, at the
time, "the DSL lexer + parser don't speak SQL; using them for SQL
input would mark almost everything as Identifier/Error and
mislead." That premise no longer holds. ADR-0030 / ADR-0031 /
ADR-0032 moved the SQL surface into the **same unified, mode-aware
walker grammar** (ADR-0023 / ADR-0024). The walker now speaks SQL:
in `Mode::Advanced` it parses `SELECT` (and, from ADR-0033, DML),
highlights SQL keywords, resolves slot hints, and produces
completion candidates — exactly the inputs ambient assistance
needs.

### The bug this fixes

Despite the walker being mode-aware, the **UI never surfaced**
advanced-mode ambient assistance:

- `render_hint_panel` hard-returned `None` for advanced mode (the
  stale §12 gate), so the hint panel showed only `panel.hint_empty`
  — no prose hints and no candidate preview for SQL.
- The hint resolver (`hint_resolution_at_input` →
  `expected_for_hint_snapshot`) and `ambient_hint` never threaded
  the mode, so even the engine-level calls defaulted to
  `Mode::Simple` and gated a SQL statement as "this is SQL".

The result: in advanced mode, hinting and completion-preview for
SQL were completely dead, even for a bare `SELECT`.

This gap survived Phase 2 because ADR-0032's cross-cut matrix rows
for "Tab completion works for SQL keywords" / "Hint-panel prose
appears at every SQL slot" were validated by **engine-level** tests
(`completion_probe_in_mode(…, Advanced)`, `hint_mode_*` called
directly) — which prove the walker *can* produce SQL
hints/candidates but never exercise the UI that suppressed them.
This is the "free-for-free claim shipping without a real-app test"
failure mode the project's process pins call out.

### What changed

- **`ambient_hint_in_mode(input, cursor, memo, cache, mode)`** —
  the mode-aware ambient entry point. `ambient_hint` is now a thin
  wrapper that forwards `Mode::Simple`. Its sub-calls
  (`input_diagnostics_in_mode`, `hint_resolution_at_input_in_mode`,
  `candidates_at_cursor_in_mode`, the fallback `parse_command_in_mode`)
  all run in the supplied `mode`.
- **`hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot`**
  now set `ctx.mode`, so the hint walk respects the active mode.
- **`render_hint_panel`** calls `ambient_hint_in_mode` with the
  effective mode for *all* modes (no more advanced-mode `None`).
- **One-shot `:` handling.** In one-shot advanced mode the raw
  input carries the `:` sigil, which is not part of the grammar.
  The panel strips it (mirroring `App::submit`) before the ambient
  walk, so `: sel` hints `select` rather than the sigil.

### What still holds

- **Simple mode is unchanged.** The simple-mode entry point keeps
  gating SQL as "this is SQL"; advanced assistance is opt-in via
  mode, never leaked into simple mode (regression-locked).
- **Syntax highlighting** already ran with `Mode::Advanced` and is
  unaffected.
- **The validity indicator** was already mode-aware (ADR-0032
  §10.6); this amendment aligns the ambient hint panel with it.
- **§13 performance posture** is unchanged — one walk per render,
  now in the active mode.

### Coverage

App-level regression at the layer Phase 2 missed:
`src/ui.rs::advanced_mode_hint_panel_surfaces_sql_candidates`
(renders the panel in advanced mode and asserts the FROM-slot
table candidate appears). Ambient-layer locks:
`src/input_render.rs::advanced_mode_ambient_offers_sql_from_slot_candidate`
and `simple_mode_ambient_does_not_surface_sql_candidates`.

### 14. Catalog additions

```yaml
hint:
  ambient_complete: "submit with Enter"
  ambient_expected: "expected: {expected}"
  ambient_error_with_usage: "{message} — usage: {usage}"
  completion_loading: "(loading suggestions…)"
  completion_none: "no completions for this position"
panel:
  hint_completion_title: "Completions ({count})"
  # The existing `panel.hint_empty` keeps its current text.
```

### 15. Snapshot test churn

Existing UI snapshots in `src/snapshots/` cover the input
and output panels at fg-only colour. The render changes
re-baseline those snapshots. New snapshots cover:

- Input field with one token of each lex class.
- Input field with a definite-error overlay.
- Input field with cursor mid-token.
- Hint panel in each ambient sub-state.
- Hint panel in completion mode with multiple candidates.
- Hint panel after Tab on a single candidate (visual confirm
  of inserted text + closed completion mode).

The snapshots are the regression net for "did we change the
visual output unexpectedly".

## Amendment 2 — Candidate ordering: schema identifiers before keywords (2026-05-21)

This amendment **reverses the candidate-ordering call made in the
handoff-14 ranker discussion** (keywords before schema
identifiers). That call was never recorded in an ADR — it lived
only in `tests/typing_surface/candidate_ordering.rs` — so this
amendment also gives the ordering a decision record.

### The obsolete premise

Handoff-14 ordered command-part keywords before schema
identifiers on the rationale that "grammar parts are read before
the content that fills them," so `add column to table T` reads in
order. That held while candidate lists were short. The SQL surface
(ADR-0030/0031/0032/0033) made lists long — an expression position
such as `where Name ` or `order by ` legitimately offers the
column names *plus* the full expression-continuation keyword run
(`is not like between in and or`, plus `asc`/`desc` in ORDER BY).

The hint panel's candidate line is **single-row and
window-scrolled** (`render_candidate_line`): when it overflows it
centres on the selected item, or on item 0 when nothing is
selected (the ambient, just-typed state). With keywords first, the
schema identifiers sat at the tail and scrolled off behind the `>`
marker — invisible until the user Tab-cycled to them.

### The decision

Schema identifiers (table / column / relationship names) now sort
**before** keywords in the candidate list. A name the user would
otherwise have to look up is the highest-value completion —
valuable even to experts, who come to know the keywords over time —
so it must stay visible by default. Within each section the prior
rules are unchanged: identifiers alphabetised; keywords in
grammar-declaration order (`to` before `table`); then type names,
composite literals, branching punct, flags. The existing colour
split (`tok_identifier` teal vs `tok_keyword` purple, §colour)
makes the section boundary legible once both are on screen.

### Related fix — Repeated trailing optionals

This amendment shipped alongside a `walk_repeated` fix: a
comma-separated list (`Repeated`) was discarding the last matched
item's trailing-optional expectations at a clean item boundary, so
`order by Name ` offered no `asc`/`desc`, `select Name ` no `as`,
and `create table … Code(text) ` no `not`/`unique`/`default`/
`check`. Those now surface (the separator `,` itself is
deliberately not surfaced). This is what made identifier
visibility pressing — the lists these positions produce are now
both correct and long.

### Deferred — two-line hint box

As hint lists grow, a **two-line candidate box** (more candidates
visible without scrolling) is worth considering. Deferred for now
on screen-space grounds; recorded so it is not lost.

### Coverage

`tests/typing_surface/candidate_ordering.rs` rewritten to assert
identifiers precede keywords (header invariant #2 inverted; the
`to`-before-`table` keyword-order invariant #1 retained).
`completion::tests::identifiers_come_before_keywords_in_grammar_order`
and `identifiers_precede_keywords_at_expression_position` lock the
ordering; `order_by_after_sort_item_offers_direction`,
`projection_after_item_offers_alias_keyword`, and
`create_table_after_column_spec_offers_constraints` lock the
trailing-optional fix. ~20 typing-surface snapshots re-baselined.

## Amendment 3 — Ambient-hint fallback parses with the schema (2026-05-29)

Amendment 1's "What changed" listed *the fallback
`parse_command_in_mode`* among the sub-calls threaded through the
active `mode`. That fallback (the bottom rung of `ambient_hint`'s
candidate-or-prose ladder, which produces the `ambient_complete` /
`ambient_expected` / `ambient_error_with_usage` prose) was
**schemaless** — it took the input and mode but not the `SchemaCache`,
even though every earlier rung (`input_diagnostics_in_mode`,
`hint_resolution_at_input_in_mode`, `candidates_at_cursor_in_mode`)
already ran schema-aware. That was an oversight, not a decision: the
ADR's whole intent is a schema- and mode-consistent hint surface.

The gap surfaced as a user-reported bug (issue #2). Between two values
of an `insert … values (…)` tuple, the type-blind (schemaless) grammar
closes the tuple after one value, so the expected-token prose pointed
at `)` when the schema-aware grammar — knowing the remaining columns —
expects `,`. The same schemaless parse also accepted wrong-arity closed
tuples as complete, so the prose read *"submit with Enter"* for an
input the schema-aware parse (and the on-submit path) rejects.

**Change:** the fallback rung now calls
`parse_command_with_schema_in_mode(input, cache, mode)`. The whole
ladder is now schema-consistent. No new walk — the call site already
held the `cache`; this swaps which parse function consumes it.

**What still holds:** the friendly arity diagnostic (ADR-0033 §8.1) is
checked at a higher rung, so where it fires (advanced-mode wrong-arity
tuples) it still wins over this fallback — locked by
`advanced_mode_wrong_arity_insert_keeps_friendly_diagnostic_over_fallback`.
The §13 one-walk-per-render posture is unchanged.

**Coverage:** `ambient_hint_between_values_points_to_comma_not_close_paren`,
`ambient_hint_after_last_value_points_to_close_paren`, the no-masking
guard above, and three re-baselined `typing_surface` snapshots
(`form_a_in_progress_one_value`, `form_b_too_few_values`,
`form_c_wrong_count`).

## Amendment 4 — Column types get a dedicated highlight class (2026-05-29)

§3 introduced **seven** token-class colour fields and the matching
`HighlightClass` enum. Column data-type keywords (`int`, `serial`,
`text`, …) had no class of their own: the walker driver hardcoded
`HighlightClass::Identifier` for every matched `Node::Ident`, so a
type rendered identical to a table/column name (clause keywords were
already distinct, in `tok_keyword`). A user-reported bug (issue #8)
noted that in `create table Orders (count int, id serial PRIMARY KEY)`
the identifiers and the type keywords were indistinguishably teal.

Both terminal kinds already carried a `highlight_override:
Option<HighlightClass>` field — `Node::Ident` and the `Word` struct
alike — but **both were dead**: the driver destructured the Ident's to
`_`, and `walk_word` hardcoded `Keyword`, neither ever consulting the
field. This amendment wires both through.

**Change:**

1. **New class.** `HighlightClass::Type` (the eighth variant) and a
   matching eighth `Theme` field `tok_type`, populated in both
   `dark()` and `light()` with a tone deliberately distinct from both
   `tok_keyword` and `tok_identifier` (a pink / deep-magenta in the
   red-purple range — the only free slot in the existing palette).
   `highlight_class_color` maps the new variant.
2. **Overrides are now live.** `walk_ident` emits
   `override.unwrap_or(Identifier)` and `walk_word` emits
   `override.unwrap_or(Keyword)` for the matched byte range. Every
   slot that leaves the field `None` is unchanged.
3. **Type slots opt in.** The three `IdentSource::Types` slots —
   `shared::TYPE_SLOT` (`add column` / `change column`), the inline
   create-table column-type Ident (`ddl.rs`), and `sql_create_table::
   SQL_TYPE_NAME` (advanced) — set `highlight_override: Some(Type)`.
   In advanced mode, every single-word SQL type-name *alias*
   (`float`, `varchar`, `integer`, … — ADR-0035 §3) flows through
   `SQL_TYPE_NAME` and so is type-coloured for free.
4. **`double precision` too.** The lone two-word alias (ADR-0035 §3)
   is matched as keyword tokens, not an `IdentSource::Types` Ident, so
   it cannot ride rule 3. Its `double` / `precision` grammar nodes use
   the new `Word::type_keyword` constructor (`highlight_override:
   Some(Type)`) so the spelling renders type-coloured like its
   single-word synonyms `float` / `real`.

**Pedagogy:** a dedicated colour (over the lighter option of reusing
`tok_keyword`) lets a learner tell *"this is a type"* from a clause
keyword and from a name they invented — three distinct roles, three
distinct colours.

**Coverage:** `dsl_type_keyword_classified_as_type`,
`advanced_type_keywords_classified_as_type` (the ticket's exact
example), `advanced_double_precision_classified_as_type`,
`type_colour_is_distinct_from_keyword_and_identifier`, and the
extended theme mapping/contrast tests. Text snapshots are colour-blind
(`render_to_string` strips style), so none churned.

## Amendment 5 — Hint panel grows for long prose hints (2026-05-29)

§6 specified a single-row hint panel. A long **prose** hint
(field-value hints on `insert`, the `parse.usage.*` synopses) simply
didn't fit: the renderer wrapped it but the fixed one-row panel
clipped everything past the first line, hiding the most useful tail
(issue #12). The candidate-list path was already safe — it scrolls
horizontally with `<` / `>` markers (Amendment 2's deferred *two-line
candidate box* remains deferred; this amendment does **not** touch
candidate rendering).

**Change:**

1. **Dynamic height.** `resolve_hint_lines` pre-wraps the prose body
   to the panel's inner width and `render_right_column` sizes the
   Hint panel to the line count — one content row by default, growing
   to `MAX_HINT_ROWS` (3), and reclaiming the space when the hint is
   short or empty. The candidate list stays one row.
2. **Ellipsis backstop.** `clamp_wrapped` caps the wrap at
   `MAX_HINT_ROWS`; if the hint needs more, the last visible row ends
   with `…` so overflow is signalled, not silently dropped. (A hint
   long enough to ellipsize on a narrow terminal fits in full on a
   wide one.)
3. **Shorter `create table` synopsis.** `parse.usage.sql_create_table`
   was a 299-char "instruction manual" — by far the longest hint
   string (next was 115). It is now a terse one-liner
   (`create table [if not exists] <Name> (<col> <type> [constraints],
   ...)`); the exhaustive column- and table-level grammar still lives
   in `help.ddl.sql_create_table`, reachable via `help`. The
   `insert.form_b_*` notes (the other long strings) render in the
   scrollable Output panel, not the Hint panel, so they were left
   alone.

**Why 3 rows:** the panel is usually wide (most hints fit one or two
rows), and capping at three keeps the Output panel usable; the value
can be revisited if hints routinely need more.

**Coverage:** `long_prose_hint_shows_tail_across_multiple_rows`,
`short_hint_keeps_panel_at_one_content_row` (reclaim),
`long_hint_grows_panel_but_caps_at_max_rows`,
`clamp_wrapped_truncates_with_ellipsis_past_max`, and six re-baselined
full-screen snapshots (the empty-state placeholder and the `insert`
usage hint now wrap to their full text instead of being clipped).

## Amendment 6 — Curated SQL function names: completion + typing-time typo hint (2026-05-30)

The advanced-mode SQL expression grammar (ADR-0031) accepts a
function-call *shape* — `name(args)` — at its `sql_expr_ident` slot
but, by design, does **not** know which names are real functions
(ADR-0031 §1: the slot is `IdentSource::Columns`, optimised for the
common case of a column reference; the walker is a structural matcher,
not an evaluator). That left two gaps at this slot, raised as issues
#15 and #16:

- **#16 (regression).** The earlier issue-#6 function-call validator
  fix dropped `invalid_ident_at_cursor`'s "no such column" flag at
  every `sql_expr_ident` position — necessary to stop a false positive
  on a function name like `sum`, but it also silenced the typing-time
  signal for a *genuine* column typo in an incomplete expression
  (`select Agx` before a `FROM` brings the schema-existence diagnostic
  into scope). Typing-time became *less* eager than submit-time.
- **#15 (discovery).** Tab at a `sql_expr_ident` slot offered schema
  columns + a few expression keywords (`null`, `distinct`, `case`, …)
  but no function names, so a learner had to already know `sum` / `avg`
  / `upper` to type them.

Both want the same thing: a single source of truth for *"what SQL
function names does this playground recognise"*.

**Change:**

1. **Curated list.** New `src/dsl/sql_functions.rs` with
   `KNOWN_SQL_FUNCTIONS` (sorted, lowercase — a pinned invariant) and
   an `is_known_function_prefix()` helper. A deliberately *curated
   pedagogical set*, not "every SQLite built-in": the aggregates a
   learner meets first (`count`/`sum`/`avg`/`min`/`max`), the common
   scalars (`length`/`upper`/`lower`/`trim`/`substr`/`coalesce`/`abs`/
   `round`), and a broader scalar tier (`date`/`datetime`/`strftime`/
   `hex`/`ifnull`/`nullif`/`replace`/`instr`/`typeof`/`random`).
   **`cast` is deliberately excluded** — SQLite's `CAST(expr AS type)`
   is not a plain-call shape the expression grammar parses, so
   offering it would surface a candidate that does not complete; it
   stays out until the grammar grows a dedicated `CAST` form.
2. **#16 — restore the typo flag, narrowly.** `invalid_ident_at_cursor`
   no longer bails wholesale at a `sql_expr_ident` slot. It bails only
   when the partial prefix-matches a known function name; otherwise it
   falls through to the existing schema-column check, which flags "no
   such column" unless the partial prefix-matches a real column. So
   `select Agx` warns again at typing time while `select sum` does not.
   The submit-time `unknown_column` diagnostic path is untouched; the
   issue-#6 lockdown tests (`genuine_column_typo_in_complete_select_…`,
   `advanced_select_partial_function_name_not_flagged_…`) still pass.
3. **#15 — offer functions as candidates.** A new completion source
   (Source 1.8) contributes `KNOWN_SQL_FUNCTIONS` (prefix-filtered like
   every other source) whenever the expected set contains a
   `sql_expr_ident` slot, ordered after keywords/types (a learner
   reads clause keywords first, then discovers callables).
4. **New `CandidateKind::Function` + `tok_function` colour.** Like
   Amendment 4 gave types their own class, function candidates get a
   dedicated kind and a ninth `Theme` colour field (`tok_function`,
   a blue distinct from keyword purple, identifier teal, and type
   pink/magenta in both `dark()` and `light()`) so a callable reads
   apart from a clause keyword, a column reference, and a column type.

**No-validation-allowlist posture stands (ADR-0031 §6/§7).** The list
drives *completion* and the *typo hint* only — never parse-time
acceptance. An unknown or engine-specific function still parses (the
grammar admits the call shape generically) and surfaces an
engine-neutral *execution* error, exactly as before.

**Pedagogy:** the same dedicated-colour rationale as Amendment 4 — a
learner can tell *"this is a function"* at a glance, and Tab now
*teaches* the function vocabulary instead of assuming it.

**Coverage:** `sql_functions::{list_is_sorted_and_lowercase,
list_has_no_duplicates, cast_is_excluded, prefix_match_is_case_insensitive,
empty_prefix_matches_all, unknown_prefix_does_not_match}`;
`completion::{sql_expr_slot_offers_known_function_candidates,
projection_slot_offers_known_function_candidates,
sql_function_candidates_filter_by_prefix,
sql_function_candidates_carry_function_kind,
function_candidates_absent_at_non_expression_slots,
cast_is_not_offered_as_a_function_candidate,
invalid_ident_fires_for_genuine_typo_at_sql_expr_slot,
invalid_ident_does_not_fire_for_function_prefix_at_sql_expr_slot,
invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot}`;
`input_render::advanced_select_genuine_column_typo_before_from_warns_at_typing_time`;
`theme::function_colour_is_distinct_from_keyword_identifier_and_type`.
See ADR-0031's status note for the grammar-side anchor.

## Amendment 7 — optional positional args reach the hint panel (2026-06-12)

Issue #26. At `seed <table> ▮` the hint panel showed only the
`set` / `--seed` continuation chips and never mentioned the
**optional row count** — even though a count (`seed users 50`) is
the most common next move. The count is a bare positional
`NumberLit` with no keyword/candidate text, so the candidate ladder
can't surface it; and `seed <table>` is already a *complete*
command, so the hint resolver short-circuits (empty expected set).

The existing `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node;
issue #4's CREATE-TABLE element hint) is the right tool — it shows
prose that *introduces* a position whose first-class move has no
candidate, with the keyword alternatives folded into the prose and
Tab still cycling them. But it did not reach this position: a
`Node::Hinted`'s mode lives in `pending_hint_mode`, which the very
next match clears — including the **empty** match of a skipped
`Optional`. The CREATE-TABLE element survives only because it sits
in a *required* `Repeated(min:1)`; an optional positional followed
by more optionals (the seed count) is cleared before the resolver
reads it.

### Mechanism

A small, general carry: when `walk_optional` skips its inner (the
inner didn't engage), it stashes any `IntroProse` key the inner
left in `pending_hint_mode` into a new `WalkContext` field,
`surviving_intro_hint: Option<(key, position)>`, **before** the
empty match clears `pending_hint_mode`. The trailing optionals,
which are not `IntroProse`, don't overwrite it. The hint snapshot
keeps the key **only when `position == cursor`** (the slice end),
so it shows while the cursor sits at the count slot but not once a
later clause (`set …`) consumes input past it, nor once the count
itself is supplied. The resolver returns that `IntroProse` even for
an otherwise-complete command (ahead of the empty-expected
short-circuit).

The seed grammar wraps the count in
`Hinted { IntroProse("hint.seed_count"), NumberLit }`; the prose
names the count (with its default 20) plus the `.column`
column-fill form and the `set` / `--seed` keywords (user-chosen
scope: mention every option). Only `IntroProse` is carried —
`ProseOnly` / `ForceProse` mark *active* slots and reach the
resolver through the normal path, unchanged. The CREATE-TABLE
element (in a `Repeated`, not an `Optional`) is untouched.

This is a refinement of ADR-0024 §HintMode-per-node and a sibling
of issue #4; no `AmbientHint` / renderer change. Covered by
`input_render::{seed_count_is_advertised_at_the_optional_position,
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
seed_count_hint_also_fires_after_a_column_fill_target}`.

## Amendment 8 — submission gate: hold input until the schema-cache refresh lands (2026-06-22)

§9 established the schema cache as the source of truth for the
walker's schema-aware dispatch, "refreshed by the runtime … after
successful DDL". That refresh is **asynchronous**: the runtime applies
a command on the worker thread (ADR-0010) and only afterwards posts a
`SchemaCacheRefreshed` event back through the **same FIFO channel that
carries key events**. Between dispatching a DDL command and that event
landing, `schema_cache` is stale w.r.t. the command just run.

Validation in `App::update` is pure-sync (a core invariant — it cannot
do a DB round-trip), so it can only consult that cache. Under
**faster-than-human input** (paste, scripted input, an unpaced PTY
driver), the next submission's Enter is already queued *ahead* of the
refresh event, so it is validated against the stale cache. A simple-mode
**Form-B insert** (`insert into T values (…)`, columns derived from the
cache) submitted right after `add column` then sees the pre-DDL columns,
its value arity can't match, and the friendly layer tags it *"trying to
write SQL?"* — even though the identical line succeeds at human speed
(issue **#39**).

**Decision — gate submission on the pending refresh.** `App` carries
`awaiting_schema_refresh: bool` + a `held_submissions` FIFO queue.
`dispatch_dsl` arms the flag on **every** `ExecuteDsl` dispatch; while
armed, a new DSL submission is **held** (queued in submission order)
rather than validated. The `SchemaCacheRefreshed` handler clears the
flag and **drains** the queue against the now-fresh cache, stopping as
soon as a drained command re-arms the gate (so the remainder wait for
*its* refresh — order preserved). A held command that doesn't dispatch
(parse error / pre-flight rejection) leaves the gate open and the loop
continues. App-lifecycle commands (`quit` / `help` / `load` / `undo` /
`rebuild`) route through `dispatch_app_command` *before* `dispatch_dsl`
and so are never held.

**Why arm on every dispatch, not just DDL.** It keeps at most one DSL
command in flight, so refreshes are strictly one-per-dispatch and in
order — making the gate a provably-correct boolean. Arming only on
schema-mutating commands would let a *preceding* non-DDL command's
refresh clear the gate early and drain a held Form-B insert against a
cache that predates the DDL — reintroducing the bug in a corner case.
(App-lifecycle commands also refresh but bypass the gate; they are
modal/picker-gated and so cannot overlap a rapid DSL paste, the only
thing this guards.)

**Scope.** Interactive input only. The `replay` / history-log / startup
rebuild-from-text batch path already re-snapshots the schema
**synchronously, inline, before every line** (`run_replay`,
`build_schema_cache`), so it has always had this ordering guarantee;
this amendment brings the interactive path in line with it. No
interactive-user impact (the gate clears in milliseconds); held input is
never lost because the runtime sends a `SchemaCacheRefreshed` after
*every* dispatch, success or failure. Covered by
`app::tests::form_b_insert_after_ddl_is_held_until_refresh_then_dispatched`
(Tier-1, deterministic event ordering) and the Tier-4 PTY regression
`e2e_pty::back_to_back_insert_after_ddl_still_succeeds` (the unpaced
inverse of flow 3).

## Out of scope

Deliberately deferred to keep this ADR shippable as a single
feature:

1. **"Did you mean?" near-keyword detection** (typed
   `crete`, suggest `create`). Useful but a separate
   feature with its own UX (highlight the typo? offer
   replace?). Future ADR.
2. **Fuzzy matching in completion mode** (typed `Cmrs`,
   match `Customers`). Today completion mode narrows by
   prefix only.
3. **Inline ghost text** (the rest of the unique completion
   shown ahead of the cursor in dim colour). The hint panel
   already covers the same role; ghost text would be
   redundant and risks visual clutter.
4. **User-customisable keybindings.** Tab / arrows / Enter
   / Esc are hard-coded.
5. **Schema completion across projects.** Completion is
   scoped to the currently loaded project's schema.
6. **Live-error highlighting *between* tokens** (e.g. a
   wavy underline spanning two tokens that together form
   an invalid sequence). Single-token error overlay only.
7. **Multi-line input.** Out of this ADR; tracked
   separately as I1.
8. **Highlighting in past parse-error renderings** in
   output history. Echo lines are highlighted; the
   error-block content is not retroactively re-styled.
9. **SQL highlighting / completion in advanced mode.**
   Waits on Q4.
10. **Identifier completion that crosses table boundaries**
    (e.g. completing column names across all tables). v1
    requires a `ColumnIn(table)` binding from earlier in
    the command.

## Consequences

### Positive

- **Single coherent feature** for typing assistance
  instead of three loose pieces. Voice, mechanism, and
  scope agree across colour / hint / completion.
- **The hint panel is finally useful.** It earns its
  screen real estate with always-visible content.
- **Tab does the right thing in every position.** Single
  candidate: insert. Multiple: open chooser. Zero: no-op
  (and the hint panel says why).
- **No floating UI.** Completion lives in the existing
  hint panel; no popup that overlaps text.
- **Schema-aware from day one.** `IdentSlot` taxonomy is
  in the parser; the completion engine knows what to ask.
- **Live error feedback.** Definite errors light up the
  failing token mid-typing; the hint panel surfaces the
  usage template on the spot.
- **Error overlay does not require a Tab.** Passive
  feedback you cannot miss.

### Costs

- **Largest single ADR-driven change so far**: theme + ui
  + app event flow + parser combinator audit + worker
  request type + new App state for completion mode +
  catalog additions + snapshot rebaselining. Estimated
  1500-2500 lines across changes plus tests.
- **Render-time parse** is new work. Cheap absolutely but
  it is per-keystroke. Risk of perf regression on very
  large input strings (none today; the DSL is one-line
  commands).
- **Identifier-slot taxonomy** is a new concept the parser
  combinators must thread through every `ident()` call.
  One-time refactor.
- **Schema-query worker request** adds one new entry to
  the worker's request enum.
- **App state grows** for completion mode (Option<CompletionState>),
  with input-event routing changes when the state is
  active.
- **Snapshot tests churn.** ~6 new snapshots, several
  existing ones rebaselined.
- **Catalog grows** by a handful of `hint.*` entries.

### Neutral

- **Public parser API**: gains the `IdentSlot` enum and
  the per-slot tagging via `ident_ctx`. The
  `parse_command` signature is unchanged.
- **Theme dependency direction unchanged.**
- **Tab key was previously unused** in the input panel; no
  conflict.

## Implementation notes

### Order of operations

Implementation lands as a sequence of green-after-each commits:

1. **Theme colours (small, ~150 lines).** Add seven
   `tok_*` fields with light + dark values. Add
   `Theme::token_color(&TokenKind) -> Color` helper.
2. **`ui::lex_to_spans` + input panel rewrite (medium,
   ~200 lines + snapshot rebaseline).** Live token
   colouring on the input field; cursor splitting logic.
3. **Echo-line rewrite (small, ~80 lines).**
   Simple-mode echo lines highlighted; `dsl::ECHO_PREFIX`
   constant + test.
4. **Render-time parse + error overlay (medium, ~150
   lines).** Classify input into one of three states;
   overlay error styling on the failing token.
5. **Hint panel ambient (medium, ~250 lines).** Three
   sub-states; catalog additions; rendering. The hint
   panel begins to earn its space.
6. **Identifier-slot taxonomy + parser tagging (medium,
   ~300 lines + parser audit).** `IdentSlot` enum,
   `ident_ctx` wrapper, audit every `ident()` call,
   per-command unit test asserting tagging coverage.
7. **Schema query plumbing (medium, ~200 lines).** New
   worker request, App-side dispatch, async handling
   model.
8. **Completion mode + Tab/arrow/Enter/Esc bindings
   (medium-large, ~400 lines).** App state, event
   routing, hint-panel render variant, completion-list
   filtering as user types.
9. **Snapshot test pass + manual smoke (small).**

Each stage is a checkpoint commit. The commit messages name
the stage and the cumulative state.

### Things that interact subtly

- **Cursor splitting at multi-byte UTF-8 boundaries.**
  The current input renderer handles this; the new
  span-based renderer must keep it. Walk to next char
  boundary when splitting a token's span at the cursor.
- **Empty input.** Lex returns `vec![]`, parse returns
  Empty, hint panel falls back to `panel.hint_empty`.
- **Input mid-token at cursor.** The completion engine
  treats the token-prefix-up-to-cursor as the typed
  prefix; candidate filtering matches against that
  prefix.
- **Mode transitions during completion mode.** If the
  user toggles persistent advanced mode (or types `:`)
  while completion mode is active, completion mode closes
  immediately.
- **Project switch / load while completion mode is
  active.** Same: close completion mode before any project
  state change is applied.
- **Schema cache after a DDL command.** The first
  ListNamesFor request after a schema-mutating command
  fetches fresh data; no explicit cache-invalidation
  mechanism in v1 because the worker re-reads metadata on
  each call.
- **Completion of a partial token.** Typing `Cust`, then
  Tab, where one candidate matches: the engine replaces
  `Cust` with `Customers` (not appends). The replace span
  is the partial-identifier token's span.
- **Tab at end of complete-and-valid input.** The
  expected-set may be empty or contain only `end of
  input`; Tab is a no-op (the hint panel says "submit
  with Enter").
- **`messages` setting.** Verbosity governs engine-error
  rendering (ADR-0019); ambient hint content is unaffected
  by it. Hint always shows the same level of detail.
- **Render performance.** Lex + parse + classify per render
  for typical input is microseconds; profile if bigger
  inputs land later (Query DSL, multi-line).
