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 |
#714 — harness: 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_surface ≈ teal_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_last → cache_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:
- Status line: unchanged chrome (
item_base + 4, body face, teal_text). Not a colored band.
- 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.
- 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.
- New box (
item_base + 7): same geometry; fill teal_border, border teal_muted.
- New text (
item_base + 8): same as old; teal_text + mono addText(sides.new).
- Empty
sides.new: still mount the new box (min height holds the TEAL strip).
- 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).
- 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_sides — no 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:
is_active = slot is non-null and thinking_collapse_state.isActiveTurnFull(slot, bridge.messageHead(), bridge.RING_CAP).
has_str_replace = any item with name == "str_replace" (detail optional).
- 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.
- L2 (str_replace with detail): same with
strReplaceL2PinnedOpen. Click cannot collapse while pinned.
- Other items: unchanged (operator map only).
- Busy → ready/err:
onLifecycleTransition already clears active_turn_start_slot → isActiveTurnFull 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
Item.str_replace_sides + decode assignment + decode round-trip test; keep existing splitter tests.
- Pin helpers + unit tests in
rich/toolrun.zig.
paintToolRun L2: fill tokens, .background = false, addText + cache_layout, read it.str_replace_sides.
paintToolRun L0/L2: active-turn pin via thinking_collapse_state.isActiveTurnFull (no HashMap write while pinned).
- Fallback L2
cache_layout for non-str_replace when status != .running.
docs/harness-limits.md (timeless wording above).
- Zig gates in the agent/CI checkout:
zig build test-rich + zig build test-rich-invariants + zig build harness -Doptimize=Debug + zig fmt --check.
- Operator smoke on Preview / local Wasm (checklist below).
- 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 exec → str_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)
Definition of done
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
Review notes (2026-08-19)
Reviewed issue
#715for correctness / performance / architecture / testing / cloud ops / living docs / layers / UI / cap governance.has_detailhidesstr_replaceuntil the result landsstr_replace(detail optional). L2 still requireshas_detail(bands need the payload).toolrun\t1\t…encoded line (unescape path), not a raw detail string.paintToolRunhas nobridgeimport todayconst bridge = @import("../bridge.zig")inui/toolrun.zig(same asthinking.zig) formessageHead()/RING_CAP. Do not add paintToolRun args.textLayout.background = false(rich/paint_text.zig,rich/paint_diff.zig); #692 did not. Lock that, independent of dvui defaults.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
harness: str_replace L2 bands invisible, default-closed, and stall the frame when openedplan/str-replace-l2-fixdocs/harness-limits.mdSummary
#692 (plan #691) split
str_replaceL2 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-openstr_replaceL2 (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
teal_bgstr_replaceL2 (and its L0 group) is open while the turn is in flightstr_replaceL2 closes/harnesscache_layout; split stored on decode; no per-frameaddTextMixedon the bodies; no GPA on the paint pathexec/read_filestill oneteal_textblockdocs/harness-limits.mdmatchesNon-goals / out of scope
appendStrReplaceDiff/buildStrReplacePreview/ 4 KiB-per-side / 20/6 windows+/-prefixesexec/read_file/ other tools' L2thinking_collapse(reuse as turn-membership; do not bikeshed)ember_accentas a band fill (accent stays the ✗ glyph) · GPA / host I/O insideui.frame()·addTextMixedon the side bodies every frameArchitectural decisions
ember_surface/teal_surface≈teal_bg) onlytextLayoutwas not.background = falseand surface tokens are too close toteal_bgrich/paint_text.zig,rich/paint_diff.zig) setstextLayout.background = falseso a parent fill shows through. #692 set fill on the box but left the inner expandingtextLayouton the default (covers the band → ink-only). Surfaces stay near-black even after that fix. Lock both.*_surface(failed) · C)ember_bg/teal_bg(new is invisible) · D) freehand / accentember_borderold fill,teal_bordernew fill; 1 px rimember_muted/teal_mutedui/chip.zig.color_fill = teal_border). Palette-only. Notember_accent. Distinct fromteal_bg#050a0c(ember_border#3a1e18,teal_border#152528).textLayoutbackground.background = falseteal_text· C)ember_accentember_text/teal_textthinking_collapse_state.isActiveTurnFull(slot, head, cap)str_replace(detail optional — so the row is visible while still.running). L2 pins onlystr_replaceitems that have detail (bands need the payload). Other tools stay default-collapsed L2.HashMap.putevery pinned frametoolrun_open_l1/l2while pinned; click cannot collapse an active-turnstr_replace. When the pin lifts, maps are empty → collapsed unless the operator later opens.HashMap.put— don't pay it every frame for the pin. Collapse-on-complete falls out: pin lifts, set empty.it.detailevery paint (today) · C) parse on a new bridge writesplitStrReplaceDetailonce indecode, storeItem.str_replace_sides: ?StrReplaceSides(slices into already-owneddetail)toolrunCacheSlot. Paint becomes a field read. Split stays a pure no-alloc function; existing 8 tests remain.mixed_text.addTextMixedevery frametextLayout.addText+palette.fontMono(),cache_layout = (it.status != .running)cache_layoutis the #404 contract already used on committed markdown rows; tool-run L2 never opted in. Running items may still grow viaupdate_last→cache_layout=falseuntilok/fail.lineHeight()min fill (Goal 5 / #691 Goal 2)Layer placement
Item.str_replace_sidesnative/harness/src/rich/toolrun.zigzig build test-rich; no I/Ocache_layout,.background = false, fill tokensnative/harness/src/ui/toolrun.zigpaintToolRunthinking_collapse.zig+ui/state.zigthinking_collapse_stateslotfromui.zigmessageHead/RING_CAPui/toolrun.zigimports../bridge.zig(same asthinking.zig)lib/agent/tools.ts,lib/agent/agentStream.tsdocs/harness-limits.mdCurrent baseline (live code)
Verified on
main@39a6f1a.ember_surface/teal_surfaceui/toolrun.zigpaintToolRun~258–321.background = trueon the box; innertextLayoutdoes not set.background = false(unlikerich/paint_text.zig/paint_diff.zig); nocache_layout;mixed_text.addTextMixedons.old/s.newevery frame the expander is openpalette.zigteal_bg#050a0c·teal_surface#0a1215·teal_border#152528·ember_surface#1a100c·ember_border#3a1e18·ember_text#f0d0c8·teal_text#c0e0e4. Chip visible-TEAL fill isteal_border(ui/chip.zig~79)ui/toolrun.zig~183, 227–232l2_expanded = state.toolrun_open_l2.contains(l2_key); empty map → closed. Same for L0toolrun_open_l1rich/toolrun.zigsplitStrReplaceDetail; called from paint ~258–259Item. Re-run every frame while openItemhas no sides fieldrich/toolrun.zigItem~26–32id/status/name/brief/detailonlyrich/toolrun_cache.zigparseSlot; paint ~63–64cache_layoutrich/paint_text.zig~216–231thinking_collapse.zigisActiveTurnFull/shouldRenderFull;ui/thinking.zig~70–111paintToolRunalready hasslotui.zig~387bridge.messageSlotAt(i)isActiveTurnFullwithbridge.messageHead()+RING_CAP.ui/toolrun.zigdoes not importbridgetoday — add it (thinking.zig pattern).docs/harness-limits.md· Frame budget;AGENTS.mdWorking rulesui.frame(). Cache miss decode is a documented exception. ExpanderHashMap.putis a documented exceptionrich/toolrun.zig~386–470.github/workflows/build-harness.ymllib/agent/tools.tsSTR_REPLACE_DIFF_SIDE_MAX_BYTES= 4096;agentStream.ts20/6Design
1. Visible bands
In
paintToolRunL2, whenit.str_replace_sidesis set:item_base + 4, body face,teal_text). Not a colored band.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.item_base + 6):textLayout{ .cache_layout = it.status != .running },.background = false,.color_text = palette.ember_text,.font = palette.fontMono(), thenaddText(sides.old)— notaddTextMixed.item_base + 7): same geometry; fillteal_border, borderteal_muted.item_base + 8): same as old;teal_text+ monoaddText(sides.new).sides.new: still mount the new box (min height holds the TEAL strip).teal_textblock, addcache_layout = it.status != .runningon thattextLayouttoo (same stall class if someone opens a largeexecL2; cheap lock, in scope because it is the same widget).+4..+8; sides vs fallback mutually exclusive.Do not paint sentinel lines (already stripped by the splitter).
2. Decode stores sides (O(dirty))
Itemgainsstr_replace_sides: ?StrReplaceSides = null.In
decode, after unescapingname/detail, ifname == "str_replace"setstr_replace_sides = splitStrReplaceDetail(detail)(slices into that item's owneddetail). Other tools staynull.Decoded.deinitunchanged (no extra alloc).Paint reads
it.str_replace_sides— nosplitStrReplaceDetailon the frame path.Existing splitter unit tests stay. Add one decode round-trip that asserts sides on a
str_replaceitem andnullonexec.3. Active-turn pin (open during Busy, close when the turn ends)
Pure helpers in
rich/toolrun.zig(host-testable, no dvui):Paint:
is_active =slotis non-null andthinking_collapse_state.isActiveTurnFull(slot, bridge.messageHead(), bridge.RING_CAP).has_str_replace= any item withname == "str_replace"(detail optional).l1_expandedstarts astoolRunL0PinnedOpen(is_active, has_str_replace, map.contains). After the expander: if the pin holds, re-assertl1_expanded = trueand do notput/removethe L0 map (thinking pattern). Else write the map from the expander as today.strReplaceL2PinnedOpen. Click cannot collapse while pinned.onLifecycleTransitionalready clearsactive_turn_start_slot→isActiveTurnFullfalse → pin lifts → empty maps → closed. No extra collapse loop.Refresh / Clear already
clearRetainingCapacitythe open maps (ui/state.zig). Restored historicalstr_replacestays collapsed (not in an active turn).4. Frame budget
splitStrReplaceDetailtoolrun.decode(already a documented cache-miss exception)addText+ shape of sidescache_layoutmiss) or while.runningtextLayoutcache_layouthit)thinking_collapse_state+ expanderexpandedpointer; no HashMap write while pinnedNo new GPA on the frame path. No
addTextMixedon side bodies.Edge cases
new.len == 0)replace_allERROR str_replace ….runningwith no detailname(Goal 3). L2 pin is false untilhas_detail— bands appear the frame the payload landsupdate_lastmay bump revision (decode cache miss once). This item's sides bytes are stable →cache_layoutstays true becausestatus != .running. L2 stays pinned until the turn endsstr_replaces in one active-turn groupcache_layoutafter first frameexecL2cache_layoutwhen not running.expand = .horizontal; no nested scrollArea (existing)Caps table
STR_REPLACE_DIFF_SIDE_MAX_BYTESlib/agent/tools.tsSTR_REPLACE_SIDE_HEAD_LINES/STR_REPLACE_SIDE_TAIL_LINESlib/agent/agentStream.tsfontMono().lineHeight()ui/toolrun.zigNo new cap. No raise or lower.
Cloud ops path
N/A — no Production mutate. Gates run in the agent workspace / CI (
zig build …,build-harnessGHA). Not a human laptop story.Living docs plan
docs/harness-limits.mdstr_replaceL2 paints old with fill tokenember_border/ new withteal_border(palette step above surface — visible againstteal_bg; not a 1px-outline-only band); inner textLayouts are fill-through (.background = false); inkember_text/teal_text. Open state: a group that containsstr_replacehas L0 pinned open while its Busy turn is active (even while that item is still running / no detail yet);str_replaceL2 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 sidetextLayouts usecache_layout; side bodies are monoaddText, not mixed-face.AGENTS.mdREADME.mdSECURITY.md.env.exampledocs/feature-divide.mdImplementation order
Item.str_replace_sides+ decode assignment + decode round-trip test; keep existing splitter tests.rich/toolrun.zig.paintToolRunL2: fill tokens,.background = false,addText+cache_layout, readit.str_replace_sides.paintToolRunL0/L2: active-turn pin viathinking_collapse_state.isActiveTurnFull(no HashMap write while pinned).cache_layoutfor non-str_replace whenstatus != .running.docs/harness-limits.md(timeless wording above).zig build test-rich+zig build test-rich-invariants+zig build harness -Doptimize=Debug+zig fmt --check.build-harnessCI green.Testing
toolrun\t1\t…str_replaceline (escaped\nin detail) →str_replace_sidesold/new/status match splitterzig build test-richexec→str_replace_sides == nullsplitStrReplaceDetailcases still greenstrReplaceL2PinnedOpen: active+str_replace+detail → true even if operator_open falsestrReplaceL2PinnedOpen: not active, operator_open false → falsestrReplaceL2PinnedOpen: not active, operator_open true → truestrReplaceL2PinnedOpen: active butexec/ no detail → falsetoolRunL0PinnedOpen: active + has_str_replace (name only, no detail) → truetoolRunL0PinnedOpen: active but no str_replace item → false unless operator_opennew.len == 0)zig build harness -Doptimize=Debug+zig build test-rich-invariantszig fmt --checkNo TS / vitest delta (Zig + docs only).
Operator checklist (Preview / harness canvas)
str_replace: L0 is open even while the item is still running. Once detail exists, thestr_replaceL2 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.str_replaceL2 closes by itself. Re-open still shows the bands.new_stringempty) → old band + visible empty TEAL strip.execorread_fileL2 still oneteal_textblock; not auto-opened.str_replaceis collapsed; expanding still splits.Definition of done
ember_border/teal_borderfills with muted rims; innertextLayout.background = falseaddTextwithcache_layoutwhenstatus != .runningItem.str_replace_sidesset at decode; paint does not re-splitstr_replace(name); L2 open once that item has detail (reuseisActiveTurnFull); no HashMap write while pinned; click cannot collapse+4..+8test-rich(cases 1–10)docs/harness-limits.mdupdated (timeless; fills / pin / cache)zig fmt --checkcleanzig build test-richgreenzig build test-rich-invariantsgreenzig build harness -Doptimize=Debuggreenbuild-harnessCI greendocs/harness-limits.mdonly; AGENTS/README/SECURITY/env/feature-divide N/A justifiedRisks & mitigations
textLayoutstill covers the fill after.background = falseember_mutedfill is the escalation — do not invent hex). Lock starts at*_borderbecause chips already prove that fill reads.str_replaces in one group hitching the first framecache_layoutafter first committed paint. Do not pin other tools.thinking_collapse_statelooks thinking-specificcache_layoutasserts if side text is not a stable prefix while.runningstatus != .running. Running stays live-shaped.+4..+8; sides vs fallback mutually exclusive (already #691).build-harnessis the existing Zig path.Open questions
None — in-scope locks are above. Fill tokens, pin policy, cache seam, and
.background = falseare not left to the implementer.References
native/harness/src/ui/thinking.zig+thinking_collapse.zigdocs/harness-limits.md· Frame budget ·#404cache_layout