perf(tui): render streaming token secara inkremental + kurangi redraw sia-sia

- fix(view): token streaming kini benar-benar tampil — sebelumnya cache
  display_lines tidak pernah di-rebuild saat pesan terakhir berubah
  (msg_count == cached_count), jadi teks AI streaming tidak pernah muncul
  sampai pesan baru/resize
- perf(view): streaming kini hanya re-render pesan TERAKHIR (splice di
  batas cached_last_start) → O(konten baru) per token, bukan O(seluruh
  history); guard cached_last_len mencegah re-render pada frame spinner
  tanpa token baru
- perf(run): skip chrono::Utc::now() + drain toasts saat tidak ada toast
- perf(misc): drain_expired_toasts tidak lagi clone seluruh daftar toast
- perf(view): render_toasts fast-path saat toasts kosong
This commit is contained in:
asepharyana
2026-08-27 22:23:28 +07:00
parent 171597ca05
commit 271721694b
5 changed files with 58 additions and 12 deletions
+4 -1
View File
@@ -112,9 +112,12 @@ fn run_loop_inner(
} }
// Drain expired toasts exactly once per loop iteration (was being // Drain expired toasts exactly once per loop iteration (was being
// done here AND in Action::Tick before this fix). // done here AND in Action::Tick before this fix). Skip the clock
// syscall + Vec scan when there are no toasts at all.
if !state.misc.toasts.is_empty() {
let now_ms = chrono::Utc::now().timestamp_millis(); let now_ms = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now_ms); state.misc.drain_expired_toasts(now_ms);
}
// Skip render when nothing has changed — avoids expensive markdown // Skip render when nothing has changed — avoids expensive markdown
// re-parse and layout recalculation every cycle while idle. // re-parse and layout recalculation every cycle while idle.
+2 -9
View File
@@ -179,16 +179,9 @@ impl MiscState {
self.toasts.push(toast); self.toasts.push(toast);
} }
/// Remove and return all toasts whose lifetime has expired at `now_ms`. /// Remove all toasts whose lifetime has expired at `now_ms`.
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<Toast> { pub fn drain_expired_toasts(&mut self, now_ms: i64) {
let expired: Vec<_> = self
.toasts
.iter()
.filter(|t| t.expired(now_ms))
.cloned()
.collect();
self.toasts.retain(|t| !t.expired(now_ms)); self.toasts.retain(|t| !t.expired(now_ms));
expired
} }
} }
+11
View File
@@ -103,6 +103,13 @@ pub struct AppStateRest {
pub last_render_width: u16, pub last_render_width: u16,
/// Number of messages that were in the cache when it was last built. /// Number of messages that were in the cache when it was last built.
pub cached_msg_count: usize, pub cached_msg_count: usize,
/// Cache index where the last message's rendered lines begin. Used to
/// splice streaming updates (only re-render the trailing message).
pub cached_last_start: usize,
/// Content+reasoning byte length of the last message when it was last
/// rendered. Guards the streaming branch from re-rendering on ticks
/// where no new token arrived (spinner-only frames).
pub cached_last_len: usize,
/// Terminal width at the time of the last full cache build. /// Terminal width at the time of the last full cache build.
pub render_width_at_cache: u16, pub render_width_at_cache: u16,
/// Atomic flag set when the user aborts the current turn. /// Atomic flag set when the user aborts the current turn.
@@ -180,6 +187,8 @@ impl Default for AppStateRest {
token_count_dirty: true, token_count_dirty: true,
last_render_width: 0, last_render_width: 0,
cached_msg_count: 0, cached_msg_count: 0,
cached_last_start: 0,
cached_last_len: 0,
render_width_at_cache: 0, render_width_at_cache: 0,
} }
} }
@@ -230,6 +239,8 @@ impl AppStateRest {
token_count_dirty: true, token_count_dirty: true,
last_render_width: 0, last_render_width: 0,
cached_msg_count: 0, cached_msg_count: 0,
cached_last_start: 0,
cached_last_len: 0,
render_width_at_cache: 0, render_width_at_cache: 0,
} }
} }
+35
View File
@@ -190,6 +190,8 @@ impl Component for ChatComponent {
} }
state.display_lines_cache = all_lines; state.display_lines_cache = all_lines;
state.cached_msg_count = msg_count; state.cached_msg_count = msg_count;
state.cached_last_start = state.display_lines_cache.len();
state.cached_last_len = last_msg_len(state);
state.render_width_at_cache = state.last_render_width; state.render_width_at_cache = state.last_render_width;
state.transcript_cache.dirty = false; state.transcript_cache.dirty = false;
} else if state.transcript_cache.dirty && msg_count > cached_count { } else if state.transcript_cache.dirty && msg_count > cached_count {
@@ -205,6 +207,27 @@ impl Component for ChatComponent {
state.display_lines_cache.extend(msg_lines); state.display_lines_cache.extend(msg_lines);
} }
state.cached_msg_count = msg_count; state.cached_msg_count = msg_count;
state.cached_last_start = state.display_lines_cache.len();
state.cached_last_len = last_msg_len(state);
state.transcript_cache.dirty = false;
} else if state.transcript_cache.dirty {
// Streaming update: message count unchanged, but the last
// assistant message's content grew (StreamToken). Re-render only
// that message and splice its lines back into the cache — this
// keeps updates O(new content), not O(whole history) per token.
// Without this branch, streamed tokens would never appear (the
// cache is only rebuilt on new-message or resize paths).
let cur_len = last_msg_len(state);
if cur_len != state.cached_last_len {
if let Some(last_msg) = state.transcript_cache.messages.back() {
if state.cached_last_start <= state.display_lines_cache.len() {
let new_lines = render_one_message(last_msg, content_width);
state.display_lines_cache.truncate(state.cached_last_start);
state.display_lines_cache.extend(new_lines);
}
}
state.cached_last_len = cur_len;
}
state.transcript_cache.dirty = false; state.transcript_cache.dirty = false;
} }
@@ -326,3 +349,15 @@ fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
} }
lines lines
} }
/// Byte length of the last message's content + reasoning. Used as a cheap
/// change detector for the streaming render branch — avoids re-rendering on
/// spinner-only ticks where no new token arrived.
fn last_msg_len(state: &crate::state::AppStateRest) -> usize {
state
.transcript_cache
.messages
.back()
.map(|m| m.content.len() + m.reasoning.len())
.unwrap_or(0)
}
+4
View File
@@ -86,6 +86,10 @@ pub fn draw(frame: &mut Frame, state: &AppStateRest) {
} }
fn render_toasts(frame: &mut Frame, state: &AppStateRest) { fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
// Fast path: nothing to render.
if state.misc.toasts.is_empty() {
return;
}
let now_ms = chrono::Utc::now().timestamp_millis(); let now_ms = chrono::Utc::now().timestamp_millis();
let active: Vec<&zesdex_infrastructure::Toast> = state let active: Vec<&zesdex_infrastructure::Toast> = state
.misc .misc