ADR-0027 gains a "Follow-up" section recording the completed §2 highlight + hint wiring and precise per-literal WARNING spans; the three stale As-built bullets point at it. requirements.md test baseline → 1125 and the S6 entry notes the completion + Amendment 1. handoff-19 records the run and queues the two deferred manual-testing bugs (add 1:n relationship completion/usage hint; --resume / last_project) as the next session's first work.
17 KiB
ADR-0027: Input-field validity indicator
Status
Accepted
Context
While the user types, the app already flags problems in two immediate ways (ADR-0022): per-token syntax highlighting, and an ambient hint panel describing the slot under the cursor. Both are local — they speak about the spot being edited.
What is missing is a global signal: a single, glanceable answer to "if I press Enter now, will this command run?" A learner can type something whose error is highlighted ten columns back, move the cursor to the end, and submit it without noticing.
This ADR adds a validity indicator at the right edge of the input row, and — because a meaningful indicator needs a consistent notion of "what is wrong" — a small diagnostics-severity model behind it.
The indicator is advisory. It never blocks submission: that posture is settled (ADR-0022 — the app indicates, it does not refuse; ADR-0026 §7 — even a flagged WHERE still runs if submitted).
Decision
1. Two severities
- ERROR — the input is known to fail. Either it does not parse (incomplete, or a mismatched / invalid token), or it parses but names something that does not exist (an unknown table or column).
- WARNING — the input is valid and will run, but is
very likely not what a knowledgeable user wants: a
type-mismatched comparison, or
= NULL(both from ADR-0026 §7). Amendment 1 adds a third trigger —LIKEagainst a numeric column.
The split is certainty of failure versus likely misleading. The indicator shows the highest severity present; clean input shows nothing at all.
2. The diagnostics model
The walk produces a set of diagnostics alongside its
WalkOutcome. A diagnostic is, roughly,
{ severity, span, message_key } — the severity drives the
indicator, the span drives highlighting, the message key
drives the hint text (ADR-0019 catalog).
Diagnostics come from three sources, all reachable within the single schema-aware walk:
- The parse outcome.
Incomplete,Mismatch, andValidationFailedeach yield an ERROR diagnostic; a plainMatchyields none. - Schema existence. A matched
IdentSource::TablesorIdentSource::Columnstoken whose name is absent from the schema cache yields an ERROR diagnostic. This is new behaviour. Todaywalk_identreturnsMatchedfor any identifier-shaped token and consults the schema only to populate context (current_table,current_column); an unknown table parses cleanly and fails only at execution. The check is cheap — the schema cache is already inWalkContext— but it is genuinely new code, and it must run for every Tables / Columns ident, not only thewrites_*ones. - Expression flagging (ADR-0026). A type-mismatched
comparison and
= NULLyield WARNING diagnostics.
The walker is the one schema-aware pass, so diagnostics are
emitted there, not in a separate re-resolution pass.
WalkResult gains a diagnostics field. The indicator
reads the highest severity across the outcome and the
diagnostics; highlighting and the hint panel read the
individual diagnostics for where and why. The indicator
is the summary; the existing layers remain the detail.
3. "Would Enter succeed now?" — and the debounce
The indicator answers exactly one question: if you pressed
Enter right now, what would happen? — runs clean (nothing
shown), runs but flagged ([WRN]), or will not run
([ERR]).
An incomplete, still-being-typed command is in the [ERR]
bucket: pressing Enter on it fails. The indicator does not
special-case "still typing".
Flicker is prevented not by suppressing states but by a debounce:
- On every keystroke the indicator is hidden immediately.
- After roughly one second with no typing, it reappears, showing the current verdict.
So: typing → blank; a pause → the verdict. The user gets a settled, glance-before-submit signal without a marker that thrashes on every key. Highlighting and hints stay immediate and unchanged — only the indicator's display is debounced; diagnostics themselves are still computed every keystroke to feed those immediate layers.
The indicator's display is therefore time-gated. Per the
update()-is-pure invariant, the debounce timer lives in
the runtime / event loop; App holds the indicator's
visible state, which the runtime sets when the quiet
interval elapses or a keystroke arrives. The interval is a
single tunable constant (~1 s).
4. Rendering
The indicator is a fixed-width five-column label —
[ERR] or [WRN] — or nothing when the input is clean. A
positive "all good" state is deliberately omitted: absence
is the all-clear.
It sits in the input row, at the right end. The rightmost
strip — five columns for the label plus one column of gap,
six in all — is reserved unconditionally: the text area
is always width − 6, whether or not the indicator is
currently visible. The label appears and disappears within
its already-reserved strip, so the text / horizontal-scroll
boundary never moves and the label never collides with the
typed command.
A conditionally reserved strip was rejected: it would make a long, horizontally-scrolled command jump up to six columns sideways on every typing-pause transition. Unconditional reservation costs ~6 columns of typing width — negligible — for a stable layout.
Border-mounting the indicator (as chrome on the field's frame, like the mode label) was also considered and not chosen: a right-edge border decoration reads differently from a top-border label, and the in-row position is the intended look.
[ERR] uses the existing theme.error. [WRN] needs a
new warning theme colour — an orange / amber —
defined for both the light and dark themes (NFR-5: colour
conveys information; NFR-7: legible on either background).
5. The indicator never blocks submission
Enter always submits, whatever the indicator shows. A user who wants to run a flagged command — or even a doomed one, to see what the engine does — may; the error lands in the output as usual. The indicator's job is to make "this will not do what you think" visible before submission, not to prevent it. This is the same advisory posture as ADR-0022 and ADR-0026 §7.
6. The existing-cases sweep, and discoverability
Implementation includes a sweep of the current command
surface for failures that are detectable before submission
but not yet surfaced as diagnostics — chiefly unknown table
and column references, across every command that takes an
identifier. Each becomes an ERROR diagnostic via §2. (The
WARNING set is thin until ADR-0026 lands; type mismatch and
= NULL are its first members.)
The diagnostics model is documented as the route for any future "we can tell, before it runs, that this is wrong or dubious" case. This ADR is cross-referenced from the diagnostic-producing code and from related ADRs, so new cases plug into the model rather than becoming one-off checks.
7. Out of scope
- Blocking submission — never; the indicator is advisory (§5).
- A positive
[OK]state — clean input shows nothing. - Raw advanced-mode SQL — there is no SQL parser yet
(
Q1). The indicator covers simple-mode DSL and the app-level commands the walker parses; when SQL parsing lands, SQL diagnostics route through this same model. - Per-diagnostic display in the indicator itself — the indicator is a one-glyph summary; where and why stay with highlighting and the hint panel.
Consequences
WalkResultgains adiagnosticsfield; the walker emits diagnostics (parse-outcome and schema-existence) as it walks.- A new schema-existence check on
IdentSource::Tables/Columnsmatches. Small, but genuinely new — today an unknown identifier parses cleanly and fails only at execution. - The event loop gains a debounce timer for the indicator's
display. It lives in the runtime, so
update()stays pure. - A new
warningtheme colour; the input field reserves a fixed six-column right strip. - The indicator is advisory; submission is never gated.
- A reusable diagnostics-severity model that future pre-submit checks — and, eventually, advanced-mode SQL — extend.
- The WARNING severity has no triggers until ADR-0026 is implemented. The indicator may ship ERROR-only first and gain WARNING with C5a, or ship after C5a; the model carries both severities regardless.
Implementation notes
A sensible order, each step test-guarded:
- The
Diagnostictype and thediagnosticsfield onWalkResult; map the parse outcome to diagnostics. - The schema-existence check in
walk_identforTables/Columnsidents. - The
warningtheme colour; the fixed six-column input strip; the[ERR]/[WRN]rendering. - The debounce in the runtime, and the indicator's
visible state on
App. - The sweep — confirm every identifier-taking command surfaces unknown-name diagnostics; cross-reference this ADR from the diagnostic sites.
ADR-0026's expression diagnostics (type mismatch, = NULL)
land with that ADR's implementation and feed the WARNING
severity.
As-built notes (2026-05-19)
All five build-order steps are implemented, and — because
ADR-0026 deferred its own step 5 — the ADR-0026 §7
expression diagnostics (type mismatch, = NULL) were folded
in here as the WARNING severity's first triggers. Realization
choices and deviations from the sketch above:
- Diagnostics are computed post-walk, not emitted during
the walk. §2 says "diagnostics are emitted there [in the
walker]". As built, the walk produces the
MatchedPathand theCommandas before;walk()then runs two post-walk passes —schema_existence_diagnosticsover the matched path, andexpr_warningsover the builtExpr— and stores the result inWalkResult::diagnostics. This keeps diagnostics off the walker's speculative-rollback paths (aChoicebranch that matches an ident then rolls back would otherwise leak a diagnostic); the post-walk passes see only the surviving terminals. Same single schema-aware context, one logical pass later. Diagnosticcarries a pre-renderedmessage: String, not the{severity, span, message_key}of §2. Each diagnostic source catalog-translates at creation. This sidesteps the awkwardness of a parse-style message having no single static key, and the model stays "roughly{severity, span, message}".- The parse outcome is not a synthetic
Diagnostic. The indicator reads it straight fromWalkOutcome(§2's "highest severity across the outcome and the diagnostics");diagnosticscarries only the schema-aware findings layered on a structurally-valid parse. Schema- existence therefore runs only on aMatch. MatchedKind::Identgained anIdentSourcefield so the post-walk schema check can tell a table reference from a column reference from an invented name without hard-coding role strings.- The debounce uses
tokio::time::timeoutper event-loop iteration, nottokio::select!— it keeps the existing ~100-line event-handler body un-reindented.recvis time-boxed only while a recompute is owed; an idle session still blocks plainly. Any event resets the window (not only keystrokes) — a slight over-reset, harmless in practice. - WARNING spans are per-literal. Each
OperandAST node carries the byte span of the terminal it was built from, so an expression WARNING is anchored to exactly the offending literal. (The initial ship used a coarse whole-WHERE-clause span; made precise in the Follow-up section below.) - Highlight & hint wiring — complete. §2's "highlighting and the hint panel read the individual diagnostics" is wired — see the Follow-up section below. (The initial ship had the indicator and the model only; the build order did not enumerate the wiring as a step.)
- Debounce decision logic is unit-tested — it is the
IndicatorDebouncestate machine inruntime(Follow-up section). Thetokio::time::timeoutinteraction itself stays integration-level. The pure verdict (input_verdict/input_validity_verdict) and the indicator rendering were covered from the initial ship;input_verdicthas a parse-outcome / schema-existence / expression-warning / existing-cases-sweep test set.
Follow-up — highlight & hint wiring, precise spans (2026-05-19)
The As-built notes above left three items open; this pass closes them. §2's design — "the indicator is the summary; highlighting and the hint panel read the individual diagnostics for where and why" — is now fully realised.
Operandcarries a source span. Each WHERE-expression operand records the byte range of the terminal it was built from.Operand'sPartialEqis hand-written to ignore the span — it is editor metadata — soCommandequality, and the largeExprtest corpus, stay whitespace- and position-independent. Expression WARNINGs are now anchored to exactly the offending literal, not the whole clause.walker::input_diagnosticsis the shared entry point both UI layers read — the sibling ofinput_verdictthat returns the diagnostic set rather than its severity summary.- Highlight overlay.
render_input_runsoverlays the schema-aware diagnostics on the input field: an unknown table / column ERROR in the error colour, an expression WARNING intheme.warning. The overlay is global — every flagged token is coloured wherever it sits, not only under the cursor, which is precisely the problem §Context describes. The pre-existing cursor-local invalid-identifier overlay is kept (it covers in-progress identifiers, which produce no diagnostics); the two are additive and idempotent.overlay_spanrecolours a token's whole byte range (the olderoverlay_errorhit only a single byte). - Hint panel.
ambient_hintsurfaces a diagnostic's message.input_diagnosticsis non-empty only for a command that structurally parses — so a non-empty result means "complete and submittable, but wrong or dubious". It is checked early, ahead of slot hints and completions, so a flawed-but-parsing command no longer gets the misleading "Submit with Enter" prose. The diagnostic under the cursor wins (the panel explains where the user is looking); otherwise the most severe. - Debounce. The indicator-debounce decision logic moved
out of the event loop into the
IndicatorDebouncestate machine, unit-tested for the keystroke / settle cycle.
The one piece still integration-level: a PTY-tier test of the
actual tokio debounce timing (as distinct from the
now-tested decision logic) — consistent with the four-tier
strategy (ADR-0008), where Tier 4 is wired only for the
critical flows.
Amendment 1 — LIKE on a numeric column (2026-05-19)
§1 defined the WARNING set as exactly a type-mismatched
comparison and = NULL. A third trigger is added:
LIKEagainst a numeric column.LIKEis a text-pattern match —%/_wildcards over characters. Applied to a column of a numeric type (int,real,decimal,serial) it still runs — the engine matches the pattern against the value's text form — but is almost never what the user wants; they most likely mean a comparison orBETWEEN. It is a WARNING, consistent with the advisory posture (§5): the command still submits. The negation is irrelevant —NOT LIKEon a numeric column is just as dubious.
Scope is deliberately narrow — only numeric target columns are flagged:
boolis integer-backed (0/1) but excluded; the handoff item this implements named numeric columns specifically.- The text-backed types (
text,shortid,date,datetime) are not flagged:LIKE 'A%'on text is its intended use, and a prefix match on an ISOdate/datetimestring is genuinely useful. blobis left unflagged.
Widening to those types is a future model extension if a need appears.
This is exactly the kind of "we can tell, before it runs,
that this is dubious" case §6 anticipated: the trigger plugs
into the existing model rather than being a one-off check. It
lives alongside the others in walker::predicate_warnings
(the Predicate::Like arm, via like_numeric_warning); the
target column operand's span drives the highlight; the
message key is diagnostic.like_numeric.
See also
- ADR-0003 — input modes; the input field and its mode label.
- ADR-0019 — the friendly-message catalog the diagnostic message keys resolve through.
- ADR-0022 — ambient typing assistance: the immediate highlighting and hint layers this indicator summarises.
- ADR-0026 — complex WHERE expressions; the type-mismatch
and
= NULLWARNING diagnostics.