feat(tui): cache rendered message lines and throttle streaming redraws to improve TUI responsiveness

- Introduce `MessageRenderContext` and `MessageCacheEntry` for caching wrapped lines per message.
- Implement `render_message_lines_cached` using cache, invalidating on updates.
- Add role/style helpers and content hashing for cache validation.
- Throttle UI redraws in the main loop during active streaming (50 ms interval) and adjust idle tick timing.
- Update drawing logic to use cached rendering and manage draw intervals.
- Remove unused `role_color` function and adjust imports accordingly.
This commit is contained in:
2025-10-12 15:02:33 +02:00
parent acbfe47a4b
commit d2a193e5c1
4 changed files with 276 additions and 125 deletions

View File

@@ -9,7 +9,7 @@ use tui_textarea::TextArea;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, ModelSelectorItemKind};
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind};
use owlen_core::model::DetailedModelInfo;
use owlen_core::types::{ModelInfo, Role};
use owlen_core::ui::{FocusedPanel, InputMode};
@@ -677,127 +677,54 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let content_width = area.width.saturating_sub(4).max(20);
app.set_viewport_dimensions(viewport_height, usize::from(content_width));
let conversation = app.conversation();
let total_messages = app.message_count();
let mut formatter = app.formatter().clone();
// Reserve space for borders and the message indent so text fits within the block
formatter.set_wrap_width(usize::from(content_width));
// Build the lines for messages
let mut lines: Vec<Line> = Vec::new();
for (message_index, message) in conversation.messages.iter().enumerate() {
let role = &message.role;
let (emoji, name) = match role {
Role::User => ("👤 ", "You: "),
Role::Assistant => ("🤖 ", "Assistant: "),
Role::System => ("⚙️ ", "System: "),
Role::Tool => ("🔧 ", "Tool: "),
// Build the lines for messages using cached rendering
let mut lines: Vec<Line<'static>> = Vec::new();
let show_role_labels = formatter.show_role_labels();
for message_index in 0..total_messages {
let is_streaming = {
let conversation = app.conversation();
conversation.messages[message_index]
.metadata
.get("streaming")
.and_then(|v| v.as_bool())
.unwrap_or(false)
};
// Extract content without thinking tags for assistant messages
let content_to_display = if matches!(role, Role::Assistant) {
let (content_without_think, _) = formatter.extract_thinking(&message.content);
content_without_think
} else if matches!(role, Role::Tool) {
// Format tool results nicely
format_tool_output(&message.content)
} else {
message.content.clone()
};
let formatted: Vec<String> = content_to_display
.trim()
.lines()
.map(|s| s.to_string())
.collect();
let is_streaming = message
.metadata
.get("streaming")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let show_role_labels = formatter.show_role_labels();
if show_role_labels {
// Role name line
let mut role_line_spans = vec![
Span::raw(emoji),
Span::styled(name, role_color(role, &theme).add_modifier(Modifier::BOLD)),
];
// Add loading indicator if applicable
if matches!(role, Role::Assistant)
&& app.get_loading_indicator() != ""
&& message_index == conversation.messages.len() - 1
&& is_streaming
{
role_line_spans.push(Span::styled(
format!(" {}", app.get_loading_indicator()),
Style::default().fg(theme.info),
));
}
lines.push(Line::from(role_line_spans));
// Join all formatted lines into single content string
let content = formatted.join("\n");
// Wrap content with available width minus indent (2 spaces)
let indent = " ";
let available_width = (content_width as usize).saturating_sub(2);
let chunks = if available_width > 0 {
wrap(&content, available_width)
} else {
vec![]
};
let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() {
let style = if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
};
let mut spans = vec![Span::styled(format!("{indent}{}", seg), style)];
if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor)));
}
lines.push(Line::from(spans));
}
} else {
// No role labels - just show content
let content = formatted.join("\n");
let chunks = wrap(&content, content_width as usize);
let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() {
let style = if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
};
let mut spans = vec![Span::styled(seg.into_owned(), style)];
if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor)));
}
lines.push(Line::from(spans));
}
}
// Add an empty line after each message, except the last one
if message_index < conversation.messages.len() - 1 {
lines.push(Line::from(""));
let message_lines = app.render_message_lines_cached(
message_index,
MessageRenderContext::new(
&mut formatter,
show_role_labels,
content_width as usize,
message_index + 1 == total_messages,
is_streaming,
app.get_loading_indicator(),
&theme,
),
);
lines.extend(message_lines);
if message_index + 1 < total_messages {
lines.push(Line::from(String::new()));
}
}
// Add loading indicator ONLY if we're loading and there are no messages at all,
// or if the last message is from the user (no Assistant response started yet)
let last_message_is_user = conversation
.messages
.last()
.map(|msg| matches!(msg.role, Role::User))
.unwrap_or(true);
let last_message_is_user = if total_messages == 0 {
true
} else {
let conversation = app.conversation();
conversation
.messages
.last()
.map(|msg| matches!(msg.role, Role::User))
.unwrap_or(true)
};
if app.get_loading_indicator() != "" && last_message_is_user {
let loading_spans = vec![
@@ -2704,15 +2631,6 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
.split(vertical[1])[1]
}
fn role_color(role: &Role, theme: &owlen_core::theme::Theme) -> Style {
match role {
Role::User => Style::default().fg(theme.user_message_role),
Role::Assistant => Style::default().fg(theme.assistant_message_role),
Role::System => Style::default().fg(theme.unfocused_panel_border),
Role::Tool => Style::default().fg(theme.info),
}
}
/// Format tool output JSON into a nice human-readable format
pub(crate) fn format_tool_output(content: &str) -> String {
// Try to parse as JSON