feat(tui): add support for reasoning in chat messages and update transcript handling
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user