diff --git a/AGENTS.md b/AGENTS.md index 6601894..043f2cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -296,6 +296,7 @@ invincible/ | Cloud multi-device harness session (Redis multi-session, `/api/sessions*`, hybrid local+cloud; **phase 0 #515 envelope + Blob transcript carrier**) | `app/api/sessions/*` (+ `app/api/sessions/[id]/envelope/*`, `[id]/transcript/*`), `lib/sessionRepository.ts`, `lib/sessionCloudCaps.ts`, `lib/sessions/*` (+ `lib/sessions/blobStore.ts`, `blobStores.ts`), `lib/tenancy/harnessSessionsRedis.ts`, `lib/tenancy/harnessSessions.ts` (archive read + shared validator), `lib/di/index.ts` (root), `app/harness/HarnessHost.tsx`, `middleware.ts`, [docs/session-model.md](docs/session-model.md), [docs/bring-your-own.md](docs/bring-your-own.md), [SECURITY.md](SECURITY.md) — one-shot Postgres→Redis backfill: GHA **`sessions-redis-backfill`** (idempotent per-user marker); Postgres `harness_sessions` is a read-only archive. P1/GAP-1 (#452): session-carrier `meta.{logicalCwd,activeSandboxId}` folds into the PUT body and restores on pull/adopt; **plan #616 (source #610)** adds the reserved `meta.selectedModel` session carrier for the selected model pick (restore by id after the model catalog push; server **drops a poisoned value to unset**, never a 400). **Phase 0 (#515):** the transcript lives in **Vercel Blob** (`BLOB_READ_WRITE_TOKEN` / BYO S3-R2 seam) pointed to by `meta.transcriptPointer` on the small Redis envelope (`harness:envelope:*`); server mints short-lived scoped upload URLs for **client→Blob** uploads; legacy full-record GET stays for roll-forward while old blobs stay small. Envelope upsert/read: `PUT`/`GET /api/sessions/:id/envelope`; mint/read: `POST`/`GET /api/sessions/:id/transcript` | | Harness stream chrome (Thinking collapse/caps, live tools) | `lib/harnessChat.ts`, `native/harness/src/ui.zig` (facade + `frame`; transcript band owner), `native/harness/src/ui/thinking.zig` (Thinking kind), `native/harness/src/transcript_split.zig` (collapsible left rail + session list in the transcript band), `native/harness/src/session_catalog.zig` (v17 catalog + pending switch), protocol **v18** in `lib/harnessBridge.ts` (Stop cancel v9; Thinking kind v8; tool-run kind 6 v10→live paint v11; skill-attach kind 7 v12; **status-slot store v13**; **turn-clock feed v14**; **v14 addendum** `inv_set_busy_tick`; **v16** model persist; **v17** session-rail catalog + pending switch; **v18** `inv_queued_count` submit-queue depth) | +| Keyboard shortcuts (keymap, leader, help overlay) | `native/harness/src/keymap.zig` (single chord table + reserved-browser deny-list + leader machine; **NEW caps** `KEYMAP_MAX`=64, `LEADER_WINDOW_MS`=800), `native/harness/src/ui/keymap_dispatch.zig` (one per-frame walk of `dvui.events()`, handled-marking, leader dvui-timer arm/expiry), `native/harness/src/ui/help_overlay.zig` (in-canvas TEAL panel), wired in `native/harness/src/ui.zig` (dispatch before textEntry; overlay paint) + `ui/queue_band.zig` (scan removed; `queue_save`/`cancel_queue_edit` routed via dispatcher). **DOM adds no keyboard UI / `window` keydown / React cheatsheet** | | Workspace status bar (protocol v13 status-slot store; bridge overall **v14** — plan #538/#541 + Phase 2 git #540 + Phase 3 context/usage #539, **two-line bottom status bar under the composer — #554/#555/#570**) | `native/harness/src/{bridge,ui,model_picker,model_catalog}.zig` (status-slot store + two-line 64 px bar directly **below the composer**: **line 1** = identity (spinner · `h:{build-id}` · model menu) relocated from the deleted header band, **line 2** = `paintStatusSlots` right-aligned slot pack — header merged by plan #570; each line has explicit 32 px height so the model picker (`PICKER_TRIGGER_H`=32) fits and slots never clip; sandbox/cwd/git + context/usage slots — context painted generically via `STATUS_SLOT_DROP_ORDER`), `lib/harnessBridge.ts` (`StatusSlot`, `setStatusSlot`/`getStatusSlot`/`clearStatusSlot`/`clearStatusSlots`, `STATUS_SLOT_MAX_BYTES` mirror), `lib/harnessChat.ts` (`foldStatusSlots` — folds `activeSandboxId` + `cwd` + **context/usage** (`formatUsageSummary`, re-sanitized on read) after hydrate, after **every** agent turn — success **and** fail (403-clear / committed `change_dir` repaint the pack — PR #543), and **live mid-turn on tool results** (Phase 2 #627 / #625: a confirmed `change_dir` or successful `meta_sandbox_switch` repaints sandbox/cwd immediately, plus the host persists via `onSessionPatch`); context default **hidden** on missing usage, abort/cancel carries the prior honest value forward; host-ellipsized to the byte cap before the wire; `refreshGitStatusSlot` — host polls the read-only `GET /api/harness/status` probe on a ~10 s cadence **and** on-demand after a successful `exec` or `meta_sandbox_switch` mid-turn (not only the cadence), fail-soft keeps the last git value on transient error/429), `app/harness/HarnessHost.tsx` (Clear/New clears the pack; wires the git cadence + `onSessionPatch` → persist), `app/api/harness/status/route.ts` (read-only git probe: envelope-authoritative bind (`meta.activeSandboxId` wins over Redis-safe `?sandboxId=` carry), `resolveSandbox` → bounded argv-only read-only git at the bind workspace root via `lib/agent/statusProbe.ts`, per-instance rate cap `STATUS_PROBE_MIN_INTERVAL_MS`; middleware matcher + in-route `requireSessionUser` dual gate; never mutates a session/envelope — no Production write), `lib/agent/statusProbe.ts` (`STATUS_GIT_PROBE_OUT_MAX_BYTES`=512, fail-soft `{}`), `lib/sessionCloudCaps.ts` (`STATUS_SLOT_MAX_BYTES` = 96 + `STATUS_PROBE_MIN_INTERVAL_MS` = 2000 — client-safe single sources), **context/usage carrier:** `lib/agent/usageSummary.ts` (bounded provider-usage mapper `mapProviderUsage` / read-side `sanitizeUsageSummary` / host `formatUsageSummary`, `USAGE_SUMMARY_MAX_BYTES` = 96 — NEW cap), emitted **live mid-stream** from `finish` parts (aggregate only — never `finish-step` per-step counts) in `lib/agent/agentStream.ts` (SSE `usage` event), reconciled at the final `done.usage` / JSON result / chat result in `lib/agent/runAgent.ts` (+ `app/api/chat/route.ts`), parsed by `lib/agentApi.ts` / `lib/chatApi.ts`, mirrored on `SessionSnapshot.usage` (`lib/sessionStore.ts`; reserved cloud `meta.usage` JSON string, drop-to-unset on poison), docs: [docs/feature-divide.md](docs/feature-divide.md), [docs/harness-limits.md](docs/harness-limits.md), [docs/agent-stream.md](docs/agent-stream.md), [docs/session-model.md](docs/session-model.md) | | | Tool-run aggregation + expandable transcript control (#325) | `lib/agent/agentStream.ts` (backend `tool_result.preview` — bounded/redacted L2 detail), `lib/toolRun.ts` (encode/decode, host aggregation, `meaningfulDetail` preview→`detail`, `mergeToolRunPayloads`/`encodeToolRunPayload` hydrate coalesce), `lib/harnessChat.ts` (stream/JSON aggregation → kind 6 `tool_run`, **live-painted**: a tool event opens/grows ONE card immediately via `update_last` — grouping keys off the host's `lastRingRowIsToolRun` flag, the only ring writer: grow iff the last ring row is a tool-run, else a NEW card at `1`; a thinking/assistant/user/error row last is a separator; commit-once is removed; reload coalescing of consecutive `tool_run` rows via `coalesceToolRunMessages` in `pushSessionToBridge`), `lib/sessionStore.ts` role `tool_run`, `native/harness/src/rich/toolrun.zig` (decode), `native/harness/src/ui/toolrun.zig` (`paintToolRun` — **headerless**: no `tools` kind band; 📋 copy on the header row; status glyphs as the single channel from embedded faces, `✓`/`✗` DejaVu symbols + `…` Noto; L2 preview in Vera Sans Mono for command/output tools **or any multi-line detail**, body otherwise; short single-line results → static label, no blank expander), `native/harness/src/bridge.zig` + `lib/harnessBridge.ts` (protocol **v11**; additive test-only ring readback `inv_message_*_at`), protocol **v11**; expand state + stick-to-bottom reuse dvui `reorder_tree.zig` / `scrolling.zig` idioms | diff --git a/docs/feature-divide.md b/docs/feature-divide.md index 54e7413..28ba8bf 100644 --- a/docs/feature-divide.md +++ b/docs/feature-divide.md @@ -46,6 +46,7 @@ optional login chrome). | Busy / error presentation for turns | **Wasm** | EMBER for errors | | Whole-turn `mm:ss` clock (Busy) | **Wasm** (busy row) fed by the **DOM** host | The host owns the only reliable wall-clock (no WASI clock in Wasm) and ticks it ~1 Hz, pushing the elapsed seconds into the Wasm busy row via protocol **v14** `inv_set_turn_elapsed` (plan #567). The canvas appends `Waiting for model… · 0:42` in-canvas while a turn runs; reset to 0 on Ready/Stop/error so no `0:00` lingers. Composer/Stop stay **Wasm** | | 2×4 busy spinner (plan #574) | **Wasm paint** fed by the **DOM** host | **Wasm** paints a 2×4 WARM rectangle grid left of `Waiting for model…` (clockwise pulse; pure LUT `busy_spinner.zig`, zero I/O/alloc in the frame path). **DOM host** drives the pulse phase on the same Busy ticker at **`HARNESS_BUSY_TICK_HZ` = 10 Hz** (`HarnessBridge.setBusyTick` → additive `inv_set_busy_tick`; the v14 `mm:ss` clock is fed every 10th tick ≈ 1 Hz). **Reduced motion** (read fresh at each busy start): the host skips only the per-tick pulse push, grid static at phase 0 — the `mm:ss` **clock keeps ticking** (no reduced-motion clock regression). Idle/Stop/error clears both to 0. Old host + new Wasm degrades to a static grid (busy_tick stays 0) | +| Keyboard (keymap table, leader, help overlay) | **Wasm** | All chords in `native/harness/src/keymap.zig` + one dispatcher `ui/keymap_dispatch.zig`; in-canvas help panel `ui/help_overlay.zig`. **DOM adds no shortcut UI / React cheatsheet / `window` keydown** | | Empty / onboarding copy for agent | **Wasm** | | | Asteronica canvas theme | **Wasm** | `palette.zig` | | Frame loop / WebGL | **Wasm** | dvui | diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 71b8f74..9f81ba5 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -43,13 +43,25 @@ click/tap scrolls the transcript so the message is back in view near the top. ## Keyboard & focus +All harness chords are rows of one keymap table +(`native/harness/src/keymap.zig`) and every key event is dispatched by a single +walk of `dvui.events()` before the composer textEntry is built +(`native/harness/src/ui/keymap_dispatch.zig`). `ui.zig` and `queue_band.zig` +never scan keys inline. A **reserved browser chord** (new tab, reload, +find/devtools, copy/paste, back/forward, tab-switch) is **never marked handled** +— the canvas yields it to the browser, even if a row name overlaps (fail +closed). + | Chord | Action | |-------|--------| | **Enter** (composer focused) | Insert a newline (composer is multi-line) | | **Ctrl+Enter** / **Cmd+Enter** (composer focused) | Send prompt when idle; **enqueue** a follow-up when Busy | +| **Ctrl+Enter** / **Cmd+Enter** (queue-row editor) | **Save** the queued-item edit | | **↑** / **↓** (composer focused) | **↑** on an empty composer loads the newest user message; further ↑ walk older user rows. **↓** walks forward; ↓ past the newest restores the in-progress draft. Only user messages appear (assistant/thinking/tool/system/error/skill rows never load). Works while Busy. Ring window only (Load earlier for prompts that have aged out) | -| **Escape** (harness focused, Busy) | Cancel the in-progress turn (same as ■ Stop). Queue-row editor: dismisses editor first (does not abort turn). Idle: no-op | -| Tab | DOM nav / Clear (canvas uses pointer + dvui focus) | +| **Escape** | Cancel the in-progress turn when Busy (same as ■ Stop); **dismisses a queue-row editor first** (does not abort the turn); **closes the help overlay** (wins over busy cancel); **disarms the leader**. Idle: no-op (textEntry / dvui menus keep it) | +| **Ctrl+**/**Cmd+/** | Toggle the in-canvas **help overlay** (TEAL panel over the transcript band) | +| **Ctrl+Shift+Space** | Arm the **leader** prefix (800 ms). Within the window press **`?`** to toggle help; **Escape** cancels; an unmatched key swallows (never lands in the prompt); a reserved browser chord still yields to the browser | +| Tab / Ctrl+Left / Ctrl+Right | DOM nav / text caret (not harness chords; Ctrl+Left/Right are word-jumps, left to the textEntry) | | Composer focus | Requested on ready and after each send | ## Touch / mobile (~390px) diff --git a/native/harness/build.zig b/native/harness/build.zig index 11c4e4d..539d1c6 100644 --- a/native/harness/build.zig +++ b/native/harness/build.zig @@ -213,6 +213,20 @@ pub fn build(b: *std.Build) void { test_rich.dependOn(&b.addRunArtifact(composer_history_tests).step); } + // Host unit tests for keymap.zig (plan #741): the single chord table + + // reserved-browser deny-list + leader machine. Pure, no dvui, no bridge. + { + const keymap_tests = b.addTest(.{ + .name = "keymap", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/keymap.zig"), + .target = host_target, + .optimize = optimize, + }), + }); + test_rich.dependOn(&b.addRunArtifact(keymap_tests).step); + } + // Host unit tests for cwd_slot.zig (plan #579, adversarial review #584 // Minor L6): the "."-hidden predicate. Pure, no dvui. { diff --git a/native/harness/src/keymap.zig b/native/harness/src/keymap.zig new file mode 100644 index 0000000..1ae574d --- /dev/null +++ b/native/harness/src/keymap.zig @@ -0,0 +1,623 @@ +//! Single keymap for the harness — plan #741. +//! +//! OWNERSHIP: the keymap table + reserved-browser deny-list + leader state +//! live here (pure, no dvui / no bridge — host-unit-tested like +//! `composer_history.zig`). The per-frame dispatch that iterates dvui events, +//! marks `handled`, and runs actions lives in `ui/keymap_dispatch.zig`. +//! +//! Every product chord is one row below; `ui.zig` / `queue_band.zig` no longer +//! match keys inline (Goal 1). The reserved-browser set is a fail-closed deny +//! list: a chord there is NEVER marked handled, even if a row also recognizes +//! it (Goal 3 / "we never consume what the browser needs"). +const std = @import("std"); + +/// Static-table cap (generous for follow-up chords; not a wire; plan-only cap). +pub const KEYMAP_MAX: usize = 64; + +/// Leader prefix window (ms) between Ctrl+Shift+Space and the command key. +pub const LEADER_WINDOW_MS: u64 = 800; + +/// Logical key codes the harness interprets (self-contained subset of +/// dvui's `enums.Key`; dispatch converts via `fromDvui`). Kept here so the +/// pure module never imports dvui. +pub const Key = enum { + enter, + escape, + up, + down, + space, + slash, // '/' and '?' (the latter via mods.shift) + tab, + left, + right, + f5, + // letters referenced by the reserved-browser set / potential future chords + c, + v, + x, + a, + z, + s, + f, + t, + n, + w, + r, + l, + p, + i, + j, + /// Any other key the harness doesn't recognize by name (b, digits, + /// punctuation, Backspace, function keys beyond f5, etc.). Dispatched into + /// `match` like any key: while a leader is pending an unmatched `.unknown` + /// is swallowed (handled + disarmed) so it never lands in the prompt; with + /// no leader it passes through (`.none`) to the textEntry / browser. + unknown, +}; + +/// Key action reported by the browser/dvui. +pub const KeyAction = enum { down, repeat, up }; + +/// Live modifier state from dvui. +pub const Mods = struct { + control: bool = false, + command: bool = false, + shift: bool = false, + alt: bool = false, +}; + +/// Modifier requirement for a chord row. Mutually exclusive prereqs; the +/// dispatcher builds `Mods` from dvui and keymap matches one prereq. +pub const ModPrereq = enum { + /// No modifiers pressed. + none, + /// Control OR Command (submit / queue_save / help_toggle). + ctrl_or_cmd, + /// Control AND Shift, and NOT Command (leader prefix — Control both platforms). + ctrl_shift, + /// Shift only (leader `?` = Shift+/). + shift, + /// Alt only (browser back/forward Alt+Left / Alt+Right). + alt, +}; + +fn modsMatch(mods: Mods, prereq: ModPrereq) bool { + return switch (prereq) { + .none => !mods.control and !mods.command and !mods.shift and !mods.alt, + .ctrl_or_cmd => !mods.shift and !mods.alt and (mods.control or mods.command), + .ctrl_shift => mods.control and mods.shift and !mods.command and !mods.alt, + .shift => mods.shift and !mods.control and !mods.command and !mods.alt, + .alt => mods.alt and !mods.control and !mods.command and !mods.shift, + }; +} + +/// Product action a handled chord triggers. The dispatcher has a `*const fn` +/// handler for each (via `ui/state.zig` + `ui.zig`/`queue_band.zig` seams). +pub const Action = enum { + submit, + queue_save, + history_older, + history_newer, + cancel_turn, + cancel_queue_edit, + help_close, + help_toggle, + help_toggle_leader, + /// Ctrl+Shift+Space — arm the leader window (dispatcher). + leader, + /// Escape while leader pending — disarm (no insert, no product action). + leader_cancel, +}; + +/// Frame context bits the `when` gating reads. `leader_pending` is managed by +/// the leader state machine (dispatcher) each frame. +/// +/// Matching convention: in a `when_true` / `when_false`, `true` constrains the +/// field and `false` means "don't care". The dispatcher builds the LIVE ctx +/// (every field explicit). +pub const Context = struct { + composer: bool = false, + queue_editing: bool = false, + busy: bool = false, + help_open: bool = false, + leader_pending: bool = false, + /// Composer prompt is empty (row 4 / history_older when this OR in_history). + prompt_empty: bool = false, + /// Already walking composer history (rows 4/5). + in_history: bool = false, +}; + +/// Outcome of a single key event through `match`. +pub const Outcome = enum { + /// Nothing to do; do not mark handled (textEntry / browser keep it). + none, + /// Reserved browser chord — fail-closed: do NOT mark handled. + browser, + /// Handle + run `action`. + action, + /// Leader pending, key is not the command: swallow (handled, no insert), + /// disarm the leader. Reserved chords were filtered to `.browser` above. + swallow_leader, +}; + +/// Pure match result: verdict + which product action (if `.action`). +pub const Match = struct { + outcome: Outcome, + action: ?Action = null, +}; + +/// Static table row. +pub const Row = struct { + /// Stable id (also the `help_label` source id for the overlay). + id: []const u8, + key: Key, + prereq: ModPrereq, + /// Context bits that must all be true to match. + when_true: Context = .{}, + /// Context bits that must all be false to match. + when_false: Context = .{}, + action: Action, + help: []const u8 = "", +}; + +/// Does `ctx` satisfy every constraint in `when`? A `true` field in `when` +/// must be true in `ctx`; a `false` field in `when` means "don't care". +fn ctxSubset(ctx: Context, when: Context) bool { + if (when.composer and !ctx.composer) return false; + if (when.queue_editing and !ctx.queue_editing) return false; + if (when.busy and !ctx.busy) return false; + if (when.help_open and !ctx.help_open) return false; + if (when.leader_pending and !ctx.leader_pending) return false; + if (when.prompt_empty and !ctx.prompt_empty) return false; + if (when.in_history and !ctx.in_history) return false; + return true; +} + +/// Shipped rows (Goal 1: every harness chord is one table row). Escapes are +/// ordered help_close → cancel_queue_edit → leader_cancel → cancel_turn so a +/// competing context resolves predictably (help wins, then queue edit, then +/// leader, then busy cancel — matches current behavior + plan precedence). +pub const KEY_TABLE = [_]Row{ + // ── send / enqueue (composer) ───────────────────────────────────────── + .{ + .id = "submit", + .key = .enter, + .prereq = .ctrl_or_cmd, + .when_true = .{ .composer = true }, + .when_false = .{ .queue_editing = true }, + .action = .submit, + .help = "Send (enqueue when busy)", + }, + // ── queue-row save-edit (queue_editing context) ─────────────────────── + .{ + .id = "queue_save", + .key = .enter, + .prereq = .ctrl_or_cmd, + .when_true = .{ .queue_editing = true }, + .action = .queue_save, + .help = "Save queued item", + }, + // ── history older (↑) ────────────────────────────────────────────────── + .{ + .id = "history_older", + .key = .up, + .prereq = .none, + .when_true = .{ .composer = true, .prompt_empty = true }, + .when_false = .{ .queue_editing = true }, + .action = .history_older, + .help = "Older message", + }, + .{ + .id = "history_older_in", + .key = .up, + .prereq = .none, + .when_true = .{ .composer = true, .in_history = true }, + .when_false = .{ .queue_editing = true }, + .action = .history_older, + .help = "Older message", + }, + // ── history newer (↓, only while in history) ─────────────────────────── + .{ + .id = "history_newer", + .key = .down, + .prereq = .none, + .when_true = .{ .composer = true, .in_history = true }, + .when_false = .{ .queue_editing = true }, + .action = .history_newer, + .help = "Newer message", + }, + // ── escape family (ordered help → queue → leader → busy) ────────────── + .{ + .id = "help_close", + .key = .escape, + .prereq = .none, + .when_true = .{ .help_open = true }, + .action = .help_close, + .help = "Close help", + }, + .{ + .id = "cancel_queue_edit", + .key = .escape, + .prereq = .none, + .when_true = .{ .queue_editing = true }, + .action = .cancel_queue_edit, + .help = "Discard queued item edit", + }, + .{ + .id = "leader_cancel", + .key = .escape, + .prereq = .none, + .when_true = .{ .leader_pending = true }, + .action = .leader_cancel, + .help = "Cancel leader", + }, + .{ + .id = "cancel_turn", + .key = .escape, + .prereq = .none, + .when_true = .{ .busy = true }, + .when_false = .{ .queue_editing = true, .help_open = true }, + .action = .cancel_turn, + .help = "Stop the turn", + }, + // ── leader prefix (global) ───────────────────────────────────────────── + .{ + .id = "leader", + .key = .space, + .prereq = .ctrl_shift, + .when_true = .{}, + .action = .leader, + .help = "Leader: Ctrl+Shift+Space", + }, + // ── help toggle (direct Ctrl/Cmd+/) ─────────────────────────────────── + .{ + .id = "help_toggle", + .key = .slash, + .prereq = .ctrl_or_cmd, + .when_true = .{}, + .action = .help_toggle, + .help = "Toggle help", + }, + // ── leader command: ? ───────────────────────────────────────────────── + .{ + .id = "help_toggle_leader", + .key = .slash, + .prereq = .shift, + .when_true = .{ .leader_pending = true }, + .action = .help_toggle_leader, + .help = "Toggle help (leader)", + }, +}; + +/// Reserved-browser deny-list — (key, prereq) pairs never marked handled. +/// From the plan: Ctrl/Cmd+T N W R L P S F, Ctrl+Shift+T, Ctrl+Tab / +/// Ctrl+Shift+Tab, Alt+Left/Right, F5 / Ctrl+R, Ctrl+Shift+C I J (devtools +/// family), Ctrl/Cmd+C V X A Z (editing — leave to textEntry / browser). +/// Ctrl+/ and Ctrl+Shift+Space are NOT reserved. +const RESERVED = [_]struct { key: Key, prereq: ModPrereq }{ + .{ .key = .t, .prereq = .ctrl_or_cmd }, + .{ .key = .n, .prereq = .ctrl_or_cmd }, + .{ .key = .w, .prereq = .ctrl_or_cmd }, + .{ .key = .r, .prereq = .ctrl_or_cmd }, + .{ .key = .l, .prereq = .ctrl_or_cmd }, + .{ .key = .p, .prereq = .ctrl_or_cmd }, + .{ .key = .s, .prereq = .ctrl_or_cmd }, + .{ .key = .f, .prereq = .ctrl_or_cmd }, + .{ .key = .c, .prereq = .ctrl_or_cmd }, + .{ .key = .v, .prereq = .ctrl_or_cmd }, + .{ .key = .x, .prereq = .ctrl_or_cmd }, + .{ .key = .a, .prereq = .ctrl_or_cmd }, + .{ .key = .z, .prereq = .ctrl_or_cmd }, + .{ .key = .i, .prereq = .ctrl_or_cmd }, + .{ .key = .j, .prereq = .ctrl_or_cmd }, + .{ .key = .tab, .prereq = .ctrl_or_cmd }, + .{ .key = .f5, .prereq = .none }, + // Alt+Left / Alt+Right (browser back/forward) — notice Ctrl+Left/Right are + // text caret word-moves, NOT reserved (textEntry keeps them). + .{ .key = .left, .prereq = .alt }, + .{ .key = .right, .prereq = .alt }, +}; + +pub fn isReserved(key: Key, mods: Mods) bool { + for (RESERVED) |r| { + if (r.key == key and modsMatch(mods, r.prereq)) return true; + } + return false; +} + +/// Pure leader state — armed_until_ms on the frame clock (dispatcher feeds +/// `now_ms` from dvui `timerGet`). +pub fn leaderTimedOut(armed: bool, elapsed_ms: u64) bool { + return armed and elapsed_ms >= LEADER_WINDOW_MS; +} + +fn rowMatches(row: Row, key: Key, mods: Mods, ctx: Context) bool { + if (row.key != key) return false; + if (!modsMatch(mods, row.prereq)) return false; + if (!ctxSubset(ctx, row.when_true)) return false; + if (ctxIntersects(row.when_false, ctx)) return false; + return true; +} + +fn ctxIntersects(forbidden: Context, ctx: Context) bool { + return (forbidden.composer and ctx.composer) or + (forbidden.queue_editing and ctx.queue_editing) or + (forbidden.busy and ctx.busy) or + (forbidden.help_open and ctx.help_open) or + (forbidden.leader_pending and ctx.leader_pending) or + (forbidden.prompt_empty and ctx.prompt_empty) or + (forbidden.in_history and ctx.in_history); +} + +/// Dispatch one key event through the table. Pure: no dvui, no bridge, no I/O. +/// +/// Leader handling is table-driven: the `leader` row (space+ctrl_shift) fires +/// the `.leader` action (the dispatcher arms on `.down` only); while +/// `ctx.leader_pending` the `help_toggle_leader` (`?`) and `leader_cancel` +/// (Escape) rows have product actions; any other key that no row matches +/// swallows (`.swallow_leader` — handled + disarmed) so it never lands in the +/// prompt — except reserved browser chords, which were filtered to `.browser` +/// first (and never consumed). +pub fn match(key: Key, action: KeyAction, mods: Mods, ctx: Context) Match { + // Reserved deny-list wins over everything (fail closed — never consume + // what the browser needs, even if a row also recognizes the chord). + if (isReserved(key, mods)) return .{ .outcome = .browser }; + + // Fire on .down / .repeat (matching current ui.zig behaviour — held Enter + // is handled so it doesn't inject newlines, but only .down submits once; + // the dispatcher likewise arms the leader on .down only). + if (action == .up) return .{ .outcome = .none }; + + // Leader window: while `leader_pending`, the prefix swallows EVERYTHING + // except its own command chords — `?` toggles help, Escape cancels, and + // the leader chord itself re-arms. A recognized product row does NOT fire + // during the window (the first key after the prefix is either the command + // or it cancels/disarms the leader). + if (ctx.leader_pending) { + if (key == .slash and modsMatch(mods, .shift)) { + return .{ .outcome = .action, .action = .help_toggle_leader }; + } + if (key == .escape) { + // Help wins over the leader: with the overlay already open, Esc + // closes it (one Esc), per docs/harness-limits ("closes the help + // overlay ...; disarms the leader"). Otherwise Escape disarms the + // pending leader. + if (ctx.help_open) return .{ .outcome = .action, .action = .help_close }; + return .{ .outcome = .action, .action = .leader_cancel }; + } + if (key == .space and modsMatch(mods, .ctrl_shift)) { + return .{ .outcome = .action, .action = .leader }; // re-arm + } + // Unmatched key while leader pending: swallow (handled, disarmed) so it + // never lands in the prompt (fail closed). + return .{ .outcome = .swallow_leader }; + } + + for (KEY_TABLE) |row| { + if (rowMatches(row, key, mods, ctx)) { + return .{ .outcome = .action, .action = row.action }; + } + } + return .{ .outcome = .none }; +} + +/// Table length helper for the help overlay + cap assertion. +pub fn tableLen() usize { + return KEY_TABLE.len; +} + +test "keymap: table within KEYMAP_MAX and has the shipped rows" { + try std.testing.expect(tableLen() <= KEYMAP_MAX); + try std.testing.expect(tableLen() >= 6); +} + +test "keymap: submit — Ctrl+Enter idle composer" { + const m = match(.enter, .down, .{ .control = true }, .{ .composer = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.submit, m.action.?); +} + +test "keymap: submit — Cmd+Enter idle composer" { + const m = match(.enter, .down, .{ .command = true }, .{ .composer = true }); + try std.testing.expectEqual(Action.submit, m.action.?); +} + +test "keymap: submit — held Enter marks handled (repeat) without extra action" { + const m = match(.enter, .repeat, .{ .control = true }, .{ .composer = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.submit, m.action.?); +} + +test "keymap: submit — key up is not handled" { + const m = match(.enter, .up, .{ .control = true }, .{ .composer = true }); + try std.testing.expectEqual(Outcome.none, m.outcome); +} + +test "keymap: queue_save — Ctrl+Enter queue_editing (5b regression of submitChord)" { + const m = match(.enter, .down, .{ .control = true }, .{ .queue_editing = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.queue_save, m.action.?); +} + +test "keymap: queue_save wins over submit when queue_editing" { + const m = match(.enter, .down, .{ .control = true }, .{ .composer = true, .queue_editing = true }); + try std.testing.expectEqual(Action.queue_save, m.action.?); +} + +test "keymap: escape busy not queue-editing → cancel_turn (row 2)" { + const m = match(.escape, .down, .{}, .{ .composer = true, .busy = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.cancel_turn, m.action.?); +} + +test "keymap: escape idle → none (textEntry keeps it, closes menus)" { + const m = match(.escape, .down, .{}, .{ .composer = true, .busy = false }); + try std.testing.expectEqual(Outcome.none, m.outcome); +} + +test "keymap: escape queue_editing → cancel_queue_edit, NOT cancel_turn (row 3, #705)" { + const m = match(.escape, .down, .{}, .{ .queue_editing = true, .busy = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.cancel_queue_edit, m.action.?); +} + +test "keymap: escape help_open → help_close wins over cancel_turn (row 4)" { + const m = match(.escape, .down, .{}, .{ .busy = true, .help_open = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.help_close, m.action.?); +} + +test "keymap: ↑ empty composer → history_older (row 5)" { + const m = match(.up, .down, .{}, .{ .composer = true, .prompt_empty = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.history_older, m.action.?); +} + +test "keymap: ↑ draft text not in history → none (caret moves)" { + const m = match(.up, .down, .{}, .{ .composer = true, .prompt_empty = false, .in_history = false }); + try std.testing.expectEqual(Outcome.none, m.outcome); +} + +test "keymap: ↑ queue_editing → none (queue editor owns caret)" { + const m = match(.up, .down, .{}, .{ .queue_editing = true }); + try std.testing.expectEqual(Outcome.none, m.outcome); +} + +test "keymap: ↓ in history → history_newer" { + const m = match(.down, .down, .{}, .{ .composer = true, .in_history = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.history_newer, m.action.?); +} + +test "keymap: ↓ not in history → none" { + const m = match(.down, .down, .{}, .{ .composer = true, .in_history = false }); + try std.testing.expectEqual(Outcome.none, m.outcome); +} + +test "keymap: browser reserved never handled (row 6)" { + try std.testing.expectEqual(Outcome.browser, match(.f, .down, .{ .control = true }, .{ .composer = true }).outcome); + try std.testing.expectEqual(Outcome.browser, match(.t, .down, .{ .control = true }, .{ .composer = true }).outcome); + try std.testing.expectEqual(Outcome.browser, match(.l, .down, .{ .command = true }, .{ .composer = true }).outcome); + try std.testing.expectEqual(Outcome.browser, match(.c, .down, .{ .control = true }, .{ .composer = true }).outcome); + try std.testing.expectEqual(Outcome.browser, match(.r, .down, .{ .control = true }, .{ .composer = true }).outcome); +} + +test "keymap: reserved even when its chord overlaps a row (fail closed)" { + // Ctrl+T isn't a shipped row, but ensure the deny path exists and wins. + try std.testing.expectEqual(Outcome.browser, match(.t, .down, .{ .control = true }, .{ .composer = true }).outcome); +} + +test "keymap: Ctrl+/ toggles help (works on win/linux ctrl)" { + const m = match(.slash, .down, .{ .control = true }, .{ .composer = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.help_toggle, m.action.?); +} + +test "keymap: Cmd+/ toggles help (mac)" { + const m = match(.slash, .down, .{ .command = true }, .{ .composer = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.help_toggle, m.action.?); +} + +test "keymap: bare / is not help (printable slash in composer)" { + const m = match(.slash, .down, .{}, .{ .composer = true }); + try std.testing.expectEqual(Outcome.none, m.outcome); +} + +test "keymap: leader arms on Ctrl+Shift+Space (row 7)" { + const m = match(.space, .down, .{ .control = true, .shift = true }, .{ .composer = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.leader, m.action.?); +} + +test "keymap: Ctrl+Space (IME) and Cmd+Space (Spotlight) do NOT arm" { + try std.testing.expectEqual(Outcome.none, match(.space, .down, .{ .control = true }, .{ .composer = true }).outcome); + try std.testing.expectEqual(Outcome.none, match(.space, .down, .{ .command = true }, .{ .composer = true }).outcome); +} + +test "keymap: leader + ? within window → help_toggle_leader (row 7)" { + const m = match(.slash, .down, .{ .shift = true }, .{ .leader_pending = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.help_toggle_leader, m.action.?); +} + +test "keymap: leader + Esc → leader_cancel (not turn cancel)" { + const m = match(.escape, .down, .{}, .{ .leader_pending = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.leader_cancel, m.action.?); +} + +test "keymap: leader + unmatched printable → swallow (never inserts)" { + const m = match(.a, .down, .{}, .{ .leader_pending = true }); + try std.testing.expectEqual(Outcome.swallow_leader, m.outcome); +} + +test "keymap: leader + .unknown key (letters outside subset, digits, etc.) → swallow + disarm (never inserts)" { + // The Major L1 fix: an unrecognized key (e.g. `h`, `1`, Backspace) routed + // as `.unknown` must be swallowed (handled + disarmed), never land in the + // prompt, never leave the leader armed. Previously these never reached + // `match` at all (`fromDvui(...) orelse continue`). + const m = match(.unknown, .down, .{}, .{ .leader_pending = true }); + try std.testing.expectEqual(Outcome.swallow_leader, m.outcome); + try std.testing.expect(m.action == null); +} + +test "keymap: .unknown with no leader → none (pass through to textEntry)" { + // Unrecognized key outside the leader window must pass through. + const m = match(.unknown, .down, .{}, .{ .composer = true }); + try std.testing.expectEqual(Outcome.none, m.outcome); + try std.testing.expect(m.action == null); +} + +test "keymap: .unknown repeat with leader → swallow (held key does not re-insert)" { + const m = match(.unknown, .repeat, .{}, .{ .leader_pending = true }); + try std.testing.expectEqual(Outcome.swallow_leader, m.outcome); +} + +test "keymap: .unknown key-up while leader pending → none (release never disarms/swallows)" { + // `.up` early-returns `.none` before leader handling; a modifier or + // unrecognized key *release* must never disarm the leader on its own. + const m = match(.unknown, .up, .{}, .{ .leader_pending = true }); + try std.testing.expectEqual(Outcome.none, m.outcome); +} + +test "keymap: leader + Esc with help open → help_close (help wins, one Esc, L8)" { + const m = match(.escape, .down, .{}, .{ .leader_pending = true, .help_open = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.help_close, m.action.?); +} + +test "keymap: leader + Esc without help → leader_cancel (unchanged)" { + const m = match(.escape, .down, .{}, .{ .leader_pending = true, .help_open = false }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.leader_cancel, m.action.?); +} + +test "keymap: leader + reserved browser chord → disarm browser (test 9)" { + // Leader pending + Ctrl+T is reserved → `.browser` (never handled), and the + // dispatcher disarms. The deny-list runs before leader handling. + const m = match(.t, .down, .{ .control = true }, .{ .leader_pending = true }); + try std.testing.expectEqual(Outcome.browser, m.outcome); +} + +test "keymap: leader pending swallows a recognized chord (Ctrl+Enter is not submit during the window)" { + // While a leader is pending, a recognized product chord (Ctrl+Enter submit) + // does NOT fire — the leader prefix swallows the first key + disarms. Only + // the leader's own command chords (`?`, Escape, re-arm) have an action. + const m = match(.enter, .down, .{ .control = true }, .{ .leader_pending = true }); + try std.testing.expectEqual(Outcome.swallow_leader, m.outcome); + try std.testing.expect(m.action == null); +} + +test "keymap: leaderTimedOut — armed within window, expired after" { + try std.testing.expectEqual(false, leaderTimedOut(true, 0)); + try std.testing.expectEqual(false, leaderTimedOut(true, LEADER_WINDOW_MS - 1)); + try std.testing.expectEqual(true, leaderTimedOut(true, LEADER_WINDOW_MS)); + try std.testing.expectEqual(false, leaderTimedOut(false, 0)); + try std.testing.expectEqual(false, leaderTimedOut(false, 10_000)); +} + +test "keymap: bare Enter (no ctrl/cmd) is a newline — none (plan non-goal)" { + const m = match(.enter, .down, .{}, .{ .composer = true }); + try std.testing.expectEqual(Outcome.none, m.outcome); +} diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index 5feca18..ef4f910 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -29,6 +29,8 @@ const composer = @import("ui/composer.zig"); const composer_history = @import("ui/composer_history.zig"); const composer_chrome = @import("ui/composer_chrome.zig"); const queue_band = @import("ui/queue_band.zig"); +const keymap_dispatch = @import("ui/keymap_dispatch.zig"); +const help_overlay = @import("ui/help_overlay.zig"); /// Baked at compile time (`-Dbuild-id=…`); shown in header to detect stale wasm. pub const BUILD_ID: []const u8 = build_options.build_id; @@ -485,6 +487,11 @@ pub fn frame() !void { // New/Clear or a session switch ghosts a band and blocks promote // until an unmarked Escape (adversarial review #666 Major L1). queue_band.resetQueueEditState(); + // Close the help overlay + disarm the leader (plan #741) — a New / + // Clear / session hydrate refreshes the surface; a staled overlay or + // armed leader would ghost chrome. + state.help_overlay_open = false; + state.leader_armed = false; // Drop composer arrow-key history state (plan #667) — a New / // Clear / session hydrate drops the ring, so ordinals are stale. // Only restore the saved draft when actually in history (#686 R6): @@ -580,6 +587,25 @@ pub fn frame() !void { break :blk msg_y < view_top - metrics.CHIP_VISIBILITY_MARGIN; } else false; + // ── Help overlay (plan #741) — in-canvas TEAL panel over the transcript band. + // Painted after the transcript laid out (top), before the queue band / bars. + if (state.help_overlay_open) { + // Size against the FULL band (avail.w), not the leftover transcript + // width (avail.w - pane_w). With the left rail open (~220px) on a + // ~390px canvas the leftover is ~170 < HELP_OVERLAY_MIN_W and the panel + // silently no-ops (review L1). It's a modal in-canvas panel — centering + // across the whole window is correct. + help_overlay.paint(0, scroll_y, avail.w, scroll_h, .{ + .composer = state.queue_editing_index == null, + .queue_editing = state.queue_editing_index != null, + .busy = busy, + .help_open = state.help_overlay_open, + .leader_pending = state.leader_armed, + .prompt_empty = state.prompt_buf[0] == 0, + .in_history = state.history_index != null, + }); + } + // ── Submit queue band (absolute rect — above composer, below transcript) ── if (queue_band_h > 0) { queue_band.paint(queue_band_y, queue_band_h, avail.w); @@ -614,76 +640,13 @@ pub fn frame() !void { // COMPOSER_INPUT_MAX_H and then scrolls inside it — there is no second // action row to crush (#344), and no hint copy below the field. Plain // Enter inserts a newline; the send chord is Ctrl+Enter (Cmd+Enter on - // mac). dvui's web backend reports modifier bits on keydown, and a - // multiline `textEntry` ignores modifiers on Enter (it consumes Enter - // and inserts '\n'), so we detect the chord here in the pending event - // list and mark it handled — which also stops the widget from inserting - // a stray newline for the submit keystroke. - var composer_submit = false; - { - const es = dvui.events(); - for (0..es.len) |idx| { - const e = &es[idx]; - if (e.handled) continue; - const ke = switch (e.evt) { - .key => |k| k, - else => continue, - }; - - if (ke.code == .enter and (ke.mod.control() or ke.mod.command())) { - // A multiline textEntry consumes Enter and inserts '\n', - // ignoring the modifier (verified against pinned dvui), so - // mark EVERY enter-chord event (.down and .repeat) handled - // to stop it injecting a stray newline for the submit - // stroke. Submit once per gesture, on the initial .down. - e.handled = true; - if (ke.action == .down) composer_submit = true; - continue; - } - - // ── Escape — cancel in-progress turn (plan #705) ───────────── - // Same path as the ■ Stop icon. Only .down fires cancel (single- - // fire). Gated on busy so idle Esc is not marked handled and can - // still close dvui menus (MenuWidget.processEventsAfter). Skip - // when a queue-row editor is open — queue_band owns Esc there. - if (busy and ke.code == .escape and ke.action == .down and - state.queue_editing_index == null) - { - e.handled = true; - bridge.queueCancelFromUi(); - continue; - } - - // ── Composer arrow-key history (plan #667) ─────────────────── - // ↑ enters history when the composer is empty OR while already - // in history. ↓ walks history forward; outside history ↓ passes - // through (textEntry moves the caret). Both .down and .repeat - // are handled so a held arrow walks instead of also moving the - // caret (matching shell readline). - // - // When editing a queued item the queue-row editor owns the caret, - // so arrows pass through. History works during inference because - // historyApply is a pure in-memory read (no bridge write, no - // alloc, no I/O). - if (state.queue_editing_index == null) { - if (ke.action == .down or ke.action == .repeat) { - if (ke.code == .up) { - const in_hist = state.history_index != null; - const buf_empty = state.prompt_buf[0] == 0; - if (in_hist or buf_empty) { - e.handled = true; - historyApply(.older); - } - } else if (ke.code == .down) { - if (state.history_index != null) { - e.handled = true; - historyApply(.newer); - } - } - } - } - } - } + // mac). All key handling lives in the single dispatcher (plan #741) — + // it marks the chord handled before the textEntry is built (inside the + // extracted chrome), so the widget never injects a stray newline for + // the submit stroke. Submit is requested via `state.request_submit` + // (consumed after the chrome paint, which hands back the live prompt + // text + measured outer height). + keymap_dispatch.dispatch(.{ .history = &historyApply }); // The chrome paints the field on an explicit trailing-RESERVED sub-rect // of width `avail.w − (TOUCH_H×n + 8)` so its reported min width (the @@ -714,10 +677,16 @@ pub fn frame() !void { const content_h = @max(metrics.TOUCH_H, @min(raw_content, metrics.COMPOSER_INPUT_MAX_H)); state.composer_last_h = @max(metrics.COMPOSER_IDLE_CHROME_H, @min(content_h + 2 * metrics.COMPOSER_HUG_PAD, metrics.COMPOSER_MAX_CHROME_H)); - if (composer_submit and typed.len > 0) { - composer.submitOrEnqueue(typed); - typed = state.prompt_buf[0..0]; - state.want_composer_focus = true; + // Plan #741: the dispatcher set `request_submit` on the submit chord + // before the textEntry was built. After the chrome paint the live prompt + // buffer holds the final keystrokes — submit the current text. + if (state.request_submit) { + state.request_submit = false; + if (typed.len > 0) { + composer.submitOrEnqueue(typed); + typed = state.prompt_buf[0..0]; + state.want_composer_focus = true; + } } } diff --git a/native/harness/src/ui/help_overlay.zig b/native/harness/src/ui/help_overlay.zig new file mode 100644 index 0000000..55f12c1 --- /dev/null +++ b/native/harness/src/ui/help_overlay.zig @@ -0,0 +1,149 @@ +//! In-canvas help overlay — plan #741. +//! +//! A TEAL absolute-rect panel over the transcript band listing the product +//! keymap (help labels + chord); rows whose `when` is currently false are +//! greyed (WARM-muted, not EMBER). Pure paint from the static `keymap.KEY_TABLE` +//! — no GPA alloc, no host I/O, no per-frame parsing (frame budget). +//! +//! Open/close state lives in `state.help_overlay_open` (toggled by the keymap +//! dispatcher: Ctrl/Cmd+/ and leader+`?`; Esc closes — wins over busy cancel). +const dvui = @import("dvui"); +const keymap = @import("../keymap.zig"); +const palette = @import("../palette.zig"); +const metrics = @import("metrics.zig"); +const mixed_text = @import("../rich/mixed_text.zig"); + +/// Chord glyph for each shipped row, derived from the table's key+prereq. +/// `leader` is the prefix itself; `help_toggle_leader` is shown under the +/// leader family (`?`). Help copy comes from the table's `row.help` (the +/// overlay IS the table — keymap.zig owns the strings). Order mirrors KEY_TABLE. +fn rowChord(row: keymap.Row) []const u8 { + return switch (row.action) { + .submit => "Ctrl/Cmd+Enter", + .queue_save => "Ctrl/Cmd+Enter", + .history_older => "↑", + .history_newer => "↓", + .cancel_turn => "Esc", + .cancel_queue_edit => "Esc", + .help_close => "Esc", + .help_toggle => "Ctrl/Cmd+/", + .help_toggle_leader => "Leader Space, then ?", + .leader => "Ctrl+Shift+Space", + .leader_cancel => "Esc (leader)", + }; +} + +/// Is the row's product action currently available in the live context? Used +/// to grey rows whose `when` is false (WARM-muted instead of EMBER). +fn rowActive(row: keymap.Row, ctx: keymap.Context) bool { + // A row matches when_true (subset) and has no forbidden context hit. + var in_true = true; + if (row.when_true.composer and !ctx.composer) in_true = false; + if (row.when_true.queue_editing and !ctx.queue_editing) in_true = false; + if (row.when_true.busy and !ctx.busy) in_true = false; + if (row.when_true.help_open and !ctx.help_open) in_true = false; + if (row.when_true.leader_pending and !ctx.leader_pending) in_true = false; + if (row.when_true.prompt_empty and !ctx.prompt_empty) in_true = false; + if (row.when_true.in_history and !ctx.in_history) in_true = false; + if (!in_true) return false; + var forbidden = false; + if (row.when_false.composer and ctx.composer) forbidden = true; + if (row.when_false.queue_editing and ctx.queue_editing) forbidden = true; + if (row.when_false.busy and ctx.busy) forbidden = true; + if (row.when_false.help_open and ctx.help_open) forbidden = true; + if (row.when_false.leader_pending and ctx.leader_pending) forbidden = true; + if (row.when_false.prompt_empty and ctx.prompt_empty) forbidden = true; + if (row.when_false.in_history and ctx.in_history) forbidden = true; + return !forbidden; +} + +/// Paint the help overlay over the transcript band. `x,y,w,h` is the transcript +/// band rect (the overlay centers within it), `ctx` the live keymap context for +/// greying. Paints every shipped row (≤ KEYMAP_MAX). +pub fn paint(x: f32, y: f32, w: f32, h: f32, ctx: keymap.Context) void { + if (w < metrics.HELP_OVERLAY_MIN_W or h < metrics.HELP_OVERLAY_MIN_H) return; + + const panel_w = @min(metrics.HELP_OVERLAY_W, w - 2 * metrics.HELP_OVERLAY_MARGIN_X); + const panel_h = @min(metrics.HELP_OVERLAY_H, h - 2 * metrics.HELP_OVERLAY_MARGIN_Y); + if (panel_w < 1 or panel_h < 1) return; + const px = x + (w - panel_w) / 2; + const py = y + (h - panel_h) / 2; + + var panel = dvui.box(@src(), .{ .dir = .vertical }, .{ + .rect = .{ .x = px, .y = py, .w = panel_w, .h = panel_h }, + .background = true, + .color_fill = palette.teal_surface, + .color_border = palette.teal_accent, + .border = .all(1), + .padding = .{ .x = 12, .y = 10, .w = 12, .h = 10 }, + }); + defer panel.deinit(); + + { + var tl = dvui.textLayout(@src(), .{}, .{ + .color_text = palette.teal_text, + .font = .theme(.heading), + }); + tl.addText("Keyboard shortcuts", .{}); + var muted = dvui.textLayout(@src(), .{}, .{ + .color_text = palette.teal_muted, + }); + muted.addText(" Esc closes · grey rows are context-off", .{}); + muted.deinit(); + tl.deinit(); + } + + var scroll_area = dvui.scrollArea(@src(), .{ + .scroll_info = &ctx_scroll, + .vertical_bar = .auto, + }, .{ + .expand = .both, + .color_fill = palette.teal_surface, + .padding = .all(0), + }); + defer scroll_area.deinit(); + + var prev_action: ?keymap.Action = null; + for (keymap.KEY_TABLE) |row| { + // The overlay is the table: two rows (`history_older` and + // `history_older_in`) share a single action but differ only in `when` + // context — present each distinct action once (L8). + if (prev_action == row.action) continue; + prev_action = row.action; + + const active = rowActive(row, ctx); + const chord_str = rowChord(row); + var line = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .min_size_content = .{ .w = 40, .h = metrics.TOUCH_H - 6 }, + .padding = .{ .x = 4, .y = 2, .w = 4, .h = 2 }, + }); + defer line.deinit(); + + var chord = dvui.textLayout(@src(), .{}, .{ + .color_text = if (active) palette.teal_accent else palette.warm_muted, + .font = .theme(.mono), + .gravity_y = 0.5, + }); + // addTextMixed routes `↑`/`↓` (Arrows block) to the DejaVu symbols + // face — Noto + Vera (mono) have no Arrows glyphs (L9, same tofu class + // as #732). ASCII chords keep the mono face. + mixed_text.addTextMixed(chord, chord_str, .theme(.mono), .{ + .color_text = if (active) palette.teal_accent else palette.warm_muted, + }); + chord.deinit(); + + var help = dvui.textLayout(@src(), .{}, .{ + .color_text = if (active) palette.teal_text else palette.warm_muted, + .gravity_y = 0.5, + }); + help.addText(row.help, .{}); + help.deinit(); + } +} + +// Scroll state for the overlay list (persists across frames while open). +var ctx_scroll: dvui.ScrollInfo = .{ + .vertical = .auto, + .horizontal = .none, +}; diff --git a/native/harness/src/ui/keymap_dispatch.zig b/native/harness/src/ui/keymap_dispatch.zig new file mode 100644 index 0000000..a722a04 --- /dev/null +++ b/native/harness/src/ui/keymap_dispatch.zig @@ -0,0 +1,235 @@ +//! Per-frame keymap dispatcher — plan #741. +//! +//! Runs ONCE per frame, before the composer textEntry is built (same insertion +//! point as the old inline loop), so handled chords never insert `\n`/space/ +//! arrows into the field. It walks `dvui.events()` and feeds each key event to +//! the pure `keymap.match`; every `.action` / `.swallow_leader` is marked +//! handled and routed to a product seam; every `.browser` (reserved chord) is +//! left unhandled so the browser keeps it (Goal 3). +//! +//! This dispatcher OWNS all key handling — `ui.zig`, `queue_band.handleEscape` +//! and `queue_band.submitChord` no longer scan `for (dvui.events())` for keys. +//! No `for (dvui.events())` key loop lives outside this module (DoD). +//! +//! Leader window: `dvui.timer(LEADER_TIMER_ID, LEADER_WINDOW_MS*1000)` arms on +//! `Action.leader` (`.down` only); `timerDone` expires it. No host I/O, no new +//! exports — the window/timer clock is already in dvui (plan decision #2). +const std = @import("std"); +const dvui = @import("dvui"); +const keymap = @import("../keymap.zig"); +const state = @import("state.zig"); +const bridge = @import("../bridge.zig"); +const queue_band = @import("queue_band.zig"); +const composer_history = @import("composer_history.zig"); + +/// Distinct timer id for the leader window (dvui timer ids are an internal +/// namespace; a fixed value here is collision-safe vs widget ids which come +/// from `Id.extendId`). +const LEADER_TIMER_ID: dvui.Id = @enumFromInt(0x7410_0000_0000_0001); + +/// Product seams that live in `ui.zig` (which imports this module) — passed in +/// to avoid an import cycle: `history` calls ui.zig's `historyApply`. +pub const Handlers = struct { + history: *const fn (composer_history.Step) void, +}; + +/// Convert dvui's `enums.Key` to the keymap `Key` subset. +/// +/// Pure modifier / lock keys (`left_shift`/`right_shift`/`left_control`/ +/// `right_control`/`left_alt`/`right_alt`/`left_command`/`right_command`, +/// `menu`, `num_lock`, `caps_lock`, `print`, `scroll_lock`, `pause` and dvui's +/// own `unknown`) return null — the dispatcher passes them through untouched +/// (Goal 3) and they NEVER disarm the leader: releasing Ctrl/Shift after +/// arming (`Ctrl+Shift+Space`) or pressing Shift again for the `?` command must +/// not kill the window. +/// +/// Every OTHER unrecognized key (letters outside the subset, digits, +/// punctuation, Backspace, function keys beyond f5, navigation) maps to +/// `.unknown` and flows through `keymap.match` like any key: while a leader is +/// pending that makes it `.swallow_leader` (handled + disarmed) so it never +/// lands in the prompt; otherwise `.none` (pass-through). +fn fromDvui(code: anytype) ?keymap.Key { + return switch (code) { + .enter => .enter, + .escape => .escape, + .up => .up, + .down => .down, + .space => .space, + .slash => .slash, + .tab => .tab, + .left => .left, + .right => .right, + .f5 => .f5, + .c => .c, + .v => .v, + .x => .x, + .a => .a, + .z => .z, + .s => .s, + .f => .f, + .t => .t, + .n => .n, + .w => .w, + .r => .r, + .l => .l, + .p => .p, + .i => .i, + .j => .j, + // Pure modifiers / locks — pass through, never disarm the leader. + .left_shift, .right_shift, .left_control, .right_control, .left_alt, .right_alt, .left_command, .right_command => null, + .menu, .num_lock, .caps_lock, .print, .scroll_lock, .pause, .unknown => null, + // Everything else: route to the keymap so `match` can swallow it during + // the leader window (and pass through otherwise). + else => .unknown, + }; +} + +fn keyAction(ke: anytype) keymap.KeyAction { + return switch (ke.action) { + .down => .down, + .repeat => .repeat, + .up => .up, + }; +} + +fn currentCtx() keymap.Context { + const busy = bridge.getLifecycle() == .busy; + return .{ + // `composer` proxy: the composer is the only text input; when a + // queue-row editor is open, queue_editing owns caret/keys. Matches the + // old inline gating (plan decision #4). + .composer = state.queue_editing_index == null, + .queue_editing = state.queue_editing_index != null, + .busy = busy, + .help_open = state.help_overlay_open, + .leader_pending = state.leader_armed, + .prompt_empty = state.prompt_buf[0] == 0, + .in_history = state.history_index != null, + }; +} + +fn disarmLeader() void { + state.leader_armed = false; +} + +fn armLeader() void { + state.leader_armed = true; + dvui.timer(LEADER_TIMER_ID, @intCast(keymap.LEADER_WINDOW_MS * 1000)); +} + +/// Actions that fire on held (repeat) too — history walks (matches old ↑↓ +/// repeat handling). All other actions fire once on `.down`. +fn firesOnRepeat(action: keymap.Action) bool { + return switch (action) { + .history_older, .history_newer => true, + else => false, + }; +} + +fn runAction(action: keymap.Action, down: bool, handlers: Handlers) void { + switch (action) { + .submit => { + if (!down) return; + // The composer textEntry hasn't been built yet this frame + // (dispatch runs before it). Request the submit; ui.zig consumes + // the flag after `te.deinit` and reads the live prompt buffer. + state.request_submit = true; + }, + .queue_save => { + if (!down) return; + if (state.queue_editing_index) |idx| { + const text = std.mem.sliceTo(state.queue_edit_buf[0..], 0); + queue_band.saveEdit(@intCast(idx), text); + } + }, + .history_older => { + if (!down and !firesOnRepeat(action)) return; + handlers.history(.older); + }, + .history_newer => { + if (!down and !firesOnRepeat(action)) return; + handlers.history(.newer); + }, + .cancel_turn => { + if (!down) return; + bridge.queueCancelFromUi(); + }, + .cancel_queue_edit => { + if (!down) return; + queue_band.cancelEditFromUi(); + }, + .help_close => { + if (!down) return; + state.help_overlay_open = false; + disarmLeader(); + }, + .help_toggle => { + if (!down) return; + state.help_overlay_open = !state.help_overlay_open; + }, + .help_toggle_leader => { + if (!down) return; + state.help_overlay_open = !state.help_overlay_open; + disarmLeader(); + }, + .leader => { + if (!down) return; + armLeader(); + }, + .leader_cancel => { + if (!down) return; + disarmLeader(); + }, + } +} + +/// One wire-up per frame: resolve the leader timeout, then scan every key +/// event and route it. Called from `ui.zig` `frame()` before the composer +/// textEntry is built. +pub fn dispatch(handlers: Handlers) void { + // Expire the leader window first (timerDone true on the first frame after). + if (state.leader_armed and dvui.timerDone(LEADER_TIMER_ID)) { + disarmLeader(); + } + + const es = dvui.events(); + for (0..es.len) |idx| { + const e = &es[idx]; + if (e.handled) continue; + const ke = switch (e.evt) { + .key => |k| k, + else => continue, + }; + + const kcode = fromDvui(ke.code) orelse continue; + const mods = keymap.Mods{ + .control = ke.mod.control(), + .command = ke.mod.command(), + .shift = ke.mod.shift(), + .alt = ke.mod.alt(), + }; + const act = keyAction(ke); + const m = keymap.match(kcode, act, mods, currentCtx()); + + switch (m.outcome) { + .browser => { + // Reserved browser chord — never marked handled. If the leader + // was pending, disarm it (the browser still gets the key, per + // plan test 9). + disarmLeader(); + }, + .none => {}, + .swallow_leader => { + // Leader pending + unmatched key: consume (no insert) + disarm. + e.handled = true; + disarmLeader(); + }, + .action => { + e.handled = true; + if (m.action) |action| { + runAction(action, act == .down, handlers); + } + }, + } + } +} diff --git a/native/harness/src/ui/metrics.zig b/native/harness/src/ui/metrics.zig index eecd6ae..00fc118 100644 --- a/native/harness/src/ui/metrics.zig +++ b/native/harness/src/ui/metrics.zig @@ -66,6 +66,16 @@ pub const CHIP_VISIBILITY_MARGIN: f32 = 8; /// Submit-queue band (plan #664): header + up to this many visible rows. pub const QUEUE_BAND_MAX_ROWS: u32 = 3; +/// Help overlay (plan #741): fixed panel size centered over the transcript band. +pub const HELP_OVERLAY_W: f32 = 460; +pub const HELP_OVERLAY_H: f32 = 320; +/// Min band size under which the overlay is not painted. +pub const HELP_OVERLAY_MIN_W: f32 = 300; +pub const HELP_OVERLAY_MIN_H: f32 = 200; +/// Margin from the transcript band edges (keeps the panel off the rail / bars). +pub const HELP_OVERLAY_MARGIN_X: f32 = 16; +pub const HELP_OVERLAY_MARGIN_Y: f32 = 16; + /// Gap (px) added to each slot's measured text width — matches the `margin.w` /// on each slot textLayout below, so the budget math equals the paint exactly. pub const STATUS_SLOT_GAP: f32 = 10; diff --git a/native/harness/src/ui/queue_band.zig b/native/harness/src/ui/queue_band.zig index 765601e..502fead 100644 --- a/native/harness/src/ui/queue_band.zig +++ b/native/harness/src/ui/queue_band.zig @@ -134,7 +134,9 @@ pub fn paint(band_y: f32, band_h: f32, avail_w: f32) void { } if (state.queue_editing_index != null) { - handleEscape(); + // Key handling (Escape dismiss + Ctrl/Cmd+Enter save) moved to the + // single keymap dispatcher (`ui/keymap_dispatch.zig`, plan #741) — + // this painter reads no keys. The blur-save below is mouse-focus-only. // Blur-save: if the queue-row textEntry lost focus this frame // (e.g. operator clicked the composer), save the edit and close // so promote isn't stalled behind a ghost edit (plan #664, review @@ -200,9 +202,8 @@ fn paintRow(src: std.builtin.SourceLocation, i: u32) void { if (fid == te.data().id) state.queue_edit_seen_focused = true; } te.deinit(); - if (submitChord()) { - saveEdit(i, typed); - } + // Ctrl/Cmd+Enter save-edit is handled by the keymap dispatcher + // (`queue_save` row, plan #741) — `submitChord` inline scan removed. } else { var preview_buf: [queue_preview.QUEUE_PREVIEW_MAX_BYTES + 1]u8 = undefined; const raw = bridge.queuedItemAt(i) orelse ""; @@ -288,37 +289,10 @@ pub fn closeEdit() void { state.queue_closed_edit = true; } -fn submitChord() bool { - const es = dvui.events(); - var hit = false; - for (0..es.len) |idx| { - const e = &es[idx]; - if (e.handled) continue; - const ke = switch (e.evt) { - .key => |k| k, - else => continue, - }; - if (ke.code == .enter and (ke.mod.control() or ke.mod.command())) { - e.handled = true; - if (ke.action == .down) hit = true; - } - } - return hit; -} - -fn handleEscape() void { - const es = dvui.events(); - for (0..es.len) |idx| { - const e = &es[idx]; - if (e.handled) continue; - const ke = switch (e.evt) { - .key => |k| k, - else => continue, - }; - if (ke.code == .escape and ke.action == .down) { - e.handled = true; - cancelEdit(); - return; - } - } +/// Plan #741 — queue-row Escape dismiss, called by the keymap dispatcher's +/// `cancel_queue_edit` action. Same semantics as the removed `handleEscape` +/// inline scan (dismiss the editor, not the turn). Pub so the dispatcher can +/// reach it without calling the private `cancelEdit`. +pub fn cancelEditFromUi() void { + if (state.queue_editing_index != null) cancelEdit(); } diff --git a/native/harness/src/ui/state.zig b/native/harness/src/ui/state.zig index 1791c94..e7c40db 100644 --- a/native/harness/src/ui/state.zig +++ b/native/harness/src/ui/state.zig @@ -126,6 +126,20 @@ pub var queue_list_scroll: dvui.ScrollInfo = .{ /// Enqueue runs after the band paints this frame (plan #699). pub var queue_follow: bool = false; +/// Plan #741 — help overlay visibility (in-memory, toggled by Ctrl/Cmd+/ and +/// leader+`?`; Esc closes). Not persisted, no session carrier. +pub var help_overlay_open: bool = false; + +/// Plan #741 — leader prefix armed (within the LEADER_WINDOW_MS window). +/// Driven by `keymap_dispatch` (dvui.timer expiry). `currentCtx().leader_pending` +/// reads this so the pure `keymap.match` sees it on the next key event. +pub var leader_armed: bool = false; + +/// Plan #741 — a submit chord was handled by the dispatcher before the composer +/// textEntry was built. `ui.zig` consumes it after `te.deinit` (reads the live +/// prompt buffer) and calls `composer.submitOrEnqueue`. +pub var request_submit: bool = false; + pub fn resetTranscriptScroll() void { transcript_scroll = .{ .vertical = .auto, @@ -158,4 +172,7 @@ pub fn resetTranscriptScroll() void { .horizontal = .none, }; queue_follow = false; + help_overlay_open = false; + leader_armed = false; + request_submit = false; }