@@ -72,6 +72,63 @@ maya::Element compaction_divider_row() {
7272 && !mm.tool_calls .empty ();
7373}
7474
75+ // Cheap byte-based row estimate for a single message's contribution
76+ // to a frozen Turn. NOT a render — a coarse proxy (avg ~60 cols/row)
77+ // used only to BOUND the frozen canvas height, where over/under by a
78+ // few rows is harmless. Shared by rehydrate_frozen (budget walk) and
79+ // freeze_range (per-entry frozen_rows accounting).
80+ std::size_t estimate_msg_rows (const Message& mm) {
81+ std::size_t bytes = mm.text .size () + mm.streaming_text .size ();
82+ for (const auto & tc : mm.tool_calls ) {
83+ bytes += tc.output ().size ();
84+ bytes += tc.args_streaming .size ();
85+ // The RENDERED body of a settled tool card comes from its
86+ // ARGS, not its output: a write card shows args["content"]
87+ // (the whole new file, show_all), an edit card shows every
88+ // hunk's old/new text under args["edits"], a read/grep card
89+ // shows its result text. tc.output() is only the one-line
90+ // "wrote N lines" footer. Counting just output() under-
91+ // estimated a 3000-line write as ~1 row, so the row cap never
92+ // tripped and the canvas ballooned to thousands of rows while
93+ // frozen_row_total still read tiny. Approximate the body by
94+ // the serialized args size; coarse is fine (this only BOUNDS
95+ // the canvas height, it is never a render).
96+ if (!tc.args .is_null ()) {
97+ // dump() is O(args) but args are already in memory and this
98+ // runs once per freeze, not per frame. Use a compact dump
99+ // (no indent) so the byte count tracks content, not
100+ // formatting whitespace.
101+ bytes += tc.args .dump ().size ();
102+ }
103+ // Header / footer / chrome rows per tool card (~4 rows even
104+ // for an empty body — title, divider, status, blank).
105+ bytes += 4 * 60 ;
106+ }
107+ // Per-message envelope (header, gap, divider).
108+ bytes += 3 * 60 ;
109+ return bytes / 60 + 1 ;
110+ }
111+
112+ // Estimated rows for the run messages[from..to) that collapse into
113+ // ONE frozen Turn entry.
114+ int estimate_run_rows (const Model& m, std::size_t from, std::size_t to) {
115+ std::size_t rows = 0 ;
116+ for (std::size_t k = from; k < to && k < m.d .current .messages .size (); ++k)
117+ rows += estimate_msg_rows (m.d .current .messages [k]);
118+ return static_cast <int >(rows);
119+ }
120+
121+ // Push a built frozen Element together with its estimated row count,
122+ // keeping m.ui.frozen / m.ui.frozen_rows / m.ui.frozen_row_total in
123+ // lockstep. EVERY push into m.ui.frozen must go through here so the
124+ // row accounting never drifts from the element vector.
125+ void push_frozen (Model& m, maya::Element e, int rows) {
126+ if (rows < 1 ) rows = 1 ;
127+ m.ui .frozen .push_back (std::move (e));
128+ m.ui .frozen_rows .push_back (rows);
129+ m.ui .frozen_row_total += static_cast <std::size_t >(rows);
130+ }
131+
75132// Run-level safety gate: a frozen turn captures an Element snapshot
76133// whose hash_id is stamped once and never recomputed. If we freeze a
77134// run that still contains a Pending / Approved / Running tool, that
@@ -131,14 +188,14 @@ void freeze_range(Model& m, std::size_t from, std::size_t to) {
131188 }
132189
133190 if (needs_compaction_divider (i)) {
134- m. ui . frozen . push_back ( compaction_divider_row ());
191+ push_frozen (m, compaction_divider_row (), 1 );
135192 }
136193
137194 // Leading gap: one blank row before every turn except the
138195 // very first frozen row (avoid a top-of-thread gap).
139196 const bool first_overall = m.ui .frozen .empty ();
140197 if (!first_overall) {
141- m. ui . frozen . push_back ( gap_row ());
198+ push_frozen (m, gap_row (), 1 );
142199 }
143200
144201 const Message& head = m.d .current .messages [i];
@@ -162,7 +219,8 @@ void freeze_range(Model& m, std::size_t from, std::size_t to) {
162219 kb.add (m.d .current .messages [j].compute_render_key ());
163220 }
164221 cfg.hash_id = kb.build ();
165- m.ui .frozen .push_back (maya::Turn{std::move (cfg)}.build ());
222+ push_frozen (m, maya::Turn{std::move (cfg)}.build (),
223+ estimate_run_rows (m, i, run_end));
166224 ++m.ui .frozen_turn ;
167225 } else {
168226 // User / compaction-summary single-message Turn.
@@ -176,7 +234,8 @@ void freeze_range(Model& m, std::size_t from, std::size_t to) {
176234 .add (std::string_view{head.id .value })
177235 .add (head.compute_render_key ())
178236 .build ();
179- m.ui .frozen .push_back (maya::Turn{std::move (cfg)}.build ());
237+ push_frozen (m, maya::Turn{std::move (cfg)}.build (),
238+ estimate_run_rows (m, i, run_end));
180239 }
181240
182241 i = run_end;
@@ -194,6 +253,8 @@ void freeze_through(Model& m, std::size_t live_start) {
194253
195254void clear_frozen (Model& m) {
196255 m.ui .frozen .clear ();
256+ m.ui .frozen_rows .clear ();
257+ m.ui .frozen_row_total = 0 ;
197258 m.ui .frozen_through = 0 ;
198259 m.ui .frozen_turn = 0 ;
199260}
@@ -236,20 +297,6 @@ void rehydrate_frozen(Model& m) {
236297 const std::size_t kRehydrateRowBudget = static_cast <std::size_t >(
237298 std::max (8 , term_size.height .value - kComposerReserve ));
238299
239- auto estimate_msg_rows = [](const Message& mm) -> std::size_t {
240- std::size_t bytes = mm.text .size () + mm.streaming_text .size ();
241- for (const auto & tc : mm.tool_calls ) {
242- bytes += tc.output ().size ();
243- bytes += tc.args_streaming .size ();
244- // Header / footer / chrome rows per tool card (~4 rows
245- // even for an empty body — title, divider, status, blank).
246- bytes += 4 * 60 ;
247- }
248- // Per-message envelope (header, gap, divider).
249- bytes += 3 * 60 ;
250- return bytes / 60 + 1 ;
251- };
252-
253300 // Walk backward counting speaker-runs until EITHER cap trips.
254301 std::size_t units = 0 ;
255302 std::size_t row_budget = 0 ;
@@ -293,38 +340,86 @@ void rehydrate_frozen(Model& m) {
293340}
294341
295342maya::Cmd<Msg> trim_frozen_if_oversized (Model& m) {
296- // Soft cap on the frozen vector . Above this , the oldest entries
297- // are dropped — maya's row diff sees a shorter live tree and the
343+ // Soft cap on the frozen prefix . Above it , the oldest entries are
344+ // dropped — maya's row diff sees a shorter live tree and the
298345 // already-overflowed rows naturally commit to native scrollback.
299346 //
300- // Tradeoff: memory + every-frame render_tree cost vs in-app
301- // scroll reach. Render cost dominates on tool-heavy sessions —
302- // every settled turn appends a multi-row Element to frozen and
303- // the canvas auto-resizes to `total_rows + 8`. canvas_.clear()
304- // streaming_fills the entire surface each frame and render_tree
305- // walks every node to position it; 240 entries of write/edit/bash
306- // panels reaches ~5000 rows and pushes per-frame render past
307- // 15 ms, which the user feels as input lag.
347+ // Why ROWS, not entries: the inline canvas auto-resizes to
348+ // `frozen_row_total + chrome`, and maya re-derives a full
349+ // O(rows x width) canvas witness EVERY frame (see maya
350+ // canvas_witness.cpp verify_canvas / verify_shadow). So the
351+ // per-frame render cost — and the animation lag the user feels on
352+ // a long thread — scales with TOTAL FROZEN ROWS, not entry count.
353+ // A single full `write`/`edit` body is hundreds of rows in ONE
354+ // entry, so an entry-count cap alone can't bound the canvas: 80
355+ // entries of fat tool panels still reach ~5000 rows and push
356+ // per-frame render past 15 ms. Capping rows keeps the canvas
357+ // bounded regardless of how tall any individual entry is.
308358 //
309- // 80 entries ≈ 25-30 full turns of recent work — enough for the
310- // in-flight task to stay visible, small enough that the canvas
311- // never blows past ~2000 rows. Older turns are still in the
312- // terminal's native scrollback (committed there when they
313- // overflowed during the live session). 30-entry trim chunk
314- // amortises the per-trim cost across many appends.
315- constexpr std::size_t kFrozenMax = 80 ;
316- constexpr std::size_t kFrozenTrim = 30 ;
317-
318- if (m.ui .frozen .size () <= kFrozenMax ) return maya::Cmd<Msg>::none ();
319-
320- const std::size_t n = std::min (kFrozenTrim ,
321- m.ui .frozen .size () > kFrozenMax / 2
322- ? m.ui .frozen .size () - kFrozenMax / 2
323- : std::size_t {0 });
324- if (n == 0 ) return maya::Cmd<Msg>::none ();
359+ // Older turns stay in the terminal's native scrollback (committed
360+ // there when they overflowed live), and the full message history
361+ // is intact on disk — only the in-app re-render window shrinks.
362+ // Composer history (↑) and thread reload are unaffected.
363+ //
364+ // The per-frame inline render cost is dominated by THREE passes
365+ // that are each O(canvas_rows x width) and run EVERY tick:
366+ // 1. render_tree over the full element tree (layout/measure),
367+ // 2. canvas_.clear() (streaming_fill over every cell),
368+ // 3. the canvas/shadow witness scan (verify_canvas).
369+ // canvas_rows tracks frozen_row_total, so to keep the spinner /
370+ // input latency flat on an arbitrarily long thread we must keep
371+ // frozen_row_total bounded to a SMALL multiple of the viewport.
372+ // Anything that has scrolled past the top of the viewport already
373+ // lives in the terminal's OWN scrollback (it was painted live
374+ // once, full body and all) — re-rendering it inside agentty every
375+ // frame buys nothing but lag. The user scrolls back through it
376+ // with the terminal, not the app.
377+ //
378+ // ~600 rows ≈ a handful of full viewports of recent work; at that
379+ // height the warm per-frame render measures ~4 ms (vs ~12 ms at
380+ // 1500 and ~25-97 ms when a single tall write/edit body is left
381+ // un-capped). The entry cap is a secondary guard against
382+ // pathological counts of tiny entries. Trimming drops whole
383+ // entries from the front until BOTH caps are satisfied, leaving at
384+ // least the most recent few entries no matter how tall they are —
385+ // full bodies are NEVER collapsed (the `show_all` UX is intact);
386+ // they simply graduate from the in-app re-render window into
387+ // native terminal scrollback.
388+ constexpr std::size_t kFrozenMaxRows = 600 ;
389+ constexpr std::size_t kFrozenMaxEntries = 60 ;
390+ constexpr std::size_t kKeepMinEntries = 3 ;
391+
392+ const bool over_rows = m.ui .frozen_row_total > kFrozenMaxRows ;
393+ const bool over_entries = m.ui .frozen .size () > kFrozenMaxEntries ;
394+ if (!over_rows && !over_entries) return maya::Cmd<Msg>::none ();
395+
396+ // Drop entries from the front until both caps are satisfied, but
397+ // never below kKeepMinEntries so the live context stays visible
398+ // even when the tail is a single enormous write/edit body.
399+ std::size_t drop = 0 ;
400+ const std::size_t max_drop =
401+ m.ui .frozen .size () > kKeepMinEntries
402+ ? m.ui .frozen .size () - kKeepMinEntries
403+ : std::size_t {0 };
404+ std::size_t rows_after = m.ui .frozen_row_total ;
405+ std::size_t entries_after = m.ui .frozen .size ();
406+ while (drop < max_drop
407+ && (rows_after > kFrozenMaxRows || entries_after > kFrozenMaxEntries )) {
408+ rows_after -= static_cast <std::size_t >(m.ui .frozen_rows [drop]);
409+ --entries_after;
410+ ++drop;
411+ }
412+ if (drop == 0 ) return maya::Cmd<Msg>::none ();
325413
414+ // Keep frozen / frozen_rows / frozen_row_total in lockstep.
415+ std::size_t removed_rows = 0 ;
416+ for (std::size_t k = 0 ; k < drop; ++k)
417+ removed_rows += static_cast <std::size_t >(m.ui .frozen_rows [k]);
326418 m.ui .frozen .erase (m.ui .frozen .begin (),
327- m.ui .frozen .begin () + static_cast <std::ptrdiff_t >(n));
419+ m.ui .frozen .begin () + static_cast <std::ptrdiff_t >(drop));
420+ m.ui .frozen_rows .erase (m.ui .frozen_rows .begin (),
421+ m.ui .frozen_rows .begin () + static_cast <std::ptrdiff_t >(drop));
422+ m.ui .frozen_row_total -= removed_rows;
328423
329424 // commit_scrollback_overflow lets maya derive the safe row count
330425 // itself (max(0, prev_rows - term_h)) — the Cmd is just a trigger
0 commit comments