You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Source:#718 — harness: tofu (.notdef) in str_replace L2 sides and composer textEntry Refs:#358 (tool-run status marks prior tofu work), #594 (fence/inline mixed prior tofu work) Status: IMPLEMENTED
Review notes (2025 — plan-review of the DRAFT)
Reviewed by plan-review on the DRAFT; verdict HANDOFF-READY with one Major + Minors now fixed in-body:
Major (Living docs): the plan missed updating docs/harness-limits.md:171 — the Tool-run Painter row currently states "side bodies are mono addText (not mixed-face)". That exact sentence is the behavior Phase 1 changes, so it must be edited alongside lines 225/235 or the doc self-contradicts the Missing-glyphs row after this PR lands. Added as Living docs item.
Minor (Living docs, line 225 rewrite): the proposed replacement for the Missing glyphs row dropped the existing "That is not mojibake; Copy still yields UTF-8 source" sentence — an existing user-facing promise. Preserved it and merged the "now covered" note instead of replacing the row wholesale.
Minor (Tests): test 1.1 Create GitHub repo invincible #3 (thinking preview uses addTextMixed) is asserted via "same seam pattern", but the seam section only declared StrReplaceTextPainter for toolrun.zig. Locked a matching ThinkingPreviewTextPainter constant in thinking.zig so the assertion actually has something to pin.
Minor (Tests):toolrun.test.zig is a new host test file (does not exist on main; verified native/harness/src). Noted it must be wired into build.zigtest-rich like paint_diff.test.zig, not just created.
Nit (toolrun.zig:321):const mono = palette.fontMono(); is declared once (line 321) inside the if (sides) |s| block and is in scope for both the old and new bands — there is no per-block re-assignment. The plan's "verify precise local var name per block" caveat is unnecessary; confirmed single mono binding.
Baseline line numbers, color passthrough, and seam pattern all verified against live code on main (diff-hunks below, unchanged by review).
Summary
Two tofu surfaces fixed (addText → addTextMixed); the textEntry gap is documented honestly (requires dvui enhancement, not in this plan).
Current baseline (verified on main)
Surface
What it does
File:Line
str_replace L2 old band
otl.addText(s.old, .{}) on palette.fontMono() (Vera Sans Mono)
toolrun.zig:344
str_replace L2 new band
ntl.addText(s.new, .{}) on palette.fontMono() (Vera Sans Mono)
pub const diffTextPainter: DiffTextPainter = .mixed — test seam ✅
paint_diff.zig:9
Root cause: dvui has no automatic per-glyph fallback. addText paints every codepoint on a single font face. addTextMixed (from mixed_text.zig) splits text runs at codepoint boundaries by face (Noto → body, DejaVu → symbols, OpenMoji → emoji). str_replace L2 sides and thinking preview use bare addText instead of addTextMixed.
TextEntry limitation: dvui's TextEntry widget uses a single Font for its internal TextLayout. There is no per-glyph face-switching hook in the dvui TextEntry API. Fixing composer/queue-row tofu requires a dvui enhancement — not in scope for this plan.
Color passthrough (verified): dvui TextLayoutWidget.addTextEx merges per-run options over the widget's own options (self.data().options.override(opts)). addTextMixed's paintSlice only sets .font when PaintOpts.color_text is null, so calling it with {} opts preserves the textLayout's existing .color_text (ember_text / teal_text / teal_muted) — no color override is needed, matching the Skill row / detail paths. Emoji still ink via palette.emoji_ink (teal) regardless, consistent with existing diff/transcript paint.
Design
Phase 1: str_replace L2 old/new bands
File:native/harness/src/ui/toolrun.zig
The mixed_text import is already present (used for it.detail at line 385). The fix is a drop-in call swap:
// Before (line 344):otl.addText(s.old, .{});
// After:mixed_text.addTextMixed(otl, s.old, mono, .{});
// Before (line 368):ntl.addText(s.new, .{});
// After:mixed_text.addTextMixed(ntl, s.new, mono, .{});
mono (const mono = palette.fontMono();) is declared once at toolrun.zig:321 inside the if (sides) |s| block and is in scope for both bands (used by mono.lineHeight() and both textLayouts) — no per-block re-assignment. The PaintOpts default {} passes through the textLayout's .color_text (already palette.ember_text / palette.teal_text) per the verified color passthrough above.
Phase 2: Thinking collapsed preview
File:native/harness/src/ui/thinking.zig
// Before (line 133):tl.addText(preview, .{});
// After:mixed_text.addTextMixed(tl, preview, .theme(.body), .{});
The mixed_text import needs to be added to thinking.zig (currently not imported; confirmed its import list at thinking.zig:1-10).
Phase 3: Composer + queue-row textEntry — document only
No code change. dvui's TextEntry uses a single font for all glyphs. The gap is already partially documented in docs/harness-limits.md:235:
Composer | Canvas textEntry uses theme body (Noto Sans); emoji/symbol while typing follows the same face rules when painted in the transcript after send
This plan updates the row to be explicit about tofu while typing:
Composer + queue-row textEntry use a single font face (Noto Sans) because dvui's TextEntry widget has no per-glyph face-switching hook. Symbols and emoji that Noto Sans does not cover will show as missing-glyph placeholders while typing. After send, user bubbles and transcript text use addTextMixed and paint correctly. Fixing textEntry requires a dvui enhancement.
Audit results (no changes needed)
Surface
File:Line
Verdict
Empty-state placeholder text
ui.zig:275-288
Static English strings — no emoji/symbols
Expander titles
toolrun.zig:138,267
Tool names like "str_replace" — never contain emoji/symbols
Status marks (✓/✗/…)
toolrun.zig:252
Already use pinned symbol/heading faces (documented in harness-limits.md:168)
paintDiffText
paint_diff.zig:100
Already uses addTextMixed ✅
str_replace detail
toolrun.zig:385
Already uses addTextMixed ✅
Tests
Zig test matrix — new filenative/harness/src/toolrun.test.zig
This is a new host test file (no toolrun.test.zig exists on main). It must be wired into build.zigtest-rich as its own addTest step (mirroring the paint_diff.test.zig registration around build.zig:690-700, including the dvui_testing + zmd imports) — not merely created on disk.
#
Test
What it verifies
1
str_replace old band uses addTextMixed
Same seam pattern as diffTextPainter constant in paint_diff.zig — a revert must flip a test constant
2
str_replace new band uses addTextMixed
Same
3
thinking preview uses addTextMixed
Same seam pattern, pinned via ThinkingPreviewTextPainter below
Test seams (matches paint_diff.zig:8-9 pattern)
Following the existing pattern in paint_diff.zig:
// In toolrun.zig, export a test-only constant:pubconstStrReplaceTextPainter=enum { mixed, plain };
pubconststrReplaceTextPainter: StrReplaceTextPainter=.mixed;
// In thinking.zig, export a matching test-only constant so test #3 has a pin:pubconstThinkingPreviewTextPainter=enum { mixed, plain };
pubconstthinkingPreviewTextPainter: ThinkingPreviewTextPainter=.mixed;
Each test asserts .mixed; a revert to addText must flip the corresponding constant and the test fails.
Living docs
docs/harness-limits.md
Line 171 (Tool-run Painter row): this row currently reads "…side bodies are mono addText (not mixed-face)" — the exact behavior Phase 1 changes. Update it to reflect the switch:
str_replace sides are split at decode (slot-cached, O(dirty)); committed side textLayouts use cache_layout; side bodies are mono addTextMixed (face-aware — DejaVu symbols + OpenMoji emoji, body face otherwise). L1 expander is extra +2; no-detail static label is +9 (never the same persist slot) |
(Keep the rest of the Painter row intact.)
Line 225 (Missing glyphs row): extend the row rather than replace it — preserve the existing "That is not mojibake; Copy still yields UTF-8 source when the browser allows clipboard write" promise:
Scripts outside these faces (notably full CJK) may still show a missing-glyph placeholder. That is not mojibake; Copy still yields UTF-8 source when the browser allows clipboard write. Note: str_replace L2 old/new bands and thinking collapsed previews do use addTextMixed, so symbols/emoji there paint from DejaVu + OpenMoji (not tofu); composer + queue-row textEntry tofu is a known dvui limitation (see Composer row).
Line 235 (Composer row): update to be explicit about the gap:
Composer + queue-row textEntry use Noto Sans only — dvui's TextEntry widget has no per-glyph face-switching hook. Symbols/emoji that Noto does not cover tofu while typing/editing. After send, transcript text uses addTextMixed and paints correctly. Fixing this requires a dvui enhancement; no DOM input workaround is planned.
Caps
N/A — no new caps, no existing caps changed. This is a paint-face change only; no schema, no protocol, no config caps.
Layer checklist
Layer
Change
Rationale
Wasm (dvui)
2 call swaps in toolrun.zig, 1 in thinking.zig, test constants (both files)
The only paint surfaces that need fixing
Vercel backend
None
No agent-tool, API, or DB change
Sandbox daemon
None
No daemon change
Client/Wasm bridge
None
No protocol change
DOM host
None
No DOM involvement
Docs
docs/harness-limits.md
Three row updates (171, 225, 235)
Cloud ops: N/A — Wasm-only, no Production mutate. Build:build-harness required (Zig changes). Phase issue rule: Single PR — all changes are same-layer (Wasm-only), no blocking dependency, ships together.
Non-goals
Full CJK face pack (out of scope)
Color emoji (monochrome OpenMoji teal is intentional)
Dual DOM composer / dual chat
Changing Copy / wire UTF-8 (source is already intact)
dvui TextEntry per-glyph enhancement (separate dvui issue, tracked in docs)
Plan: harness tofu fix — str_replace L2 sides + thinking preview
Source: #718 — harness: tofu (.notdef) in str_replace L2 sides and composer textEntry
Refs: #358 (tool-run status marks prior tofu work), #594 (fence/inline mixed prior tofu work)
Status: IMPLEMENTED
Review notes (2025 — plan-review of the DRAFT)
Reviewed by plan-review on the DRAFT; verdict HANDOFF-READY with one Major + Minors now fixed in-body:
docs/harness-limits.md:171— the Tool-run Painter row currently states "side bodies are monoaddText(not mixed-face)". That exact sentence is the behavior Phase 1 changes, so it must be edited alongside lines 225/235 or the doc self-contradicts the Missing-glyphs row after this PR lands. Added as Living docs item.StrReplaceTextPainterfortoolrun.zig. Locked a matchingThinkingPreviewTextPainterconstant inthinking.zigso the assertion actually has something to pin.toolrun.test.zigis a new host test file (does not exist onmain; verifiednative/harness/src). Noted it must be wired intobuild.zigtest-richlikepaint_diff.test.zig, not just created.const mono = palette.fontMono();is declared once (line 321) inside theif (sides) |s|block and is in scope for both the old and new bands — there is no per-block re-assignment. The plan's "verify precise local var name per block" caveat is unnecessary; confirmed singlemonobinding.Baseline line numbers, color passthrough, and seam pattern all verified against live code on
main(diff-hunks below, unchanged by review).Summary
Two tofu surfaces fixed (
addText→addTextMixed); the textEntry gap is documented honestly (requires dvui enhancement, not in this plan).Current baseline (verified on
main)otl.addText(s.old, .{})onpalette.fontMono()(Vera Sans Mono)toolrun.zig:344ntl.addText(s.new, .{})onpalette.fontMono()(Vera Sans Mono)toolrun.zig:368mixed_text.addTextMixed(tl, it.detail, detail_font, ...)— already mixed ✅toolrun.zig:385tl.addText(preview, .{})on.font = .theme(.body)thinking.zig:133dvui.textEntry(...)— no font override, dvui uses theme body (Noto) internallyui.zig:681dvui.textEntry(...)— same single-face limitationqueue_band.zig:179tl.addText(...)static English stringsui.zig:275-288dvui.expander(src, label, ...)— tool names like "str_replace"toolrun.zig:138,267paintDiffTextmixed_text.addTextMixed(tl, line, .theme(.mono), ...)— already mixed ✅paint_diff.zig:100diffTextPainterconstantpub const diffTextPainter: DiffTextPainter = .mixed— test seam ✅paint_diff.zig:9Root cause: dvui has no automatic per-glyph fallback.
addTextpaints every codepoint on a single font face.addTextMixed(frommixed_text.zig) splits text runs at codepoint boundaries by face (Noto → body, DejaVu → symbols, OpenMoji → emoji). str_replace L2 sides and thinking preview use bareaddTextinstead ofaddTextMixed.TextEntry limitation: dvui's
TextEntrywidget uses a singleFontfor its internalTextLayout. There is no per-glyph face-switching hook in the dvui TextEntry API. Fixing composer/queue-row tofu requires a dvui enhancement — not in scope for this plan.Color passthrough (verified): dvui
TextLayoutWidget.addTextExmerges per-run options over the widget's own options (self.data().options.override(opts)).addTextMixed'spaintSliceonly sets.fontwhenPaintOpts.color_textis null, so calling it with{}opts preserves the textLayout's existing.color_text(ember_text / teal_text / teal_muted) — no color override is needed, matching the Skill row / detail paths. Emoji still ink viapalette.emoji_ink(teal) regardless, consistent with existing diff/transcript paint.Design
Phase 1: str_replace L2 old/new bands
File:
native/harness/src/ui/toolrun.zigThe
mixed_textimport is already present (used forit.detailat line 385). The fix is a drop-in call swap:mono(const mono = palette.fontMono();) is declared once attoolrun.zig:321inside theif (sides) |s|block and is in scope for both bands (used bymono.lineHeight()and both textLayouts) — no per-block re-assignment. ThePaintOptsdefault{}passes through the textLayout's.color_text(alreadypalette.ember_text/palette.teal_text) per the verified color passthrough above.Phase 2: Thinking collapsed preview
File:
native/harness/src/ui/thinking.zigThe
mixed_textimport needs to be added tothinking.zig(currently not imported; confirmed its import list atthinking.zig:1-10).Phase 3: Composer + queue-row textEntry — document only
No code change. dvui's
TextEntryuses a single font for all glyphs. The gap is already partially documented indocs/harness-limits.md:235:This plan updates the row to be explicit about tofu while typing:
Audit results (no changes needed)
ui.zig:275-288toolrun.zig:138,267toolrun.zig:252harness-limits.md:168)paintDiffTextpaint_diff.zig:100addTextMixed✅toolrun.zig:385addTextMixed✅Tests
Zig test matrix — new file
native/harness/src/toolrun.test.zigdiffTextPainterconstant inpaint_diff.zig— a revert must flip a test constantThinkingPreviewTextPainterbelowTest seams (matches paint_diff.zig:8-9 pattern)
Following the existing pattern in
paint_diff.zig:Each test asserts
.mixed; a revert toaddTextmust flip the corresponding constant and the test fails.Living docs
docs/harness-limits.mdLine 171 (Tool-run Painter row): this row currently reads "…side bodies are mono
addText(not mixed-face)" — the exact behavior Phase 1 changes. Update it to reflect the switch:(Keep the rest of the Painter row intact.)
Line 225 (Missing glyphs row): extend the row rather than replace it — preserve the existing "That is not mojibake; Copy still yields UTF-8 source when the browser allows clipboard write" promise:
Line 235 (Composer row): update to be explicit about the gap:
Caps
N/A — no new caps, no existing caps changed. This is a paint-face change only; no schema, no protocol, no config caps.
Layer checklist
toolrun.zig, 1 inthinking.zig, test constants (both files)docs/harness-limits.mdCloud ops: N/A — Wasm-only, no Production mutate.
Build:
build-harnessrequired (Zig changes).Phase issue rule: Single PR — all changes are same-layer (Wasm-only), no blocking dependency, ships together.
Non-goals