The shortcut architecture (owner-approved plan, 2026-09-17):
- src/keyboard.ts: pure action registry (id, label, scope, default
binding) + lh:keymap localStorage overrides + event normalization.
The settings milestone renders this same registry as its editor.
- components/keyboard-shortcuts.tsx: the single document-level keydown
listener (window level, bubble) owning the pieces no surface owns:
- Tab is DEAD (owner decision): browser tab-focus navigation is
abandoned; Tab is suppressed everywhere, Shift+Tab cycles the
thinking level.
- Esc is a layered dismiss: topmost popover first (popovers
stopPropagation their own Esc — usePopoverDismiss + ModelControl),
then dictation stop (lh:kbd-stop-dictation), then abort of an
in-flight turn (lh:kbd-abort-turn; server-side wake turns are
honestly non-stoppable — the seam only stops local streams).
- Global actions dispatch lh:kbd-<action> window events (the
lh:chat-renamed pattern); surfaces that aren't mounted simply
don't listen.
- The input guard: inside a typing surface only mod/alt combos (and
Tab) fire; bare keys pass through to the field.
- Cheat sheet overlay (?) renders from the registry, grouped by scope.
Bindings wired this wave: cmd+d dictation toggle, cmd+k model picker,
shift+tab thinking cycle, cmd+b sidebar, cmd+shift+f sessions search,
alt+up/down + alt+enter session cycling, cmd+g graph view, cmd+n new
session, f2 rename (chat), ? cheat sheet. Wave B adds the question
keyboard machine; wave C the per-surface bindings (find, import).
Verified live in the dev shell (CDP): all of the above via
Input.dispatchKeyEvent.
Co-Authored-By: Claude Fable 4.5 plus-plus <noreply@anthropic.com>
Owner's second live test:
- DEFERRED transcript: finals accumulate in a buffer during the run and
flush into the primary input once, when dictation ends (Stop →
CloseStream → Deepgram's last finals → socket close → one joined
append; the 3s safety net covers a missing close). Nothing textual
renders while recording — the meter is the only live feedback. Live
output may return as a toggleable setting at the settings milestone;
deferred is the default behavior.
- ADAPTIVE meter: cell count derives from the measured strip width (4px
pitch, 8-48 cells, ResizeObserver; cells drop their minimum width) so
narrow rows beside the knowledge-context slider fit — verified in the
dev shell: draft 20 cells/83px, chat 48 cells/273px, no overlap.
Owner's first live mic test (2026-09-17) drove four changes:
- ONE primary transcription input at all times: while a quiz/ask
question waits, finals land in its field (text mode → 'Your answer',
select modes → 'Your thinking') via a module-level claim registry
(useDictationTarget in dictation.tsx); the composer is the fallback.
Esc in a question field also stops dictation (threaded through
DockOverlay → QuestionDock).
- No interim results: live words were distracting and shifted the meter.
Params now match the pi dictate extension exactly (no interim_results,
endpointing=300, no utterance_end_ms); the meter's width stays fixed.
- Optimistic meter: the click enters the meter view immediately (Stop
pill + meter within one frame, verified at 150ms in the dev shell);
token mint and mic acquisition run concurrently behind it.
- Wide idle button: the stats-bar middle is one full-width affordance —
a quiet bordered 'Dictate' button when idle (failure text replaces the
label), Stop pill + fixed meter while live.
Live-verified in the Electron dev shell: wide button renders on home
(167px) and chat (358px), meter + Stop appear at 150ms after click,
flat 2px bars in silence, clean return to 'Dictate' on stop.
Two live-verified Deepgram facts from the first real mic-test round:
- /v1/auth/grant returns { access_token }, not { token } — the route now
parses the right field.
- Minted JWTs authenticate over the WebSocket via the "bearer"
subprotocol; ["token", jwt] is rejected at the handshake (token is
for raw API keys only, as in the pi dictate extension).
Also documents the env-precedence gotcha in docs/research/stt.md: a
DEEPGRAM_API_KEY exported in the shell (e.g. .zshrc) silently beats
.env.local in next dev and the desktop authEnv() seam.
Two-band architecture from docs/research/stt.md: renderer captures mic
audio in an AudioWorklet (linear16 16kHz mono, ~128ms frames, per-quantum
RMS for the meter) and streams it directly to Deepgram over WebSocket —
audio never loops through our server. The real key stays server-side;
/api/stt/token mints a short-lived grant token (TTL 300s) per run.
Patterns ported from the owner's pi dictate extension (tuned in real
use): generation counter guarding every async handler, the
latest-RMS-never-resets meter (-50..-10dB window, 60ms tick), mic-first
stop + CloseStream + 3s safety net, endpointing=300 with the
utterance_end_ms=1000 backstop.
UI (owner placement): mic button in the stats bar's middle slot on all
three surfaces (home graph-view, chat, draft); while recording the meter
unfurls to fill the row's free width with the interim ghost at its
flank. Esc ends dictation from the composer. Token mint comes before
getUserMedia so a keyless app fails fast without a TCC prompt; one
automatic reconnect on abnormal socket close, errors surface inline.
Live-smoked the full error path in the dev shell (bar renders on all
three pages, 403 grant failure shows inline, mic untouched). The full
audio path needs a Deepgram key with Member permission to mint tokens.