Skip to content

Commit 8f77ae7

Browse files
committed
Fix scrollback duplication: count tool-card body rows by source lines, not bytes/width
1 parent 5e915f5 commit 8f77ae7

2 files changed

Lines changed: 58 additions & 99 deletions

File tree

src/runtime/app/update/frozen.cpp

Lines changed: 58 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -118,41 +118,10 @@ maya::Element compaction_divider_row() {
118118
}
119119

120120
// Cheap byte-based row estimate for a single message's contribution
121-
// to a frozen Turn. NOT a render — a coarse proxy (avg ~60 cols/row)
122-
// used only to BOUND the frozen canvas height, where over/under by a
123-
// few rows is harmless. Shared by rehydrate_frozen (budget walk) and
124-
// freeze_range (per-entry frozen_rows accounting).
125-
// Cheap, non-allocating estimate of a JSON value's rendered byte
126-
// footprint. Walks the node tree summing string lengths + a small
127-
// constant per scalar/structural node. Used ONLY to bound the frozen
128-
// canvas height, so precision doesn't matter — but it must be cheap:
129-
// the previous version called j.dump(), which allocated a full
130-
// serialized copy of the args for EVERY tool call on EVERY freeze. On
131-
// a thread with large write/edit args that was megabytes of JSON
132-
// serialization per resume (rehydrate_frozen freezes the whole tail)
133-
// and per user turn (freeze_through) — the dominant cost of opening an
134-
// old long thread. This walk allocates nothing.
135-
std::size_t estimate_json_bytes(const nlohmann::json& j) {
136-
switch (j.type()) {
137-
case nlohmann::json::value_t::string:
138-
return j.get_ref<const std::string&>().size();
139-
case nlohmann::json::value_t::array: {
140-
std::size_t n = 2; // brackets
141-
for (const auto& e : j) n += estimate_json_bytes(e) + 1;
142-
return n;
143-
}
144-
case nlohmann::json::value_t::object: {
145-
std::size_t n = 2; // braces
146-
for (const auto& [k, v] : j.items())
147-
n += k.size() + 2 + estimate_json_bytes(v) + 1;
148-
return n;
149-
}
150-
case nlohmann::json::value_t::null:
151-
return 0;
152-
default:
153-
return 8; // number / bool: a few bytes
154-
}
155-
}
121+
// to a frozen Turn. NOT a render — a coarse proxy used only to BOUND
122+
// the frozen canvas height, where over/under by a few rows is harmless.
123+
// Shared by rehydrate_frozen (budget walk) and freeze_range (per-entry
124+
// frozen_rows accounting).
156125

157126
// Wrapped-row count for a text body at the given content width: each
158127
// hard newline starts a fresh row, and a line longer than `cols`
@@ -178,32 +147,69 @@ std::size_t wrapped_rows(std::string_view body, int cols) {
178147
return rows == 0 ? 1 : rows;
179148
}
180149

150+
// Wrapped-row count contributed by every STRING value in a JSON args
151+
// tree. A tool card renders its big payload (write `content`, edit
152+
// `edits[].new_text`/`old_text`, etc.) one row per source line, so the
153+
// real rendered height tracks newlines in those strings — NOT the JSON
154+
// byte length. Summing per-string wrapped_rows captures multi-line
155+
// payloads accurately and biases toward OVER-counting (the safe
156+
// direction for the trim's "provably above the viewport" proof).
157+
// Non-string scalars contribute nothing (they render inline in the
158+
// header, not the body). Cheap: no allocation, no dump().
159+
std::size_t estimate_json_string_rows(const nlohmann::json& j, int cols) {
160+
switch (j.type()) {
161+
case nlohmann::json::value_t::string:
162+
return wrapped_rows(j.get_ref<const std::string&>(), cols);
163+
case nlohmann::json::value_t::array: {
164+
std::size_t n = 0;
165+
for (const auto& e : j) n += estimate_json_string_rows(e, cols);
166+
return n;
167+
}
168+
case nlohmann::json::value_t::object: {
169+
std::size_t n = 0;
170+
for (const auto& [k, v] : j.items())
171+
n += estimate_json_string_rows(v, cols);
172+
return n;
173+
}
174+
default:
175+
return 0;
176+
}
177+
}
178+
181179
std::size_t estimate_msg_rows(const Message& mm) {
182180
const int cols = estimate_wrap_cols();
183-
const std::size_t w = static_cast<std::size_t>(cols);
184181

185182
// Prose body: count real wrapped rows (newline-aware), not bytes/60.
186183
std::size_t rows = 0;
187184
if (!mm.text.empty()) rows += wrapped_rows(mm.text, cols);
188185
if (!mm.streaming_text.empty()) rows += wrapped_rows(mm.streaming_text, cols);
189186

190187
for (const auto& tc : mm.tool_calls) {
191-
// The RENDERED body of a settled tool card comes from its
192-
// ARGS, not its output: a write card shows args["content"]
193-
// (the whole new file, show_all), an edit card shows every
194-
// hunk's old/new text under args["edits"], a read/grep card
195-
// shows its result text. tc.output() is only the one-line
196-
// "wrote N lines" footer. Counting just output() under-
197-
// estimated a 3000-line write as ~1 row, so the row cap never
198-
// tripped and the canvas ballooned. We don't have the laid-out
199-
// text here, so approximate the body's wrapped height from a
200-
// cheap (non-allocating) byte walk of the args JSON divided by
201-
// the real content width — NOT dump(), which allocated a full
202-
// serialized copy per freeze and made resuming a long thread
203-
// slow. output() bytes wrap too.
204-
std::size_t body_bytes = tc.output().size() + tc.args_streaming.size();
205-
if (!tc.args.is_null()) body_bytes += estimate_json_bytes(tc.args);
206-
rows += (body_bytes + w - 1) / w;
188+
// The RENDERED body of a settled tool card is one ROW PER SOURCE
189+
// LINE (line-numbered write, per-hunk edit diff, read/grep
190+
// output), not bytes/width. The old `body_bytes / w` estimate
191+
// catastrophically UNDER-counted any body with many short lines:
192+
// a 300-line write of ~20-char lines is ~300 rendered rows but
193+
// bytes/width put it at ~79. That under-count is the root of the
194+
// scrollback duplication — trim_frozen_above_viewport's "provably
195+
// above the viewport" proof reads these row counts, and an
196+
// under-counted (still on-screen) entry gets dropped, re-emitting
197+
// committed scrollback rows shifted = the turn appears twice.
198+
// Count newlines in the rendered text sources instead; wrapping
199+
// only ADDS rows, so newline-count is a safe LOWER bound that's
200+
// far closer to reality, and we keep the byte term as an extra
201+
// over-count cushion for wrapped long lines.
202+
std::size_t tool_rows = 0;
203+
if (!tc.output().empty())
204+
tool_rows += wrapped_rows(tc.output(), cols);
205+
if (!tc.args_streaming.empty())
206+
tool_rows += wrapped_rows(tc.args_streaming, cols);
207+
// Args JSON: the big body is a string field (write `content`,
208+
// edit `edits[].new_text`/`old_text`). Count newlines across all
209+
// string values so a multi-line payload counts its real lines.
210+
if (!tc.args.is_null())
211+
tool_rows += estimate_json_string_rows(tc.args, cols);
212+
rows += tool_rows;
207213
// Header / footer / chrome rows per tool card (~4 rows even
208214
// for an empty body — title, divider, status, blank). Fixed
209215
// row count, width-independent.

test_stream.md

Lines changed: 0 additions & 47 deletions
This file was deleted.

0 commit comments

Comments
 (0)