@@ -106,6 +106,28 @@ constexpr std::size_t kLiveTailLines = 64;
106106 return std::string{s.substr (start)};
107107}
108108
109+ // First `keep_lines` lines of `s`, head-anchored. Unlike tail_window,
110+ // this is APPEND-ONLY as `s` grows: line 1..keep_lines never change
111+ // content when more lines arrive at the end, they just stop being the
112+ // last thing rendered. Used for the streaming WRITE preview so the
113+ // rows it commits to native scrollback are byte-identical to the
114+ // settled show_all render's first rows — a tail-anchored preview
115+ // instead SHIFTS its top rows as the file grows, so when those rows
116+ // have already overflowed into scrollback and the card later settles to
117+ // a head-anchored full body, the committed rows no longer match and the
118+ // card is re-emitted below them (the duplicated-write ghost).
119+ [[nodiscard]] std::string head_window (std::string_view s,
120+ std::size_t keep_lines) {
121+ if (s.empty ()) return {};
122+ std::size_t nl_seen = 0 ;
123+ for (std::size_t i = 0 ; i < s.size (); ++i) {
124+ if (s[i] == ' \n ' ) {
125+ if (++nl_seen >= keep_lines) return std::string{s.substr (0 , i + 1 )};
126+ }
127+ }
128+ return std::string{s};
129+ }
130+
109131// Body for a terminal line-oriented tool sitting in the LIVE tail.
110132// Frozen builds keep the full body (full content, painted once); the
111133// live path elides to a bounded tail window so per-frame split_lines
@@ -300,44 +322,40 @@ maya::ToolBodyPreview::Config tool_body_preview_config(
300322 out.kind = Kind::FileWrite;
301323 out.text_color = text_tertiary;
302324 out.show_footer_stats = true ;
303- // During streaming the body grows every ~120 ms — `show_all`
304- // would make the card height balloon row-by-row as deltas
305- // arrive, and any rows already pushed to native scrollback
306- // disagree with the next live re-render (rails fragment,
307- // comments from later lines bleed onto earlier rows in the
308- // viewport). Pin the streaming preview to a head+tail window
309- // so the card stays a fixed height for the whole stream;
310- // expand to `show_all` only when the tool is terminal and
311- // the body is final.
312- out.show_all = !streaming_now;
325+ // While streaming, the body grows every ~120 ms. The PRIOR
326+ // approach pinned the preview to a TAIL window (last N lines):
327+ // as the file grew, the top rows of that window kept changing
328+ // content. Once they overflowed into native scrollback they
329+ // were frozen there — but on settle the card switches to a
330+ // head-anchored show_all render from line 1, so the committed
331+ // rows no longer matched and the whole card was re-emitted
332+ // below them: the write appeared TWICE in scrollback.
333+ //
334+ // Fix: render the streaming preview HEAD-anchored with
335+ // show_all over a bounded head window. The first N lines never
336+ // change as more arrive (append-only growth), and they are
337+ // byte-identical to the settled show_all render's first N
338+ // lines — so anything that commits to scrollback mid-stream
339+ // stays valid through the freeze handoff. No row is ever
340+ // rewritten; the card only grows downward.
313341 out.is_streaming = streaming_now;
314342 if (streaming_now) {
315- // Feed maya only the tail window. The widget renders just
316- // the last few lines while streaming (show_all=false), so
317- // slicing here makes the per-frame copy + split_lines
318- // O(window) instead of O(file) — the dominant cost when a
319- // large write streams in. maya's footer derives N-lines /
320- // bytes from out.text, so a sliced body would show a wrong
321- // total; suppress it mid-stream (the status bar carries the
322- // live byte/tok rate) and it returns with the true total
323- // the instant the tool goes terminal (full body, show_all).
324- out.text = tail_window (content, kStreamTailLines );
343+ // Head window, full render: append-only and seam-stable.
344+ // O(window) per frame (the slice bounds split_lines).
345+ // Footer derives line/byte totals from out.text, which is
346+ // a partial head here, so suppress it mid-stream (the
347+ // status bar carries the live byte/tok rate); it returns
348+ // with the true total the instant the tool settles.
349+ out.text = head_window (content, kStreamTailLines );
350+ out.show_all = true ;
325351 out.show_footer_stats = false ;
326- } else if (!building_frozen ()) {
327- // Terminal, but sitting in the LIVE tail (run not settled
328- // yet, re-rendered every frame). The content is FINAL —
329- // no more deltas arrive — so show it in full immediately
330- // instead of waiting for the next Tick's
331- // freeze_settled_subturns to graduate it into the frozen
332- // prefix. Waiting caused a visible "card stays a stub for
333- // a beat after the tool finished, then suddenly expands"
334- // lag. Per-frame split_lines over a stable body for the
335- // one-or-two frames before the freeze handoff is
336- // negligible, and the frozen snapshot reuses the same
337- // show_all body so the freeze instant is seamless (no
338- // height pop).
339- out.text = std::move (content);
340352 } else {
353+ // Terminal: full body, head-anchored, in BOTH the live
354+ // tail and the frozen snapshot. Identical bytes / anchor /
355+ // height across the freeze instant, and a pure superset of
356+ // the streaming head window above — so the handoff never
357+ // moves a committed row.
358+ out.show_all = true ;
341359 out.text = std::move (content);
342360 }
343361 } else if (tc.is_running ()) {
0 commit comments