Skip to content

plan: str_replace L2 — visible EMBER/TEAL bands, open during turn, cached paint #715

Description

@btipling

Review notes (2026-08-19)

Reviewed issue #715 for correctness / performance / architecture / testing / cloud ops / living docs / layers / UI / cap governance.

Issue Severity Resolution
L0 pin keyed on has_detail hides str_replace until the result lands Minor (L1) Locked: L0 pins when the group has any item named str_replace (detail optional). L2 still requires has_detail (bands need the payload).
Decode sides test could call the splitter instead of the wire Minor (L6) Locked: decode round-trip uses a real toolrun\t1\t… encoded line (unescape path), not a raw detail string.
paintToolRun has no bridge import today Minor (L1) Locked: const bridge = @import("../bridge.zig") in ui/toolrun.zig (same as thinking.zig) for messageHead() / RING_CAP. Do not add paintToolRun args.
Baseline claimed unverified "dvui default fill covers the band" Nit (L1) Restated: house markdown/diff paint sets textLayout .background = false (rich/paint_text.zig, rich/paint_diff.zig); #692 did not. Lock that, independent of dvui defaults.
Living docs "EMBER border fill" reads as a 1px outline Nit (L8) Docs say fill token ember_border / teal_border (palette step above surface), not "a border with no fill".

Status: HANDOFF-READY
Reviewed: 2026-08-19 (correctness, performance, architecture, testing, cloud ops N/A, living docs, layers/UI, caps)

Plan header

Field Value
Status HANDOFF-READY
Date 2026-08-19
Type single
Parent N/A
Source issue #714harness: str_replace L2 bands invisible, default-closed, and stall the frame when opened
Branch plan/str-replace-l2-fix
Layers harness (Wasm)
Reusability impact none
Production mutate? no
Cloud ops path N/A — no Production mutate
Living docs docs/harness-limits.md

Summary

#692 (plan #691) split str_replace L2 into two stacked boxes, but the operator still sees an ink-only old side, a collapsed expander, and a stalled canvas if they open it. This plan fixes the three #714 failures in one Wasm paint slice: visible EMBER/TEAL fills, pin-open str_replace L2 (and its L0 group) for the active Busy turn, collapse when the turn ends, and O(dirty) cached paint (no per-frame reshape / mixed-face walk of the bodies). No protocol bump. No emit / preview / cap change. No TS.

Goals

# Goal Success signal
1 Old/new bands read as EMBER and TEAL fills against teal_bg Operator sees two colored slabs, not an ink-only old side
2 str_replace L2 (and its L0 group) is open while the turn is in flight No click required to watch the diff mid-turn
3 When the turn completes, that str_replace L2 closes Matches committed-thinking default-collapsed; operator can re-open
4 Open L2 does not stall /harness Committed sides use cache_layout; split stored on decode; no per-frame addTextMixed on the bodies; no GPA on the paint path
5 Empty new still a visible TEAL strip; other tools unchanged Delete = old band + one-line TEAL fill; exec/read_file still one teal_text block
6 docs/harness-limits.md matches Tool-run L2 / Open state / Frame budget rows describe visible fills, active-turn pin, cached paint

Non-goals / out of scope

  • Emit / appendStrReplaceDiff / buildStrReplacePreview / 4 KiB-per-side / 20/6 windows
  • Per-line +/- prefixes
  • Pin-opening exec / read_file / other tools' L2
  • Renaming thinking_collapse (reuse as turn-membership; do not bikeshed)
  • Dual DOM chat
  • Protocol bump / bridge field
  • New caps or any change to existing caps
  • Forbidden wiring: dual DOM chat · secrets in Wasm · laptop-only ops · freehand hex · ember_accent as a band fill (accent stays the ✗ glyph) · GPA / host I/O inside ui.frame() · addTextMixed on the side bodies every frame

Architectural decisions

Decision Options considered Choice Why
Why bands are invisible A) Tokens too dark (ember_surface/teal_surfaceteal_bg) only B) Both: child textLayout was not .background = false and surface tokens are too close to teal_bg House markdown/diff paint (rich/paint_text.zig, rich/paint_diff.zig) sets textLayout .background = false so a parent fill shows through. #692 set fill on the box but left the inner expanding textLayout on the default (covers the band → ink-only). Surfaces stay near-black even after that fix. Lock both.
Visible fill tokens A) Keep *_surface (failed) · C) ember_bg / teal_bg (new is invisible) · D) freehand / accent B) ember_border old fill, teal_border new fill; 1 px rim ember_muted / teal_muted Same tokens the sticky chip already uses for a visible TEAL fill (ui/chip.zig .color_fill = teal_border). Palette-only. Not ember_accent. Distinct from teal_bg #050a0c (ember_border #3a1e18, teal_border #152528).
Side textLayout background A) leave default B) .background = false Same lock as markdown/diff paint. Lets the parent box fill show through. This is the actual #692 miss.
Side ink A) both teal_text · C) ember_accent B) keep ember_text / teal_text Secondary channel on top of the fill; accent stays the fail glyph.
Active-turn pin A) default-open forever · C) new lifecycle module · D) pin every tool L2 B) reuse thinking_collapse_state.isActiveTurnFull(slot, head, cap) Already wired to idle→busy / busy→ready|err and correct under ring wrap/saturation. Thinking already pins full while Busy and collapses on complete. Do not invent a second turn-membership State. L0 pins when the group has any item named str_replace (detail optional — so the row is visible while still .running). L2 pins only str_replace items that have detail (bands need the payload). Other tools stay default-collapsed L2.
Pin vs operator set A) HashMap.put every pinned frame B) thinking pattern: pin overrides the expander; do not write toolrun_open_l1/l2 while pinned; click cannot collapse an active-turn str_replace. When the pin lifts, maps are empty → collapsed unless the operator later opens. Frame-budget known exception is expander HashMap.put — don't pay it every frame for the pin. Collapse-on-complete falls out: pin lifts, set empty.
Split work A) re-scan it.detail every paint (today) · C) parse on a new bridge write B) run splitStrReplaceDetail once in decode, store Item.str_replace_sides: ?StrReplaceSides (slices into already-owned detail) Decode already runs O(dirty) via toolrunCacheSlot. Paint becomes a field read. Split stays a pure no-alloc function; existing 8 tests remain.
Side text paint A) keep mixed_text.addTextMixed every frame B) textLayout.addText + palette.fontMono(), cache_layout = (it.status != .running) Bodies are source text (ASCII/mono). Mixed-face walk is wasted CPU. cache_layout is the #404 contract already used on committed markdown rows; tool-run L2 never opted in. Running items may still grow via update_lastcache_layout=false until ok/fail.
Empty new band A) skip · C) 2 px pad only B) keep one mono lineHeight() min fill (Goal 5 / #691 Goal 2) Unchanged from #691; still required so delete is visible.

Layer placement

Concern Layer Path(s) Rationale
Split + pin helpers + Item.str_replace_sides harness native/harness/src/rich/toolrun.zig Pure; zig build test-rich; no I/O
Side-band paint, cache_layout, .background = false, fill tokens harness native/harness/src/ui/toolrun.zig paintToolRun In-canvas; palette only
Active-turn membership harness existing thinking_collapse.zig + ui/state.zig thinking_collapse_state Already updated on lifecycle; paintToolRun already receives slot from ui.zig
messageHead / RING_CAP harness ui/toolrun.zig imports ../bridge.zig (same as thinking.zig) Do not add paintToolRun parameters
Emit / L2 preview Vercel backend lib/agent/tools.ts, lib/agent/agentStream.ts No change
Docs docs docs/harness-limits.md Timeless Tool-run / Frame budget

Current baseline (live code)

Verified on main @ 39a6f1a.

Claim Path / symbol Notes
L2 str_replace paints two boxes with ember_surface / teal_surface ui/toolrun.zig paintToolRun ~258–321 .background = true on the box; inner textLayout does not set .background = false (unlike rich/paint_text.zig / paint_diff.zig); no cache_layout; mixed_text.addTextMixed on s.old / s.new every frame the expander is open
Palette palette.zig teal_bg #050a0c · teal_surface #0a1215 · teal_border #152528 · ember_surface #1a100c · ember_border #3a1e18 · ember_text #f0d0c8 · teal_text #c0e0e4. Chip visible-TEAL fill is teal_border (ui/chip.zig ~79)
L2 default collapsed ui/toolrun.zig ~183, 227–232 l2_expanded = state.toolrun_open_l2.contains(l2_key); empty map → closed. Same for L0 toolrun_open_l1
Split is a paint-time scan rich/toolrun.zig splitStrReplaceDetail; called from paint ~258–259 No-alloc slices; not stored on Item. Re-run every frame while open
Item has no sides field rich/toolrun.zig Item ~26–32 id/status/name/brief/detail only
Decode is slot-cached rich/toolrun_cache.zig parseSlot; paint ~63–64 O(dirty) for decode. Does not help L2 text shaping
Body rows use cache_layout rich/paint_text.zig ~216–231 Committed markdown. Tool-run L2 never got it (#714 stall)
Active-turn pin already exists for thinking thinking_collapse.zig isActiveTurnFull / shouldRenderFull; ui/thinking.zig ~70–111 idle→busy records start slot; busy→ready|err clears → collapse. PaintToolRun does not consult it
paintToolRun already has slot ui.zig ~387 bridge.messageSlotAt(i) Enough to call isActiveTurnFull with bridge.messageHead() + RING_CAP. ui/toolrun.zig does not import bridge today — add it (thinking.zig pattern).
Frame budget docs/harness-limits.md · Frame budget; AGENTS.md Working rules No app GPA / host I/O in ui.frame(). Cache miss decode is a documented exception. Expander HashMap.put is a documented exception
Existing splitter tests rich/toolrun.zig ~386–470 8 cases (normal, delete, replace_all, escaped sentinels, ERROR, one sentinel, windowed, empty old)
Existing GHA .github/workflows/build-harness.yml Standard harness CI — no change
Caps lib/agent/tools.ts STR_REPLACE_DIFF_SIDE_MAX_BYTES = 4096; agentStream.ts 20/6 Unchanged

Design

1. Visible bands

In paintToolRun L2, when it.str_replace_sides is set:

  1. Status line: unchanged chrome (item_base + 4, body face, teal_text). Not a colored band.
  2. Old box (item_base + 5): .background = true, .color_fill = palette.ember_border, .color_border = palette.ember_muted, .padding = .all(2), min_size_content.h = fontMono().lineHeight(), .expand = .horizontal.
  3. Old text (item_base + 6): textLayout { .cache_layout = it.status != .running }, .background = false, .color_text = palette.ember_text, .font = palette.fontMono(), then addText(sides.old) — not addTextMixed.
  4. New box (item_base + 7): same geometry; fill teal_border, border teal_muted.
  5. New text (item_base + 8): same as old; teal_text + mono addText(sides.new).
  6. Empty sides.new: still mount the new box (min height holds the TEAL strip).
  7. Fallback (no sides / other tools): existing single teal_text block, add cache_layout = it.status != .running on that textLayout too (same stall class if someone opens a large exec L2; cheap lock, in scope because it is the same widget).
  8. Extras stay +4..+8; sides vs fallback mutually exclusive.

Do not paint sentinel lines (already stripped by the splitter).

2. Decode stores sides (O(dirty))

Item gains str_replace_sides: ?StrReplaceSides = null.

In decode, after unescaping name/detail, if name == "str_replace" set str_replace_sides = splitStrReplaceDetail(detail) (slices into that item's owned detail). Other tools stay null. Decoded.deinit unchanged (no extra alloc).

Paint reads it.str_replace_sidesno splitStrReplaceDetail on the frame path.

Existing splitter unit tests stay. Add one decode round-trip that asserts sides on a str_replace item and null on exec.

3. Active-turn pin (open during Busy, close when the turn ends)

Pure helpers in rich/toolrun.zig (host-testable, no dvui):

pub fn toolRunL0PinnedOpen(is_active_turn: bool, has_str_replace: bool, operator_open: bool) bool {
    return operator_open or (is_active_turn and has_str_replace);
}

pub fn strReplaceL2PinnedOpen(is_active_turn: bool, name: []const u8, has_detail: bool, operator_open: bool) bool {
    if (operator_open) return true;
    return is_active_turn and has_detail and std.mem.eql(u8, name, "str_replace");
}

Paint:

  1. is_active = slot is non-null and thinking_collapse_state.isActiveTurnFull(slot, bridge.messageHead(), bridge.RING_CAP).
  2. has_str_replace = any item with name == "str_replace" (detail optional).
  3. L0: l1_expanded starts as toolRunL0PinnedOpen(is_active, has_str_replace, map.contains). After the expander: if the pin holds, re-assert l1_expanded = true and do not put/remove the L0 map (thinking pattern). Else write the map from the expander as today.
  4. L2 (str_replace with detail): same with strReplaceL2PinnedOpen. Click cannot collapse while pinned.
  5. Other items: unchanged (operator map only).
  6. Busy → ready/err: onLifecycleTransition already clears active_turn_start_slotisActiveTurnFull false → pin lifts → empty maps → closed. No extra collapse loop.

Refresh / Clear already clearRetainingCapacity the open maps (ui/state.zig). Restored historical str_replace stays collapsed (not in an active turn).

4. Frame budget

Work When Where
splitStrReplaceDetail decode / cache miss only toolrun.decode (already a documented cache-miss exception)
addText + shape of sides first paint of a dirty committed item (cache_layout miss) or while .running textLayout
Steady-state open committed L2 zero re-split, zero re-shape (cache_layout hit) paint
Pin reads thinking_collapse_state + expander expanded pointer; no HashMap write while pinned paint

No new GPA on the frame path. No addTextMixed on side bodies.

Edge cases

Case Behavior
Delete (new.len == 0) Old EMBER band + visible empty TEAL strip (min one mono line)
replace_all One pair (splitter already)
ERROR str_replace … Splitter returns null → single-block fallback
str_replace still .running with no detail L0 pinned (name match). L1 is static name (Goal 3). L2 pin is false until has_detail — bands appear the frame the payload lands
str_replace ok mid-turn, more tools follow Group update_last may bump revision (decode cache miss once). This item's sides bytes are stable → cache_layout stays true because status != .running. L2 stays pinned until the turn ends
Two str_replaces in one active-turn group Both L2s pinned. Previews are already 20/6/side; committed cache_layout after first frame
Operator opens L2 after the turn Map put; stays open until they close or Clear
Operator tries to collapse during Busy Pin re-asserts; cannot close until the turn ends
exec L2 Unchanged fallback path + cache_layout when not running
~390 px Bands .expand = .horizontal; no nested scrollArea (existing)
Refresh Historical rows collapsed (open maps cleared; not active-turn)

Caps table

Cap / ceiling Value Rationale Code location
STR_REPLACE_DIFF_SIDE_MAX_BYTES 4096 UTF-8 B/side Existing, unchanged. Paint still consumes the already-capped preview. lib/agent/tools.ts
STR_REPLACE_SIDE_HEAD_LINES / STR_REPLACE_SIDE_TAIL_LINES 20 / 6 Existing, unchanged. lib/agent/agentStream.ts
Empty-band min height one fontMono().lineHeight() Existing #691 lock, unchanged. Not a named ceiling. ui/toolrun.zig

No new cap. No raise or lower.

Cloud ops path

N/A — no Production mutate. Gates run in the agent workspace / CI (zig build …, build-harness GHA). Not a human laptop story.

Living docs plan

Surface Change Notes
docs/harness-limits.md Tool-run Two-level expand: successful str_replace L2 paints old with fill token ember_border / new with teal_border (palette step above surface — visible against teal_bg; not a 1px-outline-only band); inner textLayouts are fill-through (.background = false); ink ember_text / teal_text. Open state: a group that contains str_replace has L0 pinned open while its Busy turn is active (even while that item is still running / no detail yet); str_replace L2 pins once detail exists; both collapse when the turn completes (operator may re-open). Other tools stay default-collapsed L2. Frame budget / Painter: split runs at decode (slot cache); committed side textLayouts use cache_layout; side bodies are mono addText, not mixed-face. Timeless; no phase/issue numbers in the product sentence
AGENTS.md N/A Frame budget already stated; no ownership/infra change
README.md N/A Visitor-facing entry unchanged
SECURITY.md N/A No secrets, no trust boundary change
.env.example N/A No new env
docs/feature-divide.md N/A Paint stays Wasm; no DOM chat

Implementation order

  1. Item.str_replace_sides + decode assignment + decode round-trip test; keep existing splitter tests.
  2. Pin helpers + unit tests in rich/toolrun.zig.
  3. paintToolRun L2: fill tokens, .background = false, addText + cache_layout, read it.str_replace_sides.
  4. paintToolRun L0/L2: active-turn pin via thinking_collapse_state.isActiveTurnFull (no HashMap write while pinned).
  5. Fallback L2 cache_layout for non-str_replace when status != .running.
  6. docs/harness-limits.md (timeless wording above).
  7. Zig gates in the agent/CI checkout: zig build test-rich + zig build test-rich-invariants + zig build harness -Doptimize=Debug + zig fmt --check.
  8. Operator smoke on Preview / local Wasm (checklist below).
  9. Wait for build-harness CI green.

Testing

# Case Layer Type Command / method
1 Decode a real encoded toolrun\t1\t… str_replace line (escaped \n in detail) → str_replace_sides old/new/status match splitter harness unit zig build test-rich
2 Decode execstr_replace_sides == null harness unit same
3 Existing 8 splitStrReplaceDetail cases still green harness unit same
4 strReplaceL2PinnedOpen: active+str_replace+detail → true even if operator_open false harness unit same
5 strReplaceL2PinnedOpen: not active, operator_open false → false harness unit same
6 strReplaceL2PinnedOpen: not active, operator_open true → true harness unit same
7 strReplaceL2PinnedOpen: active but exec / no detail → false harness unit same
8 toolRunL0PinnedOpen: active + has_str_replace (name only, no detail) → true harness unit same
9 toolRunL0PinnedOpen: active but no str_replace item → false unless operator_open harness unit same
10 Delete empty-new still splits (new.len == 0) harness unit existing splitter test
11 Other tools' L2 still one block (exec / read_file) harness operator checklist
12 Zig compile + invariants harness build zig build harness -Doptimize=Debug + zig build test-rich-invariants
13 zig fmt --check harness style named files
14 build-harness CI harness CI Green on self-hosted runner

No TS / vitest delta (Zig + docs only).

Operator checklist (Preview / harness canvas)

  • During an in-flight turn that runs str_replace: L0 is open even while the item is still running. Once detail exists, the str_replace L2 is open, two visibly colored bands (EMBER old, TEAL new) — not ink-only, not near-black-on-near-black. Status line above. No raw -old_string / +new_string.
  • Canvas stays interactive while that L2 is open (scroll, type, no hitch that "destroys" the frame).
  • When the turn completes: str_replace L2 closes by itself. Re-open still shows the bands.
  • Clicking the expander during Busy does not keep it closed (pin).
  • Delete (new_string empty) → old band + visible empty TEAL strip.
  • exec or read_file L2 still one teal_text block; not auto-opened.
  • ~390 px: bands readable; no horizontal gutter.
  • Refresh: restored str_replace is collapsed; expanding still splits.

Definition of done

  • Bands use ember_border / teal_border fills with muted rims; inner textLayout .background = false
  • Side bodies are mono addText with cache_layout when status != .running
  • Item.str_replace_sides set at decode; paint does not re-split
  • Active-turn pin: L0 open while Busy if the group has a str_replace (name); L2 open once that item has detail (reuse isActiveTurnFull); no HashMap write while pinned; click cannot collapse
  • Turn complete → L2 (and unpinned L0) closed
  • Empty new = visible TEAL strip; other tools unchanged; extras +4..+8
  • Pin helpers + decode sides tests in test-rich (cases 1–10)
  • docs/harness-limits.md updated (timeless; fills / pin / cache)
  • Operator checklist above
  • zig fmt --check clean
  • zig build test-rich green
  • zig build test-rich-invariants green
  • zig build harness -Doptimize=Debug green
  • build-harness CI green
  • No TS changes → no vitest gate needed
  • Cloud ops: N/A (no Production mutate)
  • Living docs: docs/harness-limits.md only; AGENTS/README/SECURITY/env/feature-divide N/A justified

Risks & mitigations

Risk Mitigation
textLayout still covers the fill after .background = false Operator checklist is the gate; if still invisible, next token is still palette-only (ember_muted fill is the escalation — do not invent hex). Lock starts at *_border because chips already prove that fill reads.
Pin-open of several str_replaces in one group hitching the first frame Previews are 20/6/side and already byte-capped. cache_layout after first committed paint. Do not pin other tools.
Reusing thinking_collapse_state looks thinking-specific The module is turn-membership (physical slot range). Comment at the call site. Do not rename in this slice.
cache_layout asserts if side text is not a stable prefix while .running Only enable when status != .running. Running stays live-shaped.
IMGUI id collision Keep locked extras +4..+8; sides vs fallback mutually exclusive (already #691).
Laptop-only cutover / missing GHA N/A — no Production mutate; build-harness is the existing Zig path.

Open questions

None — in-scope locks are above. Fill tokens, pin policy, cache seam, and .background = false are not left to the implementer.

References

  • Source: #714
  • Failed ship: #692 / plan #691
  • Product ask: #687 · parent #368
  • Analog: native/harness/src/ui/thinking.zig + thinking_collapse.zig
  • Frame budget: docs/harness-limits.md · Frame budget · #404 cache_layout

Metadata

Metadata

Assignees

No one assigned

    Labels

    harnessHarness / agent UIui

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions