From cac662658651ae092cffddfc88f39a775afef942 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 20 Jul 2026 14:51:16 +0700 Subject: [PATCH] feat(tui): implement streaming support for LLM responses and update transcript handling --- apps/interfaces/tui/src/action.rs | 14 ++++++++++++++ apps/interfaces/tui/src/state.rs | 11 +++++++++++ apps/interfaces/tui/src/turn.rs | 28 ++++++++++++++++++++-------- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/apps/interfaces/tui/src/action.rs b/apps/interfaces/tui/src/action.rs index 8ad0978..77d6f0a 100644 --- a/apps/interfaces/tui/src/action.rs +++ b/apps/interfaces/tui/src/action.rs @@ -158,6 +158,20 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { zesdex_infrastructure::TurnEvent::Error(msg) => { state.toast_error(msg); } + zesdex_infrastructure::TurnEvent::StreamStart => { + state.push_transcript(crate::state::ChatMessageDisplay::new( + zesdex_domain::core::Role::Assistant, + String::new(), + )); + } + zesdex_infrastructure::TurnEvent::StreamToken(text) => { + state.append_to_last_transcript(&text); + } + zesdex_infrastructure::TurnEvent::StreamDone(_msg) => { + // The final message is already accumulated in the transcript. + // We might want to trigger a save or something here, but for UI, we just mark dirty. + state.dirty = true; + } zesdex_infrastructure::TurnEvent::Compacted(msgs) => { if let Some(ref mut rt) = state.session_runtime { rt.messages = msgs; diff --git a/apps/interfaces/tui/src/state.rs b/apps/interfaces/tui/src/state.rs index 6e45593..40930d0 100644 --- a/apps/interfaces/tui/src/state.rs +++ b/apps/interfaces/tui/src/state.rs @@ -1008,6 +1008,17 @@ impl AppStateRest { self.dirty = true; } + /// Append text to the last assistant message in the transcript, if one exists. + pub fn append_to_last_transcript(&mut self, text: &str) { + if let Some(msg) = self.transcript_cache.messages.back_mut() { + if msg.role == zesdex_domain::core::Role::Assistant { + msg.content.push_str(text); + self.transcript_cache.dirty = true; + self.dirty = true; + } + } + } + /// Mark the app state as dirty, triggering a TUI re-render. pub fn mark_dirty(&mut self) { self.dirty = true; diff --git a/apps/interfaces/tui/src/turn.rs b/apps/interfaces/tui/src/turn.rs index 2384299..9c7a213 100644 --- a/apps/interfaces/tui/src/turn.rs +++ b/apps/interfaces/tui/src/turn.rs @@ -128,13 +128,27 @@ fn run_turn( debug!("agent turn iteration {iteration}"); - // Blocking LLM call (reqwest::blocking::Client is sync) - let result = client.chat_with_tools_non_streaming( + // Stream the LLM response + push_event(turn_events, TurnEvent::StreamStart); + + let result = client.chat_with_tools_streaming( messages, Some(defs.clone()), - Some(4096), Some(0.7), - None, // no atomic abort flag for the sync API + Some(4096), + |event| { + if abort.load(Ordering::SeqCst) { + return false; + } + match event { + zesdex_domain::core::StreamEvent::Token(s) | zesdex_domain::core::StreamEvent::Reasoning(s) => { + push_event(turn_events, TurnEvent::StreamToken(s.clone())); + } + _ => {} + } + true + }, + Some(&abort), ); match result { @@ -142,6 +156,8 @@ fn run_turn( let content = assistant_msg.content.clone().unwrap_or_default(); let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default(); + push_event(turn_events, TurnEvent::StreamDone(assistant_msg.clone())); + if let Some((tokens_in, tokens_out)) = usage { push_event(turn_events, TurnEvent::Usage { tokens_in, @@ -149,10 +165,6 @@ fn run_turn( }); } - if !content.is_empty() { - push_event(turn_events, TurnEvent::AssistantMessage(assistant_msg.clone())); - } - if tool_calls.is_empty() { messages.push(ChatMessage::assistant(Some(content))); break;