Compare commits

...
2 Commits
Author SHA1 Message Date
semantic-release-bot 9ea3d361b1 chore(release): 1.18.4 [skip ci]
## [1.18.4](https://github.com/asepharyana/zesdex/compare/v1.18.3...v1.18.4) (2026-08-27)

### Performance Improvements

* **tui:** render streaming token secara inkremental + kurangi redraw sia-sia ([2717216](https://github.com/asepharyana/zesdex/commit/271721694beb62e9fa5ff932312b293aa1d56823))
2026-08-27 15:33:34 +00:00
asepharyana 271721694b 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
2026-08-27 22:23:28 +07:00
7 changed files with 66 additions and 13 deletions
+7
View File
@@ -1,3 +1,10 @@
## [1.18.4](https://github.com/asepharyana/zesdex/compare/v1.18.3...v1.18.4) (2026-08-27)
### Performance Improvements
* **tui:** render streaming token secara inkremental + kurangi redraw sia-sia ([2717216](https://github.com/asepharyana/zesdex/commit/271721694beb62e9fa5ff932312b293aa1d56823))
## [1.18.3](https://github.com/asepharyana/zesdex/compare/v1.18.2...v1.18.3) (2026-08-27)
+1 -1
View File
@@ -15,7 +15,7 @@ members = [
]
[workspace.package]
version = "1.18.3"
version = "1.18.4"
edition = "2021"
authors = ["asepharyana <superaseph@gmail.com>"]
+4 -1
View File
@@ -112,9 +112,12 @@ fn run_loop_inner(
}
// 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();
state.misc.drain_expired_toasts(now_ms);
}
// Skip render when nothing has changed — avoids expensive markdown
// re-parse and layout recalculation every cycle while idle.
+2 -9
View File
@@ -179,16 +179,9 @@ impl MiscState {
self.toasts.push(toast);
}
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<Toast> {
let expired: Vec<_> = self
.toasts
.iter()
.filter(|t| t.expired(now_ms))
.cloned()
.collect();
/// Remove all toasts whose lifetime has expired at `now_ms`.
pub fn drain_expired_toasts(&mut self, now_ms: i64) {
self.toasts.retain(|t| !t.expired(now_ms));
expired
}
}
+11
View File
@@ -103,6 +103,13 @@ pub struct AppStateRest {
pub last_render_width: u16,
/// Number of messages that were in the cache when it was last built.
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.
pub render_width_at_cache: u16,
/// Atomic flag set when the user aborts the current turn.
@@ -180,6 +187,8 @@ impl Default for AppStateRest {
token_count_dirty: true,
last_render_width: 0,
cached_msg_count: 0,
cached_last_start: 0,
cached_last_len: 0,
render_width_at_cache: 0,
}
}
@@ -230,6 +239,8 @@ impl AppStateRest {
token_count_dirty: true,
last_render_width: 0,
cached_msg_count: 0,
cached_last_start: 0,
cached_last_len: 0,
render_width_at_cache: 0,
}
}
+35
View File
@@ -190,6 +190,8 @@ impl Component for ChatComponent {
}
state.display_lines_cache = all_lines;
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.transcript_cache.dirty = false;
} 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.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;
}
@@ -326,3 +349,15 @@ fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
}
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) {
// Fast path: nothing to render.
if state.misc.toasts.is_empty() {
return;
}
let now_ms = chrono::Utc::now().timestamp_millis();
let active: Vec<&zesdex_infrastructure::Toast> = state
.misc