Files
owlen/crates/owlen-core/src/formatting.rs
vikingowl 55e6b0583d feat(ui): add configurable role label display and syntax highlighting support
- Introduce `RoleLabelDisplay` enum (inline, above, none) and integrate it into UI rendering and message formatting.
- Replace `show_role_labels` boolean with `role_label_mode` across config, formatter, session, and TUI components.
- Add `syntax_highlighting` boolean to UI settings with default `false` and support in message rendering.
- Update configuration schema version to 1.3.0 and provide deserialization handling for legacy boolean values.
- Extend theme definitions with code block styling fields (background, border, text, keyword, string, comment) and default values in `Theme`.
- Adjust related modules (`formatting.rs`, `ui.rs`, `session.rs`, `chat_app.rs`) to use the new settings and theme fields.
2025-10-12 16:44:53 +02:00

113 lines
3.5 KiB
Rust

use crate::types::Message;
use crate::ui::RoleLabelDisplay;
/// Formats messages for display across different clients.
#[derive(Debug, Clone)]
pub struct MessageFormatter {
wrap_width: usize,
role_label_mode: RoleLabelDisplay,
preserve_empty_lines: bool,
}
impl MessageFormatter {
/// Create a new formatter
pub fn new(wrap_width: usize, role_label_mode: RoleLabelDisplay) -> Self {
Self {
wrap_width: wrap_width.max(20),
role_label_mode,
preserve_empty_lines: false,
}
}
/// Override whether empty lines should be preserved
pub fn with_preserve_empty(mut self, preserve: bool) -> Self {
self.preserve_empty_lines = preserve;
self
}
/// Update the wrap width
pub fn set_wrap_width(&mut self, width: usize) {
self.wrap_width = width.max(20);
}
/// The configured role label layout preference.
pub fn role_label_mode(&self) -> RoleLabelDisplay {
self.role_label_mode
}
/// Whether any role label should be shown alongside messages.
pub fn show_role_labels(&self) -> bool {
!matches!(self.role_label_mode, RoleLabelDisplay::None)
}
/// Update the role label layout preference.
pub fn set_role_label_mode(&mut self, mode: RoleLabelDisplay) {
self.role_label_mode = mode;
}
pub fn format_message(&self, message: &Message) -> Vec<String> {
message
.content
.trim()
.lines()
.map(|s| s.to_string())
.collect()
}
/// Extract thinking content from <think> tags, returning (content_without_think, thinking_content)
/// This handles both complete and incomplete (streaming) think tags.
pub fn extract_thinking(&self, content: &str) -> (String, Option<String>) {
let mut result = String::new();
let mut thinking = String::new();
let mut current_pos = 0;
while let Some(start_pos) = content[current_pos..].find("<think>") {
let abs_start = current_pos + start_pos;
// Add content before <think> tag to result
result.push_str(&content[current_pos..abs_start]);
// Find closing tag
if let Some(end_pos) = content[abs_start..].find("</think>") {
let abs_end = abs_start + end_pos;
let think_content = &content[abs_start + 7..abs_end]; // 7 = len("<think>")
if !thinking.is_empty() {
thinking.push_str("\n\n");
}
thinking.push_str(think_content.trim());
current_pos = abs_end + 8; // 8 = len("</think>")
} else {
// Unclosed tag - this is streaming content
// Extract everything after <think> as thinking content
let think_content = &content[abs_start + 7..]; // 7 = len("<think>")
if !thinking.is_empty() {
thinking.push_str("\n\n");
}
thinking.push_str(think_content);
current_pos = content.len();
break;
}
}
// Add remaining content
result.push_str(&content[current_pos..]);
let thinking_result = if thinking.is_empty() {
None
} else {
Some(thinking)
};
// If the result is empty but we have thinking content, show a placeholder
if result.trim().is_empty() && thinking_result.is_some() {
result.push_str("[Thinking...]");
}
(result, thinking_result)
}
}