diff --git a/AGENTS.md b/AGENTS.md index 1bebfc3..d8d892d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -296,7 +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 **v20** 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; **v19** `inv_set_queue_promote_allowed` — host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready **never drains the queue**; only idle ▶ / Ctrl+Enter with an empty composer + non-empty queue promotes, plan #760; **v20** `inv_queued_insert_front` — **turn retry that never drains the queue** (plan #759): the host retries a retryable agent-turn error up to `TURN_RETRY_ATTEMPTS`=5 (NEW cap) with bounded backoff via the additive `classify` seam (`lib/sandbox/resilience.ts`), then gives up onto `Lifecycle.Error` (a failed turn is never terminal for the Wasm promote gate — `ui.zig` promotes only on successful Ready), inserting `Continue the current turn` at the queue head (`inv_queued_insert_front`) when non-empty; permanent `PERMANENT_TURN_STATUS` statuses (400/401/403/404/413/422) give up after one attempt (no loop); 408/429/5xx and timeout/empty retry up to 5 attempts — but **1 attempt** once the live stream has painted a ring row past the user line (fail-closed: replaying would re-run tools / duplicate bubbles). **In-canvas Pause (submit-queue hold):** a Wasm-internal `queue_paused` latch folded into `submit_queue.canPromote` (via `bridge.tryPromoteQueued`, the single promote seam) holds **every promote path** — auto-promote on successful Ready and idle empty-▶ / empty Ctrl+Enter Play — so the next turn reads from the composer; typed send + FIFO contents + enqueue/edit/remove/Clear unaffected; **auto-clears when the FIFO empties**; TEAL `· paused` toggle on the queue-band header (`n>0`); **no new export / no protocol bump / no cap change** (Wasm-ephemeral like the queue) | -| 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** | +| 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` (modal `floatingWindow` **wide two-column table** over the transcript band — fixed chord column + remaining-width help column; wheel/trackpad scrolls the list **inside** the panel, never the transcript; a backdrop click-outside closes it; every looping widget uses a loop-unique `id_extra`, no duplicate-id red outlines), `native/harness/src/ui/metrics.zig` (help-overlay size = band fractions `HELP_OVERLAY_W_FRACTION`/`H_FRACTION` + `_MIN_*`/`_FLOOR_*` floors + `HELP_OVERLAY_CHORD_COL_W`; the fixed 460×320 `HELP_OVERLAY_W/H` cap is retired), 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/harness-limits.md b/docs/harness-limits.md index 8536092..eb4e93f 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -59,13 +59,34 @@ closed). | **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** | 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**. A Busy cancel **consumes nothing** — the submit queue stays exactly as-is (no promote on the Stop terminal; only an explicit idle ▶ / Ctrl+Enter with an empty composer + non-empty queue starts the next item). Idle: no-op (textEntry / dvui menus keep it) | -| **Ctrl+**/**Cmd+/** | Toggle the in-canvas **help overlay** (TEAL panel over the transcript band) | +| **Ctrl+**/**Cmd+/** | Toggle the in-canvas **help overlay** — a modal **wide two-column table** over the transcript band. See **Help overlay** below | | **Ctrl+I** | Arm the **leader** prefix (800 ms). Within the window press **`?`** to toggle help; **Ctrl+I** re-arms (a bare `i` does **not** — it swallows and disarms); **`t`** toggles **thinking default-collapsed** (collapse thinking by default, including the active Busy turn; in-memory only, resets to on on reload/New/Clear); **Escape** cancels; an unmatched key swallows (never lands in the prompt); a reserved browser chord (Ctrl+Shift+I Inspect) 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) | | **Shift+click** (composer / queue-row editor focused; desktop only) | **Range-selects** the text in that `textEntry` field — a stock web behavior the canvas was missing. Click to place the caret (or drag-select a base range), then **Shift+click** to extend the selection from an anchor edge to the clicked point; further Shift+clicks keep extending from the same anchor edge, and a Shift+click never counts toward the next word/line double-click. The range uses the **existing TEAL `text_select` fill** (no new hex). **Ctrl/Cmd+C** then copies that substring to the system clipboard (the chord stays browser-reserved). A **plain** (non-shift) click still moves the caret and clears the range. Touch/mobile has no shift key — **📋** stays the reliable copy path and canvas Shift+click is documented desktop-only | | **Shift+click** (read-only body text; desktop only) | **Range-selects** text inside a single **read-only** `textLayout` (transcript rich-MD bodies, fenced code, thinking/preview/skill rows, tool-run detail): a plain click anchors, **Shift+click** extends the highlighted range from that anchor, and **Ctrl/Cmd+C** copies the active slice. **A link is still a link first** — a **Shift+click (or plain click) on a link inside a body navigates / copies per the **Links** row and does *not* extend the range**; Shift+click range-selection is for non-link body text (use 📋 for reliable body copy). Selection never spans two `textLayout`s / messages. See Transcript copy / paste → Read-only body text Shift+click | | Composer focus | Requested on ready and after each send | +### Help overlay + +Opened by **Ctrl+/**/**Cmd+/** (or **Ctrl+I** leader then `?`); closed by **Esc** +(wins over busy cancel), **Ctrl+/**/**Cmd+/** (toggle), New/Clear, or a **backdrop +click-outside** (it is a modal). It is a **modal in-canvas `floatingWindow`** +subwindow that fills most of the transcript band (`HELP_OVERLAY_W_FRACTION` / +`HELP_OVERLAY_H_FRACTION` + `_MIN_*` / `_FLOOR_*` floors; the old fixed 460×320 +`HELP_OVERLAY_W/H` cap is retired). It is a real **two-column table**: a fixed +chord column (`HELP_OVERLAY_CHORD_COL_W`) plus a remaining-width help column, so +every chord and every help string starts on a stable x. One row per distinct +action in `KEY_TABLE` order; context-off rows stay grey (**WARM-muted**, never +EMBER). **Wheel / trackpad over the panel scrolls the help list** inside the +panel (`ctx_scroll`) — the modal captures it, so the transcript's +`state.transcript_scroll` never moves while it is open. Every looping widget uses +a loop-unique `id_extra` (no duplicate-id red outlines). **Reopening resumes at +the top**: `ctx_scroll` resets on every close path (Esc, backdrop, toggle, and +New/Clear). Help copy wraps to the leftover column width (no per-row height cap, +so a long help string on a ~390 px band is *shown wrapped*, never clipped/one +line). ~390 px stays on-canvas with internal scroll (matches the repo no-h-scroll +policy). + ## Touch / mobile (~390px) | Topic | Behavior | diff --git a/native/harness/build.zig b/native/harness/build.zig index dc84e50..6a3dc90 100644 --- a/native/harness/build.zig +++ b/native/harness/build.zig @@ -235,8 +235,12 @@ pub fn build(b: *std.Build) void { // against stale "Space" copy. `rowChord` is a hardcoded parallel switch — // not derived from the keymap table — so without a test a revert to the // pre-#761 "Leader Space" strings would ship while keymap.zig tests stay - // green. help_overlay imports dvui (via mixed_text), so wire dvui_testing - // (no frame, and no web-backend: the module's imports stop at + // green. Since #781 the suite also drives the overlay modal `floatingWindow` + // through the dvui TESTING backend (`dvui.testing.init/.settle/.step/paint`) + // to lock the wide two-column table, wheel-stays-in-panel, backdrop-close, + // and key-through-modal behavior — the frame mounts a transcript stand-in + // exactly as ui.zig does. help_overlay imports dvui (via mixed_text), so + // wire dvui_testing (still no web-backend: the module's imports stop at // mixed_text/unicode_face, which are bridge-free). { const help_overlay_tests = b.addTest(.{ diff --git a/native/harness/src/help_overlay.test.zig b/native/harness/src/help_overlay.test.zig index 8c4ad6b..b630136 100644 --- a/native/harness/src/help_overlay.test.zig +++ b/native/harness/src/help_overlay.test.zig @@ -1,18 +1,32 @@ -//! Host unit tests for ui/help_overlay.zig (plan #761 Nit L6). +//! Host unit tests for ui/help_overlay.zig. //! -//! `rowChord` is a hardcoded parallel `switch (row.action)` — the chord glyphs -//! are NOT derived from the keymap table. Without a test, a revert of -//! help_overlay.zig to the pre-#761 "Leader Space" strings would ship while -//! keymap.zig tests stay green. These tests pin the leader-family glyphs so a -//! revert fails CI. +//! Two families: +//! * plan #761 Nit L6 — pin the leader chord glyphs (`rowChord` is a hardcoded +//! parallel `switch (row.action)`, not derived from the keymap table; a +//! revert to the pre-#761 "Leader Space" strings must fail CI). +//! * plan #781 — paint the overlay modal `floatingWindow` under the dvui +//! testing backend and lock the new behavior: wide fill-band panel (no more +//! 460×320 cap), two-column table (stable chord/help x), ~390px on-canvas +//! with internal scroll, wheel stays in the panel (overlay list scrolls), and +//! backdrop click-outside closes (NEW at #781). //! -//! No dvui frame needed: the tests call `rowChord` only. The module imports -//! dvui (via mixed_text), so the test wires dvui_testing and nothing else -//! (the module compiles, `paint` is not invoked). +//! The testing backend never rasterizes — geometry via `tagGet().rect` only +//! (physical px; 2× logical by default). `settle()` runs frames until the +//! modal stops requesting refresh (its first frame hides the panel, the second +//! draws it). const std = @import("std"); +const t = std.testing; +const dvui = @import("dvui"); const help = @import("ui/help_overlay.zig"); const keymap = @import("keymap.zig"); +/// Physical-pixel scale of the dvui testing backend (logical × 2). +const PX: f32 = 2.0; +/// Tolerance for same-column x equality. +const COL_EPS: f32 = 3.0; + +// ── plan #761 Nit L6 string pins (kept unchanged) ────────────────────────────── + test "help_overlay: leader chord glyphs are Ctrl+I family (plan #761 Nit L6)" { const want = [_]struct { action: keymap.Action, chord: []const u8 }{ .{ .action = .leader, .chord = "Ctrl+I" }, @@ -24,28 +38,349 @@ test "help_overlay: leader chord glyphs are Ctrl+I family (plan #761 Nit L6)" { for (keymap.KEY_TABLE) |row| { if (row.action == w.action) { matched = true; - try std.testing.expectEqualStrings(w.chord, help.rowChord(row)); + try t.expectEqualStrings(w.chord, help.rowChord(row)); } } - try std.testing.expect(matched); + try t.expect(matched); } } test "help_overlay: no stale Space / Ctrl+Shift+Space chord survives (plan #761 Nit L6)" { - // The old leader prefix register (Ctrl+Shift+Space, plan #741) is gone. - // Any residual "Space" in a rendered chord is stale copy from the pre-#761 - // table; a revert to `Leader Space, then ?` / `Ctrl+Shift+Space` fails here. var saw_leader = false; for (keymap.KEY_TABLE) |row| { if (row.action == .leader) saw_leader = true; const chord = help.rowChord(row); - try std.testing.expect(std.mem.indexOf(u8, chord, "Space") == null); + try t.expect(std.mem.indexOf(u8, chord, "Space") == null); } - try std.testing.expect(saw_leader); + try t.expect(saw_leader); } test "help_overlay: row.help copy carries no stale Space chord (plan #761 Nit L6)" { for (keymap.KEY_TABLE) |row| { - try std.testing.expect(std.mem.indexOf(u8, row.help, "Space") == null); + try t.expect(std.mem.indexOf(u8, row.help, "Space") == null); + } +} + +// ── plan #781 paint locks (dvui testing backend) ─────────────────────────────── + +/// Drives `help_overlay.paint` over a full-window band. `got_close` records the +/// paint's backdrop-close return so tests can assert click-outside behavior. +/// +/// The frame paints a TRANSCRIPT STAND-IN scroll container BEFORE the overlay +/// modal — the same order `ui.zig` builds `state.transcript_scroll`'s container +/// (ui.zig:639 paints the overlay only after the transcript laid out). This is +/// what makes the Goal-2 wheel-isolation test able to FAIL: if the overlay +/// regresses to the pre-#781 absolute `dvui.box` (no subwindow), a wheel over +/// the panel is routed to the transcript's container (`windowFor` returns the +/// base window), the transcript offset moves, and the test catches the leak it +/// exists to prevent (adversarial review #783 Major L6). +const Frame = struct { + var band_w: f32 = 640; + var band_h: f32 = 400; + var got_close: bool = false; + /// ScrollInfo for the transcript stand-in (mirrors `state.transcript_scroll`). + var transcript_scroll: dvui.ScrollInfo = .{ + .vertical = .auto, + .horizontal = .none, + }; + /// Goal-2c instrumentation: when `record_keys` is set, Frame.paint scans the + /// frame's events after the overlay modal has processed them and records + /// whether ANY key event was present, and whether any was marked handled. A + /// floatingWindow subwindow only routes pointer/wheel, so Esc / Ctrl+/ / + /// leader-? must survive unhandled for keymap_dispatch (which runs after + /// paint in ui.zig) — keys reach the dispatcher THROUGH the open modal. + /// Asserting presence too closes the vacuous case (an empty event list would + /// otherwise satisfy "none handled" — review #783 round-3 Nit L6). + var record_keys: bool = false; + var any_key_handled: bool = false; + var any_key_present: bool = false; + + fn paint() !dvui.App.Result { + // Transcript stand-in: a scroll container over the same band, painted + // before the overlay so its `processEvents` runs ahead of the modal. + // Overflow pseudo-rows give it a non-zero scrollMax so a leaked wheel + // would measurably move its offset (the break scenario). + { + var ts = dvui.scrollArea(@src(), .{ + .scroll_info = &Frame.transcript_scroll, + .vertical_bar = .auto, + }, .{ + .expand = .both, + .padding = .all(0), + .tag = "transcript-scroll", + }); + defer ts.deinit(); + var i: usize = 0; + while (i < 40) : (i += 1) { + // Loop-unique id so the stand-in never paints duplicate-id red + // outlines either (same Goal-1 rule as the overlay rows). + var row = dvui.box(@src(), .{}, .{ + .id_extra = @intCast(i), + .min_size_content = .{ .w = Frame.band_w * PX, .h = 40 * PX }, + }); + row.deinit(); + } + } + + got_close = help.paint(0, 0, band_w, band_h, .{ .composer = true }); + + // Goal-2c instrumentation: the overlay modal has now processed this + // frame's events (its deinit ran processEventsAfter), so the handled + // flags are final. The modal routes pointer/wheel only, so keys must stay + // unhandled — otherwise keymap_dispatch (which runs after paint in + // ui.zig) would never receive them. + if (record_keys) { + any_key_handled = false; + any_key_present = false; + for (dvui.events()) |*e| { + if (e.evt == .key) { + any_key_present = true; + if (e.handled) any_key_handled = true; + } + } + } + return .ok; } +}; + +fn settleOverlay() void { + _ = dvui.testing.settle(Frame.paint) catch @panic("help overlay settle failed"); +} + +test "wide band: panel fills most of the band (no 460×320 cap) and is tall" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = 1200, .h = 800 } }); + defer tr.deinit(); + Frame.band_w = 1200; + Frame.band_h = 800; + settleOverlay(); + + const sa = try dvui.testing.tagGet("overlay-scroll-area"); + // Physical px. The old cap was 460 logical = 920 physical; the scroll area + // (≈ the panel interior) alone must be far wider and taller. + try t.expect(sa.rect.w > 1700.0); // ≫ 920 (old fixed 460 cap) + try t.expect(sa.rect.h > 900.0); // ≫ 640 (old fixed 320 cap) +} + +test "two-column table: every chord and every help string starts on a stable x" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = 800, .h = 600 } }); + defer tr.deinit(); + Frame.band_w = 800; + Frame.band_h = 600; + settleOverlay(); + + const c0 = try dvui.testing.tagGet("overlay-chord-0"); + const c1 = try dvui.testing.tagGet("overlay-chord-1"); + const h0 = try dvui.testing.tagGet("overlay-help-0"); + const h1 = try dvui.testing.tagGet("overlay-help-1"); + + // Chord column: fixed x across rows. + try t.expectApproxEqAbs(c0.rect.x, c1.rect.x, COL_EPS); + // Help column: remaining-width x, stable across rows. + try t.expectApproxEqAbs(h0.rect.x, h1.rect.x, COL_EPS); + // Help is to the right of the chord column (fixed chord width). + try t.expect(h0.rect.x > c0.rect.x); +} + +test "~390px band: panel on-canvas, no horizontal overflow, list still scrolls" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = 390, .h = 300 } }); + defer tr.deinit(); + Frame.band_w = 390; + Frame.band_h = 300; + settleOverlay(); + + const sa = try dvui.testing.tagGet("overlay-scroll-area"); + const win_w_px = dvui.windowRectPixels().w; + // No horizontal overflow: the panel (≈ scroll area) stays within the window. + try t.expect(sa.rect.x >= -1.0); + try t.expect(sa.rect.x + sa.rect.w <= win_w_px + 1.0); + // Still a usable on-canvas panel (fills most of a ~390 band). + try t.expect(sa.rect.w > 0.6 * win_w_px); + // Internal scroll: many rows overflow the list viewport. + try t.expect(help.ctx_scroll.scrollMax(.vertical) > 0); +} + +test "wheel over the panel scrolls the overlay list, not the transcript (Goal 2)" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = 600, .h = 300 } }); + defer tr.deinit(); + Frame.band_w = 600; + Frame.band_h = 300; + Frame.got_close = false; + Frame.transcript_scroll = .{ .vertical = .auto, .horizontal = .none }; + settleOverlay(); + + // BOTH containers must have room to scroll, so the wheel *could* move either. + try t.expect(help.ctx_scroll.scrollMax(.vertical) > 0); + try t.expect(Frame.transcript_scroll.scrollMax(.vertical) > 0); + + const before = help.ctx_scroll.offset(.vertical); + const t_before = Frame.transcript_scroll.offset(.vertical); + // The modal subwindow owns `overlay-scroll-area`: wheel over it is routed to + // the overlay's own ScrollInfo (never the transcript stand-in painted below + // in the same frame). Negative wheel_y scrolls content DOWN (offset increases; + // ScrollContainer does `scrollByOffset(.vertical, -wheel_y)`), moving away + // from the top clamp — asserted against BOTH infos so the transcript is + // proven untouched (adversarial review #783 Major L6). + try dvui.testing.moveTo("overlay-scroll-area"); + _ = try dvui.currentWindow().addEventMouseWheel(-6.0, .vertical, null); + _ = try dvui.testing.step(Frame.paint); + + // Overlay list scrolled (offset moved). + const after = help.ctx_scroll.offset(.vertical); + try t.expect(after > before); + // Transcript offset did NOT move — the wheel stayed in the panel. + const t_after = Frame.transcript_scroll.offset(.vertical); + try t.expectApproxEqAbs(t_after, t_before, 0.001); +} + +test "backdrop click-outside closes; click inside the panel does not (2b)" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = 640, .h = 480 } }); + defer tr.deinit(); + Frame.band_w = 640; + Frame.band_h = 480; + Frame.got_close = false; + settleOverlay(); + + // Click inside the panel (on the first chord row) → no close. + try dvui.testing.moveTo("overlay-chord-0"); + _ = try dvui.currentWindow().addEventMouseButton(.left, .press); + _ = try dvui.currentWindow().addEventMouseButton(.left, .release); + _ = try dvui.testing.step(Frame.paint); + try t.expect(!Frame.got_close); + + // Click the dimmed backdrop far from the panel → close requested. + Frame.got_close = false; + _ = try dvui.currentWindow().addEventMouseMotion(.{ .pt = .{ .x = 8 * PX, .y = 8 * PX } }); + _ = try dvui.currentWindow().addEventMouseButton(.left, .press); + _ = try dvui.currentWindow().addEventMouseButton(.left, .release); + _ = try dvui.testing.step(Frame.paint); + try t.expect(Frame.got_close); +} + +test "Esc / Ctrl+/ / leader-? key events survive the open modal for the dispatcher (plan test 2c)" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = 600, .h = 300 } }); + defer tr.deinit(); + Frame.band_w = 600; + Frame.band_h = 300; + Frame.record_keys = true; + Frame.any_key_present = false; + settleOverlay(); + + // With the modal open, inject the exact chords the overlay dispatches on + // (Esc closes, Ctrl+/ toggles, leader `?` = shift+slash closes once armed). + // A floatingWindow subwindow routes pointer/wheel only — none of these key + // events may be marked handled by the modal, or keymap_dispatch (which runs + // after the overlay paint in ui.zig) would never receive them. The old + // pre-#781 absolute `dvui.box` overlay was not a subwindow either, so it too + // left keys alone; a future subwindow that starts swallowing keys fails here. + try dvui.testing.pressKey(.escape, .none); + try dvui.testing.pressKey(.slash, .lcontrol); + try dvui.testing.pressKey(.slash, .lshift); + _ = try dvui.testing.step(Frame.paint); + + // The injected key events were really IN this frame (not an empty event list + // vacuously satisfying "none handled"), and the modal left them unhandled. + try t.expect(Frame.any_key_present); + try t.expect(!Frame.any_key_handled); + Frame.record_keys = false; +} + +test "left-drag on the panel padding is neither grabbed nor moves the panel (Minor L1)" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = 640, .h = 480 } }); + defer tr.deinit(); + Frame.band_w = 640; + Frame.band_h = 480; + settleOverlay(); + + const before = (try dvui.testing.tagGet("overlay-scroll-area")).rect; + + // LEFT-press in the panel's LEFT PADDING ring: nothing sits there (the + // scroll area starts at panel interior), so no child can grab the press and + // it reaches the floating window's drag handler (`Button.pointer()` is + // left-or-touch only — an injected `.middle` press is invisible to + // processEventsAfter, which made the pre-round-3 test vacuous). With + // `.resize = .none` dvui's DEFAULT drag_area is the FULL panel, so the old + // behavior translated the centered panel then snapped it back next frame + // (rect_store recomputes). The empty drag_area must leave it stationary + // THIS frame — measured right after the single step, before any recenter. + const px = before.x - 8; // interior-left minus padding → left-drag zone + const py = before.y + before.h / 2; + _ = try dvui.currentWindow().addEventMouseMotion(.{ .pt = .{ .x = px, .y = py } }); + _ = try dvui.currentWindow().addEventMouseButton(.left, .press); + _ = try dvui.currentWindow().addEventMouseMotion(.{ .pt = .{ .x = px + 12 * PX, .y = py + 6 * PX } }); + _ = try dvui.currentWindow().addEventMouseButton(.left, .release); + _ = try dvui.testing.step(Frame.paint); + + const after = (try dvui.testing.tagGet("overlay-scroll-area")).rect; + try t.expectApproxEqAbs(before.x, after.x, COL_EPS); + try t.expectApproxEqAbs(before.y, after.y, COL_EPS); +} + +test "two-column x-lock holds across a long vs short chord (hard case, Minor L6)" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = 800, .h = 600 } }); + defer tr.deinit(); + Frame.band_w = 800; + Frame.band_h = 600; + settleOverlay(); + + // The fixed chord column keeps the LONGEST chord ("Ctrl/Cmd+Enter", row 0 = + // submit) and the SHORTEST chord ("↑", row 2 = history_older) on the same x, + // and both help columns too. The pair above (chord-0/1) was the weak case — + // queue_save shares submit's identical long chord, so it never separated the + // short-vs-long asymmetry this lock exists to catch. + const chord_long = try dvui.testing.tagGet("overlay-chord-0"); // "Ctrl/Cmd+Enter" + const chord_short = try dvui.testing.tagGet("overlay-chord-2"); // "↑" + const help_long = try dvui.testing.tagGet("overlay-help-0"); // "Send (enqueue when busy)" + const help_short = try dvui.testing.tagGet("overlay-help-2"); // "Older message" + + try t.expectApproxEqAbs(chord_long.rect.x, chord_short.rect.x, COL_EPS); + try t.expectApproxEqAbs(help_long.rect.x, help_short.rect.x, COL_EPS); + // Help column is still right of the fixed chord column. + try t.expect(help_short.rect.x > chord_short.rect.x); +} + +test "help column wraps long and short copy to the same leftover width (wrap, not clip — Minor L1)" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = 800, .h = 600 } }); + defer tr.deinit(); + Frame.band_w = 800; + Frame.band_h = 600; + settleOverlay(); + + // A very long help string ("Send (enqueue when busy)", row 0) and a very + // short one ("Close help", row 4) differ dram-atically in natural text + // length. `.expand = .horizontal` forces BOTH help textLayouts to the SAME + // leftover column width — a revert to an unwrapped textLayout sizes each to + // its natural text width (differing), so this is a direct probe that the + // wrap constraint is engaged (long copy wraps to the column instead of + // clipping past it). The ~390 px band test separately pins no horizontal + // overflow. + const hw0 = (try dvui.testing.tagGet("overlay-help-0")).rect.w; + const hw4 = (try dvui.testing.tagGet("overlay-help-4")).rect.w; + try t.expectApproxEqAbs(hw0, hw4, 6.0); +} + +test "help column on a ~390 band wraps long copy TALLER than one line (wrap, not clip — round-3 Minor L1+L6)" { + // First measure the SAME long string at a wide band where the help column + // easily fits it on one line (row 0 "Send (enqueue when busy)", 800 px band). + var tr = try dvui.testing.init(.{ .window_size = .{ .w = 800, .h = 600 } }); + defer tr.deinit(); + Frame.band_w = 800; + Frame.band_h = 600; + settleOverlay(); + const one_line_h = (try dvui.testing.tagGet("overlay-help-0")).rect.h; + + // Now the same help string at ~390 where the leftover column is narrow — it + // must wrap to MORE than one line. The pre-round-3 code set + // `max_size_content.h = TOUCH_H - 6`, which clamped the widget's min via + // `WidgetData.init/minSizeSetAndRefresh`, clipping wrapped lines inside a + // one-line well regardless of width. With the height cap dropped (width + // ceiling kept through `MaxSize.width`), the textLayout reports its wrapped + // height, so a ~390 band renders the copy TALLER than the wide-band one-line + // height — proving wrap is engaged, not clipped. Comparing the SAME string's + // two heights (not an absolute px bound) makes the assertion robust to the + // testing backend's fallback font metrics. + Frame.band_w = 390; + Frame.band_h = 420; + settleOverlay(); + const narrow_h = (try dvui.testing.tagGet("overlay-help-0")).rect.h; + try t.expect(narrow_h > one_line_h + PX); } diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index e0bd5e5..f78c7c9 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -133,6 +133,13 @@ pub fn onInit() void { @memset(&state.prompt_buf, 0); state.want_composer_focus = true; state.resetTranscriptScroll(); + // `state.resetTranscriptScroll` also closes the help overlay + // (`help_overlay_open = false`) but does NOT reset its list scroll — an + // in-process re-init (wasm reload / host re-mount, see the comment below) + // would otherwise leave `ctx_scroll` mid-table, so the next open resumes + // mid-list (review #783 round-4 Nit L1). Reset the overlay surface here, + // the authoritative re-init site; `state.zig` stays free of ui paint imports. + help_overlay.resetScroll(); rich.clearCache(); // Reset the previous-frame hug to idle: an in-process re-init (wasm reload // / host re-mount) must not keep a stale multi-line 124 px band until the @@ -516,9 +523,13 @@ pub fn frame() !void { 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. + // armed leader would ghost chrome. Reset the list to the top too: a + // mid-table `ctx_scroll` offset must not survive a reopen (review #783 + // round-3 Minor L1 — New/Clear previously skipped the reset that + // Esc/backdrop help_close already applied). state.help_overlay_open = false; state.leader_armed = false; + help_overlay.resetScroll(); // Plan #742 — a fresh surface resets the thinking preference to its // collapsed-default (same reset site as help_overlay_open / leader_armed). state.thinking_default_collapsed = true; @@ -631,15 +642,18 @@ 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. + // ── Help overlay (plan #741 → #781) — modal in-canvas floatingWindow over + // the transcript band. Painted after the transcript laid out (top). It + // captures pointer + wheel (scrolling stays in the panel, never the + // transcript), fills most of the band, and closes on a backdrop + // click-outside (paint returns true → close). 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, .{ + // Modal subwindow sized against the FULL band (avail.w), not the leftover + // transcript width — it fills the band minus margins regardless of the + // left rail (plan #781 Goal 3). Esc / Ctrl+//leader still reach the keymap + // dispatcher below (a floatingWindow only routes mouse events), so the + // overlay toggles off exactly as before. + if (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, @@ -647,7 +661,13 @@ pub fn frame() !void { .leader_pending = state.leader_armed, .prompt_empty = state.prompt_buf[0] == 0, .in_history = state.history_index != null, - }); + })) { + // Backdrop click-outside closes (mirror help_close: also disarm leader + // and reset the list to the top so a reopen resumes at the top). + state.help_overlay_open = false; + state.leader_armed = false; + help_overlay.resetScroll(); + } } // ── Submit queue band (absolute rect — above composer, below transcript) ── diff --git a/native/harness/src/ui/help_overlay.zig b/native/harness/src/ui/help_overlay.zig index ea406b7..545e719 100644 --- a/native/harness/src/ui/help_overlay.zig +++ b/native/harness/src/ui/help_overlay.zig @@ -1,12 +1,26 @@ -//! In-canvas help overlay — plan #741. +//! In-canvas help overlay — plans #741, #761, #781. //! -//! 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). +//! A MODAL `dvui.floatingWindow` subwindow over the transcript band listing the +//! product keymap (help labels + chord); rows whose `when` is currently false +//! are greyed (WARM-muted, never EMBER). Being a modal subwindow it: +//! (a) captures pointer + wheel so scrolling stays in the panel and never moves +//! the transcript's `state.transcript_scroll` (plan #781 Goal 2), +//! (b) dims the whole window so a backdrop click-outside closes it (NEW at #781), +//! (c) sizes to fill most of the band (named fractions — the fixed 460×320 cap +//! was retired, human-approved 2026-08-22). //! -//! Open/close state lives in `state.help_overlay_open` (toggled by the keymap -//! dispatcher: Ctrl/Cmd+/ and leader+`?`; Esc closes — wins over busy cancel). +//! The list is a two-column table: a fixed-width chord column (mono, with +//! `mixed_text.addTextMixed` for `↑`/`↓`) plus a remaining-width help column, so +//! every chord and every help string starts on a stable x. Every looping widget +//! carries a loop-unique `.id_extra` (distinct bases per widget kind) so dvui +//! never paints duplicate-id red outlines (Goal 1). +//! +//! 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). The caller paints only while +//! open, so the subwindow deregisters itself when closed. +const std = @import("std"); const dvui = @import("dvui"); const keymap = @import("../keymap.zig"); const palette = @import("../palette.zig"); @@ -39,6 +53,12 @@ pub fn rowChord(row: keymap.Row) []const u8 { }; } +/// Panel horizontal padding and row-box horizontal padding, shared by the +/// widget `Options.padding` below AND the `help_max_w` wrap-ceiling arithmetic so +/// the two can never drift (review #783 round-3 Nit L8). +const PANEL_PAD_X: f32 = 12; +const ROW_PAD_X: f32 = 4; + /// 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 { @@ -63,27 +83,76 @@ fn rowActive(row: keymap.Row, ctx: keymap.Context) bool { 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; +/// After the modal subwindow has consumed the frame, scan for a press/release on +/// the dimmed backdrop (outside the panel). The modal wall routes every click to +/// the subwindow, so no base-window widget is steered; we only ask "was it a +/// backdrop click?" (NEW plan #781 behavior — explicit, tested). +fn backdropPressed(panel_phys: dvui.Rect.Physical) bool { + if (panel_phys.w <= 0 or panel_phys.h <= 0) return false; + const evts = dvui.events(); + for (evts) |*e| { + if (e.handled) continue; + if (e.evt != .mouse) continue; + const me = e.evt.mouse; + if (me.action != .press and me.action != .release) continue; + if (me.button == .none) continue; + if (panel_phys.contains(me.p)) continue; + return true; + } + return false; +} - 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; +/// Paint the help overlay as a modal in-canvas subwindow over the transcript +/// band. `x,y,w,h` is the band rect (logical), `ctx` the live keymap context for +/// greying. Returns TRUE when a backdrop click-outside this frame should close +/// the overlay (the caller flips the open state). Paints every shipped row (≤ +/// KEYMAP_MAX). +pub fn paint(x: f32, y: f32, w: f32, h: f32, ctx: keymap.Context) bool { + if (w < metrics.HELP_OVERLAY_MIN_W or h < metrics.HELP_OVERLAY_MIN_H) return false; + + // Width-first fill-band size: high fraction of the band, clamped to the band + // minus margins so nothing overflows horizontally, floored so a ~390 px + // canvas still gets a usable panel (the floor is clamped away when the band + // sits near the min gate). + const panel_w = @min(@max(metrics.HELP_OVERLAY_W_FRACTION * w, metrics.HELP_OVERLAY_FLOOR_W), w - 2 * metrics.HELP_OVERLAY_MARGIN_X); + const panel_h = @min(@max(metrics.HELP_OVERLAY_H_FRACTION * h, metrics.HELP_OVERLAY_FLOOR_H), h - 2 * metrics.HELP_OVERLAY_MARGIN_Y); + if (panel_w < 1 or panel_h < 1) return false; 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 }, + // Remaining-width bound for the help column: the panel interior (minus the + // panel's horizontal padding 2·12 and the row box's 2·4) less the fixed + // chord column. Long help strings wrap to this line width instead of + // clipping on a narrow ~390 band (review #783 Minor L1). When the vertical + // scrollbar is showing, the row box is allocated the narrower client width + // and `.expand = .horizontal` wraps to that instead — this value is a safe + // ceiling, never wider than the panel. + const help_max_w = panel_w - 2 * PANEL_PAD_X - 2 * ROW_PAD_X - metrics.HELP_OVERLAY_CHORD_COL_W; + + // Scratch rect the floatingWindow reads every frame (`init_options.rect`) so + // the panel follows the band on a resize (not just on its first frame). + var rect_store: dvui.Rect = .{ .x = px, .y = py, .w = panel_w, .h = panel_h }; + var fw = dvui.floatingWindow(@src(), .{ + .modal = true, + .modal_alpha = 170, + .resize = .none, + .rect = &rect_store, + }, .{ .background = true, .color_fill = palette.teal_surface, .color_border = palette.teal_accent, .border = .all(1), - .padding = .{ .x = 12, .y = 10, .w = 12, .h = 10 }, + .padding = .{ .x = PANEL_PAD_X, .y = 10, .w = PANEL_PAD_X, .h = 10 }, }); - defer panel.deinit(); + + // The panel is centered and never meant to be dragged. With `.resize = + // .none`, dvui's default `drag_area` is the FULL panel rect, so an unhandled + // press on interior chrome (rows handle their own presses) would translate + // the panel then snap it back next frame (review #783 Minor L1). Zero the + // drag area so no press can start a panel drag. The drag test drives a + // LEFT press (`.middle` could never reach the handler — `enums.Button. + // pointer()` is left/touch only). + fw.dragAreaSet(.{}); { var tl = dvui.textLayout(@src(), .{}, .{ @@ -94,62 +163,123 @@ pub fn paint(x: f32, y: f32, w: f32, h: f32, ctx: keymap.Context) void { var muted = dvui.textLayout(@src(), .{}, .{ .color_text = palette.teal_muted, }); - muted.addText(" Esc closes · grey rows are context-off", .{}); + muted.addText(" Esc / click outside 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 }, + { + var scroll_area = dvui.scrollArea(@src(), .{ + .scroll_info = &ctx_scroll, + .vertical_bar = .auto, + }, .{ + .expand = .both, + .color_fill = palette.teal_surface, + .padding = .all(0), + .tag = "overlay-scroll-area", }); - defer line.deinit(); + defer scroll_area.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 prev_action: ?keymap.Action = null; + var row_i: usize = 0; + 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). `row_i` (not the + // raw loop index) is the rendered-row counter, so id_extra stays + // dense across the de-dupe. + if (prev_action == row.action) continue; + prev_action = row.action; - 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(); + // Loop-unique ids with distinct bases per widget kind so a box, chord, + // and help never share an id in the same iteration (duplicate-id red + // outlines — plan #781 Goal 1). + const id_row = row_i; + const id_chord = row_i + keymap.KEYMAP_MAX; + const id_copy = row_i + 2 * keymap.KEYMAP_MAX; + + const active = rowActive(row, ctx); + const chord_str = rowChord(row); + + // Tags are per-frame copied by dvui, so a reusable stack buffer per + // row yields distinct tag strings for the test column-x locks. + var chord_tag_buf: [48]u8 = undefined; + var copy_tag_buf: [48]u8 = undefined; + const chord_tag = std.fmt.bufPrint(&chord_tag_buf, "overlay-chord-{d}", .{row_i}) catch "overlay-chord"; + const copy_tag = std.fmt.bufPrint(©_tag_buf, "overlay-help-{d}", .{row_i}) catch "overlay-help"; + + var line = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .id_extra = id_row, + .expand = .horizontal, + .min_size_content = .{ .w = 40, .h = metrics.TOUCH_H - 6 }, + .padding = .{ .x = ROW_PAD_X, .y = 2, .w = ROW_PAD_X, .h = 2 }, + }); + defer line.deinit(); + + var chord = dvui.textLayout(@src(), .{}, .{ + .id_extra = id_chord, + .tag = chord_tag, + .color_text = if (active) palette.teal_accent else palette.warm_muted, + .font = .theme(.mono), + .gravity_y = 0.5, + // Fixed chord column width keeps every help string's left edge on + // a stable x (two-column table, Goal 4). + .min_size_content = .{ .w = metrics.HELP_OVERLAY_CHORD_COL_W, .h = metrics.TOUCH_H - 6 }, + }); + // 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(), .{}, .{ + .id_extra = id_copy, + .tag = copy_tag, + .color_text = if (active) palette.teal_text else palette.warm_muted, + .gravity_y = 0.5, + // Wrap the help column to the leftover row width (`expand` lets + // the row box allocate the remaining width after the fixed chord + // column; `max_size_content.w` is the ceiling so long copy never + // clips past the panel at a ~390 band). There is NO height cap: + // `WidgetData.init`/`minSizeSetAndRefresh` clamps min_size to + // max_sizeGet(), so a wrapped two-line string under a TOUCH_H-high + // max would report a clipped one-line min — dropping max.h lets + // the row grow tall enough to show wrapped lines (review #783 + // round-3 Minor L1+L6). + .expand = .horizontal, + .max_size_content = .width(help_max_w), + .min_size_content = .{ .h = metrics.TOUCH_H - 6 }, + }); + help.addText(row.help, .{}); + help.deinit(); + + row_i += 1; + } } + + // Dispatch settles the panel's physical rect before the subwindow deinits. + const panel_phys = fw.data().rectScale().r; + fw.deinit(); + + // Backdrop click-outside now that every child has had a chance to mark its + // events handled (subwindow deinit runs processEventsAfter). + return backdropPressed(panel_phys); } // Scroll state for the overlay list (persists across frames while open). -var ctx_scroll: dvui.ScrollInfo = .{ +// `pub` so the host wheel test can assert `ctx_scroll.offset(.vertical)` moves. +pub var ctx_scroll: dvui.ScrollInfo = .{ .vertical = .auto, .horizontal = .none, }; + +/// Reset the overlay list back to the top when the overlay closes (both the +/// Esc `help_close` in keymap_dispatch and the backdrop click-outside in ui.zig +/// call this), so a reopen resumes at the top rather than mid-table +/// (review #783 Nit L1). Re-initializing the ScrollInfo also drops any +/// stale viewport/offset from the previous open. +pub fn resetScroll() void { + ctx_scroll = .{ .vertical = .auto, .horizontal = .none }; +} diff --git a/native/harness/src/ui/keymap_dispatch.zig b/native/harness/src/ui/keymap_dispatch.zig index 4a750a8..9f7b129 100644 --- a/native/harness/src/ui/keymap_dispatch.zig +++ b/native/harness/src/ui/keymap_dispatch.zig @@ -21,6 +21,7 @@ const state = @import("state.zig"); const bridge = @import("../bridge.zig"); const queue_band = @import("queue_band.zig"); const composer_history = @import("composer_history.zig"); +const help_overlay = @import("help_overlay.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 @@ -164,15 +165,21 @@ fn runAction(action: keymap.Action, down: bool, handlers: Handlers) void { .help_close => { if (!down) return; state.help_overlay_open = false; + help_overlay.resetScroll(); disarmLeader(); }, .help_toggle => { if (!down) return; state.help_overlay_open = !state.help_overlay_open; + // Reopen resumes at the top the same way Esc/backdrop close resets the + // list (review #783 round-3 Minor L1) — on closing via the primary + // toggle AND on a fresh open, never carry a stale mid-table offset. + help_overlay.resetScroll(); }, .help_toggle_leader => { if (!down) return; state.help_overlay_open = !state.help_overlay_open; + help_overlay.resetScroll(); disarmLeader(); }, .leader => { diff --git a/native/harness/src/ui/metrics.zig b/native/harness/src/ui/metrics.zig index 00fc118..6a8d5b3 100644 --- a/native/harness/src/ui/metrics.zig +++ b/native/harness/src/ui/metrics.zig @@ -66,15 +66,31 @@ 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; +/// Help overlay (plan #741 → #781): a modal in-canvas subwindow that fills most +/// of the transcript band. The fixed 460×320 cap (`HELP_OVERLAY_W/H`) is +/// retired (human-approved cap change 2026-08-22) in favor of named band +/// fractions + small absolute floors. Pure visual-paint metric — no wire or +/// transport ceiling (the panel never exceeds the band rect; a scrollArea +/// handles internal overflow). +/// +/// Panel size (width-first): `min(max(fraction·band, floor), band − 2·margin)`. +/// The floor keeps a ~390 px canvas on-canvas with no horizontal overflow; it is +/// clamped away when the band sits near the MIN gate (matches the repo no-h-scroll +/// policy already applied to composer chrome). +pub const HELP_OVERLAY_W_FRACTION: f32 = 0.92; +pub const HELP_OVERLAY_H_FRACTION: f32 = 0.86; /// 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; +/// Absolute floor so a ~390 px canvas still gets a usable on-canvas panel. +pub const HELP_OVERLAY_FLOOR_W: f32 = 360; +pub const HELP_OVERLAY_FLOOR_H: f32 = 240; /// 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; +/// Fixed (min) width of the chord column in the two-column help table. Every +/// chord textLayout gets this min width so every help string starts on a stable x. +pub const HELP_OVERLAY_CHORD_COL_W: f32 = 140; /// 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.