Skip to content
81 changes: 81 additions & 0 deletions crates/reasonix-render/src/frame_cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/// Frame identity cache — skips deserialization and rendering when consecutive
/// frames are byte-identical. The trace scene emitted by useSceneTrace only
/// changes when cards, model, or activity label change, so most frames between
/// streaming deltas are duplicates.
///
/// Ratatui's Terminal::draw already diffs cells against the previous terminal
/// state before emitting ANSI. This cache operates one layer above: it avoids
/// the JSON parse + scene render + ratatui diff pipeline entirely for
/// unchanged frames, saving CPU on both the JS and Rust sides of the bridge.
#[derive(Default)]
pub struct FrameCache {
/// Raw JSON line from the previous frame.
prev_raw: String,
/// Number of consecutive skipped frames.
skipped: u64,
/// False until the first frame is seen — guarantees is_new returns true for frame 0.
has_prev: bool,
}

impl FrameCache {
/// Returns true if this raw JSON line differs from the previous one
/// (or if no previous frame exists). False means the frame is a
/// byte-for-byte duplicate and can be skipped entirely.
pub fn is_new(&mut self, raw: &str) -> bool {
if !self.has_prev {
self.prev_raw.clear();
self.prev_raw.push_str(raw);
self.has_prev = true;
return true;
}
if raw == self.prev_raw {
self.skipped = self.skipped.saturating_add(1);
return false;
}
self.prev_raw.clear();
self.prev_raw.push_str(raw);
self.skipped = 0;
true
}

/// Number of consecutive frames skipped since the last rendered frame.
pub fn skipped_count(&self) -> u64 {
self.skipped
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn first_frame_is_always_new() {
let mut cache = FrameCache::default();
assert!(cache.is_new(r#"{"v":1}"#));
}

#[test]
fn identical_frame_is_skipped() {
let mut cache = FrameCache::default();
assert!(cache.is_new(r#"{"v":1}"#));
assert!(!cache.is_new(r#"{"v":1}"#));
assert_eq!(cache.skipped_count(), 1);
}

#[test]
fn changed_frame_is_not_skipped() {
let mut cache = FrameCache::default();
assert!(cache.is_new(r#"{"v":1}"#));
assert!(cache.is_new(r#"{"v":2}"#));
assert_eq!(cache.skipped_count(), 0);
}

#[test]
fn empty_string_is_valid_frame() {
let mut cache = FrameCache::default();
// Empty lines are filtered before reaching the cache in main.rs,
// but if one arrives it should be treated normally.
assert!(cache.is_new(""));
assert!(!cache.is_new(""));
}
}
1 change: 1 addition & 0 deletions crates/reasonix-render/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod decode_only;
pub mod frame_cache;
pub mod input;
pub mod render;
pub mod scene;
8 changes: 8 additions & 0 deletions crates/reasonix-render/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;

use reasonix_render::decode_only::run_decode_only;
use reasonix_render::frame_cache::FrameCache;
use reasonix_render::input::{is_quit, paste_event, translate_key, translate_mouse};
use reasonix_render::render::render_frame;
use reasonix_render::scene::SceneFrame;
Expand Down Expand Up @@ -43,11 +44,18 @@ fn main() -> Result<()> {

fn run_stream_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
let stdin = io::stdin();
let mut cache = FrameCache::default();
for (lineno, line) in stdin.lock().lines().enumerate() {
let line = line.with_context(|| format!("read line {}", lineno + 1))?;
if line.trim().is_empty() {
continue;
}
// Skip deserialization + rendering for byte-identical consecutive
// frames. The trace scene only changes when cards, model, or activity
// label change, so most frames between streaming deltas are duplicates.
if !cache.is_new(&line) {
continue;
}
let frame: SceneFrame =
serde_json::from_str(&line).with_context(|| format!("decode line {}", lineno + 1))?;
terminal.draw(|f| {
Expand Down
28 changes: 24 additions & 4 deletions src/cli/ui/cards/useIncrementalWrap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { wrapToCells } from "../../../frame/width.js";

export interface WrapCache {
Expand Down Expand Up @@ -70,11 +70,31 @@ export function wrapIncremental(
};
}

/** Streaming-aware wrap. Monotonic growth re-wraps only the tail logical line. */
/** Debounce interval for lineCells changes — holds the previous width during
* a terminal resize to avoid breaking monotonicity and triggering a full
* re-wrap cascade while streaming tokens are still arriving. */
const LINE_CELLS_DEBOUNCE_MS = 120;

/** Streaming-aware wrap. Monotonic growth re-wraps only the tail logical line.
* Terminal resize is debounced: the previous width is held briefly so streaming
* content stays on the fast monotonic path, then snaps to the new width. */
export function useIncrementalWrap(text: string, lineCells: number): string[] {
const cacheRef = useRef<WrapCache | null>(null);

// Debounced lineCells value. useState + useEffect so committing the
// new width after the debounce window naturally triggers a re-render.
const [effectiveCells, setEffectiveCells] = useState<number>(lineCells);
const pendingCellsRef = useRef<number>(lineCells);

useEffect(() => {
if (pendingCellsRef.current === lineCells) return;
pendingCellsRef.current = lineCells;
const id = setTimeout(() => setEffectiveCells(lineCells), LINE_CELLS_DEBOUNCE_MS);
return () => clearTimeout(id);
}, [lineCells]);

return useMemo(() => {
cacheRef.current = wrapIncremental(text, lineCells, cacheRef.current);
cacheRef.current = wrapIncremental(text, effectiveCells, cacheRef.current);
return cacheRef.current.visualLines;
}, [text, lineCells]);
}, [text, effectiveCells]);
}
47 changes: 44 additions & 3 deletions src/cli/ui/layout/CardStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,34 @@ export function CardStream({

/** Thin wrapper that captures a card's row height on every render and reports
* it to the scroll store. Wrapping in React.memo would defeat the purpose —
* we *want* the effect to re-run when the streaming card grows. */
* we *want* the effect to re-run when the streaming card grows.
*
* Monotonic height lock (#549 / #700): for unsettled (streaming/reasoning)
* cards, height is only reported when it INCREASES. Yoga can emit intermediate
* shrink measurements during re-layout that would otherwise cause the virtual
* window to oscillate. Settled cards always report the exact final height. */
function MeasuredCard({
card,
report,
}: { card: Card; report: (id: string, rows: number) => void }): React.ReactElement {
const ref = useRef<DOMElement>(null!);
const m = useBoxMetrics(ref);
const lastReportedRef = useRef<number>(0);
const settled = isCardSettled(card);

useEffect(() => {
if (m.height > 0) report(card.id, m.height);
}, [card.id, m.height, report]);
const h = m.height;
if (h <= 0) return;
// Dedup: skip if height hasn't changed since last report.
if (h === lastReportedRef.current) return;
// Monotonic lock: for unsettled cards, only report growth.
// Yoga may emit transient shrink values during streaming re-layout
// that would otherwise feed back into scroll position oscillation.
if (!settled && h < lastReportedRef.current) return;
lastReportedRef.current = h;
report(card.id, h);
}, [card.id, m.height, report, settled]);

return (
<Box ref={ref} flexDirection="column" flexShrink={0}>
<CardRenderer card={card} />
Expand Down Expand Up @@ -184,3 +202,26 @@ function isFullySettled(card: Card): boolean {
return true;
}
}

/** True when a card's content is final — no more streaming deltas expected.
* Drives the monotonic height lock in MeasuredCard: settled cards always
* report their exact height; unsettled cards only report growth.
*
* If a future card kind legitimately shrinks in height while still in-flight,
* add an explicit entry here — otherwise the monotonic lock won't catch it
* and default:true will report the stale larger height. */
function isCardSettled(card: Card): boolean {
switch (card.kind) {
case "reasoning":
return !card.streaming || !!card.aborted;
case "streaming":
return card.done || !!card.aborted;
case "tool":
return card.done || !!card.aborted;
case "task":
case "subagent":
return card.status !== "running";
default:
return true;
}
}
7 changes: 7 additions & 0 deletions src/cli/ui/state/chat-scroll-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export function createChatScrollStore(): ChatScrollStore {
// While a burst of card-collapse re-measurements arrives, we hold the latest
// target here and apply it once on a microtask flush, so subscribers see one
// settled transition instead of N oscillating snaps.
// Grows still apply immediately — streaming content should auto-scroll without
// latency. Growth oscillation is prevented upstream by the monotonic height
// lock in CardStream.MeasuredCard: card heights only increase during streaming,
// so maxScroll growth is naturally monotonic.
let pendingMaxShrink: number | null = null;
let shrinkTimer: NodeJS.Timeout | null = null;

Expand Down Expand Up @@ -143,6 +147,9 @@ export function createChatScrollStore(): ChatScrollStore {
// re-measurements during an Esc-abort would otherwise snap scrollRows N
// times, producing a visible flicker. Grows still apply immediately so
// normal streaming output keeps the viewport pinned without latency.
// Growth oscillation is prevented upstream by the monotonic height lock
// in CardStream.MeasuredCard — card heights only increase during streaming,
// so maxScroll growth is naturally monotonic without coalescing.
const currentMax = pendingMaxShrink ?? state.maxScroll;
if (state.pinned && m < currentMax) {
pendingMaxShrink = m;
Expand Down
Loading