The contextual hint overlay (ADR-0053) opens on F1, but F1 is an escape sequence the autocast recorder can't emit — so casts (and presenter / teacher sessions) couldn't trigger the most teaching-relevant overlay. In demo mode only, Ctrl-G now aliases F1: it runs the same hint logic and badges AS [F1], so a recording is visually identical to a real F1 press. Ctrl-G is the only fit — Ctrl+digit (e.g. Ctrl-1) isn't encodable in a legacy terminal (arrives as a bare `1`), and the kitty protocol that would encode it needs escape sequences autocast can't send (and the app doesn't enable keyboard-enhancement flags). Demo-gated, so the shipped keymap stays F1-only; outside demo mode Ctrl-G is inert. - app.rs: hint_key guard gains the demo-gated Ctrl-G disjunct; demo_badge_label maps Ctrl-G -> [F1]; 3 Tier-1 tests + badge assertion. - ADR-0047 Amendment 1 + README index; also removed two stray </content> / </invoke> lines accidentally committed in the ADR file. docs: drop three more stale "deferred" entries from CLAUDE.md — readline shortcuts (I1b, ADR-0049), tab completion (I3), and syntax highlighting (I4) are all implemented; only multi-line input (I1) remains open.
24 KiB
ADR-0047: Demonstration overlay layer — keystroke badges and step captions
Status
Accepted (2026-06-10); implemented 2026-06-11, phased A→B→C (closes
Gitea #22). Addresses Gitea #22. Builds the in-app
overlay/annotation primitive that screencast recording (ADR-website-001
§2, the autocast pipeline) and a future guided-lesson system both
need. Adjacent to ADR-0046 (the nav-mode sidebar overlay it must
coexist with) and unblocks the polished version of the assistive-editor
and projects (#24) casts.
Implementation (commits f879d54 → 2d0f4b2). Phase A
(f879d54): --demo flag + RDBMS_PLAYGROUND_DEMO env →
App.demo_mode, mirroring the --no-undo plumbing; help text mentions
only the visible badges (the Ctrl+] caption trigger stays
low-profile, D6). Phase B (2584e76): automatic keystroke badges — pure
demo_badge_label, set in App::update before the modal gate, expired
by a ~1.5 s runtime timer via the new nearest_deadline helper that
extends the time-boxed-recv arm condition without regressing the
ADR-0027 indicator debounce (the rewrite tracks Instant deadlines;
verified equivalent). Phase C (241f60c): the stealth Ctrl+]
caption buffer in App::update, intercepted before the modal gate so
captions work over the load picker. Post-build (2d0f4b2, user
decision): the overlays render as flat filled yellow rectangles (no
border glyphs, one-cell text margin) to read as a distinct callout. A
whole-implementation /runda pass returned PASS with no blockers;
the only untested wiring is the run_loop badge timer (not unit-testable
in isolation — same posture as the existing IndicatorDebounce; the
pure pieces are all tested). One intentional, user-acknowledged
behaviour: Ctrl-C is inert while capturing (every non-Ctrl+] key is,
by spec; exit capture with Ctrl+]). Tests: 2290 passing / 0 failing /
0 skipped (Tier-1 label fn + caption FSM + nearest_deadline, Tier-2
dark/light/stacked/wrapped/clamp snapshots + black-on-yellow style,
CLI parse/env); clippy clean.
All primary forks and the visual placement were user-confirmed —
including the two follow-ups settled after the first draft: the trigger
key (Ctrl+], the maximally-obscure valid single-byte code, over
Ctrl+! which autocast cannot send) and caption sizing (wrap to 3
lines). A /runda pass over this ADR ran before implementation and
tightened it — its findings are folded in below (caption/badge
interception placement, in-capture key disposition, badge suppression
during capture, the timer arm-condition, box clamping, the new
output-rect field, and the control-code decode note).
Requirements traceability. There is no requirements.md item
for this work — verified by sweep. It is tracked as Gitea issue #22
plus this ADR, consistent with the project's convention ("issues are
the lightweight tracker; ADRs are the decisions"). The website-side cast
scope lives in ADR-website-001 (website branch), not main's
requirements.md.
Context
The website records its demos as asciinema .cast files driven by
autocast (ADR-website-001 §2; STYLE.md): source step-lists in
casts-src/casts.mjs (type / wait / key) expand to one key per
character, Enter = ^M, recorded against the real target/debug
binary. The hard constraint — the same one that drove #24 — is that
autocast can only emit typeable characters, ASCII control codes
(^X), and waits. It cannot send arrow keys, function keys, or any
multi-byte escape sequence.
Two classes of on-screen event are therefore invisible or unexplained in a cast:
- Keystrokes that cause a visible change but render no glyph of
their own — most acutely Tab completion: the command line
jumps from
show data botoshow data bookswith no sign a key was pressed. Enter, the arrows, Ctrl-O, Esc are the same. - Step structure / "what just happened" narration — a cast is a silent moving picture; there is no channel to separate or explain steps for a visual learner.
asciinema-player has no inline keystroke overlay, and a website-side HTML overlay layered on the player would be fragile (its timings would have to track every recording and break on each re-record). The robust place to solve this is in the app: if the app renders the overlay, the cast captures it natively and it re-records for free. The same primitive is exactly what a future guided-lesson system needs to point at things and narrate steps — so it is built as a general capability, not a cast-only hack (the issue's "pays off twice"). It is also directly useful for a teacher demonstrating the playground live — pressing Tab in front of a class has the same invisible-keystroke problem as a cast.
The app's renderer is a pure function of App state and already draws
two kinds of last-pass overlay over the base render with no layout
reflow: modals and the ADR-0046 nav-mode sidebar overlay. The event
loop already time-boxes event_rx.recv() with a tokio timeout
(the ADR-0027 IndicatorDebounce) and redraws when the timer elapses —
the exact mechanism a self-expiring badge needs. These two existing
seams make the feature cheap.
Decisions
D1 — Activation: a --demo flag (+ env var), off by default
Demonstration mode is entered with a --demo CLI flag, or
equivalently the RDBMS_PLAYGROUND_DEMO environment variable (set
truthy) — mirroring the existing --log-file / RDBMS_PLAYGROUND_LOG_FILE
pair. It combines freely with every other flag (--resume, --mode, a
positional path); there are no exclusions.
When the flag is off (the default), none of the key handling or
rendering below is active and the app behaves exactly as today — zero
footprint for real users (R8). autocast sets the flag when it
launches the binary; a teacher sets it on their own command line.
It is framed as a general demonstration mode, not "cast mode" — the
honest name for what it does, and it reads sensibly in --help. The
flag is documented in the CLI banner (one line); obscurity is not a
security property here and a harmless opt-in flag is better surfaced
than hidden. What stays "low-profile" (per #22) is that there is no
normal in-app command for it and no persistent on-screen indicator
(see D7) — so a cast frame is never polluted by a [DEMO] marker.
D2 — Keystroke badges: automatic, app-detected
In demo mode the app shows a transient badge automatically whenever it handles one of a curated set of otherwise-invisible keys. The cast does nothing special — it presses the key it was going to press anyway, and the badge re-records for free. The set:
| Key | Badge | Key | Badge | |
|---|---|---|---|---|
| Tab | [TAB] |
Home | [HOME] |
|
| Shift-Tab | [SHIFT-TAB] |
End | [END] |
|
| Enter | [ENTER] |
PageUp | [PGUP] |
|
| Esc | [ESC] |
PageDown | [PGDN] |
|
| ↑ | [UP] |
Backspace | [BKSP] |
|
| ↓ | [DOWN] |
Delete | [DEL] |
|
| ← | [LEFT] |
Ctrl-O | [CTRL-O] |
|
| → | [RIGHT] |
Plain character keys render a glyph on the input line already, so they
produce no badge (that is the definition of the set — "invisible"
keys). The badge fires on key press, regardless of whether the key
had an effect in the current state (e.g. ↑ with no history still shows
[UP]): simpler, and the demo author controls the script. Badge text is
bracketed ASCII ([TAB]) per the user's preference — renders on every
terminal and is cast-safe, unlike the ⇥ glyph mocked earlier.
The label mapping is a pure function demo_badge_label(&KeyEvent) -> Option<&'static str> (Tier-1 testable). The badge auto-expires on a
timer (D5).
D3 — Step captions: a stealth, control-code-delimited input buffer
Caption text must arrive through typeable input only (R4). A single
toggle control code — Ctrl+] (byte 0x1D) drives a stealth
capture buffer. Ctrl+] was chosen (over the bound Ctrl-O/Ctrl-C,
the readline-reserve letters Ctrl-A/E/W/K/U, the tmux-prefix Ctrl-B, the
signal/flow-control codes Ctrl-\=SIGQUIT and Ctrl-S/Q=XON/XOFF, and a
plain letter chord like Ctrl-G) because it is maximally non-obvious
— the classic telnet escape, almost never pressed by accident — while
still being a single ASCII control byte autocast can emit. It has no
signal or flow-control baggage and is multiplexer-safe. Note
collision risk is already near-zero in casts (a fresh --demo binary
sees only scripted keys); the obscurity mainly protects a live teacher
from a stray trigger.
- First
Ctrl+]opens capture. The command input line and the output are untouched. If a caption is already visible, opening clears it (you are starting a new annotation). - Subsequent typed characters accumulate into the caption buffer
invisibly — they do not appear on the prompt, do not execute,
and do not enter history.
Backspacedeletes the last buffered character. Every other key while capturing — Enter, the arrows, Tab, … — is inert (swallowed, no effect): only typing andCtrl+]do anything. - A second
Ctrl+]commits the buffer to the caption box (D4). An empty commit (toggle-toggle with nothing typed) clears any visible caption — the author's explicit dismiss.
Because nothing about the capture shows on the prompt, the caption
"pops" into its box with no ugly typing artifact, while the caption text
still lives inline in casts.mjs at the right spot (one source of
truth, no separate notes file to keep ordered).
This is all keyboard-stream interpretation, so it lives in the
pure-sync App::update() (Tier-1 testable) and is only active in demo
mode — when off, Ctrl+] is inert and characters reach the input
line normally.
Placement in handle_key — before the modal gate (runda finding).
The capture interception (Ctrl+] and the accumulating characters)
and the "clear a visible caption on the next keystroke" check sit at
the very top of handle_key, before the self.modal.is_some()
gate — not alongside the Ctrl-O handler, which is gated behind it.
This is required so captions can be authored while a modal is open —
specifically the load-picker, which is exactly the projects / #24
cast (annotating "press j/k to move", with an [ENTER] badge as the
selection is made). While capturing, the modal is frozen (capture
swallows keys), which is the intended behaviour. App exposes
demo_capturing so the runtime can read it (see D5).
The control-code path is sound end to end, verified against our
crossterm (0.29, event/sys/unix/parse.rs:110-113): autocast emits
^] = byte 0x1D; crossterm decodes 0x1C..=0x1F →
KeyCode::Char('4'..='7') + CONTROL, so Ctrl+] (0x1D) arrives in
the app as KeyCode::Char('5') + KeyModifiers::CONTROL — that is the
pattern handle_key matches. (The same routine decodes 0x09/0x0D/
0x1B/0x7F to the named Tab/Enter/Esc/Backspace keys and
0x01..=0x1A to Ctrl+a..z, so 0x1D is unambiguously distinct.) The
canonical way to produce it is Ctrl+]; on some layouts Ctrl+5
yields the same byte. (This is the Unix/Linux decode path — the
cast-recording platform; crossterm's separate Windows backend would be
confirmed by test if live --demo on Windows is exercised.)
D4 — Both overlays are floating boxes at the output panel's inner bottom-right
The badge and the caption both render as floating, flat filled
rectangles anchored to the inside of the output panel's bottom-right
corner (inset one cell from the panel's inner edge), drawn last over
the base render — after modals, so they remain visible while the
load-picker (the #24 cast) or any modal is up, and with no layout
reflow (consistent with the modal / nav-overlay precedent; honours
R8).
Flat rectangle, not a bordered box (user decision, post-build). The
overlays draw as a solid yellow rectangle with no border glyphs and
a one-cell margin around the text — deliberately unlike the app's
rounded-border panels, so they read as a distinct callout that "stands
out nicely" rather than as another panel. Implemented with a borderless
Block fill (the paint_background mechanism) plus a Paragraph inset
into a one-cell Margin.
The top-level render() does not currently know the output-panel rect
(it is computed inside render_right_column), so a new field
App.last_output_area: Rect is set in render_output_panel and read
at the top-level draw pass to anchor the overlay — the established
"renderer reports metrics back to App" pattern (sibling to
note_output_viewport, which stores row counts, not a rect).
When both are present, the keystroke badge stacks directly above the caption box (both right-aligned in the corner) so they never overlap.
Styling — deliberately high-contrast: bold black text on a yellow
fill — hard to overlook, identical in light and dark themes (a fixed
high-contrast pair centralised in theme.rs, not theme-derived).
Caption sizing (user-confirmed). The caption is word-wrapped to at
most 3 lines within a content width of min(40, output_inner_width − 4) columns, ellipsised beyond the third line. So the caption rectangle
is 3–5 rows tall (1–3 text rows + a one-cell margin top and bottom),
its height varying with the text — a full sentence fits without forcing
the author to split it, while the 3-line cap keeps it corner-sized. The
badge rectangle is always a single short token ([TAB] …
[SHIFT-TAB]), so it is a fixed 3 rows (1 text row + the margin),
narrow.
Clamping (runda finding). Stacked, the two boxes are up to 8 rows
(5 caption + 3 badge); the output panel's inner height is only Min(5),
so on a short terminal they could exceed it. Both boxes are clamped to
the output inner area: width to output_inner_width, the caption's
wrap-line count reduced so the stack fits the available height (badge
first — it is the time-critical one), and if a box cannot fit at all
(pathologically small terminal) it is not drawn rather than
overflowing. Cast geometry (90×26) leaves ~18 output rows — ample; the
guard only protects a real user who runs --demo in a tiny window.
D5 — Timing: badges expire on a ~1.5 s timer; captions persist until the next keystroke
- Keystroke badge: auto-expires on a time-based TTL, default
1.5 s (a single tunable constant; the user asked for 1–2 s). This
matters for both media: in a cast the badge fades on its own so a
trailing
waitends on a clean frame, and in live teaching the badge clears without the presenter needing another key. A new badge replaces the current one and resets the timer. - Caption: persists until the next keystroke, which clears it
and is then processed normally (or until an explicit empty-
Ctrl+]dismiss, or replacement by a new caption).
The timer reuses the runtime's existing time-boxed-recv pattern: the
loop already arms a tokio::time::timeout for the indicator debounce.
Arm-condition extension (runda finding). Today the loop time-boxes
recv only while debounce.is_armed() — and the debounce settles
at INDICATOR_DEBOUNCE (1000 ms), shorter than the 1500 ms badge TTL.
So the arm condition becomes debounce.is_armed() || badge_pending,
and the loop waits on the nearest deadline of the two. On a wake it
checks each independently: at the 1000 ms debounce deadline it settles
the indicator without clearing the badge; at the 1500 ms badge
deadline it clears the badge; then redraws. The pure "nearest deadline"
computation is unit-testable on its own.
The badge's expiry Instant lives in the runtime (so App stays
clock-free and Tier-1-pure, exactly as IndicatorDebounce keeps timing
out of App); App.demo_badge: Option<&'static str> is the render
mirror, set by the runtime on a significant key and cleared on timer
elapse.
Badge suppression during capture (runda finding). Because the
runtime sets badges from the raw key independently of App state, it
must not badge a key that capture swallowed (e.g. an inert Tab
while a caption is being typed would otherwise flash [TAB] for a
no-op). The runtime sets a badge only when !app.demo_capturing.
Ownership note. demo_caption is mutated inside update()
(input-driven) while demo_badge is mutated by the runtime
(timing-driven). This split is deliberate and mirrors the existing
input (set in update()) vs input_indicator (set by the runtime
from IndicatorDebounce) pair — not an inconsistency.
D6 — Help text and strings
The CLI banner (help.cli_banner in en-US.yaml) gains a --demo
line. User-facing wording obeys the house rules (no engine name, no
"DSL"): "Demonstration mode — show on-screen badges for otherwise-
invisible keys (Tab, Enter, …), for screencasts and live teaching."
The help text deliberately mentions only the visible badges, not the
Ctrl+] step-caption mechanism (user decision): the caption trigger
stays low-profile, true to #22's "secret trigger" framing — a cast
author or lesson script knows it; a casual --help reader is not
pointed at it. Badge labels and the […] chrome are fixed ASCII, not
localised; caption content is author-supplied free text and likewise
not a catalog string.
Alternatives considered
- Scripted badges (cast pushes each badge explicitly) — rejected: the app already sees every key, so automatic detection (D2) is more robust and re-records for free. (User-confirmed.)
- Typed hidden command for captions (a secret-prefixed line) — rejected: the command is briefly visible being typed on the prompt. Preloaded notes file + advance key — rejected: a separate file that must stay ordered/in-sync with the cast. The stealth buffer (D3) is self-contained in the cast script and leaves the prompt clean. (User-confirmed.)
- Fixed-corner HUD badge / badge by the input line — rejected in favour of a floating box at the output panel's bottom-right; top banner / subtitle band for captions — rejected in favour of the matching floating box. (User-confirmed via mockups.)
- A persistent
[DEMO]status-bar marker — rejected: it would show in every cast frame. Demo mode is silent except for the transient overlays (D7). - Caption persists for a fixed time (instead of until next keystroke) — noted as a one-constant change if the next-keystroke rule proves too eager in practice; the user chose next-keystroke.
- Trigger via
Ctrl+!/ a Kitty-protocol chord — rejected: not representable as a single ASCII control byte, so autocast cannot send it (fails R4, the same wall as arrow keys).Ctrl+G/ a letter chord — workable but less non-obvious; the user chose the maximally-obscureCtrl+]from the valid single-byte set. - Single-line ellipsised caption — rejected in favour of wrap-to-3- lines so a full sentence fits. (User-confirmed via mockups.)
Consequences
- A general overlay primitive exists that the cast pipeline uses now and
the guided-lesson system can reuse later (
App.demo_captionand the badge channel are the seam). autocastcasts gain a real Tab-completion moment, key indicators for the projects/#24round-trip, and step captions — all by addingkey: ^G/type:/key: ^Gand ordinary keys tocasts.mjs, then re-runningpnpm casts. No website-side overlay machinery.- Teachers get the same affordance live via
--demo. - One new control-code binding (
Ctrl+]) is consumed, but only inside demo mode — normal sessions are unaffected, so it does not encroach on the reserved readline chords (I1b). - The renderer must expose the output-panel rect to
App; a small, pattern-consistent addition.
Scope / non-goals (OOS)
- Manual/scripted badge push and badges for plain character keys — out; badges are automatic over the fixed invisible-key set.
- Configurable overlay styling or placement — out; fixed black-on-yellow boxes at the output panel's bottom-right.
- The guided-lesson / tutorial system itself — out (its own ADR); this ADR only builds the primitive it will reuse.
- Persisting demo mode across project switches / sessions — out; it is a per-run flag.
- Localising caption content — out; captions are author-supplied free text.
- Output-pane scroll-in-casts and other arrow-only interactions — out (separate enhancement; same autocast limitation as noted in #24).
Testing
Per ADR-0008 and the project's test discipline (test-first; green, no skips):
- Tier 1 (
app.rsunits):demo_badge_labelmapping over the full key set and the no-badge cases (plain chars,Ctrl+],Ctrl-C); the stealth-caption state machine — open onCtrl+]; characters accumulate with the input line unchanged;Backspaceedits the buffer; non-typing keys inert while capturing; commit sets the caption; empty commit clears; opening over a visible caption clears it; next keystroke clears a visible caption then processes normally; capture works with a modal open (caption set while the load-picker modal is up, picker state untouched); the demo-off gate (Ctrl+]inert, characters reach the input, no caption/badge state ever set); the pure "nearest deadline" helper. - Tier 2 (insta snapshots,
ui.rs): badge box, caption box, both stacked, at 90×26 in light and dark — verifying the bottom-right anchor, the stack order, and the black-on-yellow styling; plus a short-terminal case exercising the clamp/skip guard. - Tier 3 (integration):
--demoplumbsapp.demo_mode; a significant-key event setsapp.demo_badgeand a swallowed key during capture does not; aCtrl+]/ type /Ctrl+]sequence setsapp.demo_captionwithout touchingapp.input. - CLI (
cli.rsunits):--demoparses (mirrors--no-undo); theRDBMS_PLAYGROUND_DEMOenv fallback; default-off.
Honest coverage limit. The badge timer-expiry wiring runs inside
run_loop (terminal + db worker), which is not unit-testable in
isolation; it is a thin reuse of the already-proven IndicatorDebounce
time-boxed-recv path. We therefore test the pure pieces
exhaustively (label fn, capture state machine, nearest-deadline helper)
and assert plumbing via Tier-3, rather than over-claiming an integration
test of the tokio timeout itself.
Amendment 1 — Ctrl-G demo-mode alias for F1 (2026-06-15)
Context. The contextual hint overlay (ADR-0053 / H2) is opened with
F1. But F1 reaches the app only as an escape sequence (\eOP /
\e[11~), and the autocast recorder used for our screencasts cannot
emit escape sequences — so a cast can never trigger F1, and the single
most teaching-relevant overlay is unreachable in recordings. The same
wall already bit step-captions (which is why Ctrl+], a single control
byte, was chosen over Ctrl+!).
Decision. In demo mode only, Ctrl-G is an alias for F1. It
runs the exact F1 hint logic (live-input → form hint; empty input →
recent-error / getting-started) and is badged as [F1] (not
[CTRL-G]) so a recorded cast is visually identical to a genuine F1
press. Ctrl-G is the only viable choice: it is a single legacy control
byte autocast can send, whereas Ctrl+digit (e.g. the mnemonic Ctrl-1)
is not encodable in a legacy terminal at all — digits have no control
byte, so Ctrl-1 arrives as a bare 1; the kitty protocol would encode
it but only as an escape sequence (the very thing autocast can't send),
and this app deliberately does not enable keyboard-enhancement flags.
Why demo-gated. The shipped keymap stays F1-only — a real user never
trips the alias, and demo mode is also the mode teachers/presenters run,
so the alias is available exactly where it's wanted. Outside demo mode
Ctrl-G falls through to the inert catch-all (the Char(c) insert arm
excludes CONTROL, so no g is typed).
Scope. hint_key guard in App::handle_key gains the demo-gated
Ctrl-G disjunct; demo_badge_label maps Ctrl-G → [F1] (consulted
only in demo mode). Test-first: three app.rs Tier-1 tests (alias fires
on input + on empty input; inert when demo off) + the badge-map
assertion. The keybinding strip (ADR-0051) is not changed — F1 stays
the advertised key; Ctrl-G is a recorder aid, and the badge already
reads [F1].
(Editorial: this amendment also removed two stray </content> /
</invoke> lines accidentally committed at the end of this file.)