@@ -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+
181179std::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.
0 commit comments