diff --git a/AGENTS.md b/AGENTS.md index a87165a7..f5b65a40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -294,9 +294,11 @@ invincible/ | Agent read-before-edit / file freshness | `lib/agent/fileFreshness.ts`, `lib/agent/pathLock.ts` (per-path apply serialization), `lib/agent/tools.ts`, `lib/agent/runAgent.ts`, [docs/sandbox.md](docs/sandbox.md) | | Logical agent cwd + workspace-root↔abs canonicalization (`change_dir` / session / default env; `canonicalizePath(R, p)` / `workspaceAbsToRel(R, abs)` / `resolvePathForTool(R, cwd, p)` / `rewriteExecRootToRel(R, text)` in `lib/agent/workPath.ts`) | `lib/agent/workPath.ts`, `lib/agent/tools.ts`, `lib/agent/runAgent.ts`, `lib/agent/agentBody.ts`, `lib/sandbox/config.ts`, `lib/sessionStore.ts`, `lib/harnessChat.ts`, `lib/agentApi.ts`, `lib/sessionCloudCaps.ts` (shared client-safe `sanitizeSessionCwd` + Redis-safe opaque id predicate), [docs/sandbox.md](docs/sandbox.md), [docs/session-model.md](docs/session-model.md), [docs/agent-stream.md](docs/agent-stream.md). Tool paths accept **in-jail absolute paths** on all FS tools + `change_dir` + `exec` cwd: an absolute under the per-binding jail root R (`resolved.value.workspaceRoot` → `RunAgentParams.workspaceRoot` → `createAgentTools`) is canonicalized to the same workspace-relative freshness key as its relative form (BYO + Vercel parity); out-of-jail absolutes and `..`/symlink escapes fail closed. Absolute paths under `R` that **appear in `exec` stdout/stderr** are likewise rewritten to workspace-relative (`rewriteExecRootToRel` in `lib/agent/workPath.ts`, applied to `result.stdout`/`result.stderr` separately) so `exec pwd` ≡ `pwd`/annotations; when `R` is unresolvable the exec output passes through byte-for-byte (fail-open), and rewrites are capped and never throw. When R is unresolvable (BYO daemon down/pre-v2 — `workspaceRoot === null`) absolute is rejected (“root unavailable — use workspace-relative”) while relative + cwd still work. Initial request/session `cwd` stays relative-only; `.` is the workspace-root default session start (there is no `SANDBOX_DEFAULT_CWD` env knob), `..` walks up toward the workspace root and errors only past it, and an **exact ancestor** of cwd re-roots cleanly (`change_dir invincible` from `cwd=invincible/docs` → `invincible`, not the phantom `invincible/docs/invincible`) while a name-prefix sibling is never re-rooted. P1/GAP-1 (#452/#330): `cwd` + `activeSandboxId` are **session-owned** and ride the Redis record (`meta.{logicalCwd,activeSandboxId}`). `activeSandboxId` is now **server-resolved** (routing override via `lib/tenancy/resolveSandbox.ts` `requestedSandboxId`), not carry-only. A **confirmed successful `change_dir`** is persisted as the session cwd even when the turn later cancels / times out / hard-errors (`lib/harnessChat.ts` host-side `liveCwd`); the success path still prefers the authoritative `agentResult.cwd`, and only a confirmed `change_dir` (never an errored one) is stored on a failed/aborted turn | | 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` (Thinking kind; transcript band owner), `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 **v17** 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) | + +| 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 **v17** 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) | | 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.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 | +| 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 | + | Builtin HTTPS fetch (`http_get`) | `lib/agent/httpFetch*.ts`, `lib/agent/vercelSandboxHttpRunner.ts`, `lib/net/publicUrlPolicy.ts`, `docs/builtin-http.md` — always-available when a running HTTP instance exists; attach-only (Settings → Sandbox) | | Tenancy schema / migrations | `db/schema.ts`, `db/migrations/` | | Tenancy crypto / first-run bootstrap | `lib/tenancy/*` | diff --git a/docs/agent-stream.md b/docs/agent-stream.md index e70f52f9..77c44b3c 100644 --- a/docs/agent-stream.md +++ b/docs/agent-stream.md @@ -139,5 +139,5 @@ Product philosophy: **no live-tool / thinking-segment UX walls** — cancel with | Route SSE vs JSON | `app/api/agent/route.ts` | | Logical cwd parse / default | `lib/agent/agentBody.ts`, `lib/sandbox/config.ts`, `lib/agent/workPath.ts` | | Host consumer + collapse/caps | `lib/harnessChat.ts`, `lib/agentApi.ts` | -| Thinking paint | `native/harness/src/ui.zig` (protocol v8 kind) | +| Thinking paint | `native/harness/src/ui/thinking.zig` (protocol v8 kind) | | Feature divide | [feature-divide.md](feature-divide.md) | diff --git a/docs/feature-divide.md b/docs/feature-divide.md index cb97ca09..5c939210 100644 --- a/docs/feature-divide.md +++ b/docs/feature-divide.md @@ -136,7 +136,7 @@ re-resolved each turn. | BYOK resolve | `lib/tenancy/resolveInference*.ts`, `lib/gateway/byokProviders.ts` | | Chat turn | `lib/harnessChat.ts` | | Session | `lib/sessionStore.ts`, `lib/sessionRepository.ts`, `lib/sessions/*` (incl. `blobStore.ts`/`blobStores.ts`), [session-model.md](session-model.md) | -| Zig UI | `native/harness/src/ui.zig` | +| Zig UI | `native/harness/src/ui.zig` (facade + `frame`) + `native/harness/src/ui/*.zig` | | Bridge Zig | `native/harness/src/bridge.zig` | | Theme | `native/harness/src/palette.zig` ↔ `lib/palette.ts` | | Export whitelist | `native/harness/build.zig` | diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 34905d24..1d5449d4 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -93,7 +93,7 @@ Vertical bands inside the Wasm root (not a DOM panel): | Git probe (server) | **`GET /api/harness/status`** (`app/api/harness/status/route.ts`) resolves the caller's **envelope-authoritative** active bind (envelope `meta.activeSandboxId` wins over a Redis-safe `?sandboxId=` carry) and runs a **bounded, argv-only, read-only** git probe at the **bind workspace root** (`lib/agent/statusProbe.ts` — `rev-parse --abbrev-ref HEAD` + `--short HEAD` + `status --porcelain`; probe `cwd` is always `.`, never a caller session cwd). Non-git / empty-git-dir / probe error → `{ git: {} }` (fail-soft, still 200; git slot stays muted). stdout truncated to `STATUS_GIT_PROBE_OUT_MAX_BYTES` (512). Server-side **per-instance best-effort** rate cap `STATUS_PROBE_MIN_INTERVAL_MS` (2000) serves a cached value + `rate_limited:true` when inside the window (never 429-spam). Auth edge = middleware matcher + in-route `requireSessionUser` (dual gate, mirror `/api/agent`/`/api/sandboxes`). No bind secrets / `base_url` / token ever on the wire | | Context slot (Phase 3 #539 + #628) | Paints **provider token usage** — absolute tokens only (`N in · M out · T tok`; **no `% of window` — v1 has no model max-context source**). Updated **live mid-stream** when the AI SDK reports **aggregate** usage on a `finish` part (SSE `usage` event — never `finish-step` per-step counts), and reconciled at the final `done.usage` / JSON result / chat result. Carrier: `mapProviderUsage` (`lib/agent/usageSummary.ts`, cap `USAGE_SUMMARY_MAX_BYTES` = 96); the host parses it (`sanitizeUsageSummary`), mirrors it on `SessionSnapshot.usage`, and `foldStatusSlots` paints the slot from `formatUsageSummary`. **Default on missing usage = hidden** — never a client estimate. A completed turn with no provider usage **clears** the slot; an **aborted/cancelled** turn (no completion) carries the prior honest value forward. Live `usage` events with no usable counts are never emitted (no flicker). Rides reserved cloud `meta.usage` (JSON string; drop-to-unset on poison); host folds from `SessionSnapshot.usage`. Read-side re-sanitized in the fold so a poisoned in-memory usage can never paint | | Cap | Per slot **`STATUS_SLOT_MAX_BYTES` = 96** UTF-8 bytes (`lib/sessionCloudCaps.ts` == TS bridge `MAX_STATUS_SLOT_LEN` == Wasm `MAX_STATUS_SLOT_LEN`). The host **ellipsizes** fold values to the cap at a UTF-8 boundary (`harnessChat.ts` `truncateStatusValue`, trailing `…`) before pushing, so a present-but-oversize bound/cwd renders `<…>` instead of being dropped; a raw over-cap push is still **rejected** at the bridge/Wasm (authoritative, never a silent wire truncation) | -| Narrow canvas | The strip is always mounted at fixed `STATUS_BAR_H`; slots right-align within it. Unlike the old header pack (which traded against the primary controls), the slots now trade against the **full strip width** (`statusPackMaxWidth` = strip content width − `STATUS_PACK_BUDGET_SAFETY`) — a wider container, so the drop/ellipsis behavior it triggers is a monotone change. Slots drop by `STATUS_SLOT_DROP_ORDER` (git → context → cwd → sandbox; sandbox survives last) and, when even the surviving sandbox slot can't fit at full width, it is **pixel-ellipsized** to the leftover budget at paint time (`ui.zig` `truncateToWidthPx`) so the bound sandbox identity stays visible instead of the pack painting nothing | +| Narrow canvas | The strip is always mounted at fixed `STATUS_BAR_H`; slots right-align within it. Unlike the old header pack (which traded against the primary controls), the slots now trade against the **full strip width** (`statusPackMaxWidth` = strip content width − `STATUS_PACK_BUDGET_SAFETY`) — a wider container, so the drop/ellipsis behavior it triggers is a monotone change. Slots drop by `STATUS_SLOT_DROP_ORDER` (git → context → cwd → sandbox; sandbox survives last) and, when even the surviving sandbox slot can't fit at full width, it is **pixel-ellipsized** to the leftover budget at paint time (`ui/status.zig` `truncateToWidthPx`) so the bound sandbox identity stays visible instead of the pack painting nothing | | Colors | TEAL default, muted when empty (empty slot hides), WARM when busy; **EMBER never** for these slots | ## Transcript scroll @@ -101,7 +101,7 @@ Vertical bands inside the Wasm root (not a DOM panel): | Topic | Behavior | |-------|----------| | Scroller | One outer **Wasm** `scrollArea` for the whole transcript (not a DOM panel). Full-bleed on the canvas (`teal_bg`, no pane pad/border); the `.auto` vertical bar is flush with the canvas right edge (no inset rail) | -| State | `ScrollInfo` persists across frames (`native/harness/src/ui.zig`) | +| State | `ScrollInfo` persists across frames (`native/harness/src/ui/state.zig`) | | Input | Mouse wheel / trackpad / touch drag on the canvas **transcript region only** | | Stick-to-bottom | Follow when user was **near bottom** (~48px), when a **new user** line arrives, on **session hydrate** / Clear, or when **in-place stream growth** makes the transcript taller (`update_last` thinking/assistant tokens) while still near bottom | | Reading older lines | If the user scrolled **up**, new assistant/tool/system lines **and** stream growth do **not** yank the viewport down | @@ -150,7 +150,7 @@ see Group boundaries). | Header (default) | `N tools called` / `1 tool called`, default-**collapsed**. Right-aligned count chips shown only when >0: success **✓ N** (TEAL), failed **✗ N** (EMBER — danger only), pending **… N** (WARM). Status marks paint from embedded faces (`✓`/`✗` via DejaVu Sans Symbols; `…` via Noto heading) — **no tofu**. The **📋** (copy) control lives on this header row | | Two-level expand | Level 1: one one-liner per tool — colored status glyph **✓/✗/…** is the *single* status channel, plus a preview: `brief` (≤64 chars) when level-2 detail exists, else the tool **`name`** (no redundant `name · ok/failed` marker). Level 2: that tool's inline `detail` — a bounded, redacted **preview** (phase 3 #353) built server-side from flattened+redacted tool output: `exec` command/exit/stdout+stderr head/tail, `read_file`/`write_file`/`str_replace`/`list_dir` path/size/entries/brief preview, `http_*` URL/status/bounded body. Short single-line results carry **no** detail → a static label (no blank, duplicate-of-L1 expander). Clicking a row toggles; second click collapses; per-item isolation | | Detail vs scroll | Level-2 detail paints **inline** inside the one outer transcript scroller — there is **no** nested `scrollArea`. Command/output previews use the embedded **Vera Sans Mono** face (`exec`/filesystem/`http_*`, and any **multi-line** detail so MCP/custom-tool output also reads as a block); prose/single-line detail stays the body face. Long detail is bounded per-tool by the server preview cap (`TOOL_RUN_PREVIEW_MAX_CHARS` = 100k) with the **real** head **40** / tail **10** lines + `… (M more lines)`, and the whole group is bounded by the encode budget + hard clamp below, so the transcript wheel is not trapped | -| Painter | `native/harness/src/ui.zig` → `paintToolRun`; payload decode in `native/harness/src/rich/toolrun.zig` (fail-open → raw body text) | +| Painter | `native/harness/src/ui/toolrun.zig` → `paintToolRun`; payload decode in `native/harness/src/rich/toolrun.zig` (fail-open → raw body text) | | Open state | Two module-level `std.AutoHashMap(dvui.Id, void)` open-branch maps (per message id / per item id) survive repaint / `update_last`; cleared on reload / Clear / truncate → collapsed-by-default | | Group boundaries | Grouping keys off the **last painted ring row** via the host's `lastRingRowIsToolRun` flag (the host is the only ring writer): a tool event grows the open card **iff** the last ring row is a tool-run; a **thinking row last**, a real (non-empty trimmed) assistant segment, a user send, or an error/turn-end opens a NEW card at `1`; empty **and whitespace-only** assistant (or a blank `text_delta`) is **not** a boundary. Counts **paint live** — each tool event opens/grows the kind-6 card immediately (`1 tool called` → `2…`) via `update_last`, never withheld until a boundary (removed commit-once). A group still rolls to a new card at `TOOL_RUN_ITEMS_MAX`, and the rolled (full) card is never grown | | Group bound | A group stores at most **200** items (`TOOL_RUN_ITEMS_MAX`); a longer streak rolls a new `tool_run` group (counts stay exact across groups). The whole group encodes into **one** message, so the host enforces a group **encode budget** (`TOOL_RUN_GROUP_DETAIL_ENC_MAX` ≈ 229 KiB of encoded `detail`) that clips/omits previews plus an encode-time hard clamp to `TOOL_RUN_MSG_HARD_MAX` (`262 144`) — a multi-item streak of large previews can **never** overflow the ring/cloud per-msg cap (it clips an explicit `…` or falls back to the L1 static label, never a silent mid-payload truncation) | diff --git a/native/harness/README.md b/native/harness/README.md index eb6c52b8..93401209 100644 --- a/native/harness/README.md +++ b/native/harness/README.md @@ -64,7 +64,7 @@ build-harness → artifact harness-wasm ```text src/main.zig # dvui_init / deinit / update + Asteronica themeSet -src/ui.zig # frame: transcript, composer, Send +src/ui.zig # facade — frame() band layout; delegates to src/ui/* modules src/bridge.zig # inv_* export fns + ring buffer + pending submit src/palette.zig # TEAL/WARM/EMBER hex (sync with lib/palette.ts) src/rich/ # Markdown + fence paint (zmd MIT parse → cache → registry) diff --git a/native/harness/src/rich/kinds.zig b/native/harness/src/rich/kinds.zig index bb3df2e0..1fe9fbe1 100644 --- a/native/harness/src/rich/kinds.zig +++ b/native/harness/src/rich/kinds.zig @@ -14,8 +14,9 @@ pub const KIND_TOOL: u8 = 6; pub const KIND_SKILL: u8 = 7; /// User, assistant, and thinking monologues get GFM. System/tool-run lines and -/// errors stay plain (tool-run has its own custom expandable paint in ui.zig; -/// skill rows have a compact headerless paint in ui.zig). +/// errors stay plain (tool-run has its own custom expandable paint in +/// `ui/toolrun.zig`; skill rows have a compact headerless paint in +/// `ui/skill.zig`). pub fn shouldPaintMarkdown(kind: u8) bool { return kind == KIND_USER or kind == KIND_ASSISTANT or kind == KIND_THINKING; } diff --git a/native/harness/src/rich/toolrun.zig b/native/harness/src/rich/toolrun.zig index 6a948006..87ff1912 100644 --- a/native/harness/src/rich/toolrun.zig +++ b/native/harness/src/rich/toolrun.zig @@ -236,7 +236,7 @@ test "decode tolerates malformed item lines" { } test "ui id packing is unique for a full group and across rows" { - // Mirrors the IMGUI id scheme in ui.zig paintToolRun: each item owns a + // Mirrors the IMGUI id scheme in `ui/toolrun.zig` paintToolRun: each item owns a // `item_stride`-wide namespace (`it_id * item_stride + slot`) under a row // that advances by `row_step` = msg_index *% 1000003. Serves as the // reviewer-requested cheap guard that MAX_ITEMS items never alias. diff --git a/native/harness/src/thinking_collapse.zig b/native/harness/src/thinking_collapse.zig index dac565fb..33a1588c 100644 --- a/native/harness/src/thinking_collapse.zig +++ b/native/harness/src/thinking_collapse.zig @@ -9,7 +9,7 @@ //! newest slot. //! - When a turn **completes** (Busy -> ready/err), every committed thinking //! row collapses to the compact expandable control. Re-clicking a row opens -//! it (operator set lives in `ui.zig`, in-memory only — thinking is +//! it (operator set lives in `ui/state.zig`, in-memory only — thinking is //! ephemeral and never survives refresh). //! //! Turn membership is decided by **physical ring-slot contiguity**, not by a diff --git a/native/harness/src/transcript_split.zig b/native/harness/src/transcript_split.zig index e634ece7..94fdeeda 100644 --- a/native/harness/src/transcript_split.zig +++ b/native/harness/src/transcript_split.zig @@ -17,7 +17,7 @@ const dvui = @import("dvui"); const palette = @import("palette.zig"); const session_catalog = @import("session_catalog.zig"); -/// Closed rail width (px) — one touch target, matches `ui.zig` `TOUCH_H`. +/// Closed rail width (px) — one touch target, matches `ui/metrics.zig` `TOUCH_H`. pub const SIDEBAR_RAIL_W: f32 = 40; /// Open column width (px). Session list scrolls inside this width. pub const SIDEBAR_OPEN_W: f32 = 220; diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index 6a290c38..de349a61 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -1,10 +1,6 @@ -//! Harness product UI (dvui) — Phase 4 Wasm-primary agent workspace. -//! Polish (4.7): density, focus composer, touch targets, scroll stick-to-bottom. -//! #131 / plan #135: persistent transcript ScrollInfo + conditional stick rules. -//! #251: stick also on in-place stream growth (update_last / content height). -//! #137/#579: absolute-rect bands for transcript + composer (composer hugs one -//! line via previous-frame measured height, grows up to cap); status bar stays -//! absolute-rect at the very bottom. Build id (`h:…`) detects stale wasm. +//! Harness product UI facade — public API for main.zig + frame() band layout. +//! Paint helpers live under `ui/*.zig` with shared state in `ui/state.zig` +//! (plan #656, source issue #652). Build id (`h:…`) detects stale wasm. const std = @import("std"); const dvui = @import("dvui"); const bridge = @import("bridge.zig"); @@ -13,1067 +9,42 @@ const build_options = @import("build_options"); const rich = @import("rich/root.zig"); const mixed_text = @import("rich/mixed_text.zig"); const composer_text = @import("composer_text.zig"); -const cwd_slot = @import("cwd_slot.zig"); -const toolrun = @import("rich/toolrun.zig"); const thinking_collapse = @import("thinking_collapse.zig"); const busy_row = @import("busy_row.zig"); const transcript_split = @import("transcript_split.zig"); const model_picker = @import("model_picker.zig"); -const chip_preview = @import("chip_preview.zig"); const rect_spinner = @import("rect_spinner.zig"); +const state = @import("ui/state.zig"); +const metrics = @import("ui/metrics.zig"); +const scroll = @import("ui/scroll.zig"); +const kinds = @import("ui/kinds.zig"); +const chrome = @import("ui/chrome.zig"); +const toolrun = @import("ui/toolrun.zig"); +const thinking = @import("ui/thinking.zig"); +const chip = @import("ui/chip.zig"); +const status = @import("ui/status.zig"); +const skill = @import("ui/skill.zig"); +const composer = @import("ui/composer.zig"); + /// Baked at compile time (`-Dbuild-id=…`); shown in header to detect stale wasm. pub const BUILD_ID: []const u8 = build_options.build_id; -var prompt_buf: [bridge.SUBMIT_CAP]u8 = [_]u8{0} ** bridge.SUBMIT_CAP; - -/// First frame after init: focus the composer once. -var want_composer_focus: bool = true; -/// Shown line count last frame (messages + optional busy row). -var last_shown_count: usize = 0; -/// Ring message count last frame (for clear / hydrate / user-send detection). -var last_msg_count: usize = 0; -/// Virtual content scrollMax from last frame — stream growth detection (#251). -var last_scroll_max_y: f32 = 0; -/// Persistent across frames — frame-local ScrollInfo zeros viewport every paint. -var transcript_scroll: dvui.ScrollInfo = .{ - .vertical = .auto, - .horizontal = .none, -}; - -/// Two-level expand state for tool-run rows (#325 / plan #345), keyed by -/// per-message and per-item ids. Keeps open groups across repaints/frames the -/// way `reorder_tree.zig` keeps its open branches; cleared on reload/clear/ -/// truncate so a fresh surface starts collapsed. -var toolrun_open_buf: [16384]u8 = undefined; -var toolrun_open_fba = std.heap.FixedBufferAllocator.init(&toolrun_open_buf); -var toolrun_open_l1 = std.AutoHashMap(dvui.Id, void).init(toolrun_open_fba.allocator()); -var toolrun_open_l2 = std.AutoHashMap(dvui.Id, void).init(toolrun_open_fba.allocator()); - -/// #424 — in-memory operator-open set for committed (collapsed) thinking rows, -/// keyed by per-message thinking id. Mirrors `toolrun_open_l1` so a collapsed -/// thinking row the operator expands stays open across frames until toggled or -/// the transcript is cleared. Thinking is ephemeral (never survives refresh), -/// so no persisted state is needed. -var thinking_open_buf: [8192]u8 = undefined; -var thinking_open_fba = std.heap.FixedBufferAllocator.init(&thinking_open_buf); -var thinking_open_l1 = std.AutoHashMap(dvui.Id, void).init(thinking_open_fba.allocator()); - -fn clearThinkingOpenState() void { - thinking_open_l1.clearRetainingCapacity(); -} - -/// #424 — the Wasm collapse-policy state: tracks the active Busy turn start so -/// the policy knows which thinking rows are "current turn" (full) vs committed -/// (collapsed). -var thinking_collapse_state: thinking_collapse.State = .{}; -/// Previous lifecycle seen by `frame()` — for busy->ready/err edge detection. -var prev_lifecycle: thinking_collapse.Lifecycle = .boot; - -fn clearToolRunOpenState() void { - toolrun_open_l1.clearRetainingCapacity(); - toolrun_open_l2.clearRetainingCapacity(); -} - -/// Touch-friendly control height (CSS px ≈). -const TOUCH_H: f32 = 40; -/// Near-bottom epsilon for stick-to-bottom follow (plan #135). -const NEAR_BOTTOM_PX: f32 = 48; -/// Ignore subpixel layout noise when detecting in-place stream growth (#251). -const CONTENT_GREW_EPS: f32 = 1.0; -/// Reserved bottom chrome: single-row textEntry + trailing TOUCH_H icon + margins -/// (plan #457 — replaces the old textEntry + Send/Stop action row from plan #138). -/// Per-edge margin between textEntry content rect and composer-chrome box edge -/// (Options.padding.y). Plan #579 replaces the old fixed COMPOSER_PAD_Y (4 px -/// top-only) with 2 px per edge on the new dynamic hug box. -const COMPOSER_HUG_PAD: f32 = 2; -/// Inset between glyphs and the textEntry's teal border (px). #584 zeroed this -/// so dvui's padding-bake would not double-count in the outer→content convert; -/// glyphs then sat on the stroke. 5 px is the operator ask after #584 shipped. -/// Passed as textEntry Options.padding — TextEntryWidget.init bakes it into -/// min/max_size_content then nulls the option, so we subtract 2× this from -/// those sizes to keep the 44/124 chrome caps. -const COMPOSER_TE_PAD: f32 = 5; -/// Multi-line composer visible-height cap (px). Wrapped lines grow the entry up -/// to this, then it scrolls vertically **inside** the entry — never a horizontal -/// gutter past the trailing icon (plan #457, repo no-h-scroll policy). The -/// composer-chrome box caps at COMPOSER_INPUT_MAX_H + 2*COMPOSER_HUG_PAD via -/// max_size_content; taller pasted content scrolls internally (plan #334 / #323). -const COMPOSER_INPUT_MAX_H: f32 = 120; -/// Absolute floor so a short canvas still has a scroll band. -const SCROLL_FLOOR_H: f32 = 32; -/// Status-bar band height (px) — a two-line always-mounted full-width strip painted -/// directly BELOW the composer (plan #555 → #554, header merged by plan #570). -/// Reserved into the bottom chrome budget so the transcript never overlaps it and -/// the transcript+composer stack never jumps vertically when a sandbox attaches. -/// 64 px = two 32 px rows, exactly fitting the model picker (PICKER_TRIGGER_H = 32) + status slots. -/// Total chrome (64) ≤ old header+bar (92) — net −28 px transcript gain (plan #570). -const STATUS_BAR_H: f32 = 64; - -/// Maximum composer-chrome outer height (px). The composer box rect grows up to -/// this from idle hug (~44 px) via previous-frame measured height. Multi-line -/// content past this cap scrolls internally (plan #457). The rect is still -/// absolute (Options.rect), so the scrollArea never publishes virtual content -/// height into the root flex (dvui `.auto` bar overwrites min_size.h — -/// adversarial review #584 Blocker L1). -const COMPOSER_MAX_CHROME_H: f32 = COMPOSER_INPUT_MAX_H + 2 * COMPOSER_HUG_PAD; -/// Idle composer-chrome outer height (px) when the field is a single line. -/// TOUCH_H (40) + 2 px top + 2 px bottom padding = 44 px. The composer band -/// is never smaller than this so the textEntry always has a touch target. -const COMPOSER_IDLE_CHROME_H: f32 = TOUCH_H + 2 * COMPOSER_HUG_PAD; - -/// TextEntry border overhead (px). The textEntry's computed min_size (via -/// dvui.minSizeGet after deinit) is the OUTER height (content + border). -/// dvui TextEntryWidget.init *bakes* options.padding into min_size_content -/// and nulls options.padding, so minSizeGet adds border only (2) — NOT -/// padding. We convert outer→content: content_h = outer_h − TE_OVERHEAD. -/// Border is set explicitly on the textEntry below. Padding is COMPOSER_TE_PAD -/// (5); we pass min/max_size_content *already minus* 2×TE_PAD so the bake -/// restores the named 40/120 wells and the 44/124 chrome caps (#584 squeeze -/// was subtracting pad *again* from a post-null minSizeGet). -const TE_BORDER_H: f32 = 1 + 1; // border.y + border.h -const TE_OVERHEAD: f32 = TE_BORDER_H; // 2 - -/// Previous-frame measured composer-chrome outer height (px). Initialized to -/// idle so the first frame shows a compact composer. Updated after each frame -/// from the textEntry's natural wrapped height (sampled via dvui.minSizeGet -/// after te.deinit — NOT te.data().min_size which is the options seed); clamped -/// to [IDLE, MAX] so the band never collapses and never exceeds the multi-line -/// cap. The transcript rect is computed from this value, so both bands shift in -/// tandem (one-frame settle lag, no visual jump — adversarial review #584 -/// Round 2 Major L1 and Round 3 Major L1+L9). -var composer_last_h: f32 = COMPOSER_IDLE_CHROME_H; - -/// Sticky last-user-message chip (plan #645, source issue #339). -const CHIP_VISIBILITY_MARGIN: f32 = 8; - -/// Per-slot content-local y-offset in scroll content space. Indexed by physical -/// ring slot (0..RING_CAP). Pre-allocated 2048 × 4 = 8 KiB — zero frame-path alloc. -/// Populated during the message paint loop; cleared on reset/clear. -var msg_content_y: [2048]f32 = [_]f32{0} ** 2048; -/// Physical ring slot of the most recent user message, or null when no user -/// messages exist in the current ring window. -var last_user_slot: ?usize = null; -/// Chip visibility from the PREVIOUS frame — drives scroll-area rect adjustment -/// and chip paint this frame. One-frame settle (same pattern as composer_last_h). -var prev_chip_visible: bool = false; - pub fn onInit() void { bridge.reset(); - @memset(&prompt_buf, 0); - want_composer_focus = true; - resetTranscriptScroll(); + @memset(&state.prompt_buf, 0); + state.want_composer_focus = true; + state.resetTranscriptScroll(); 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 // next wrap sample (adversarial review #584 Round 4 Minor L8). - composer_last_h = COMPOSER_IDLE_CHROME_H; + state.composer_last_h = metrics.COMPOSER_IDLE_CHROME_H; transcript_split.reset(); } -fn resetTranscriptScroll() void { - transcript_scroll = .{ - .vertical = .auto, - .horizontal = .none, - }; - last_shown_count = 0; - last_msg_count = 0; - last_scroll_max_y = 0; - clearToolRunOpenState(); - clearThinkingOpenState(); - thinking_collapse_state.reset(); - prev_lifecycle = .boot; - @memset(&msg_content_y, 0); - last_user_slot = null; - prev_chip_visible = false; -} - -fn isNearBottom(si: *const dvui.ScrollInfo) bool { - return si.offsetFromMax(.vertical) <= NEAR_BOTTOM_PX; -} - -fn clampScrollToContent(si: *dvui.ScrollInfo) void { - const max_y = si.scrollMax(.vertical); - if (si.viewport.y > max_y) si.viewport.y = max_y; - if (si.viewport.y < 0) si.viewport.y = 0; -} - -fn scrollToBottom(si: *dvui.ScrollInfo) void { - si.viewport.y = si.scrollMax(.vertical); -} - pub fn onDeinit() void {} -fn kindLabel(kind: u8) []const u8 { - return switch (kind) { - 1 => "you", - 2 => "assistant", - 3 => "system", - 4 => "error", - 5 => "thinking", - 6 => "tools", - 7 => "skill", - else => "msg", - }; -} - -fn kindTextColor(kind: u8) dvui.Color { - return switch (kind) { - 1 => palette.teal_accent, - 2 => palette.warm_accent, - 3 => palette.teal_muted, - 4 => palette.ember_accent, - // Muted warm — thinking monologue (not EMBER, not pure blue). - 5 => palette.warm_muted, - else => palette.teal_text, - }; -} - -fn kindFill(kind: u8) ?dvui.Color { - return switch (kind) { - 1 => palette.teal_bg, - 2 => palette.teal_surface, - 3 => null, - 4 => palette.ember_surface, - 5 => palette.warm_bg, - else => null, - }; -} - -/// Build a human-readable multi-line summary of a tool-run payload for the Copy -/// button — never the dense `toolrun\t…` wire text. Falls back to the raw body -/// when the payload doesn't decode so we never lose data. -fn toolRunClipboard(text: []const u8) []const u8 { - const alloc = dvui.currentWindow().arena(); - var decoded = toolrun.decode(alloc, text) orelse return text; - defer decoded.deinit(); - var out = std.ArrayList(u8).empty; - errdefer out.deinit(alloc); - var line_buf: [512]u8 = undefined; - for (decoded.run.items) |it| { - const g: []const u8 = switch (it.status) { - .ok => "✓", - .fail => "✗", - .running => "…", - }; - const name = if (it.name.len > 0) it.name else "tool"; - const label = if (it.brief.len > 0) it.brief else name; - const line = std.fmt.bufPrint(&line_buf, "{s} {s} — {s}\n", .{ g, name, label }) catch continue; - out.appendSlice(alloc, line) catch break; - } - return out.toOwnedSlice(alloc) catch return text; -} - -/// Paint one L0 count chip: a status mark run (a face that covers the code -/// point — ✓/✗ come from the embedded DejaVu Sans Symbols face, `…` is already -/// in the Noto heading face) followed by a heading-face count run. The glyph and -/// the count are separate runs because each face only covers a subset of the -/// code points (DejaVu Sans Symbols has ✓/✗ but no ASCII digits; Noto has digits -/// but no ✓/✗), so a single-face combined "✓ 3" run would tofu the other half. -/// L0 chrome mark size — DejaVu symbols at default body size read as dust next to -/// the "N tools called" heading; bump so ✓/✗/… match the count digits. -fn chromeMarkFont(base: dvui.Font) dvui.Font { - return base.withSize(base.size + 5).withLineHeight(1.0); -} - -/// L0 count digit size — keep with the mark, slightly larger than default heading. -fn chromeCountFont() dvui.Font { - const h = dvui.Font.theme(.heading); - return h.withSize(h.size + 2).withLineHeight(1.0); -} - -/// Clipboard emoji on the copy control — OpenMoji outlines need ~2× body px. -fn chromeCopyFont() dvui.Font { - const body = dvui.Font.theme(.body); - return palette.fontEmoji() - .withSize(body.size * 1.9) - .withLineHeight(1.0); -} - -/// Trailing composer icon glyphs (plan #457) — DejaVu Sans Symbols covers both -/// `▶` (launch/send, U+25B6) and `■` (stop, U+25A0); bump size so they read at -/// the fixed TOUCH_H square instead of tofu/dust. -fn composerIconFont() dvui.Font { - const body = dvui.Font.theme(.body); - return palette.fontSymbols() - .withSize(body.size + 4) - .withLineHeight(1.0); -} - -fn paintStatusChip( - src: std.builtin.SourceLocation, - box_id: usize, - glyph_id: usize, - count_id: usize, - color: dvui.Color, - mark: []const u8, - count: u32, - mark_font: dvui.Font, -) void { - var chip = dvui.box(src, .{ .dir = .horizontal }, .{ - .gravity_y = 0.5, - .id_extra = box_id, - // No extra pad — chips sit in the trailing chrome row with the copy btn. - .margin = .{ .x = 0, .y = 0, .w = 2, .h = 0 }, - }); - defer chip.deinit(); - { - var tl = dvui.textLayout(src, .{}, .{ - .id_extra = glyph_id, - .color_text = color, - .font = chromeMarkFont(mark_font), - .gravity_y = 0.5, - .margin = .{ .x = 0, .y = 0, .w = 3, .h = 0 }, - }); - tl.addText(mark, .{}); - tl.deinit(); - } - { - var tl = dvui.textLayout(src, .{}, .{ - .id_extra = count_id, - .color_text = color, - .font = chromeCountFont(), - .gravity_y = 0.5, - .margin = .{ .x = 0, .y = 0, .w = 4, .h = 0 }, - }); - tl.format("{d}", .{count}, .{}); - tl.deinit(); - } -} - -/// True for tool names whose level-2 detail is a command/output block (exec -/// stdout/stderr, filesystem results, http bodies). Those previews paint in the -/// embedded Vera Sans Mono face for readable alignment (phase 3 #353); other -/// tools fall back to the body face. -fn isCommandLikeRun(name: []const u8) bool { - return std.mem.eql(u8, name, "exec") or - std.mem.eql(u8, name, "http_get") or - std.mem.eql(u8, name, "http_head") or - std.mem.eql(u8, name, "read_file") or - std.mem.eql(u8, name, "write_file") or - std.mem.eql(u8, name, "str_replace") or - std.mem.eql(u8, name, "list_dir") or - std.mem.eql(u8, name, "change_dir") or - std.mem.eql(u8, name, "pwd"); -} - -/// True when a tool-run level-2 body should paint in the embedded Vera Sans Mono -/// face: command-like builtins (allowlist above) OR any multi-line detail (an -/// MCP/custom tool's dense stdout). Single-line prose detail stays the body face -/// (adversarial review #359 Minor — multi-line output should read as a block). -fn detailUsesMono(name: []const u8, detail: []const u8) bool { - if (isCommandLikeRun(name)) return true; - return std.mem.indexOfScalar(u8, detail, '\n') != null; -} - -/// Paint an aggregated tool-run control (protocol v10 / kind 6). -/// -/// Level 0 (default-collapsed): header `N tools called` + colored count chips -/// (TEAL✓ success / EMBER✗ fail-only / WARM… pending) as a touch-height hit -/// target. Level 1: one one-liner per tool (colored status glyph). Level 2: -/// that tool's inline detail. Returns true when the payload decoded and painted; -/// false means fail-open → caller renders the raw body as plain text. -fn paintToolRun( - src: std.builtin.SourceLocation, - msg_index: usize, - text: []const u8, - slot: ?usize, - revision: u32, -) bool { - // #404: slot-keyed decode cache keyed on (physical ring slot, write-revision). - // An unchanged revision reuses the previously decoded summary with zero - // re-decode (O(dirty), not O(N) every frame). The cached summary lives in - // the long-lived gpa and is owned by the cache — never deinit'd here. The - // frame loop always passes a slot, so the fresh per-frame arena decode below - // is only a safety net for hypothetical non-ring callers. - var owned: ?toolrun.Decoded = null; - defer if (owned) |*d| d.deinit(); - const arena = dvui.currentWindow().arena(); - const run: ?*const toolrun.ToolRun = if (slot) |s| - rich.toolrunCacheSlot(s, revision, text) - else blk: { - const dec = toolrun.decode(arena, text) orelse break :blk null; - owned = dec; - break :blk &owned.?.run; - }; - if (run == null) return false; - const runv = run.?; - // Decoders recount ok/fail/pending from the kept (capped) items, so the - // header count can never disagree with what actually paints (review). - const total = runv.ok + runv.fail + runv.pending; - - // IMGUI identity: every widget in this control is keyed off `src` (the - // single paintToolRun call site), so two tool-run rows must NOT share - // id_extra — the codebase pattern is `msg_index *% …` (rich/paint.zig, - // message Copy). `id_base` = msg_index times an odd factor; for the ring's - // realistic msg_index (≤ MAX_MSG) products stay < 2^32 with no wrap. - // Items get a 1024-wide namespace each (see item loop); 1000003 > 200·1024, - // so the whole group stays below the next row's id_base and rows never - // overlap, and within a row no two (item, widget) pairs can alias. - const id_base: usize = @as(usize, msg_index) *% 1000003; - - const l1_raw: usize = id_base + 7; - const l1_key: dvui.Id = @enumFromInt(l1_raw); - var l1_expanded = toolrun_open_l1.contains(l1_key); - - if (total == 0) return false; - - // ── Level 0 header: expander label + right-aligned colored count chips ── - var header_label: [40]u8 = undefined; - const label = - if (total == 1) - (std.fmt.bufPrint(&header_label, "1 tool called", .{}) catch "tools") - else - (std.fmt.bufPrint(&header_label, "{d} tools called", .{total}) catch "tools"); - - { - // Single horizontal row: expander (fills) + one trailing chrome pack so - // clipboard + status chips share the same vertical center as the label. - var head = dvui.box(src, .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .min_size_content = .{ .w = 120, .h = TOUCH_H - 4 }, - .id_extra = id_base + 1, - }); - defer head.deinit(); - - // Natural label height + gravity_y centers caret/text with the trail pack. - // Do NOT force TOUCH_H min height on the expander — dvui pins the label to - // the top of a tall expander box (operator: "1 tool called" looked top-aligned). - const open = dvui.expander(src, label, .{ .expanded = &l1_expanded }, .{ - .expand = .horizontal, - .gravity_y = 0.5, - .id_extra = id_base + 2, - }); - if (open) toolrun_open_l1.put(l1_key, {}) catch {} else _ = toolrun_open_l1.remove(l1_key); - - // Trailing pack (right): 📋 then ✓N / ✗N / …N — one gravity box so - // padding/baseline match (operator: glyphs were tiny + clipboard pad fat - // + row misaligned when chips and button were separate gravity_x children). - { - var trail = dvui.box(src, .{ .dir = .horizontal }, .{ - .gravity_x = 1.0, - .gravity_y = 0.5, - .min_size_content = .{ .w = 0, .h = TOUCH_H - 8 }, - .id_extra = id_base + 3, - }); - defer trail.deinit(); - - // Compact clipboard — tight pad, larger OpenMoji glyph. - if (dvui.button(src, "📋", .{}, .{ - .gravity_y = 0.5, - .style = .content, - .id_extra = id_base + 29, - .min_size_content = .{ .w = 22, .h = 22 }, - .padding = .{ .x = 4, .y = 4, .w = 4, .h = 4 }, - .font = chromeCopyFont(), - .corners = .round(5), - .color_fill = palette.teal_bg, - .color_text = palette.teal_accent, - .color_border = palette.teal_border, - .margin = .{ .x = 0, .y = 0, .w = 6, .h = 0 }, - })) { - // Same-frame write only — do not retain ring slices. - dvui.clipboardTextSet(toolRunClipboard(text)); - } - - // Status marks must not tofu: ✓/✗ come from DejaVu Sans Symbols, - // `…` from the Noto heading face (see paintStatusChip). - if (runv.ok > 0) paintStatusChip(src, id_base + 20, id_base + 21, id_base + 22, palette.teal_accent, "✓", runv.ok, palette.fontSymbols()); - if (runv.fail > 0) paintStatusChip(src, id_base + 23, id_base + 24, id_base + 25, palette.ember_accent, "✗", runv.fail, palette.fontSymbols()); - if (runv.pending > 0) paintStatusChip(src, id_base + 26, id_base + 27, id_base + 28, palette.warm_accent, "…", runv.pending, .theme(.heading)); - } - } - - // ── Level 1 + level 2 (expanded) ──────────────────────────────────────── - if (l1_expanded) { - var list = dvui.box(src, .{ .dir = .vertical }, .{ - .expand = .horizontal, - .margin = .{ .x = 10, .y = 0, .w = 0, .h = 0 }, - .id_extra = id_base + 10, - }); - defer list.deinit(); - - for (runv.items) |it| { - const l2_key: dvui.Id = @enumFromInt(l1_raw *% 31 + it.id); - // `it.id` is 1-based per group with up to MAX_ITEMS items. Each item - // owns a 1024-wide namespace (`it_id *% 1024`) under this message's - // id_base, holding up to 5 widget slots, so every (item, widget) - // pair is unique within the row even for a full 200-item group; - // 1000003 > 200·1024 keeps distinct rows disjoint. Matches the - // rich/paint.zig `msg_index *% …` discipline (id = src + id_extra, - // not a parent chain). - const it_id: usize = it.id; - // Widget slots inside the item's 1024-wide namespace: - // +0 item box · +1 status glyph · +2 expander / static label - // +3 detail box · +4 detail body - const item_base: usize = id_base + it_id *% 1024; - const has_detail = it.detail.len > 0; - var l2_expanded = toolrun_open_l2.contains(l2_key); - - { - var item_head = dvui.box(src, .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .min_size_content = .{ .w = 120, .h = TOUCH_H - 6 }, - .id_extra = item_base + 0, - }); - defer item_head.deinit(); - - const glyph_color: dvui.Color = switch (it.status) { - .ok => palette.teal_accent, - .fail => palette.ember_accent, - .running => palette.warm_accent, - }; - // Status marks must render (no tofu): ✓/✗ come from the embedded - // DejaVu Sans Symbols face; `…` is already in the Noto heading face. - const glyph_font: dvui.Font = switch (it.status) { - .ok, .fail => palette.fontSymbols(), - .running => .theme(.heading), - }; - { - var tl = dvui.textLayout(src, .{}, .{ - .id_extra = item_base + 1, - .color_text = glyph_color, - .gravity_y = 0.5, - .font = chromeMarkFont(glyph_font), - .margin = .{ .x = 4, .y = 0, .w = 4, .h = 0 }, - }); - tl.addText(switch (it.status) { - .ok => "✓", - .fail => "✗", - .running => "…", - }, .{}); - tl.deinit(); - } - - // No-detail items carry only the host's status-suffixed fallback - // `brief` (`name · ✓/✗/running`); the colored glyph is the single - // status channel, so paint those labels from `name` to avoid a - // redundant second status affordance (parent Goal 3 / issue review). - const item_label: []const u8 = if (has_detail and it.brief.len > 0) it.brief else it.name; - if (has_detail) { - // Same as L0: natural height so label centers with the status glyph. - const open = dvui.expander(src, item_label, .{ .expanded = &l2_expanded }, .{ - .id_extra = item_base + 2, - .expand = .horizontal, - .gravity_y = 0.5, - }); - if (open) toolrun_open_l2.put(l2_key, {}) catch {} else _ = toolrun_open_l2.remove(l2_key); - } else { - // No level-2 detail (e.g. a short/empty summary) — mount a - // static label, not a blank expander (review nit). The no-detail label - // is painted from `name`, so the colored glyph is the only status channel. - var tl = dvui.textLayout(src, .{}, .{ - .id_extra = item_base + 2, // expander slot — mutually exclusive - .expand = .horizontal, - .color_text = palette.teal_text, - .gravity_y = 0.5, - }); - mixed_text.addTextMixed(tl, item_label, .theme(.body), .{ - .color_text = palette.teal_text, - }); - tl.deinit(); - } - } - - if (has_detail and l2_expanded) { - var detail = dvui.box(src, .{ .dir = .vertical }, .{ - .id_extra = item_base + 3, - .expand = .horizontal, - .margin = .{ .x = 26, .y = 0, .w = 0, .h = 0 }, - }); - defer detail.deinit(); - var tl = dvui.textLayout(src, .{}, .{ - .id_extra = item_base + 4, - .expand = .horizontal, - .color_text = palette.teal_text, - }); - // Phase 3 (#353): command/output previews (exec, filesystem, - // http) paint in the embedded Vera Sans Mono face for a readable - // aligned block; prose/other detail stays the body face. Symbols - // still route to their DejaVu/OpenMoji faces via addTextMixed. - const detail_font: dvui.Font = if (detailUsesMono(it.name, it.detail)) - palette.fontMono() - else - .theme(.body); - mixed_text.addTextMixed(tl, it.detail, detail_font, .{ - .color_text = palette.teal_text, - }); - tl.deinit(); - } - } - } - return true; -} - -/// One-line muted preview of a thinking monologue for the collapsed header -/// (text-only, bounded — no markdown parse, no full-body read). Returns a slice -/// into `buf`. Stops at the first newline and caps at ~80 bytes. Review: the -/// byte cap must never cut a multi-byte UTF-8 sequence mid-codepoint (CJK, -/// emoji, combining marks) — that would hand `textLayout` an invalid trailing -/// run, so we back off any truncated multibyte char to a codepoint boundary. -fn thinkingPreview(buf: *[96]u8, text: []const u8) []const u8 { - var out: usize = 0; - for (text) |c| { - if (out >= 80) break; - if (c == '\n' or c == '\r') break; - buf[out] = c; - out += 1; - } - // Trailing whitespace isn't part of the preview. - while (out > 0 and (buf[out - 1] == ' ' or buf[out - 1] == '\t')) out -= 1; - // Drop a multi-byte char truncated by the byte cap: first any trailing - // continuation bytes, then a truncated leading byte (0xC0..0xFF). What's - // left ends on a valid single-byte (ASCII) boundary — never mojibake. - while (out > 0 and chip_preview.isUtf8Continuation(buf[out - 1])) out -= 1; - if (out > 0 and (buf[out - 1] & 0xC0) == 0xC0) out -= 1; - return buf[0..out]; -} - -/// Paint the sticky last-user-message chip as an absolute-rect strip above the -/// transcript scroll area (plan #645). The entire strip is a clickable button: -/// one click scrolls the transcript so the last user message is aligned at the -/// top of the viewport. The chip shows a truncated one-line preview of the user -/// message; hidden when the message is already visible or there are no user -/// messages. -fn paintLastUserChip( - src: std.builtin.SourceLocation, - slot: usize, - chip_y: f32, - pane_w: f32, - avail: dvui.Rect, -) void { - // Find the user message text for this physical ring slot. - const n = bridge.messageCount(); - var chip_text: []const u8 = ""; - for (0..n) |i| { - if (bridge.messageSlotAt(i)) |s| { - if (s == slot) { - if (bridge.messageAt(i)) |m| { - chip_text = m.text; - } - break; - } - } - } - - var preview_buf: [chip_preview.LAST_USER_CHIP_PREVIEW_MAX_BYTES + 1]u8 = undefined; - const preview = chip_preview.chipPreview(&preview_buf, chip_text); - - // Pixel-ellipsize the preview to fit the available chip width on narrow - // canvas (~390 px phone with open rail). " ↑" suffix + 16 px total - // horizontal padding must be reserved so the label never overflows and - // dvui clips mid-glyph (adversarial review #646 Minor L9). - // - // U+2191 is in the Arrows block — Noto body does not ship it (tofu). DejaVu - // Symbols does. Measure the arrow on the symbols face; paint the combined - // label through addTextMixed (same face split as rich text). Do **not** pin - // `.font = fontSymbols()` on the whole chip — that face has no Latin - // (adversarial review #648 Blocker). - const body = dvui.Font.theme(.body); - const font_symbols = palette.fontSymbols().withSize(body.size); - const chip_w = @max(0, avail.w - pane_w); - const suffix = " ↑"; - const suffix_w = body.textSize(" ").w + font_symbols.textSize("↑").w; - const max_text_w = @max(0, chip_w - 16 - suffix_w); - var ellip_buf: [chip_preview.LAST_USER_CHIP_PREVIEW_MAX_BYTES + 4]u8 = undefined; - const display_preview = if (body.textSize(preview).w <= max_text_w) - preview - else - truncateToWidthPx(body, &ellip_buf, preview, max_text_w); - - // Preview (Noto) + " ↑" (DejaVu) — mixed in one label, same as rich text. - var label_buf: [chip_preview.LAST_USER_CHIP_PREVIEW_MAX_BYTES + 8]u8 = undefined; - const label = std.fmt.bufPrint(&label_buf, "{s}{s}", .{ display_preview, suffix }) catch display_preview; - - // Same as dvui.button but the label is addTextMixed, not labelNoFmt (one - // face). ButtonWidget so the strip stays one click target. - var bw: dvui.ButtonWidget = undefined; - bw.init(src, .{}, .{ - .rect = .{ .x = pane_w, .y = chip_y, .w = @max(0, avail.w - pane_w), .h = TOUCH_H }, - .expand = .horizontal, - .style = .content, - .min_size_content = .{ .w = 120, .h = TOUCH_H }, - .color_fill = palette.teal_border, - .color_border = palette.teal_muted, - .color_text = palette.teal_text, - .margin = dvui.Rect.all(0), - .padding = .{ .x = 8, .y = 0, .w = 8, .h = 0 }, - }); - bw.processEvents(); - bw.drawBackground(); - const clicked = bw.clicked(); - { - var tl = dvui.textLayout(@src(), .{ - .break_lines = false, - }, .{ - .background = false, - .color_text = palette.teal_text, - .gravity_x = 0.0, - .gravity_y = 0.5, - .expand = .horizontal, - .padding = dvui.Rect.all(0), - }); - mixed_text.addTextMixed(tl, label, body, .{ .color_text = palette.teal_text }); - tl.deinit(); - } - bw.drawFocus(); - bw.deinit(); - if (clicked) { - transcript_scroll.viewport.y = msg_content_y[slot]; - clampScrollToContent(&transcript_scroll); - } -} - -/// Paint a thinking row (protocol v8 kind / bridge kind 5, #424). -/// -/// When the row belongs to the active Busy turn (or the operator has expanded -/// it), render the FULL GFM monologue through the existing slot-keyed markdown -/// painter. Otherwise render a compact default-collapsed control mirroring the -/// tool-run L0 header: a `Thinking` expander + a bounded muted one-line preview -/// + a Copy button (copies the full source). Toggling flips the in-memory open -/// set so a committed row the operator opens stays open until re-toggled or the -/// transcript is cleared. -/// -/// id namespace: `msg_index *% 1000033` (odd prime, distinct from tool-run's -/// `1000003` and the body's `1024`) so thinking chrome never aliases other rows' -/// tool-run/body ids. -fn paintThinking( - src: std.builtin.SourceLocation, - msg_index: usize, - text: []const u8, - slot: ?usize, - revision: u32, -) void { - const id_base: usize = @as(usize, msg_index) *% 1000033; - const key: dvui.Id = @enumFromInt(id_base + 7); - - // Active-turn rows are pinned FULL while Busy (policy) — not togglable. - // Committed rows are operator-toggled: `thinking_open_l1` is the only input. - // Membership is the policy's job (pure, host-tested) — the live streaming - // newest row is part of the active turn's ring-forward slot range, so it - // stays full automatically (no ui-side `is_live_newest` escape hatch). The - // policy took over the saturated-ring guard the old index-threshold used. - const slotp = slot orelse { - // A thinking row always maps to a ring slot; bail defensively rather - // than derive membership from a bare visible index. - return; - }; - const ring_head = bridge.messageHead(); - const ring_cap = bridge.RING_CAP; - const is_active = thinking_collapse_state.isActiveTurnFull(slotp, ring_head, ring_cap); - const open_by_operator = thinking_open_l1.contains(key); - // Single policy entry point — both the active-turn pin and the operator - // override live inside `shouldRenderFull`. - const full = thinking_collapse_state.shouldRenderFull(slotp, ring_head, ring_cap, open_by_operator); - - // Layout mutates `expanded` across the head + body blocks below. Starts at - // the policy/output state; for a pinned active-turn row we re-assert `full` - // after the expander so a click cannot collapse the live reasoning. - var expanded = full; - { - var head = dvui.box(src, .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .min_size_content = .{ .w = 120, .h = TOUCH_H - 4 }, - // same warm surface as the kind row uses for thinking (kindFill 5). - .background = true, - .color_fill = palette.warm_bg, - .color_border = palette.teal_border, - .padding = .{ .x = 8, .y = 2, .w = 8, .h = 2 }, - .id_extra = id_base + 1, - }); - defer head.deinit(); - - // Natural label height + gravity_y centers caret with preview/copy trail. - const open = dvui.expander(src, "Thinking", .{ .expanded = &expanded }, .{ - .expand = .horizontal, - .gravity_y = 0.5, - .id_extra = id_base + 2, - .color_text = palette.warm_muted, - .font = .theme(.heading), - }); - if (is_active) { - // Pinned; never let a click collapse the live/active-turn reasoning. - expanded = true; - } else if (open) { - thinking_open_l1.put(key, {}) catch {}; - expanded = true; - } else { - _ = thinking_open_l1.remove(key); - expanded = false; - } - - var trail = dvui.box(src, .{ .dir = .horizontal }, .{ - .gravity_x = 1.0, - .gravity_y = 0.5, - .min_size_content = .{ .w = 0, .h = TOUCH_H - 8 }, - .id_extra = id_base + 3, - }); - defer trail.deinit(); - - // Collapsed: bounded muted one-line preview (no markdown parse). - if (!expanded) { - var preview_buf: [96]u8 = undefined; - const preview = thinkingPreview(&preview_buf, text); - var tl = dvui.textLayout(src, .{}, .{ - .id_extra = id_base + 4, - .color_text = palette.teal_muted, - .gravity_y = 0.5, - .font = .theme(.body), - .margin = .{ .x = 0, .y = 0, .w = 6, .h = 0 }, - }); - tl.addText(preview, .{}); - tl.deinit(); - } - // Copy button (full source → clipboard), same chrome as tool-run header. - if (dvui.button(src, "📋", .{}, .{ - .gravity_y = 0.5, - .style = .content, - .id_extra = id_base + 5, - .min_size_content = .{ .w = 22, .h = 22 }, - .padding = .{ .x = 4, .y = 4, .w = 4, .h = 4 }, - .font = chromeCopyFont(), - .corners = .round(5), - .color_fill = palette.teal_bg, - .color_text = palette.teal_accent, - .color_border = palette.teal_border, - })) { - dvui.clipboardTextSet(text); - } - } - - // Full GFM monologue when expanded (functionally unchanged paint path; the - // slot-keyed parse cache + cache_layout handling is preserved). - if (expanded) { - rich.paintMessageBody(src, rich.KIND_THINKING, text, .{ - .msg_index = msg_index, - .slot = slot, - .revision = revision, - }); - } -} - -/// Paint a display-only skill-attach row (protocol v12 / kind 7). Headerless and -/// compact: a single muted-TEAL line `Skill attached: `. The text is the -/// host-built NAME line only — a skill body is never shipped to the client or -/// folded into the model prompt, so there is nothing sensitive here. A long/ -/// hostile wire line is hard-capped to ~160 bytes at a UTF-8 boundary with `…`. -fn paintSkillAttached( - src: std.builtin.SourceLocation, - msg_index: usize, - text: []const u8, -) void { - var cap_buf: [160]u8 = undefined; - const shown: []const u8 = blk: { - if (text.len <= cap_buf.len) break :blk text; - // Reserve 3 bytes for the UTF-8 ellipsis (U+2026 = \xE2\x80\xA6). - var n: usize = cap_buf.len - 3; - // Back off a multibyte char truncated by the byte cap (never mojibake). - while (n > 0 and (text[n] & 0xC0) == 0x80) n -= 1; - @memcpy(cap_buf[0..n], text[0..n]); - @memcpy(cap_buf[n .. n + 3], "…"); - break :blk cap_buf[0 .. n + 3]; - }; - var tl = dvui.textLayout(src, .{}, .{ - .expand = .horizontal, - .id_extra = msg_index *% 1024 + 1, - .color_text = palette.teal_muted, - .font = .theme(.body), - }); - mixed_text.addTextMixed(tl, shown, .theme(.body), .{ - .color_text = palette.teal_muted, - }); - tl.deinit(); -} - -/// Cap a status-slot value to `MAX_STATUS_SLOT_LEN` bytes at a UTF-8 boundary -/// with a trailing ellipsis (never mojibake). The bridge already refuses -/// oversize pushes, but defends against a hostile pre-v14 wire value anyway. -fn truncateStatusValue( - buf: *[bridge.MAX_STATUS_SLOT_LEN]u8, - src: []const u8, -) []const u8 { - if (src.len <= buf.len) return src; - // Reserve 3 bytes for the UTF-8 ellipsis (U+2026 = \xE2\x80\xA6). - var n: usize = buf.len - 3; - // Back off a multibyte char truncated by the byte cap (never mojibake). - while (n > 0 and (src[n] & 0xC0) == 0x80) n -= 1; - @memcpy(buf[0..n], src[0..n]); - @memcpy(buf[n .. n + 3], "…"); - return buf[0 .. n + 3]; -} - -/// UTF-8 code-point byte length for the leading byte at `b` (defensive: bridge -/// slot values are already valid UTF-8, so a lead byte maps to its true length; -/// a stray continuation byte (0x80..0xBF) maps to 1 so we never over-read). -fn utf8CharLen(b: u8) usize { - if (b < 0x80) return 1; - if (b < 0xC0) return 1; // continuation byte — treat as a lone 1-byte unit - if (b < 0xE0) return 2; - if (b < 0xF0) return 3; - return 4; -} - -/// Paint-time PIXEL ellipsizer (PR #543 re-run L9): shrink `src` to fit `max_w` -/// px as measured by `body`, keeping complete UTF-8 code points and a trailing -/// ellipsis. Only reached when the highest-priority status slot cannot fit at -/// full width on a very narrow canvas — so the operator still sees which sandbox -/// is bound (e.g. `sandbox 446655…`) instead of the whole pack painting nothing. -/// Never mutates the stored bridge value. Returns `src` unchanged when it already -/// fits; otherwise writes the ellipsized prefix into `buf` (caller-owned, >= the -/// slot cap) and returns a slice of it. -fn truncateToWidthPx( - body: dvui.Font, - buf: []u8, - src: []const u8, - max_w: f32, -) []const u8 { - const ell = "…"; - if (max_w <= 0 or src.len == 0) return ""; - if (body.textSize(src).w <= max_w) return src; - const ell_w = body.textSize(ell).w; - var i: usize = 0; - var used: f32 = 0; - while (i < src.len) { - const cl = @min(utf8CharLen(src[i]), src.len - i); - // Leave room for the trailing ellipsis in the caller's byte buffer: a - // cap-length slot whose prefix would fill buf entirely must still fit - // the "…" (Nit L1 — a ≥94-byte cwd would otherwise return "" and leave - // an empty gutter slot painted with just the 10px gap). - if (i + cl + ell.len > buf.len) break; - const cp = src[i .. i + cl]; - const w = body.textSize(cp).w; - if (used + w + ell_w > max_w) break; - @memcpy(buf[i .. i + cl], cp); - used += w; - i += cl; - } - if (i == 0 or i + ell.len > buf.len) return ""; - @memcpy(buf[i .. i + ell.len], ell); - return buf[0 .. i + ell.len]; -} - -/// 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. -const STATUS_SLOT_GAP: f32 = 10; -/// Extra pad subtracted from the pack budget so the reservation never races -/// live-layout rounding (a couple px either way must not push a primary control). -const STATUS_PACK_BUDGET_SAFETY: f32 = 4; - -/// Width (px) available to the status-slot pack this frame. The pack lives on -/// line 2 of the two-line bottom status bar (plan #555 → #554, header merged by -/// plan #570); the budget is the bar's content-rect width minus the rounding- -/// safety pad (`STATUS_PACK_BUDGET_SAFETY`). Line 1 holds identity controls -/// (spinner · build id · model picker), so the pack shares the bar but each -/// line has its own fixed 32 px height — neither can displace the other. The pack -/// still DROPS slots per `STATUS_SLOT_DROP_ORDER` then pixel-ellipsizes the -/// survivor to fit, exactly as before (see the narrow-canvas ellipsize decision: -/// even here the operator still sees *which* sandbox is bound, PR #543 re-run L9). -fn statusPackMaxWidth() f32 { - const content_w = dvui.parentGet().data().contentRect().w; - return @max(0, content_w - STATUS_PACK_BUDGET_SAFETY); -} - -/// Paint the right-aligned status-slot pack into line 2 of the two-line bottom -/// status bar (protocol v13, plan #538/#541/#554, header merged by plan #570). -/// Line 2 is a fixed 32 px horizontal row sharing the 64 px bar with the identity -/// row (line 1: spinner · build id · model picker); each line has its own -/// explicit height so neither can displace the other. A narrow canvas DROPS slots -/// per `STATUS_SLOT_DROP_ORDER` then pixel-ellipsizes the kept slot to fit. -/// Sandbox + cwd render as muted TEAL one-liners (WARM when busy); an empty slot -/// is hidden (never a blank placeholder / broken layout). When there are NO -/// non-empty slots at all, the caller still mounts the fixed `STATUS_BAR_H` band -/// as a subtle empty strip (locked decision, plan #555) — it never collapses and -/// `chrome_y`/`scroll_h` stay constant, so the transcript+composer stack never -/// jumps. Slot values are already capped at `MAX_STATUS_SLOT_LEN` by the bridge; -/// this defends the paint against a stale/oversize value with a UTF-8-safe ellipsis. -fn paintStatusSlots(life: bridge.Lifecycle) void { - const busy = life == .busy; - const budget = statusPackMaxWidth(); - if (budget <= 0) return; - - // Collect non-empty slots in drop-priority order (first = least important, - // i.e. git → context → cwd → sandbox), with their truncated text + width. - const body = (dvui.Options{}).fontGet(); - var slot: [bridge.MAX_STATUS_SLOTS]u32 = undefined; - var buf: [bridge.MAX_STATUS_SLOTS][bridge.MAX_STATUS_SLOT_LEN]u8 = undefined; - var text: [bridge.MAX_STATUS_SLOTS][]const u8 = undefined; - var width: [bridge.MAX_STATUS_SLOTS]f32 = undefined; - var n: usize = 0; - for (bridge.STATUS_SLOT_DROP_ORDER) |s| { - const raw = bridge.statusSlotValue(s); - if (raw.len == 0) continue; - // Cwd "." is the workspace-root default — hide the trivial chip (plan #579). - if (s == bridge.STATUS_SLOT_CWD and !cwd_slot.isVisible(raw)) continue; - slot[n] = s; - text[n] = truncateStatusValue(&buf[n], raw); - width[n] = body.textSize(text[n]).w + STATUS_SLOT_GAP; - n += 1; - } - if (n == 0) return; - - // Drop lowest-importance slots (front of the drop order) until the pack fits - // the primary-reserved budget. Retained = [keep_from..n). - var total: f32 = 0; - for (width[0..n]) |w| total += w; - var keep_from: usize = 0; - while (keep_from < n and total > budget) : (keep_from += 1) { - total -= width[keep_from]; - } - if (keep_from >= n) { - // Even the most important slot (sandbox — last in the drop order) can't - // fit at full width on a very narrow canvas. Never paint nothing (PR - // #543 re-run L9): pixel-ellipsize that identity slot down to the leftover - // budget so the operator still sees *which* sandbox is bound (e.g. - // `sandbox 446655…`). This is the plan's "slots truncate" half that a - // pure drop-to-empty would forfeit at exactly the viewport the plan locked. - keep_from = n - 1; - const max_text_w = budget - STATUS_SLOT_GAP; - if (max_text_w <= 0) return; // no room even for the slot's gap — paint nothing - text[keep_from] = truncateToWidthPx(body, buf[keep_from][0..], text[keep_from], max_text_w); - if (text[keep_from].len == 0) return; // no room at all — no empty gutter (Nit L1) - } - - const slot_color: dvui.Color = if (busy) palette.warm_accent else palette.teal_muted; - // Right-aligned pack: gravity_x pulls the whole group to the trailing edge. - var pack = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .gravity_x = 1.0, - .gravity_y = 0.5, - .id_extra = 0x61_0001, - }); - defer pack.deinit(); - - var i = keep_from; - while (i < n) : (i += 1) { - var tl = dvui.textLayout(@src(), .{}, .{ - .background = false, - .id_extra = 0x61_0002 + @as(usize, slot[i]), - .color_text = slot_color, - .gravity_y = 0.5, - .margin = .{ .x = 0, .y = 0, .w = 10, .h = 0 }, - }); - tl.addText(text[i], .{}); - tl.deinit(); - } -} - -fn clearPrompt() void { - @memset(&prompt_buf, 0); -} - -fn submitText(text: []const u8) void { - // Normalize CRLF/lone-CR -> LF and clamp to SUBMIT_CAP at a codepoint - // boundary (composer_text.zig). Blank/whitespace after normalization is - // rejected, preserving the existing empty-send guard. In-place into - // prompt_buf is safe: normalized length never exceeds the input consumed. - const norm = composer_text.normalizeInto(text, prompt_buf[0..], bridge.SUBMIT_CAP); - if (norm.is_blank) { - clearPrompt(); - return; - } - bridge.queueSubmitFromUi(norm.text); - clearPrompt(); -} - pub fn frame() !void { const life = bridge.getLifecycle(); const busy = life == .busy; @@ -1107,11 +78,11 @@ pub fn frame() !void { // Composer height is dynamic: previous-frame measured via composer_last_h, // clamped to [IDLE, MAX], so the band hugs the field at idle (~44 px) and // grows up as lines wrap (adversarial review #584 Round 2 Major L1+L9). - const composer_h = @max(COMPOSER_IDLE_CHROME_H, @min(composer_last_h, COMPOSER_MAX_CHROME_H)); - const status_y = avail.h - STATUS_BAR_H; + const composer_h = @max(metrics.COMPOSER_IDLE_CHROME_H, @min(state.composer_last_h, metrics.COMPOSER_MAX_CHROME_H)); + const status_y = avail.h - metrics.STATUS_BAR_H; const composer_y = status_y - composer_h; const scroll_y: f32 = 0; - const scroll_h: f32 = @max(SCROLL_FLOOR_H, composer_y - scroll_y); + const scroll_h: f32 = @max(metrics.SCROLL_FLOOR_H, composer_y - scroll_y); // Read pane width *before* paint so a same-frame toggle cannot desync the // rail rect from the scrollArea x (IMGUI: click takes effect next frame). const pane_w = transcript_split.paneWidth(); @@ -1127,19 +98,19 @@ pub fn frame() !void { // scroll area's y offset moves down and its height shrinks by the same // amount so content doesn't overlap. One-frame settle (same pattern as // composer_last_h). - const chip_h: f32 = if (prev_chip_visible) TOUCH_H else 0; - const scroll_h_chip: f32 = @max(SCROLL_FLOOR_H, scroll_h - chip_h); + const chip_h: f32 = if (state.prev_chip_visible) metrics.TOUCH_H else 0; + const scroll_h_chip: f32 = @max(metrics.SCROLL_FLOOR_H, scroll_h - chip_h); // Paint the chip strip above the scroll area when visible. - if (prev_chip_visible) { - if (last_user_slot) |slot| { - paintLastUserChip(@src(), slot, scroll_y, pane_w, avail); + if (state.prev_chip_visible) { + if (state.last_user_slot) |slot| { + chip.paintLastUserChip(@src(), slot, scroll_y, pane_w, avail); } } - const near_before = isNearBottom(&transcript_scroll); - const prev_msg = last_msg_count; - const prev_shown = last_shown_count; + const near_before = scroll.isNearBottom(&state.transcript_scroll); + const prev_msg = state.last_msg_count; + const prev_shown = state.last_shown_count; const n = bridge.messageCount(); const shown = n + @as(usize, if (busy) 1 else 0); @@ -1150,13 +121,13 @@ pub fn frame() !void { // appending at), not a message count, so membership survives saturation/wrap. { const cur_lc: thinking_collapse.Lifecycle = @enumFromInt(@intFromEnum(life)); - thinking_collapse_state.onLifecycleTransition(prev_lifecycle, cur_lc, bridge.messageHead()); - prev_lifecycle = cur_lc; + state.thinking_collapse_state.onLifecycleTransition(state.prev_lifecycle, cur_lc, bridge.messageHead()); + state.prev_lifecycle = cur_lc; } var user_scroll: dvui.Point = .{}; { - var scroll = dvui.scrollArea(@src(), .{ - .scroll_info = &transcript_scroll, + var scroll_area = dvui.scrollArea(@src(), .{ + .scroll_info = &state.transcript_scroll, .vertical_bar = .auto, .user_scroll = &user_scroll, }, .{ @@ -1169,7 +140,7 @@ pub fn frame() !void { .min_size_content = .{ .w = 120, .h = scroll_h_chip }, .max_size_content = .height(scroll_h_chip), }); - defer scroll.deinit(); + defer scroll_area.deinit(); var body = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, @@ -1181,7 +152,7 @@ pub fn frame() !void { if (dvui.button(@src(), "Load earlier", .{}, .{ .expand = .horizontal, .style = .content, - .min_size_content = .{ .w = 120, .h = TOUCH_H - 4 }, + .min_size_content = .{ .w = 120, .h = metrics.TOUCH_H - 4 }, .corners = .round(6), .color_fill = palette.teal_bg, .color_text = palette.teal_accent, @@ -1237,8 +208,8 @@ pub fn frame() !void { var row = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .id_extra = i, - .background = kindFill(m.kind) != null, - .color_fill = kindFill(m.kind), + .background = kinds.kindFill(m.kind) != null, + .color_fill = kinds.kindFill(m.kind), .color_border = if (is_err) palette.ember_border else palette.teal_border, .padding = .{ .x = 8, .y = 6, .w = 8, .h = 6 }, .margin = .{ .x = 0, .y = 0, .w = 0, .h = 4 }, @@ -1264,11 +235,11 @@ pub fn frame() !void { var tl = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .id_extra = i * 2, - .color_text = kindTextColor(m.kind), + .color_text = kinds.kindTextColor(m.kind), .font = .theme(.heading), .gravity_y = 0.5, }); - tl.format("{s}", .{kindLabel(m.kind)}, .{}); + tl.format("{s}", .{kinds.kindLabel(m.kind)}, .{}); tl.deinit(); } if (m.text.len > 0) { @@ -1278,7 +249,7 @@ pub fn frame() !void { .id_extra = i *% 1024 + 2, .min_size_content = .{ .w = 22, .h = 22 }, .padding = .{ .x = 4, .y = 4, .w = 4, .h = 4 }, - .font = chromeCopyFont(), + .font = chrome.chromeCopyFont(), .corners = .round(5), .color_fill = palette.teal_bg, .color_text = palette.teal_accent, @@ -1295,7 +266,7 @@ pub fn frame() !void { // which renders the compact expandable control or, for the // active busy turn / operator-open rows, the full GFM body. if (m.kind == rich.KIND_THINKING) { - paintThinking(@src(), i, m.text, bridge.messageSlotAt(i), bridge.messageRevisionAt(i)); + thinking.paintThinking(@src(), i, m.text, bridge.messageSlotAt(i), bridge.messageRevisionAt(i)); } else if (rich.shouldPaintMarkdown(m.kind)) { // #424: for non-thinking markdown kinds, defer to the // generic slot-keyed painter below (unchanged). @@ -1309,9 +280,9 @@ pub fn frame() !void { // ` row. Headerless, compact, muted TEAL — shows // only the skill NAME, never a skill body (bodies stay // server-side in the model's system context). - paintSkillAttached(@src(), i, m.text); + skill.paintSkillAttached(@src(), i, m.text); } else if (m.kind == rich.KIND_TOOL) { - if (!paintToolRun(@src(), i, m.text, bridge.messageSlotAt(i), bridge.messageRevisionAt(i))) { + if (!toolrun.paintToolRun(@src(), i, m.text, bridge.messageSlotAt(i), bridge.messageRevisionAt(i))) { // Fail-open (plan #345): unknown/old tool-run // payload decodes to nothing → render raw text. var tl = dvui.textLayout(@src(), .{}, .{ @@ -1333,14 +304,14 @@ pub fn frame() !void { .id_extra = i *% 1024 + 2, .min_size_content = .{ .w = 22, .h = 22 }, .padding = .{ .x = 4, .y = 4, .w = 4, .h = 4 }, - .font = chromeCopyFont(), + .font = chrome.chromeCopyFont(), .corners = .round(5), .color_fill = palette.teal_bg, .color_text = palette.teal_accent, .color_border = palette.teal_border, .margin = .{ .x = 4, .y = 0, .w = 0, .h = 0 }, })) { - dvui.clipboardTextSet(toolRunClipboard(m.text)); + dvui.clipboardTextSet(chrome.toolRunClipboard(m.text)); } } } else { @@ -1361,11 +332,11 @@ pub fn frame() !void { // is fully laid out but before deinit fires (data() is still // valid). Keyed by physical ring slot so ring wrap can never // alias entries across different messages. - if (bridge.messageSlotAt(i)) |slot| { + if (bridge.messageSlotAt(i)) |slot_val| { const cr = row.data().contentRect(); - msg_content_y[slot] = cr.y; + state.msg_content_y[slot_val] = cr.y; if (m.kind == @intFromEnum(bridge.MessageKind.user)) { - last_user_slot = slot; + state.last_user_slot = slot_val; } } } @@ -1387,8 +358,8 @@ pub fn frame() !void { // Conditional stick-to-bottom (plan #135 / #131 / #251). // Count changes cover pushMessage; content_grew covers inv_update_last_message // stream growth (thinking/assistant) where msg_count is unchanged. - const max_y = transcript_scroll.scrollMax(.vertical); - const content_grew = max_y > last_scroll_max_y + CONTENT_GREW_EPS; + const max_y = state.transcript_scroll.scrollMax(.vertical); + const content_grew = max_y > state.last_scroll_max_y + metrics.CONTENT_GREW_EPS; const count_changed = shown != prev_shown; if (n < prev_msg) { @@ -1398,15 +369,15 @@ pub fn frame() !void { // parse cache (generation bump) and reset tool-run expand state so a // fresh window starts collapsed. if (n == 0) rich.clearCache(); - clearToolRunOpenState(); - clearThinkingOpenState(); - thinking_collapse_state.reset(); - transcript_scroll.velocity = .{ .x = 0, .y = 0 }; - scrollToBottom(&transcript_scroll); + state.clearToolRunOpenState(); + state.clearThinkingOpenState(); + state.thinking_collapse_state.reset(); + state.transcript_scroll.velocity = .{ .x = 0, .y = 0 }; + scroll.scrollToBottom(&state.transcript_scroll); // Clear chip state — no user messages remain (plan #645). - @memset(&msg_content_y, 0); - last_user_slot = null; - prev_chip_visible = false; + @memset(&state.msg_content_y, 0); + state.last_user_slot = null; + state.prev_chip_visible = false; } else if (count_changed or content_grew) { const newest_is_user = blk: { if (n == 0) break :blk false; @@ -1417,27 +388,27 @@ pub fn frame() !void { const hydrate = (prev_msg == 0 and n > 1) or (n >= prev_msg + 3); const should_follow = user_sent or hydrate or prev_msg == 0 or (near_before and user_scroll.y >= 0); if (should_follow) { - scrollToBottom(&transcript_scroll); + scroll.scrollToBottom(&state.transcript_scroll); } else { - clampScrollToContent(&transcript_scroll); + scroll.clampScrollToContent(&state.transcript_scroll); } } else { - clampScrollToContent(&transcript_scroll); + scroll.clampScrollToContent(&state.transcript_scroll); } // Always refresh trackers (grow, shrink, no-op) so stream deltas stay accurate. - last_shown_count = shown; - last_msg_count = n; - last_scroll_max_y = transcript_scroll.scrollMax(.vertical); + state.last_shown_count = shown; + state.last_msg_count = n; + state.last_scroll_max_y = state.transcript_scroll.scrollMax(.vertical); // Sticky last-user-message chip visibility for NEXT frame (plan #645). // Computed from this frame's final scroll state + per-message y-track array. // The chip appears when the last user message's top edge has scrolled above // the viewport with a small margin to prevent flicker at the boundary. // One-frame settle: this value drives next frame's rect adjustment + paint. - prev_chip_visible = if (last_user_slot) |s| blk: { - const msg_y = msg_content_y[s]; - const view_top = transcript_scroll.viewport.y; - break :blk msg_y < view_top - CHIP_VISIBILITY_MARGIN; + state.prev_chip_visible = if (state.last_user_slot) |s| blk: { + const msg_y = state.msg_content_y[s]; + const view_top = state.transcript_scroll.viewport.y; + break :blk msg_y < view_top - metrics.CHIP_VISIBILITY_MARGIN; } else false; // ── Composer chrome (absolute rect — hugs one line, grows up to cap) ─── @@ -1445,17 +416,17 @@ pub fn frame() !void { // [COMPOSER_IDLE_CHROME_H, COMPOSER_MAX_CHROME_H]. At idle the band is ~44 px // (Send sits on the field baseline); multi-line paste grows the rect up to // 124 px over one frame settle (adversarial review #584 Round 2 Major L1+L9). - var typed: []const u8 = prompt_buf[0..0]; + var typed: []const u8 = state.prompt_buf[0..0]; { - var chrome = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var composer_chrome = dvui.box(@src(), .{ .dir = .horizontal }, .{ .rect = .{ .x = 0, .y = composer_y, .w = 0, .h = composer_h }, .expand = .horizontal, .background = true, .color_fill = palette.teal_bg, .color_border = palette.teal_border, - .padding = .{ .x = 0, .y = COMPOSER_HUG_PAD, .w = 0, .h = COMPOSER_HUG_PAD }, + .padding = .{ .x = 0, .y = metrics.COMPOSER_HUG_PAD, .w = 0, .h = metrics.COMPOSER_HUG_PAD }, }); - defer chrome.deinit(); + defer composer_chrome.deinit(); // Single-row composer (plan #457): the multi-line field and ONE trailing // icon button pack horizontally, so a tall paste grows the field up to @@ -1490,7 +461,7 @@ pub fn frame() !void { } { var te = dvui.textEntry(@src(), .{ - .text = .{ .buffer = prompt_buf[0..] }, + .text = .{ .buffer = state.prompt_buf[0..] }, .placeholder = "Message the model…", .multiline = true, .break_lines = true, @@ -1502,19 +473,19 @@ pub fn frame() !void { // (and 44/124 chrome) survive the bake. Width max is // max_float_safe (not 0): a 0 width + 2×TE_PAD bake would // cap the internal layout at 10 px. - .min_size_content = .{ .w = 120, .h = TOUCH_H - 2 * COMPOSER_TE_PAD }, - .max_size_content = .{ .w = dvui.max_float_safe, .h = COMPOSER_INPUT_MAX_H - 2 * COMPOSER_TE_PAD }, + .min_size_content = .{ .w = 120, .h = metrics.TOUCH_H - 2 * metrics.COMPOSER_TE_PAD }, + .max_size_content = .{ .w = dvui.max_float_safe, .h = metrics.COMPOSER_INPUT_MAX_H - 2 * metrics.COMPOSER_TE_PAD }, .color_fill = palette.teal_surface, .color_text = palette.teal_text, .color_border = if (busy) palette.teal_border else palette.teal_accent, .margin = .{ .x = 0, .y = 0, .w = 8, .h = 0 }, - .padding = .{ .x = COMPOSER_TE_PAD, .y = COMPOSER_TE_PAD, .w = COMPOSER_TE_PAD, .h = COMPOSER_TE_PAD }, + .padding = .{ .x = metrics.COMPOSER_TE_PAD, .y = metrics.COMPOSER_TE_PAD, .w = metrics.COMPOSER_TE_PAD, .h = metrics.COMPOSER_TE_PAD }, .border = .{ .x = 1, .y = 1, .w = 1, .h = 1 }, }); typed = te.getText(); - if (want_composer_focus) { + if (state.want_composer_focus) { dvui.focusWidget(te.data().id, null, null); - want_composer_focus = false; + state.want_composer_focus = false; } // Capture the textEntry's natural wrapped height for next frame's // dynamic hug. `te.data().min_size` is the OPTIONS seed (re-seeded @@ -1537,14 +508,14 @@ pub fn frame() !void { // frame's measured height instead of collapsing (never a zero shot)). const te_id = te.data().id; te.deinit(); - const outer_h = if (dvui.minSizeGet(te_id)) |ms| ms.h else composer_last_h; - const raw_content = @max(0.0, outer_h - TE_OVERHEAD); - const content_h = @max(TOUCH_H, @min(raw_content, COMPOSER_INPUT_MAX_H)); - composer_last_h = @max(COMPOSER_IDLE_CHROME_H, @min(content_h + 2 * COMPOSER_HUG_PAD, COMPOSER_MAX_CHROME_H)); + const outer_h = if (dvui.minSizeGet(te_id)) |ms| ms.h else state.composer_last_h; + const raw_content = @max(0.0, outer_h - metrics.TE_OVERHEAD); + 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) { - submitText(typed); - typed = prompt_buf[0..0]; - want_composer_focus = true; + composer.submitText(typed); + typed = state.prompt_buf[0..0]; + state.want_composer_focus = true; } } @@ -1558,8 +529,8 @@ pub fn frame() !void { if (dvui.button(@src(), "■", .{}, .{ .gravity_y = 1.0, .style = .content, - .font = composerIconFont(), - .min_size_content = .{ .w = TOUCH_H, .h = TOUCH_H }, + .font = chrome.composerIconFont(), + .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H }, .corners = .round(8), .color_fill = palette.warm_bg, .color_text = palette.warm_accent, @@ -1570,13 +541,13 @@ pub fn frame() !void { } else if (dvui.button(@src(), "▶", .{}, .{ .gravity_y = 1.0, .style = .highlight, - .font = composerIconFont(), - .min_size_content = .{ .w = TOUCH_H, .h = TOUCH_H }, + .font = chrome.composerIconFont(), + .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H }, .corners = .round(8), })) { if (typed.len > 0) { - submitText(typed); - want_composer_focus = true; + composer.submitText(typed); + state.want_composer_focus = true; } } } @@ -1587,13 +558,13 @@ pub fn frame() !void { // transcript+composer stack never jumps when a sandbox attaches. { var bar = dvui.box(@src(), .{ .dir = .vertical }, .{ - .rect = .{ .x = 0, .y = status_y, .w = 0, .h = STATUS_BAR_H }, + .rect = .{ .x = 0, .y = status_y, .w = 0, .h = metrics.STATUS_BAR_H }, .expand = .horizontal, .background = true, .color_fill = palette.teal_surface, .color_border = palette.teal_border, .padding = .{ .x = 10, .y = 0, .w = 10, .h = 0 }, - .min_size_content = .{ .w = 120, .h = STATUS_BAR_H }, + .min_size_content = .{ .w = 120, .h = metrics.STATUS_BAR_H }, .id_extra = 0x61_0300, }); defer bar.deinit(); @@ -1605,7 +576,7 @@ pub fn frame() !void { var line1 = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .gravity_y = 0.5, - .min_size_content = .{ .w = 120, .h = STATUS_BAR_H / 2 }, + .min_size_content = .{ .w = 120, .h = metrics.STATUS_BAR_H / 2 }, .id_extra = 0x61_0100, }); defer line1.deinit(); @@ -1649,11 +620,11 @@ pub fn frame() !void { var line2 = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .gravity_y = 0.5, - .min_size_content = .{ .w = 120, .h = STATUS_BAR_H / 2 }, + .min_size_content = .{ .w = 120, .h = metrics.STATUS_BAR_H / 2 }, .id_extra = 0x61_0200, }); defer line2.deinit(); - paintStatusSlots(life); + status.paintStatusSlots(life); } } } diff --git a/native/harness/src/ui/chip.zig b/native/harness/src/ui/chip.zig new file mode 100644 index 00000000..e574a892 --- /dev/null +++ b/native/harness/src/ui/chip.zig @@ -0,0 +1,108 @@ +//! Sticky last-user-message chip (plan #645, source issue #339). +//! Paints a compact strip above the transcript scroll area when the last user +//! message has scrolled above the viewport. One click scrolls back to it. +const std = @import("std"); +const dvui = @import("dvui"); +const bridge = @import("../bridge.zig"); +const palette = @import("../palette.zig"); +const chip_preview = @import("../chip_preview.zig"); +const mixed_text = @import("../rich/mixed_text.zig"); +const state = @import("state.zig"); +const scroll = @import("scroll.zig"); +const metrics = @import("metrics.zig"); +const status = @import("status.zig"); + +/// Paint the sticky last-user-message chip as an absolute-rect strip above the +/// transcript scroll area (plan #645). The entire strip is a clickable button: +/// one click scrolls the transcript so the last user message is aligned at the +/// top of the viewport. The chip shows a truncated one-line preview of the user +/// message; hidden when the message is already visible or there are no user +/// messages. +pub fn paintLastUserChip( + src: std.builtin.SourceLocation, + slot: usize, + chip_y: f32, + pane_w: f32, + avail: dvui.Rect, +) void { + // Find the user message text for this physical ring slot. + const n = bridge.messageCount(); + var chip_text: []const u8 = ""; + for (0..n) |i| { + if (bridge.messageSlotAt(i)) |s| { + if (s == slot) { + if (bridge.messageAt(i)) |m| { + chip_text = m.text; + } + break; + } + } + } + + var preview_buf: [chip_preview.LAST_USER_CHIP_PREVIEW_MAX_BYTES + 1]u8 = undefined; + const preview = chip_preview.chipPreview(&preview_buf, chip_text); + + // Pixel-ellipsize the preview to fit the available chip width on narrow + // canvas (~390 px phone with open rail). " ↑" suffix + 16 px total + // horizontal padding must be reserved so the label never overflows and + // dvui clips mid-glyph (adversarial review #646 Minor L9). + // + // U+2191 is in the Arrows block — Noto body does not ship it (tofu). DejaVu + // Symbols does. Measure the arrow on the symbols face; paint the combined + // label through addTextMixed (same face split as rich text). Do **not** pin + // `.font = fontSymbols()` on the whole chip — that face has no Latin + // (adversarial review #648 Blocker). + const body = dvui.Font.theme(.body); + const font_symbols = palette.fontSymbols().withSize(body.size); + const chip_w = @max(0, avail.w - pane_w); + const suffix = " ↑"; + const suffix_w = body.textSize(" ").w + font_symbols.textSize("↑").w; + const max_text_w = @max(0, chip_w - 16 - suffix_w); + var ellip_buf: [chip_preview.LAST_USER_CHIP_PREVIEW_MAX_BYTES + 4]u8 = undefined; + const display_preview = if (body.textSize(preview).w <= max_text_w) + preview + else + status.truncateToWidthPx(body, &ellip_buf, preview, max_text_w); + + // Preview (Noto) + " ↑" (DejaVu) — mixed in one label, same as rich text. + var label_buf: [chip_preview.LAST_USER_CHIP_PREVIEW_MAX_BYTES + 8]u8 = undefined; + const label = std.fmt.bufPrint(&label_buf, "{s}{s}", .{ display_preview, suffix }) catch display_preview; + + // Same as dvui.button but the label is addTextMixed, not labelNoFmt (one + // face). ButtonWidget so the strip stays one click target. + var bw: dvui.ButtonWidget = undefined; + bw.init(src, .{}, .{ + .rect = .{ .x = pane_w, .y = chip_y, .w = @max(0, avail.w - pane_w), .h = metrics.TOUCH_H }, + .expand = .horizontal, + .style = .content, + .min_size_content = .{ .w = 120, .h = metrics.TOUCH_H }, + .color_fill = palette.teal_border, + .color_border = palette.teal_muted, + .color_text = palette.teal_text, + .margin = dvui.Rect.all(0), + .padding = .{ .x = 8, .y = 0, .w = 8, .h = 0 }, + }); + bw.processEvents(); + bw.drawBackground(); + const clicked = bw.clicked(); + { + var tl = dvui.textLayout(@src(), .{ + .break_lines = false, + }, .{ + .background = false, + .color_text = palette.teal_text, + .gravity_x = 0.0, + .gravity_y = 0.5, + .expand = .horizontal, + .padding = dvui.Rect.all(0), + }); + mixed_text.addTextMixed(tl, label, body, .{ .color_text = palette.teal_text }); + tl.deinit(); + } + bw.drawFocus(); + bw.deinit(); + if (clicked) { + state.transcript_scroll.viewport.y = state.msg_content_y[slot]; + scroll.clampScrollToContent(&state.transcript_scroll); + } +} diff --git a/native/harness/src/ui/chrome.zig b/native/harness/src/ui/chrome.zig new file mode 100644 index 00000000..4e38aa3c --- /dev/null +++ b/native/harness/src/ui/chrome.zig @@ -0,0 +1,102 @@ +//! L0 chrome: shared icon fonts + paintStatusChip + toolRunClipboard. +//! Used by tool-run + thinking Copy + composer icons. +const std = @import("std"); +const dvui = @import("dvui"); +const palette = @import("../palette.zig"); +const metrics = @import("metrics.zig"); +const rich_toolrun = @import("../rich/toolrun.zig"); + +/// L0 chrome mark size — DejaVu symbols at default body size read as dust next to +/// the "N tools called" heading; bump so ✓/✗/… match the count digits. +pub fn chromeMarkFont(base: dvui.Font) dvui.Font { + return base.withSize(base.size + 5).withLineHeight(1.0); +} + +/// L0 count digit size — keep with the mark, slightly larger than default heading. +pub fn chromeCountFont() dvui.Font { + const h = dvui.Font.theme(.heading); + return h.withSize(h.size + 2).withLineHeight(1.0); +} + +/// Clipboard emoji on the copy control — OpenMoji outlines need ~2× body px. +pub fn chromeCopyFont() dvui.Font { + const body = dvui.Font.theme(.body); + return palette.fontEmoji() + .withSize(body.size * 1.9) + .withLineHeight(1.0); +} + +/// Trailing composer icon glyphs (plan #457) — DejaVu Sans Symbols covers both +/// `▶` (launch/send, U+25B6) and `■` (stop, U+25A0); bump size so they read at +/// the fixed TOUCH_H square instead of tofu/dust. +pub fn composerIconFont() dvui.Font { + const body = dvui.Font.theme(.body); + return palette.fontSymbols() + .withSize(body.size + 4) + .withLineHeight(1.0); +} + +pub fn paintStatusChip( + src: std.builtin.SourceLocation, + box_id: usize, + glyph_id: usize, + count_id: usize, + color: dvui.Color, + mark: []const u8, + count: u32, + mark_font: dvui.Font, +) void { + var chip = dvui.box(src, .{ .dir = .horizontal }, .{ + .gravity_y = 0.5, + .id_extra = box_id, + // No extra pad — chips sit in the trailing chrome row with the copy btn. + .margin = .{ .x = 0, .y = 0, .w = 2, .h = 0 }, + }); + defer chip.deinit(); + { + var tl = dvui.textLayout(src, .{}, .{ + .id_extra = glyph_id, + .color_text = color, + .font = chromeMarkFont(mark_font), + .gravity_y = 0.5, + .margin = .{ .x = 0, .y = 0, .w = 3, .h = 0 }, + }); + tl.addText(mark, .{}); + tl.deinit(); + } + { + var tl = dvui.textLayout(src, .{}, .{ + .id_extra = count_id, + .color_text = color, + .font = chromeCountFont(), + .gravity_y = 0.5, + .margin = .{ .x = 0, .y = 0, .w = 4, .h = 0 }, + }); + tl.format("{d}", .{count}, .{}); + tl.deinit(); + } +} + +/// Build a human-readable multi-line summary of a tool-run payload for the Copy +/// button — never the dense `toolrun\t…` wire text. Falls back to the raw body +/// when the payload doesn't decode so we never lose data. +pub fn toolRunClipboard(text: []const u8) []const u8 { + const alloc = dvui.currentWindow().arena(); + var decoded = rich_toolrun.decode(alloc, text) orelse return text; + defer decoded.deinit(); + var out = std.ArrayList(u8).empty; + errdefer out.deinit(alloc); + var line_buf: [512]u8 = undefined; + for (decoded.run.items) |it| { + const g: []const u8 = switch (it.status) { + .ok => "✓", + .fail => "✗", + .running => "…", + }; + const name = if (it.name.len > 0) it.name else "tool"; + const label = if (it.brief.len > 0) it.brief else name; + const line = std.fmt.bufPrint(&line_buf, "{s} {s} — {s}\n", .{ g, name, label }) catch continue; + out.appendSlice(alloc, line) catch break; + } + return out.toOwnedSlice(alloc) catch return text; +} diff --git a/native/harness/src/ui/composer.zig b/native/harness/src/ui/composer.zig new file mode 100644 index 00000000..89d5ab52 --- /dev/null +++ b/native/harness/src/ui/composer.zig @@ -0,0 +1,23 @@ +//! Prompt submit / clear helpers — the composer widgets themselves stay in frame(). +const std = @import("std"); +const bridge = @import("../bridge.zig"); +const composer_text = @import("../composer_text.zig"); +const state = @import("state.zig"); + +pub fn clearPrompt() void { + @memset(&state.prompt_buf, 0); +} + +pub fn submitText(text: []const u8) void { + // Normalize CRLF/lone-CR -> LF and clamp to SUBMIT_CAP at a codepoint + // boundary (composer_text.zig). Blank/whitespace after normalization is + // rejected, preserving the existing empty-send guard. In-place into + // prompt_buf is safe: normalized length never exceeds the input consumed. + const norm = composer_text.normalizeInto(text, state.prompt_buf[0..], bridge.SUBMIT_CAP); + if (norm.is_blank) { + clearPrompt(); + return; + } + bridge.queueSubmitFromUi(norm.text); + clearPrompt(); +} diff --git a/native/harness/src/ui/kinds.zig b/native/harness/src/ui/kinds.zig new file mode 100644 index 00000000..180fb6f3 --- /dev/null +++ b/native/harness/src/ui/kinds.zig @@ -0,0 +1,39 @@ +//! Kind label / color / fill helpers. Pure functions — no frame state, no dvui calls. +const dvui = @import("dvui"); +const palette = @import("../palette.zig"); + +pub fn kindLabel(kind: u8) []const u8 { + return switch (kind) { + 1 => "you", + 2 => "assistant", + 3 => "system", + 4 => "error", + 5 => "thinking", + 6 => "tools", + 7 => "skill", + else => "msg", + }; +} + +pub fn kindTextColor(kind: u8) dvui.Color { + return switch (kind) { + 1 => palette.teal_accent, + 2 => palette.warm_accent, + 3 => palette.teal_muted, + 4 => palette.ember_accent, + // Muted warm — thinking monologue (not EMBER, not pure blue). + 5 => palette.warm_muted, + else => palette.teal_text, + }; +} + +pub fn kindFill(kind: u8) ?dvui.Color { + return switch (kind) { + 1 => palette.teal_bg, + 2 => palette.teal_surface, + 3 => null, + 4 => palette.ember_surface, + 5 => palette.warm_bg, + else => null, + }; +} diff --git a/native/harness/src/ui/metrics.zig b/native/harness/src/ui/metrics.zig new file mode 100644 index 00000000..cc56b610 --- /dev/null +++ b/native/harness/src/ui/metrics.zig @@ -0,0 +1,71 @@ +//! Named chrome constants shared by the facade + ui/* paint files. +//! No product-side imports — pure values only (plan #656). + +/// Touch-friendly control height (CSS px ≈). +pub const TOUCH_H: f32 = 40; +/// Near-bottom epsilon for stick-to-bottom follow (plan #135). +pub const NEAR_BOTTOM_PX: f32 = 48; +/// Ignore subpixel layout noise when detecting in-place stream growth (#251). +pub const CONTENT_GREW_EPS: f32 = 1.0; +/// Reserved bottom chrome: single-row textEntry + trailing TOUCH_H icon + margins +/// (plan #457 — replaces the old textEntry + Send/Stop action row from plan #138). +/// Per-edge margin between textEntry content rect and composer-chrome box edge +/// (Options.padding.y). Plan #579 replaces the old fixed COMPOSER_PAD_Y (4 px +/// top-only) with 2 px per edge on the new dynamic hug box. +pub const COMPOSER_HUG_PAD: f32 = 2; +/// Inset between glyphs and the textEntry's teal border (px). #584 zeroed this +/// so dvui's padding-bake would not double-count in the outer→content convert; +/// glyphs then sat on the stroke. 5 px is the operator ask after #584 shipped. +/// Passed as textEntry Options.padding — TextEntryWidget.init bakes it into +/// min/max_size_content then nulls the option, so we subtract 2× this from +/// those sizes to keep the 44/124 chrome caps. +pub const COMPOSER_TE_PAD: f32 = 5; +/// Multi-line composer visible-height cap (px). Wrapped lines grow the entry up +/// to this, then it scrolls vertically **inside** the entry — never a horizontal +/// gutter past the trailing icon (plan #457, repo no-h-scroll policy). The +/// composer-chrome box caps at COMPOSER_INPUT_MAX_H + 2*COMPOSER_HUG_PAD via +/// max_size_content; taller pasted content scrolls internally (plan #334 / #323). +pub const COMPOSER_INPUT_MAX_H: f32 = 120; +/// Absolute floor so a short canvas still has a scroll band. +pub const SCROLL_FLOOR_H: f32 = 32; +/// Status-bar band height (px) — a two-line always-mounted full-width strip painted +/// directly BELOW the composer (plan #555 → #554, header merged by plan #570). +/// Reserved into the bottom chrome budget so the transcript never overlaps it and +/// the transcript+composer stack never jumps vertically when a sandbox attaches. +/// 64 px = two 32 px rows, exactly fitting the model picker (PICKER_TRIGGER_H = 32) + status slots. +/// Total chrome (64) ≤ old header+bar (92) — net −28 px transcript gain (plan #570). +pub const STATUS_BAR_H: f32 = 64; + +/// Maximum composer-chrome outer height (px). The composer box rect grows up to +/// this from idle hug (~44 px) via previous-frame measured height. Multi-line +/// content past this cap scrolls internally (plan #457). The rect is still +/// absolute (Options.rect), so the scrollArea never publishes virtual content +/// height into the root flex (dvui `.auto` bar overwrites min_size.h — +/// adversarial review #584 Blocker L1). +pub const COMPOSER_MAX_CHROME_H: f32 = COMPOSER_INPUT_MAX_H + 2 * COMPOSER_HUG_PAD; +/// Idle composer-chrome outer height (px) when the field is a single line. +/// TOUCH_H (40) + 2 px top + 2 px bottom padding = 44 px. The composer band +/// is never smaller than this so the textEntry always has a touch target. +pub const COMPOSER_IDLE_CHROME_H: f32 = TOUCH_H + 2 * COMPOSER_HUG_PAD; + +/// TextEntry border overhead (px). The textEntry's computed min_size (via +/// dvui.minSizeGet after deinit) is the OUTER height (content + border). +/// dvui TextEntryWidget.init *bakes* options.padding into min_size_content +/// and nulls options.padding, so minSizeGet adds border only (2) — NOT +/// padding. We convert outer→content: content_h = outer_h − TE_OVERHEAD. +/// Border is set explicitly on the textEntry below. Padding is COMPOSER_TE_PAD +/// (5); we pass min/max_size_content *already minus* 2×TE_PAD so the bake +/// restores the named 40/120 wells and the 44/124 chrome caps (#584 squeeze +/// was subtracting pad *again* from a post-null minSizeGet). +pub const TE_BORDER_H: f32 = 1 + 1; // border.y + border.h +pub const TE_OVERHEAD: f32 = TE_BORDER_H; // 2 + +/// Sticky last-user-message chip (plan #645, source issue #339). +pub const CHIP_VISIBILITY_MARGIN: f32 = 8; + +/// 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; +/// Extra pad subtracted from the pack budget so the reservation never races +/// live-layout rounding (a couple px either way must not push a primary control). +pub const STATUS_PACK_BUDGET_SAFETY: f32 = 4; diff --git a/native/harness/src/ui/scroll.zig b/native/harness/src/ui/scroll.zig new file mode 100644 index 00000000..cd0921b7 --- /dev/null +++ b/native/harness/src/ui/scroll.zig @@ -0,0 +1,17 @@ +//! Pure ScrollInfo helpers — no mutable state, no dvui frame calls. +const dvui = @import("dvui"); +const metrics = @import("metrics.zig"); + +pub fn isNearBottom(si: *const dvui.ScrollInfo) bool { + return si.offsetFromMax(.vertical) <= metrics.NEAR_BOTTOM_PX; +} + +pub fn clampScrollToContent(si: *dvui.ScrollInfo) void { + const max_y = si.scrollMax(.vertical); + if (si.viewport.y > max_y) si.viewport.y = max_y; + if (si.viewport.y < 0) si.viewport.y = 0; +} + +pub fn scrollToBottom(si: *dvui.ScrollInfo) void { + si.viewport.y = si.scrollMax(.vertical); +} diff --git a/native/harness/src/ui/skill.zig b/native/harness/src/ui/skill.zig new file mode 100644 index 00000000..ea18fbd3 --- /dev/null +++ b/native/harness/src/ui/skill.zig @@ -0,0 +1,38 @@ +//! Display-only skill-attach row (protocol v12 / kind 7). +const std = @import("std"); +const dvui = @import("dvui"); +const palette = @import("../palette.zig"); +const mixed_text = @import("../rich/mixed_text.zig"); + +/// Paint a display-only skill-attach row (protocol v12 / kind 7). Headerless and +/// compact: a single muted-TEAL line `Skill attached: `. The text is the +/// host-built NAME line only — a skill body is never shipped to the client or +/// folded into the model prompt, so there is nothing sensitive here. A long/ +/// hostile wire line is hard-capped to ~160 bytes at a UTF-8 boundary with `…`. +pub fn paintSkillAttached( + src: std.builtin.SourceLocation, + msg_index: usize, + text: []const u8, +) void { + var cap_buf: [160]u8 = undefined; + const shown: []const u8 = blk: { + if (text.len <= cap_buf.len) break :blk text; + // Reserve 3 bytes for the UTF-8 ellipsis (U+2026 = \xE2\x80\xA6). + var n: usize = cap_buf.len - 3; + // Back off a multibyte char truncated by the byte cap (never mojibake). + while (n > 0 and (text[n] & 0xC0) == 0x80) n -= 1; + @memcpy(cap_buf[0..n], text[0..n]); + @memcpy(cap_buf[n .. n + 3], "…"); + break :blk cap_buf[0 .. n + 3]; + }; + var tl = dvui.textLayout(src, .{}, .{ + .expand = .horizontal, + .id_extra = msg_index *% 1024 + 1, + .color_text = palette.teal_muted, + .font = .theme(.body), + }); + mixed_text.addTextMixed(tl, shown, .theme(.body), .{ + .color_text = palette.teal_muted, + }); + tl.deinit(); +} diff --git a/native/harness/src/ui/state.zig b/native/harness/src/ui/state.zig new file mode 100644 index 00000000..ea49e893 --- /dev/null +++ b/native/harness/src/ui/state.zig @@ -0,0 +1,97 @@ +//! Shared mutable frame state — single owner for scroll / maps / chip / hug / prompt. +//! All file-level vars that were in ui.zig move here. This module imports NO other +//! ui/* paint file (no cycles). Cleared/reset helpers live here so onInit + frame() +//! hydrate/clear call them from a single owner. +const std = @import("std"); +const dvui = @import("dvui"); +const bridge = @import("../bridge.zig"); +const metrics = @import("metrics.zig"); +const thinking_collapse = @import("../thinking_collapse.zig"); + +pub var prompt_buf: [bridge.SUBMIT_CAP]u8 = [_]u8{0} ** bridge.SUBMIT_CAP; + +/// First frame after init: focus the composer once. +pub var want_composer_focus: bool = true; +/// Shown line count last frame (messages + optional busy row). +pub var last_shown_count: usize = 0; +/// Ring message count last frame (for clear / hydrate / user-send detection). +pub var last_msg_count: usize = 0; +/// Virtual content scrollMax from last frame — stream growth detection (#251). +pub var last_scroll_max_y: f32 = 0; +/// Persistent across frames — frame-local ScrollInfo zeros viewport every paint. +pub var transcript_scroll: dvui.ScrollInfo = .{ + .vertical = .auto, + .horizontal = .none, +}; + +/// Two-level expand state for tool-run rows (#325 / plan #345), keyed by +/// per-message and per-item ids. Keeps open groups across repaints/frames the +/// way `reorder_tree.zig` keeps its open branches; cleared on reload/clear/ +/// truncate so a fresh surface starts collapsed. +pub var toolrun_open_buf: [16384]u8 = undefined; +pub var toolrun_open_fba = std.heap.FixedBufferAllocator.init(&toolrun_open_buf); +pub var toolrun_open_l1 = std.AutoHashMap(dvui.Id, void).init(toolrun_open_fba.allocator()); +pub var toolrun_open_l2 = std.AutoHashMap(dvui.Id, void).init(toolrun_open_fba.allocator()); + +/// #424 — in-memory operator-open set for committed (collapsed) thinking rows, +/// keyed by per-message thinking id. Mirrors `toolrun_open_l1` so a collapsed +/// thinking row the operator expands stays open across frames until toggled or +/// the transcript is cleared. Thinking is ephemeral (never survives refresh), +/// so no persisted state is needed. +pub var thinking_open_buf: [8192]u8 = undefined; +pub var thinking_open_fba = std.heap.FixedBufferAllocator.init(&thinking_open_buf); +pub var thinking_open_l1 = std.AutoHashMap(dvui.Id, void).init(thinking_open_fba.allocator()); + +pub fn clearThinkingOpenState() void { + thinking_open_l1.clearRetainingCapacity(); +} + +/// #424 — the Wasm collapse-policy state: tracks the active Busy turn start so +/// the policy knows which thinking rows are "current turn" (full) vs committed +/// (collapsed). +pub var thinking_collapse_state: thinking_collapse.State = .{}; +/// Previous lifecycle seen by `frame()` — for busy->ready/err edge detection. +pub var prev_lifecycle: thinking_collapse.Lifecycle = .boot; + +pub fn clearToolRunOpenState() void { + toolrun_open_l1.clearRetainingCapacity(); + toolrun_open_l2.clearRetainingCapacity(); +} + +/// Previous-frame measured composer-chrome outer height (px). Initialized to +/// idle so the first frame shows a compact composer. Updated after each frame +/// from the textEntry's natural wrapped height (sampled via dvui.minSizeGet +/// after te.deinit — NOT te.data().min_size which is the options seed); clamped +/// to [IDLE, MAX] so the band never collapses and never exceeds the multi-line +/// cap. The transcript rect is computed from this value, so both bands shift in +/// tandem (one-frame settle lag, no visual jump — adversarial review #584 +/// Round 2 Major L1 and Round 3 Major L1+L9). +pub var composer_last_h: f32 = metrics.COMPOSER_IDLE_CHROME_H; + +/// Per-slot content-local y-offset in scroll content space. Indexed by physical +/// ring slot (0..RING_CAP). Pre-allocated 2048 × 4 = 8 KiB — zero frame-path alloc. +/// Populated during the message paint loop; cleared on reset/clear. +pub var msg_content_y: [2048]f32 = [_]f32{0} ** 2048; +/// Physical ring slot of the most recent user message, or null when no user +/// messages exist in the current ring window. +pub var last_user_slot: ?usize = null; +/// Chip visibility from the PREVIOUS frame — drives scroll-area rect adjustment +/// and chip paint this frame. One-frame settle (same pattern as composer_last_h). +pub var prev_chip_visible: bool = false; + +pub fn resetTranscriptScroll() void { + transcript_scroll = .{ + .vertical = .auto, + .horizontal = .none, + }; + last_shown_count = 0; + last_msg_count = 0; + last_scroll_max_y = 0; + clearToolRunOpenState(); + clearThinkingOpenState(); + thinking_collapse_state.reset(); + prev_lifecycle = .boot; + @memset(&msg_content_y, 0); + last_user_slot = null; + prev_chip_visible = false; +} diff --git a/native/harness/src/ui/status.zig b/native/harness/src/ui/status.zig new file mode 100644 index 00000000..2d3c0cc1 --- /dev/null +++ b/native/harness/src/ui/status.zig @@ -0,0 +1,170 @@ +//! Status-slot pack: paint + truncate helpers (plan #538/#541/#554). +const dvui = @import("dvui"); +const palette = @import("../palette.zig"); +const metrics = @import("metrics.zig"); +const bridge = @import("../bridge.zig"); +const cwd_slot = @import("../cwd_slot.zig"); + +/// UTF-8 code-point byte length for the leading byte at `b` (defensive: bridge +/// slot values are already valid UTF-8, so a lead byte maps to its true length; +/// a stray continuation byte (0x80..0xBF) maps to 1 so we never over-read). +pub fn utf8CharLen(b: u8) usize { + if (b < 0x80) return 1; + if (b < 0xC0) return 1; // continuation byte — treat as a lone 1-byte unit + if (b < 0xE0) return 2; + if (b < 0xF0) return 3; + return 4; +} + +/// Cap a status-slot value to `MAX_STATUS_SLOT_LEN` bytes at a UTF-8 boundary +/// with a trailing ellipsis (never mojibake). The bridge already refuses +/// oversize pushes, but defends against a hostile pre-v14 wire value anyway. +pub fn truncateStatusValue( + buf: *[bridge.MAX_STATUS_SLOT_LEN]u8, + src: []const u8, +) []const u8 { + if (src.len <= buf.len) return src; + // Reserve 3 bytes for the UTF-8 ellipsis (U+2026 = \xE2\x80\xA6). + var n: usize = buf.len - 3; + // Back off a multibyte char truncated by the byte cap (never mojibake). + while (n > 0 and (src[n] & 0xC0) == 0x80) n -= 1; + @memcpy(buf[0..n], src[0..n]); + @memcpy(buf[n .. n + 3], "…"); + return buf[0 .. n + 3]; +} + +/// Paint-time PIXEL ellipsizer (PR #543 re-run L9): shrink `src` to fit `max_w` +/// px as measured by `body`, keeping complete UTF-8 code points and a trailing +/// ellipsis. Only reached when the highest-priority status slot cannot fit at +/// full width on a very narrow canvas — so the operator still sees which sandbox +/// is bound (e.g. `sandbox 446655…`) instead of the whole pack painting nothing. +/// Never mutates the stored bridge value. Returns `src` unchanged when it already +/// fits; otherwise writes the ellipsized prefix into `buf` (caller-owned, >= the +/// slot cap) and returns a slice of it. +pub fn truncateToWidthPx( + body: dvui.Font, + buf: []u8, + src: []const u8, + max_w: f32, +) []const u8 { + const ell = "…"; + if (max_w <= 0 or src.len == 0) return ""; + if (body.textSize(src).w <= max_w) return src; + const ell_w = body.textSize(ell).w; + var i: usize = 0; + var used: f32 = 0; + while (i < src.len) { + const cl = @min(utf8CharLen(src[i]), src.len - i); + // Leave room for the trailing ellipsis in the caller's byte buffer: a + // cap-length slot whose prefix would fill buf entirely must still fit + // the "…" (Nit L1 — a ≥94-byte cwd would otherwise return "" and leave + // an empty gutter slot painted with just the 10px gap). + if (i + cl + ell.len > buf.len) break; + const cp = src[i .. i + cl]; + const w = body.textSize(cp).w; + if (used + w + ell_w > max_w) break; + @memcpy(buf[i .. i + cl], cp); + used += w; + i += cl; + } + if (i == 0 or i + ell.len > buf.len) return ""; + @memcpy(buf[i .. i + ell.len], ell); + return buf[0 .. i + ell.len]; +} + +/// Width (px) available to the status-slot pack this frame. The pack lives on +/// line 2 of the two-line bottom status bar (plan #555 → #554, header merged by +/// plan #570); the budget is the bar's content-rect width minus the rounding- +/// safety pad (`STATUS_PACK_BUDGET_SAFETY`). Line 1 holds identity controls +/// (lifecycle · build id · model picker), so the pack shares the bar but each +/// line has its own fixed 32 px height — neither can displace the other. The pack +/// still DROPS slots per `STATUS_SLOT_DROP_ORDER` then pixel-ellipsizes the +/// survivor to fit, exactly as before (see the narrow-canvas ellipsize decision: +/// even here the operator still sees *which* sandbox is bound, PR #543 re-run L9). +pub fn statusPackMaxWidth() f32 { + const content_w = dvui.parentGet().data().contentRect().w; + return @max(0, content_w - metrics.STATUS_PACK_BUDGET_SAFETY); +} + +/// Paint the right-aligned status-slot pack into line 2 of the two-line bottom +/// status bar (protocol v13, plan #538/#541/#554, header merged by plan #570). +/// Line 2 is a fixed 32 px horizontal row sharing the 64 px bar with the identity +/// row (line 1: lifecycle · build id · model picker); each line has its own +/// explicit height so neither can displace the other. A narrow canvas DROPS slots +/// per `STATUS_SLOT_DROP_ORDER` then pixel-ellipsizes the kept slot to fit. +/// Sandbox + cwd render as muted TEAL one-liners (WARM when busy); an empty slot +/// is hidden (never a blank placeholder / broken layout). When there are NO +/// non-empty slots at all, the caller still mounts the fixed `STATUS_BAR_H` band +/// as a subtle empty strip (locked decision, plan #555) — it never collapses and +/// `chrome_y`/`scroll_h` stay constant, so the transcript+composer stack never +/// jumps. Slot values are already capped at `MAX_STATUS_SLOT_LEN` by the bridge; +/// this defends the paint against a stale/oversize value with a UTF-8-safe ellipsis. +pub fn paintStatusSlots(life: bridge.Lifecycle) void { + const busy = life == .busy; + const budget = statusPackMaxWidth(); + if (budget <= 0) return; + + // Collect non-empty slots in drop-priority order (first = least important, + // i.e. git → context → cwd → sandbox), with their truncated text + width. + const body = (dvui.Options{}).fontGet(); + var slot: [bridge.MAX_STATUS_SLOTS]u32 = undefined; + var buf: [bridge.MAX_STATUS_SLOTS][bridge.MAX_STATUS_SLOT_LEN]u8 = undefined; + var text: [bridge.MAX_STATUS_SLOTS][]const u8 = undefined; + var width: [bridge.MAX_STATUS_SLOTS]f32 = undefined; + var n: usize = 0; + for (bridge.STATUS_SLOT_DROP_ORDER) |s| { + const raw = bridge.statusSlotValue(s); + if (raw.len == 0) continue; + // Cwd "." is the workspace-root default — hide the trivial chip (plan #579). + if (s == bridge.STATUS_SLOT_CWD and !cwd_slot.isVisible(raw)) continue; + slot[n] = s; + text[n] = truncateStatusValue(&buf[n], raw); + width[n] = body.textSize(text[n]).w + metrics.STATUS_SLOT_GAP; + n += 1; + } + if (n == 0) return; + + // Drop lowest-importance slots (front of the drop order) until the pack fits + // the primary-reserved budget. Retained = [keep_from..n). + var total: f32 = 0; + for (width[0..n]) |w| total += w; + var keep_from: usize = 0; + while (keep_from < n and total > budget) : (keep_from += 1) { + total -= width[keep_from]; + } + if (keep_from >= n) { + // Even the most important slot (sandbox — last in the drop order) can't + // fit at full width on a very narrow canvas. Never paint nothing (PR + // #543 re-run L9): pixel-ellipsize that identity slot down to the leftover + // budget so the operator still sees *which* sandbox is bound (e.g. + // `sandbox 446655…`). This is the plan's "slots truncate" half that a + // pure drop-to-empty would forfeit at exactly the viewport the plan locked. + keep_from = n - 1; + const max_text_w = budget - metrics.STATUS_SLOT_GAP; + if (max_text_w <= 0) return; // no room even for the slot's gap — paint nothing + text[keep_from] = truncateToWidthPx(body, buf[keep_from][0..], text[keep_from], max_text_w); + if (text[keep_from].len == 0) return; // no room at all — no empty gutter (Nit L1) + } + + const slot_color: dvui.Color = if (busy) palette.warm_accent else palette.teal_muted; + // Right-aligned pack: gravity_x pulls the whole group to the trailing edge. + var pack = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .gravity_x = 1.0, + .gravity_y = 0.5, + .id_extra = 0x61_0001, + }); + defer pack.deinit(); + + var i = keep_from; + while (i < n) : (i += 1) { + var tl = dvui.textLayout(@src(), .{}, .{ + .background = false, + .id_extra = 0x61_0002 + @as(usize, slot[i]), + .color_text = slot_color, + .gravity_y = 0.5, + .margin = .{ .x = 0, .y = 0, .w = 10, .h = 0 }, + }); + tl.addText(text[i], .{}); + tl.deinit(); + } +} diff --git a/native/harness/src/ui/thinking.zig b/native/harness/src/ui/thinking.zig new file mode 100644 index 00000000..9d4f8991 --- /dev/null +++ b/native/harness/src/ui/thinking.zig @@ -0,0 +1,162 @@ +//! Thinking-row paint: expandable control + full GFM monologue (#424). +const std = @import("std"); +const dvui = @import("dvui"); +const bridge = @import("../bridge.zig"); +const palette = @import("../palette.zig"); +const chip_preview = @import("../chip_preview.zig"); +const rich = @import("../rich/root.zig"); +const state = @import("state.zig"); +const metrics = @import("metrics.zig"); +const chrome = @import("chrome.zig"); + +/// One-line muted preview of a thinking monologue for the collapsed header +/// (text-only, bounded — no markdown parse, no full-body read). Returns a slice +/// into `buf`. Stops at the first newline and caps at ~80 bytes. Review: the +/// byte cap must never cut a multi-byte UTF-8 sequence mid-codepoint (CJK, +/// emoji, combining marks) — that would hand `textLayout` an invalid trailing +/// run, so we back off any truncated multibyte char to a codepoint boundary. +pub fn thinkingPreview(buf: *[96]u8, text: []const u8) []const u8 { + var out: usize = 0; + for (text) |c| { + if (out >= 80) break; + if (c == '\n' or c == '\r') break; + buf[out] = c; + out += 1; + } + // Trailing whitespace isn't part of the preview. + while (out > 0 and (buf[out - 1] == ' ' or buf[out - 1] == '\t')) out -= 1; + // Drop a multi-byte char truncated by the byte cap: first any trailing + // continuation bytes, then a truncated leading byte (0xC0..0xFF). What's + // left ends on a valid single-byte (ASCII) boundary — never mojibake. + while (out > 0 and chip_preview.isUtf8Continuation(buf[out - 1])) out -= 1; + if (out > 0 and (buf[out - 1] & 0xC0) == 0xC0) out -= 1; + return buf[0..out]; +} + +/// Paint a thinking row (protocol v8 kind / bridge kind 5, #424). +/// +/// When the row belongs to the active Busy turn (or the operator has expanded +/// it), render the FULL GFM monologue through the existing slot-keyed markdown +/// painter. Otherwise render a compact default-collapsed control mirroring the +/// tool-run L0 header: a `Thinking` expander + a bounded muted one-line preview +/// + a Copy button (copies the full source). Toggling flips the in-memory open +/// set so a committed row the operator opens stays open until re-toggled or the +/// transcript is cleared. +/// +/// id namespace: `msg_index *% 1000033` (odd prime, distinct from tool-run's +/// `1000003` and the body's `1024`) so thinking chrome never aliases other rows' +/// tool-run/body ids. +pub fn paintThinking( + src: std.builtin.SourceLocation, + msg_index: usize, + text: []const u8, + slot: ?usize, + revision: u32, +) void { + const id_base: usize = @as(usize, msg_index) *% 1000033; + const key: dvui.Id = @enumFromInt(id_base + 7); + + // Active-turn rows are pinned FULL while Busy (policy) — not togglable. + // Committed rows are operator-toggled: `state.thinking_open_l1` is the only input. + // Membership is the policy's job (pure, host-tested) — the live streaming + // newest row is part of the active turn's ring-forward slot range, so it + // stays full automatically (no ui-side `is_live_newest` escape hatch). The + // policy took over the saturated-ring guard the old index-threshold used. + const slotp = slot orelse { + // A thinking row always maps to a ring slot; bail defensively rather + // than derive membership from a bare visible index. + return; + }; + const ring_head = bridge.messageHead(); + const ring_cap = bridge.RING_CAP; + const is_active = state.thinking_collapse_state.isActiveTurnFull(slotp, ring_head, ring_cap); + const open_by_operator = state.thinking_open_l1.contains(key); + // Single policy entry point — both the active-turn pin and the operator + // override live inside `shouldRenderFull`. + const full = state.thinking_collapse_state.shouldRenderFull(slotp, ring_head, ring_cap, open_by_operator); + + // Layout mutates `expanded` across the head + body blocks below. Starts at + // the policy/output state; for a pinned active-turn row we re-assert `full` + // after the expander so a click cannot collapse the live reasoning. + var expanded = full; + { + var head = dvui.box(src, .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .min_size_content = .{ .w = 120, .h = metrics.TOUCH_H - 4 }, + // same warm surface as the kind row uses for thinking (kindFill 5). + .background = true, + .color_fill = palette.warm_bg, + .color_border = palette.teal_border, + .padding = .{ .x = 8, .y = 2, .w = 8, .h = 2 }, + .id_extra = id_base + 1, + }); + defer head.deinit(); + + // Natural label height + gravity_y centers caret with preview/copy trail. + const open = dvui.expander(src, "Thinking", .{ .expanded = &expanded }, .{ + .expand = .horizontal, + .gravity_y = 0.5, + .id_extra = id_base + 2, + .color_text = palette.warm_muted, + .font = .theme(.heading), + }); + if (is_active) { + // Pinned; never let a click collapse the live/active-turn reasoning. + expanded = true; + } else if (open) { + state.thinking_open_l1.put(key, {}) catch {}; + expanded = true; + } else { + _ = state.thinking_open_l1.remove(key); + expanded = false; + } + + var trail = dvui.box(src, .{ .dir = .horizontal }, .{ + .gravity_x = 1.0, + .gravity_y = 0.5, + .min_size_content = .{ .w = 0, .h = metrics.TOUCH_H - 8 }, + .id_extra = id_base + 3, + }); + defer trail.deinit(); + + // Collapsed: bounded muted one-line preview (no markdown parse). + if (!expanded) { + var preview_buf: [96]u8 = undefined; + const preview = thinkingPreview(&preview_buf, text); + var tl = dvui.textLayout(src, .{}, .{ + .id_extra = id_base + 4, + .color_text = palette.teal_muted, + .gravity_y = 0.5, + .font = .theme(.body), + .margin = .{ .x = 0, .y = 0, .w = 6, .h = 0 }, + }); + tl.addText(preview, .{}); + tl.deinit(); + } + // Copy button (full source → clipboard), same chrome as tool-run header. + if (dvui.button(src, "📋", .{}, .{ + .gravity_y = 0.5, + .style = .content, + .id_extra = id_base + 5, + .min_size_content = .{ .w = 22, .h = 22 }, + .padding = .{ .x = 4, .y = 4, .w = 4, .h = 4 }, + .font = chrome.chromeCopyFont(), + .corners = .round(5), + .color_fill = palette.teal_bg, + .color_text = palette.teal_accent, + .color_border = palette.teal_border, + })) { + dvui.clipboardTextSet(text); + } + } + + // Full GFM monologue when expanded (functionally unchanged paint path; the + // slot-keyed parse cache + cache_layout handling is preserved). + if (expanded) { + rich.paintMessageBody(src, rich.KIND_THINKING, text, .{ + .msg_index = msg_index, + .slot = slot, + .revision = revision, + }); + } +} diff --git a/native/harness/src/ui/toolrun.zig b/native/harness/src/ui/toolrun.zig new file mode 100644 index 00000000..d433fb5f --- /dev/null +++ b/native/harness/src/ui/toolrun.zig @@ -0,0 +1,277 @@ +//! Aggregated tool-run control (protocol v10 / kind 6). +//! Level 0: header + count chips. Level 1: one-liners. Level 2: inline detail. +//! Import convention: decode is `../rich/toolrun.zig` (aliased `rich_toolrun`), +//! NOT `toolrun.zig` (which would be this file itself). +const std = @import("std"); +const dvui = @import("dvui"); +const palette = @import("../palette.zig"); +const rich = @import("../rich/root.zig"); +const rich_toolrun = @import("../rich/toolrun.zig"); +const mixed_text = @import("../rich/mixed_text.zig"); +const state = @import("state.zig"); +const chrome = @import("chrome.zig"); +const metrics = @import("metrics.zig"); + +/// True for tool names whose level-2 detail is a command/output block (exec +/// stdout/stderr, filesystem results, http bodies). Those previews paint in the +/// embedded Vera Sans Mono face for readable alignment (phase 3 #353); other +/// tools fall back to the body face. +pub fn isCommandLikeRun(name: []const u8) bool { + return std.mem.eql(u8, name, "exec") or + std.mem.eql(u8, name, "http_get") or + std.mem.eql(u8, name, "http_head") or + std.mem.eql(u8, name, "read_file") or + std.mem.eql(u8, name, "write_file") or + std.mem.eql(u8, name, "str_replace") or + std.mem.eql(u8, name, "list_dir") or + std.mem.eql(u8, name, "change_dir") or + std.mem.eql(u8, name, "pwd"); +} + +/// True when a tool-run level-2 body should paint in the embedded Vera Sans Mono +/// face: command-like builtins (allowlist above) OR any multi-line detail (an +/// MCP/custom tool's dense stdout). Single-line prose detail stays the body face +/// (adversarial review #359 Minor — multi-line output should read as a block). +pub fn detailUsesMono(name: []const u8, detail: []const u8) bool { + if (isCommandLikeRun(name)) return true; + return std.mem.indexOfScalar(u8, detail, '\n') != null; +} + +/// Paint an aggregated tool-run control (protocol v10 / kind 6). +/// +/// Level 0 (default-collapsed): header `N tools called` + colored count chips +/// (TEAL✓ success / EMBER✗ fail-only / WARM… pending) as a touch-height hit +/// target. Level 1: one one-liner per tool (colored status glyph). Level 2: +/// that tool's inline detail. Returns true when the payload decoded and painted; +/// false means fail-open → caller renders the raw body as plain text. +pub fn paintToolRun( + src: std.builtin.SourceLocation, + msg_index: usize, + text: []const u8, + slot: ?usize, + revision: u32, +) bool { + // #404: slot-keyed decode cache keyed on (physical ring slot, write-revision). + // An unchanged revision reuses the previously decoded summary with zero + // re-decode (O(dirty), not O(N) every frame). The cached summary lives in + // the long-lived gpa and is owned by the cache — never deinit'd here. The + // frame loop always passes a slot, so the fresh per-frame arena decode below + // is only a safety net for hypothetical non-ring callers. + var owned: ?rich_toolrun.Decoded = null; + defer if (owned) |*d| d.deinit(); + const arena = dvui.currentWindow().arena(); + const run: ?*const rich_toolrun.ToolRun = if (slot) |s| + rich.toolrunCacheSlot(s, revision, text) + else blk: { + const dec = rich_toolrun.decode(arena, text) orelse break :blk null; + owned = dec; + break :blk &owned.?.run; + }; + if (run == null) return false; + const runv = run.?; + // Decoders recount ok/fail/pending from the kept (capped) items, so the + // header count can never disagree with what actually paints (review). + const total = runv.ok + runv.fail + runv.pending; + + // IMGUI identity: every widget in this control is keyed off `src` (the + // single paintToolRun call site), so two tool-run rows must NOT share + // id_extra — the codebase pattern is `msg_index *% …` (rich/paint.zig, + // message Copy). `id_base` = msg_index times an odd factor; for the ring's + // realistic msg_index (≤ MAX_MSG) products stay < 2^32 with no wrap. + // Items get a 1024-wide namespace each (see item loop); 1000003 > 200·1024, + // so the whole group stays below the next row's id_base and rows never + // overlap, and within a row no two (item, widget) pairs can alias. + const id_base: usize = @as(usize, msg_index) *% 1000003; + + const l1_raw: usize = id_base + 7; + const l1_key: dvui.Id = @enumFromInt(l1_raw); + var l1_expanded = state.toolrun_open_l1.contains(l1_key); + + if (total == 0) return false; + + // ── Level 0 header: expander label + right-aligned colored count chips ── + var header_label: [40]u8 = undefined; + const label = + if (total == 1) + (std.fmt.bufPrint(&header_label, "1 tool called", .{}) catch "tools") + else + (std.fmt.bufPrint(&header_label, "{d} tools called", .{total}) catch "tools"); + + { + // Single horizontal row: expander (fills) + one trailing chrome pack so + // clipboard + status chips share the same vertical center as the label. + var head = dvui.box(src, .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .min_size_content = .{ .w = 120, .h = metrics.TOUCH_H - 4 }, + .id_extra = id_base + 1, + }); + defer head.deinit(); + + // Natural label height + gravity_y centers caret/text with the trail pack. + // Do NOT force TOUCH_H min height on the expander — dvui pins the label to + // the top of a tall expander box (operator: "1 tool called" looked top-aligned). + const open = dvui.expander(src, label, .{ .expanded = &l1_expanded }, .{ + .expand = .horizontal, + .gravity_y = 0.5, + .id_extra = id_base + 2, + }); + if (open) state.toolrun_open_l1.put(l1_key, {}) catch {} else _ = state.toolrun_open_l1.remove(l1_key); + + // Trailing pack (right): 📋 then ✓N / ✗N / …N — one gravity box so + // padding/baseline match (operator: glyphs were tiny + clipboard pad fat + // + row misaligned when chips and button were separate gravity_x children). + { + var trail = dvui.box(src, .{ .dir = .horizontal }, .{ + .gravity_x = 1.0, + .gravity_y = 0.5, + .min_size_content = .{ .w = 0, .h = metrics.TOUCH_H - 8 }, + .id_extra = id_base + 3, + }); + defer trail.deinit(); + + // Compact clipboard — tight pad, larger OpenMoji glyph. + if (dvui.button(src, "📋", .{}, .{ + .gravity_y = 0.5, + .style = .content, + .id_extra = id_base + 29, + .min_size_content = .{ .w = 22, .h = 22 }, + .padding = .{ .x = 4, .y = 4, .w = 4, .h = 4 }, + .font = chrome.chromeCopyFont(), + .corners = .round(5), + .color_fill = palette.teal_bg, + .color_text = palette.teal_accent, + .color_border = palette.teal_border, + .margin = .{ .x = 0, .y = 0, .w = 6, .h = 0 }, + })) { + // Same-frame write only — do not retain ring slices. + dvui.clipboardTextSet(chrome.toolRunClipboard(text)); + } + + // Status marks must not tofu: ✓/✗ come from DejaVu Sans Symbols, + // `…` from the Noto heading face (see paintStatusChip). + if (runv.ok > 0) chrome.paintStatusChip(src, id_base + 20, id_base + 21, id_base + 22, palette.teal_accent, "✓", runv.ok, palette.fontSymbols()); + if (runv.fail > 0) chrome.paintStatusChip(src, id_base + 23, id_base + 24, id_base + 25, palette.ember_accent, "✗", runv.fail, palette.fontSymbols()); + if (runv.pending > 0) chrome.paintStatusChip(src, id_base + 26, id_base + 27, id_base + 28, palette.warm_accent, "…", runv.pending, .theme(.heading)); + } + } + + // ── Level 1 + level 2 (expanded) ──────────────────────────────────────── + if (l1_expanded) { + var list = dvui.box(src, .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .x = 10, .y = 0, .w = 0, .h = 0 }, + .id_extra = id_base + 10, + }); + defer list.deinit(); + + for (runv.items) |it| { + const l2_key: dvui.Id = @enumFromInt(l1_raw *% 31 + it.id); + // `it.id` is 1-based per group with up to MAX_ITEMS items. Each item + // owns a 1024-wide namespace (`it_id *% 1024`) under this message's + // id_base, holding up to 5 widget slots, so every (item, widget) + // pair is unique within the row even for a full 200-item group; + // 1000003 > 200·1024 keeps distinct rows disjoint. Matches the + // rich/paint.zig `msg_index *% …` discipline (id = src + id_extra, + // not a parent chain). + const it_id: usize = it.id; + // Widget slots inside the item's 1024-wide namespace: + // +0 item box · +1 status glyph · +2 expander / static label + // +3 detail box · +4 detail body + const item_base: usize = id_base + it_id *% 1024; + const has_detail = it.detail.len > 0; + var l2_expanded = state.toolrun_open_l2.contains(l2_key); + + { + var item_head = dvui.box(src, .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .min_size_content = .{ .w = 120, .h = metrics.TOUCH_H - 6 }, + .id_extra = item_base + 0, + }); + defer item_head.deinit(); + + const glyph_color: dvui.Color = switch (it.status) { + .ok => palette.teal_accent, + .fail => palette.ember_accent, + .running => palette.warm_accent, + }; + // Status marks must render (no tofu): ✓/✗ come from the embedded + // DejaVu Sans Symbols face; `…` is already in the Noto heading face. + const glyph_font: dvui.Font = switch (it.status) { + .ok, .fail => palette.fontSymbols(), + .running => .theme(.heading), + }; + { + var tl = dvui.textLayout(src, .{}, .{ + .id_extra = item_base + 1, + .color_text = glyph_color, + .gravity_y = 0.5, + .font = chrome.chromeMarkFont(glyph_font), + .margin = .{ .x = 4, .y = 0, .w = 4, .h = 0 }, + }); + tl.addText(switch (it.status) { + .ok => "✓", + .fail => "✗", + .running => "…", + }, .{}); + tl.deinit(); + } + + // No-detail items carry only the host's status-suffixed fallback + // `brief` (`name · ✓/✗/running`); the colored glyph is the single + // status channel, so paint those labels from `name` to avoid a + // redundant second status affordance (parent Goal 3 / issue review). + const item_label: []const u8 = if (has_detail and it.brief.len > 0) it.brief else it.name; + if (has_detail) { + // Same as L0: natural height so label centers with the status glyph. + const open = dvui.expander(src, item_label, .{ .expanded = &l2_expanded }, .{ + .id_extra = item_base + 2, + .expand = .horizontal, + .gravity_y = 0.5, + }); + if (open) state.toolrun_open_l2.put(l2_key, {}) catch {} else _ = state.toolrun_open_l2.remove(l2_key); + } else { + // No level-2 detail (e.g. a short/empty summary) — mount a + // static label, not a blank expander (review nit). The no-detail label + // is painted from `name`, so the colored glyph is the only status channel. + var tl = dvui.textLayout(src, .{}, .{ + .id_extra = item_base + 2, // expander slot — mutually exclusive + .expand = .horizontal, + .color_text = palette.teal_text, + .gravity_y = 0.5, + }); + mixed_text.addTextMixed(tl, item_label, .theme(.body), .{ + .color_text = palette.teal_text, + }); + tl.deinit(); + } + } + + if (has_detail and l2_expanded) { + var detail = dvui.box(src, .{ .dir = .vertical }, .{ + .id_extra = item_base + 3, + .expand = .horizontal, + .margin = .{ .x = 26, .y = 0, .w = 0, .h = 0 }, + }); + defer detail.deinit(); + var tl = dvui.textLayout(src, .{}, .{ + .id_extra = item_base + 4, + .expand = .horizontal, + .color_text = palette.teal_text, + }); + // Phase 3 (#353): command/output previews (exec, filesystem, + // http) paint in the embedded Vera Sans Mono face for a readable + // aligned block; prose/other detail stays the body face. Symbols + // still route to their DejaVu/OpenMoji faces via addTextMixed. + const detail_font: dvui.Font = if (detailUsesMono(it.name, it.detail)) + palette.fontMono() + else + .theme(.body); + mixed_text.addTextMixed(tl, it.detail, detail_font, .{ + .color_text = palette.teal_text, + }); + tl.deinit(); + } + } + } + return true; +}