diff --git a/apps/infrastructure/src/lib.rs b/apps/infrastructure/src/lib.rs index 500dc4f..620f5f5 100644 --- a/apps/infrastructure/src/lib.rs +++ b/apps/infrastructure/src/lib.rs @@ -201,6 +201,7 @@ pub enum TurnEvent { }, StreamStart, StreamToken(String), + StreamReasoning(String), StreamDone(ChatMessage), Usage { tokens_in: u64, diff --git a/apps/interfaces/tui/src/action.rs b/apps/interfaces/tui/src/action.rs index 77d6f0a..88963c1 100644 --- a/apps/interfaces/tui/src/action.rs +++ b/apps/interfaces/tui/src/action.rs @@ -165,7 +165,10 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { )); } zesdex_infrastructure::TurnEvent::StreamToken(text) => { - state.append_to_last_transcript(&text); + state.append_to_last_transcript(&text, false); + } + zesdex_infrastructure::TurnEvent::StreamReasoning(text) => { + state.append_to_last_transcript(&text, true); } zesdex_infrastructure::TurnEvent::StreamDone(_msg) => { // The final message is already accumulated in the transcript. diff --git a/apps/interfaces/tui/src/state.rs b/apps/interfaces/tui/src/state.rs index 40930d0..6323ce2 100644 --- a/apps/interfaces/tui/src/state.rs +++ b/apps/interfaces/tui/src/state.rs @@ -32,6 +32,8 @@ pub struct ChatMessageDisplay { pub role: zesdex_domain::core::Role, /// Rendered text content (plain text, no markdown). pub content: String, + /// Model reasoning (e.g. from DeepSeek-R1 block). + pub reasoning: String, /// Millisecond timestamp when this display entry was created. pub timestamp: i64, } @@ -42,6 +44,7 @@ impl ChatMessageDisplay { ChatMessageDisplay { role, content, + reasoning: String::new(), timestamp: chrono::Utc::now().timestamp_millis(), } } @@ -1009,10 +1012,14 @@ impl AppStateRest { } /// Append text to the last assistant message in the transcript, if one exists. - pub fn append_to_last_transcript(&mut self, text: &str) { + pub fn append_to_last_transcript(&mut self, text: &str, is_reasoning: bool) { if let Some(msg) = self.transcript_cache.messages.back_mut() { if msg.role == zesdex_domain::core::Role::Assistant { - msg.content.push_str(text); + if is_reasoning { + msg.reasoning.push_str(text); + } else { + msg.content.push_str(text); + } self.transcript_cache.dirty = true; self.dirty = true; } diff --git a/apps/interfaces/tui/src/turn.rs b/apps/interfaces/tui/src/turn.rs index 9c7a213..8a7fdfe 100644 --- a/apps/interfaces/tui/src/turn.rs +++ b/apps/interfaces/tui/src/turn.rs @@ -141,9 +141,12 @@ fn run_turn( return false; } match event { - zesdex_domain::core::StreamEvent::Token(s) | zesdex_domain::core::StreamEvent::Reasoning(s) => { + zesdex_domain::core::StreamEvent::Token(s) => { push_event(turn_events, TurnEvent::StreamToken(s.clone())); } + zesdex_domain::core::StreamEvent::Reasoning(s) => { + push_event(turn_events, TurnEvent::StreamReasoning(s.clone())); + } _ => {} } true diff --git a/apps/interfaces/tui/src/view/chat.rs b/apps/interfaces/tui/src/view/chat.rs index 2e81a15..4183c88 100644 --- a/apps/interfaces/tui/src/view/chat.rs +++ b/apps/interfaces/tui/src/view/chat.rs @@ -88,28 +88,73 @@ fn render_one_message( ), ]; + let mut first_line_handled = false; + + if !msg.reasoning.trim().is_empty() { + let reasoning_str = format!("[Thinking...]\n{}", msg.reasoning.trim()); + let reasoning_spans = super::markdown::render_markdown(&reasoning_str, content_width, false); + // Dim the reasoning text + let dimmed_spans: Vec = reasoning_spans.into_iter().map(|mut s| { + s.style = s.style.fg(Theme::TEXT_DIM); + s + }).collect(); + let reasoning_lines = split_spans_into_lines(dimmed_spans); + let mut lines_iter = reasoning_lines.into_iter(); + + if let Some(first) = lines_iter.next() { + let mut spans = header_prefix.clone(); + spans.extend(first.spans); + lines.push(Line::from(spans)); + first_line_handled = true; + } + + for line in lines_iter { + let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; + spans.extend(line.spans); + lines.push(Line::from(spans)); + } + } + let content_str = if msg.content.trim().is_empty() { - "(tool execution)".to_string() + if msg.reasoning.trim().is_empty() { + "(tool execution)".to_string() + } else { + String::new() + } } else { msg.content.clone() }; - let content_spans = super::markdown::render_markdown(&content_str, content_width, false); - let content_lines = split_spans_into_lines(content_spans); - let mut lines_iter = content_lines.into_iter(); + if !content_str.is_empty() { + let content_spans = super::markdown::render_markdown(&content_str, content_width, false); + let content_lines = split_spans_into_lines(content_spans); + let mut lines_iter = content_lines.into_iter(); - if let Some(first) = lines_iter.next() { - let mut spans = header_prefix; - spans.extend(first.spans); - lines.push(Line::from(spans)); - } else { - lines.push(Line::from(header_prefix)); + if !first_line_handled { + if let Some(first) = lines_iter.next() { + let mut spans = header_prefix.clone(); + spans.extend(first.spans); + lines.push(Line::from(spans)); + first_line_handled = true; + } + } else { + // Header already printed by reasoning, just indent + if let Some(first) = lines_iter.next() { + let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; + spans.extend(first.spans); + lines.push(Line::from(spans)); + } + } + + for line in lines_iter { + let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; + spans.extend(line.spans); + lines.push(Line::from(spans)); + } } - - for line in lines_iter { - let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; - spans.extend(line.spans); - lines.push(Line::from(spans)); + + if !first_line_handled { + lines.push(Line::from(header_prefix)); } lines