Both Node::Ident and Word carried a highlight_override field, and both were dead — the walker driver discarded the Ident's and walk_word hardcoded Keyword. So column types (int, serial, …) rendered identically to table/column names. Wire both overrides through, and add a dedicated HighlightClass::Type with its own theme colour (tok_type), distinct from keyword-purple and identifier-teal. The three type Ident slots opt in, so canonical types and the advanced-mode single-word SQL aliases (float, varchar, …) render as types; the two-word `double precision` alias opts in via a new Word::type_keyword constructor. ADR-0022 Amendment 4.
34 KiB
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
cretea keyword or a typo?"; - "what comes after
create table Customers?"; - "is
Customersactually 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(...):
- Valid-so-far. Lex produces zero
Errortokens AND parse succeeds OR parse fails at end-of-input (more input would make it valid). - Definite error. Lex produces an
Errortoken, OR parse fails at a position before end-of-input (a token appears where no production accepts it). - 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:
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_erroroverlay (foreground only, no underline / reverse — see reasoning in §6 of the previous draft). - Lex
Errortokens always render withtheme.tok_errorregardless 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: ". Pulls
from chumsky's expected-set at the failure point. For
multi-entry families ("
addfamily expectscolumnor1") this is one line of natural prose. - Definite error: " — usage:
". 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 sameusage::matched_entrylogic. Keeps the panel one-or-two-lines-tall.Empty input keeps the existing
panel.hint_emptycontent — the hint panel only takes over when the user starts typing.The catalog gains a small set of templates for these states:
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 replacingident()with a tagged variant at each call site: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 existingident()combinator that records the slot type alongside the parsed identifier in the parser'sextradata. The AST itself is unchanged — slot tags are render-time concerns, not AST concerns.Concretely, every
ident()call indsl/parser.rsis audited and becomes one of: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 bareident(), 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, andRelationshipNameslots. Per ADR-0010, all database access goes through the worker thread.Decision: a single new request:
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 beyondpanel.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::Advancedit parsesSELECT(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_panelhard-returnedNonefor advanced mode (the stale §12 gate), so the hint panel showed onlypanel.hint_empty— no prose hints and no candidate preview for SQL.- The hint resolver (
hint_resolution_at_input→expected_for_hint_snapshot) andambient_hintnever threaded the mode, so even the engine-level calls defaulted toMode::Simpleand 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_hintis now a thin wrapper that forwardsMode::Simple. Its sub-calls (input_diagnostics_in_mode,hint_resolution_at_input_in_mode,candidates_at_cursor_in_mode, the fallbackparse_command_in_mode) all run in the suppliedmode.hint_resolution_at_input_in_mode+expected_for_hint_snapshotnow setctx.mode, so the hint walk respects the active mode.render_hint_panelcallsambient_hint_in_modewith the effective mode for all modes (no more advanced-modeNone).- 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 (mirroringApp::submit) before the ambient walk, so: selhintsselectrather 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::Advancedand 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_candidateandsimple_mode_ambient_does_not_surface_sql_candidates.14. Catalog additions
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 Treads in order. That held while candidate lists were short. The SQL surface (ADR-0030/0031/0032/0033) made lists long — an expression position such aswhere Nameororder bylegitimately offers the column names plus the full expression-continuation keyword run (is not like between in and or, plusasc/descin 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 (
tobeforetable); then type names, composite literals, branching punct, flags. The existing colour split (tok_identifierteal vstok_keywordpurple, §colour) makes the section boundary legible once both are on screen.Related fix — Repeated trailing optionals
This amendment shipped alongside a
walk_repeatedfix: a comma-separated list (Repeated) was discarding the last matched item's trailing-optional expectations at a clean item boundary, soorder by Nameoffered noasc/desc,select Namenoas, andcreate table … Code(text)nonot/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.rsrewritten to assert identifiers precede keywords (header invariant #2 inverted; theto-before-tablekeyword-order invariant #1 retained).completion::tests::identifiers_come_before_keywords_in_grammar_orderandidentifiers_precede_keywords_at_expression_positionlock the ordering;order_by_after_sort_item_offers_direction,projection_after_item_offers_alias_keyword, andcreate_table_after_column_spec_offers_constraintslock 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_modeamong the sub-calls threaded through the activemode. That fallback (the bottom rung ofambient_hint's candidate-or-prose ladder, which produces theambient_complete/ambient_expected/ambient_error_with_usageprose) was schemaless — it took the input and mode but not theSchemaCache, 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 thecache; 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-baselinedtyping_surfacesnapshots (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
HighlightClassenum. Column data-type keywords (int,serial,text, …) had no class of their own: the walker driver hardcodedHighlightClass::Identifierfor every matchedNode::Ident, so a type rendered identical to a table/column name (clause keywords were already distinct, intok_keyword). A user-reported bug (issue #8) noted that increate 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::Identand theWordstruct alike — but both were dead: the driver destructured the Ident's to_, andwalk_wordhardcodedKeyword, neither ever consulting the field. This amendment wires both through.Change:
- New class.
HighlightClass::Type(the eighth variant) and a matching eighthThemefieldtok_type, populated in bothdark()andlight()with a tone deliberately distinct from bothtok_keywordandtok_identifier(a pink / deep-magenta in the red-purple range — the only free slot in the existing palette).highlight_class_colormaps the new variant. - Overrides are now live.
walk_identemitsoverride.unwrap_or(Identifier)andwalk_wordemitsoverride.unwrap_or(Keyword)for the matched byte range. Every slot that leaves the fieldNoneis unchanged. - Type slots opt in. The three
IdentSource::Typesslots —shared::TYPE_SLOT(add column/change column), the inline create-table column-type Ident (ddl.rs), andsql_create_table:: SQL_TYPE_NAME(advanced) — sethighlight_override: Some(Type). In advanced mode, every single-word SQL type-name alias (float,varchar,integer, … — ADR-0035 §3) flows throughSQL_TYPE_NAMEand so is type-coloured for free. double precisiontoo. The lone two-word alias (ADR-0035 §3) is matched as keyword tokens, not anIdentSource::TypesIdent, so it cannot ride rule 3. Itsdouble/precisiongrammar nodes use the newWord::type_keywordconstructor (highlight_override: Some(Type)) so the spelling renders type-coloured like its single-word synonymsfloat/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_stringstrips style), so none churned.Out of scope
Deliberately deferred to keep this ADR shippable as a single feature:
- "Did you mean?" near-keyword detection (typed
crete, suggestcreate). Useful but a separate feature with its own UX (highlight the typo? offer replace?). Future ADR. - Fuzzy matching in completion mode (typed
Cmrs, matchCustomers). Today completion mode narrows by prefix only. - 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.
- User-customisable keybindings. Tab / arrows / Enter / Esc are hard-coded.
- Schema completion across projects. Completion is scoped to the currently loaded project's schema.
- Live-error highlighting between tokens (e.g. a wavy underline spanning two tokens that together form an invalid sequence). Single-token error overlay only.
- Multi-line input. Out of this ADR; tracked separately as I1.
- Highlighting in past parse-error renderings in output history. Echo lines are highlighted; the error-block content is not retroactively re-styled.
- SQL highlighting / completion in advanced mode. Waits on Q4.
- 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.
IdentSlottaxonomy 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), 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
IdentSlotenum and the per-slot tagging viaident_ctx. Theparse_commandsignature 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:
- Theme colours (small, ~150 lines). Add seven
tok_*fields with light + dark values. AddTheme::token_color(&TokenKind) -> Colorhelper. ui::lex_to_spans+ input panel rewrite (medium, ~200 lines + snapshot rebaseline). Live token colouring on the input field; cursor splitting logic.- Echo-line rewrite (small, ~80 lines).
Simple-mode echo lines highlighted;
dsl::ECHO_PREFIXconstant + test. - Render-time parse + error overlay (medium, ~150 lines). Classify input into one of three states; overlay error styling on the failing token.
- Hint panel ambient (medium, ~250 lines). Three sub-states; catalog additions; rendering. The hint panel begins to earn its space.
- Identifier-slot taxonomy + parser tagging (medium,
~300 lines + parser audit).
IdentSlotenum,ident_ctxwrapper, audit everyident()call, per-command unit test asserting tagging coverage. - Schema query plumbing (medium, ~200 lines). New worker request, App-side dispatch, async handling model.
- 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.
- 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 topanel.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 replacesCustwithCustomers(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"). messagessetting. 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).