feat(tui): add support for reasoning in chat messages and update transcript handling

This commit is contained in:
asepharyana
2026-07-20 14:56:03 +07:00
parent cac6626586
commit 285dbb14cc
5 changed files with 78 additions and 19 deletions
+1
View File
@@ -201,6 +201,7 @@ pub enum TurnEvent {
}, },
StreamStart, StreamStart,
StreamToken(String), StreamToken(String),
StreamReasoning(String),
StreamDone(ChatMessage), StreamDone(ChatMessage),
Usage { Usage {
tokens_in: u64, tokens_in: u64,
+4 -1
View File
@@ -165,7 +165,10 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
)); ));
} }
zesdex_infrastructure::TurnEvent::StreamToken(text) => { 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) => { zesdex_infrastructure::TurnEvent::StreamDone(_msg) => {
// The final message is already accumulated in the transcript. // The final message is already accumulated in the transcript.
+9 -2
View File
@@ -32,6 +32,8 @@ pub struct ChatMessageDisplay {
pub role: zesdex_domain::core::Role, pub role: zesdex_domain::core::Role,
/// Rendered text content (plain text, no markdown). /// Rendered text content (plain text, no markdown).
pub content: String, pub content: String,
/// Model reasoning (e.g. from DeepSeek-R1 <think> block).
pub reasoning: String,
/// Millisecond timestamp when this display entry was created. /// Millisecond timestamp when this display entry was created.
pub timestamp: i64, pub timestamp: i64,
} }
@@ -42,6 +44,7 @@ impl ChatMessageDisplay {
ChatMessageDisplay { ChatMessageDisplay {
role, role,
content, content,
reasoning: String::new(),
timestamp: chrono::Utc::now().timestamp_millis(), 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. /// 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 let Some(msg) = self.transcript_cache.messages.back_mut() {
if msg.role == zesdex_domain::core::Role::Assistant { 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.transcript_cache.dirty = true;
self.dirty = true; self.dirty = true;
} }
+4 -1
View File
@@ -141,9 +141,12 @@ fn run_turn(
return false; return false;
} }
match event { 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())); push_event(turn_events, TurnEvent::StreamToken(s.clone()));
} }
zesdex_domain::core::StreamEvent::Reasoning(s) => {
push_event(turn_events, TurnEvent::StreamReasoning(s.clone()));
}
_ => {} _ => {}
} }
true true
+60 -15
View File
@@ -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<Span> = 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() { 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 { } else {
msg.content.clone() msg.content.clone()
}; };
let content_spans = super::markdown::render_markdown(&content_str, content_width, false); if !content_str.is_empty() {
let content_lines = split_spans_into_lines(content_spans); let content_spans = super::markdown::render_markdown(&content_str, content_width, false);
let mut lines_iter = content_lines.into_iter(); let content_lines = split_spans_into_lines(content_spans);
let mut lines_iter = content_lines.into_iter();
if let Some(first) = lines_iter.next() { if !first_line_handled {
let mut spans = header_prefix; if let Some(first) = lines_iter.next() {
spans.extend(first.spans); let mut spans = header_prefix.clone();
lines.push(Line::from(spans)); spans.extend(first.spans);
} else { lines.push(Line::from(spans));
lines.push(Line::from(header_prefix)); 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 { if !first_line_handled {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; lines.push(Line::from(header_prefix));
spans.extend(line.spans);
lines.push(Line::from(spans));
} }
lines lines