diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index a180330..316536d 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -1,8 +1,11 @@ use crate::ProviderConfig; use crate::Result; use crate::mode::ModeConfig; +use crate::ui::RoleLabelDisplay; +use serde::de::{self, Deserializer, Visitor}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -11,7 +14,7 @@ use std::time::Duration; pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml"; /// Current schema version written to `config.toml`. -pub const CONFIG_SCHEMA_VERSION: &str = "1.2.0"; +pub const CONFIG_SCHEMA_VERSION: &str = "1.3.0"; /// Core configuration shared by all OWLEN clients #[derive(Debug, Clone, Serialize, Deserialize)] @@ -700,8 +703,13 @@ pub struct UiSettings { pub word_wrap: bool, #[serde(default = "UiSettings::default_max_history_lines")] pub max_history_lines: usize, - #[serde(default = "UiSettings::default_show_role_labels")] - pub show_role_labels: bool, + #[serde( + rename = "role_label", + alias = "show_role_labels", + default = "UiSettings::default_role_label_mode", + deserialize_with = "UiSettings::deserialize_role_label_mode" + )] + pub role_label_mode: RoleLabelDisplay, #[serde(default = "UiSettings::default_wrap_column")] pub wrap_column: u16, #[serde(default = "UiSettings::default_show_onboarding")] @@ -712,6 +720,8 @@ pub struct UiSettings { pub scrollback_lines: usize, #[serde(default = "UiSettings::default_show_cursor_outside_insert")] pub show_cursor_outside_insert: bool, + #[serde(default = "UiSettings::default_syntax_highlighting")] + pub syntax_highlighting: bool, } impl UiSettings { @@ -727,8 +737,8 @@ impl UiSettings { 2000 } - fn default_show_role_labels() -> bool { - true + const fn default_role_label_mode() -> RoleLabelDisplay { + RoleLabelDisplay::Above } fn default_wrap_column() -> u16 { @@ -750,6 +760,62 @@ impl UiSettings { const fn default_show_cursor_outside_insert() -> bool { false } + + const fn default_syntax_highlighting() -> bool { + false + } + + fn deserialize_role_label_mode<'de, D>( + deserializer: D, + ) -> std::result::Result + where + D: Deserializer<'de>, + { + struct RoleLabelModeVisitor; + + impl<'de> Visitor<'de> for RoleLabelModeVisitor { + type Value = RoleLabelDisplay; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("`inline`, `above`, `none`, or a legacy boolean") + } + + fn visit_str(self, v: &str) -> std::result::Result + where + E: de::Error, + { + match v.trim().to_ascii_lowercase().as_str() { + "inline" => Ok(RoleLabelDisplay::Inline), + "above" => Ok(RoleLabelDisplay::Above), + "none" => Ok(RoleLabelDisplay::None), + other => Err(de::Error::unknown_variant( + other, + &["inline", "above", "none"], + )), + } + } + + fn visit_string(self, v: String) -> std::result::Result + where + E: de::Error, + { + self.visit_str(&v) + } + + fn visit_bool(self, v: bool) -> std::result::Result + where + E: de::Error, + { + if v { + Ok(RoleLabelDisplay::Above) + } else { + Ok(RoleLabelDisplay::None) + } + } + } + + deserializer.deserialize_any(RoleLabelModeVisitor) + } } impl Default for UiSettings { @@ -758,12 +824,13 @@ impl Default for UiSettings { theme: Self::default_theme(), word_wrap: Self::default_word_wrap(), max_history_lines: Self::default_max_history_lines(), - show_role_labels: Self::default_show_role_labels(), + role_label_mode: Self::default_role_label_mode(), wrap_column: Self::default_wrap_column(), show_onboarding: Self::default_show_onboarding(), input_max_rows: Self::default_input_max_rows(), scrollback_lines: Self::default_scrollback_lines(), show_cursor_outside_insert: Self::default_show_cursor_outside_insert(), + syntax_highlighting: Self::default_syntax_highlighting(), } } } @@ -919,7 +986,9 @@ mod tests { #[test] fn expand_provider_env_vars_resolves_api_key() { - std::env::set_var("OWLEN_TEST_API_KEY", "super-secret"); + unsafe { + std::env::set_var("OWLEN_TEST_API_KEY", "super-secret"); + } let mut config = Config::default(); if let Some(ollama) = config.providers.get_mut("ollama") { @@ -935,12 +1004,16 @@ mod tests { Some("super-secret") ); - std::env::remove_var("OWLEN_TEST_API_KEY"); + unsafe { + std::env::remove_var("OWLEN_TEST_API_KEY"); + } } #[test] fn expand_provider_env_vars_errors_for_missing_variable() { - std::env::remove_var("OWLEN_TEST_MISSING"); + unsafe { + std::env::remove_var("OWLEN_TEST_MISSING"); + } let mut config = Config::default(); if let Some(ollama) = config.providers.get_mut("ollama") { diff --git a/crates/owlen-core/src/formatting.rs b/crates/owlen-core/src/formatting.rs index 55d0a48..65ad786 100644 --- a/crates/owlen-core/src/formatting.rs +++ b/crates/owlen-core/src/formatting.rs @@ -1,19 +1,20 @@ use crate::types::Message; +use crate::ui::RoleLabelDisplay; /// Formats messages for display across different clients. #[derive(Debug, Clone)] pub struct MessageFormatter { wrap_width: usize, - show_role_labels: bool, + role_label_mode: RoleLabelDisplay, preserve_empty_lines: bool, } impl MessageFormatter { /// Create a new formatter - pub fn new(wrap_width: usize, show_role_labels: bool) -> Self { + pub fn new(wrap_width: usize, role_label_mode: RoleLabelDisplay) -> Self { Self { wrap_width: wrap_width.max(20), - show_role_labels, + role_label_mode, preserve_empty_lines: false, } } @@ -29,9 +30,19 @@ impl MessageFormatter { self.wrap_width = width.max(20); } - /// Whether role labels should be shown alongside messages + /// 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 { - self.show_role_labels + !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 { diff --git a/crates/owlen-core/src/providers/ollama.rs b/crates/owlen-core/src/providers/ollama.rs index 96fe8e5..90bae4a 100644 --- a/crates/owlen-core/src/providers/ollama.rs +++ b/crates/owlen-core/src/providers/ollama.rs @@ -1031,12 +1031,16 @@ mod tests { #[test] fn resolve_api_key_expands_env_var() { - std::env::set_var("OLLAMA_TEST_KEY", "secret"); + unsafe { + std::env::set_var("OLLAMA_TEST_KEY", "secret"); + } assert_eq!( resolve_api_key(Some("${OLLAMA_TEST_KEY}".into())), Some("secret".into()) ); - std::env::remove_var("OLLAMA_TEST_KEY"); + unsafe { + std::env::remove_var("OLLAMA_TEST_KEY"); + } } #[test] diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs index 66c881b..62ada6f 100644 --- a/crates/owlen-core/src/session.rs +++ b/crates/owlen-core/src/session.rs @@ -16,7 +16,7 @@ use crate::storage::{SessionMeta, StorageManager}; use crate::types::{ ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, ToolCall, }; -use crate::ui::UiController; +use crate::ui::{RoleLabelDisplay, UiController}; use crate::validation::{SchemaValidator, get_builtin_schemas}; use crate::{ChatStream, Provider}; use crate::{ @@ -264,7 +264,7 @@ impl SessionController { ); let formatter = MessageFormatter::new( config_guard.ui.wrap_column as usize, - config_guard.ui.show_role_labels, + config_guard.ui.role_label_mode, ) .with_preserve_empty(config_guard.ui.word_wrap); let input_buffer = InputBuffer::new( @@ -351,6 +351,10 @@ impl SessionController { self.formatter.set_wrap_width(width); } + pub fn set_role_label_mode(&mut self, mode: RoleLabelDisplay) { + self.formatter.set_role_label_mode(mode); + } + // Asynchronous access to the configuration (used internally). pub async fn config_async(&self) -> tokio::sync::MutexGuard<'_, Config> { self.config.lock().await diff --git a/crates/owlen-core/src/theme.rs b/crates/owlen-core/src/theme.rs index 3fb8083..76f2eaf 100644 --- a/crates/owlen-core/src/theme.rs +++ b/crates/owlen-core/src/theme.rs @@ -116,6 +116,42 @@ pub struct Theme { #[serde(serialize_with = "serialize_color")] pub cursor: Color, + /// Code block background color + #[serde(default = "Theme::default_code_block_background")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub code_block_background: Color, + + /// Code block border color + #[serde(default = "Theme::default_code_block_border")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub code_block_border: Color, + + /// Code block text color + #[serde(default = "Theme::default_code_block_text")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub code_block_text: Color, + + /// Code block keyword color + #[serde(default = "Theme::default_code_block_keyword")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub code_block_keyword: Color, + + /// Code block string literal color + #[serde(default = "Theme::default_code_block_string")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub code_block_string: Color, + + /// Code block comment color + #[serde(default = "Theme::default_code_block_comment")] + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub code_block_comment: Color, + /// Placeholder text color #[serde(deserialize_with = "deserialize_color")] #[serde(serialize_with = "serialize_color")] @@ -217,6 +253,30 @@ impl Default for Theme { } impl Theme { + const fn default_code_block_background() -> Color { + Color::Black + } + + const fn default_code_block_border() -> Color { + Color::Gray + } + + const fn default_code_block_text() -> Color { + Color::White + } + + const fn default_code_block_keyword() -> Color { + Color::Yellow + } + + const fn default_code_block_string() -> Color { + Color::LightGreen + } + + const fn default_code_block_comment() -> Color { + Color::DarkGray + } + const fn default_agent_thought() -> Color { Color::LightBlue } @@ -430,6 +490,12 @@ fn default_dark() -> Theme { selection_bg: Color::LightBlue, selection_fg: Color::Black, cursor: Color::Magenta, + code_block_background: Color::Rgb(25, 25, 25), + code_block_border: Color::LightMagenta, + code_block_text: Color::White, + code_block_keyword: Color::Yellow, + code_block_string: Color::LightGreen, + code_block_comment: Color::Gray, placeholder: Color::DarkGray, error: Color::Red, info: Color::LightGreen, @@ -473,6 +539,12 @@ fn default_light() -> Theme { selection_bg: Color::Rgb(164, 200, 240), selection_fg: Color::Black, cursor: Color::Rgb(217, 95, 2), + code_block_background: Color::Rgb(245, 245, 245), + code_block_border: Color::Rgb(142, 68, 173), + code_block_text: Color::Black, + code_block_keyword: Color::Rgb(181, 137, 0), + code_block_string: Color::Rgb(46, 139, 87), + code_block_comment: Color::Gray, placeholder: Color::Gray, error: Color::Rgb(192, 57, 43), info: Color::Green, @@ -516,6 +588,12 @@ fn gruvbox() -> Theme { selection_bg: Color::Rgb(80, 73, 69), selection_fg: Color::Rgb(235, 219, 178), cursor: Color::Rgb(254, 128, 25), + code_block_background: Color::Rgb(60, 56, 54), + code_block_border: Color::Rgb(124, 111, 100), + code_block_text: Color::Rgb(235, 219, 178), + code_block_keyword: Color::Rgb(250, 189, 47), + code_block_string: Color::Rgb(142, 192, 124), + code_block_comment: Color::Rgb(124, 111, 100), placeholder: Color::Rgb(102, 92, 84), error: Color::Rgb(251, 73, 52), // #fb4934 info: Color::Rgb(184, 187, 38), @@ -559,6 +637,12 @@ fn dracula() -> Theme { selection_bg: Color::Rgb(68, 71, 90), selection_fg: Color::Rgb(248, 248, 242), cursor: Color::Rgb(255, 121, 198), + code_block_background: Color::Rgb(68, 71, 90), + code_block_border: Color::Rgb(189, 147, 249), + code_block_text: Color::Rgb(248, 248, 242), + code_block_keyword: Color::Rgb(255, 121, 198), + code_block_string: Color::Rgb(80, 250, 123), + code_block_comment: Color::Rgb(98, 114, 164), placeholder: Color::Rgb(98, 114, 164), error: Color::Rgb(255, 85, 85), // #ff5555 info: Color::Rgb(80, 250, 123), @@ -602,6 +686,12 @@ fn solarized() -> Theme { selection_bg: Color::Rgb(7, 54, 66), selection_fg: Color::Rgb(147, 161, 161), cursor: Color::Rgb(211, 54, 130), + code_block_background: Color::Rgb(7, 54, 66), + code_block_border: Color::Rgb(38, 139, 210), + code_block_text: Color::Rgb(147, 161, 161), + code_block_keyword: Color::Rgb(181, 137, 0), + code_block_string: Color::Rgb(133, 153, 0), + code_block_comment: Color::Rgb(88, 110, 117), placeholder: Color::Rgb(88, 110, 117), error: Color::Rgb(220, 50, 47), // #dc322f (red) info: Color::Rgb(133, 153, 0), @@ -645,6 +735,12 @@ fn midnight_ocean() -> Theme { selection_bg: Color::Rgb(56, 139, 253), selection_fg: Color::Rgb(13, 17, 23), cursor: Color::Rgb(246, 140, 245), + code_block_background: Color::Rgb(22, 27, 34), + code_block_border: Color::Rgb(88, 166, 255), + code_block_text: Color::Rgb(192, 202, 245), + code_block_keyword: Color::Rgb(255, 212, 59), + code_block_string: Color::Rgb(158, 206, 106), + code_block_comment: Color::Rgb(110, 118, 129), placeholder: Color::Rgb(110, 118, 129), error: Color::Rgb(248, 81, 73), info: Color::Rgb(158, 206, 106), @@ -688,6 +784,12 @@ fn rose_pine() -> Theme { selection_bg: Color::Rgb(64, 61, 82), selection_fg: Color::Rgb(224, 222, 244), cursor: Color::Rgb(235, 111, 146), + code_block_background: Color::Rgb(38, 35, 58), + code_block_border: Color::Rgb(235, 111, 146), + code_block_text: Color::Rgb(224, 222, 244), + code_block_keyword: Color::Rgb(246, 193, 119), + code_block_string: Color::Rgb(156, 207, 216), + code_block_comment: Color::Rgb(110, 106, 134), placeholder: Color::Rgb(110, 106, 134), error: Color::Rgb(235, 111, 146), info: Color::Rgb(156, 207, 216), @@ -731,6 +833,12 @@ fn monokai() -> Theme { selection_bg: Color::Rgb(117, 113, 94), selection_fg: Color::Rgb(248, 248, 242), cursor: Color::Rgb(249, 38, 114), + code_block_background: Color::Rgb(50, 51, 46), + code_block_border: Color::Rgb(249, 38, 114), + code_block_text: Color::Rgb(248, 248, 242), + code_block_keyword: Color::Rgb(230, 219, 116), + code_block_string: Color::Rgb(166, 226, 46), + code_block_comment: Color::Rgb(117, 113, 94), placeholder: Color::Rgb(117, 113, 94), error: Color::Rgb(249, 38, 114), info: Color::Rgb(166, 226, 46), @@ -774,6 +882,12 @@ fn material_dark() -> Theme { selection_bg: Color::Rgb(84, 110, 122), selection_fg: Color::Rgb(238, 255, 255), cursor: Color::Rgb(255, 204, 0), + code_block_background: Color::Rgb(33, 43, 48), + code_block_border: Color::Rgb(128, 203, 196), + code_block_text: Color::Rgb(238, 255, 255), + code_block_keyword: Color::Rgb(255, 203, 107), + code_block_string: Color::Rgb(195, 232, 141), + code_block_comment: Color::Rgb(84, 110, 122), placeholder: Color::Rgb(84, 110, 122), error: Color::Rgb(240, 113, 120), info: Color::Rgb(195, 232, 141), @@ -817,6 +931,12 @@ fn material_light() -> Theme { selection_bg: Color::Rgb(176, 190, 197), selection_fg: Color::Rgb(33, 33, 33), cursor: Color::Rgb(194, 24, 91), + code_block_background: Color::Rgb(248, 249, 250), + code_block_border: Color::Rgb(0, 150, 136), + code_block_text: Color::Rgb(33, 33, 33), + code_block_keyword: Color::Rgb(245, 124, 0), + code_block_string: Color::Rgb(56, 142, 60), + code_block_comment: Color::Rgb(144, 164, 174), placeholder: Color::Rgb(144, 164, 174), error: Color::Rgb(211, 47, 47), info: Color::Rgb(56, 142, 60), @@ -860,6 +980,12 @@ fn grayscale_high_contrast() -> Theme { selection_bg: Color::Rgb(240, 240, 240), selection_fg: Color::Black, cursor: Color::White, + code_block_background: Color::Rgb(15, 15, 15), + code_block_border: Color::White, + code_block_text: Color::Rgb(247, 247, 247), + code_block_keyword: Color::Rgb(204, 204, 204), + code_block_string: Color::Rgb(214, 214, 214), + code_block_comment: Color::Rgb(122, 122, 122), placeholder: Color::Rgb(122, 122, 122), error: Color::White, info: Color::Rgb(200, 200, 200), diff --git a/crates/owlen-core/src/ui.rs b/crates/owlen-core/src/ui.rs index 130e3f7..74c4581 100644 --- a/crates/owlen-core/src/ui.rs +++ b/crates/owlen-core/src/ui.rs @@ -18,6 +18,17 @@ pub use crate::state::AutoScroll; /// Visual selection state for text selection pub use crate::state::VisualSelection; +use serde::{Deserialize, Serialize}; + +/// How role labels should be rendered alongside chat messages. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RoleLabelDisplay { + Inline, + Above, + None, +} + /// Extract text from a selection range in a list of lines pub fn extract_text_from_selection( lines: &[String], diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index c630a43..058cbab 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -7,13 +7,14 @@ use owlen_core::{ storage::SessionMeta, theme::Theme, types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role}, - ui::{AppState, AutoScroll, FocusedPanel, InputMode}, + ui::{AppState, AutoScroll, FocusedPanel, InputMode, RoleLabelDisplay}, }; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use textwrap::{Options, WordSeparator, wrap}; use tokio::{sync::mpsc, task::JoinHandle}; use tui_textarea::{Input, TextArea}; +use unicode_width::UnicodeWidthStr; use uuid::Uuid; use crate::commands; @@ -156,6 +157,8 @@ pub struct ChatApp { current_provider: String, // Provider backing the active session message_line_cache: HashMap, // Cached rendered lines per message show_cursor_outside_insert: bool, // Configurable cursor visibility flag + syntax_highlighting: bool, // Whether syntax highlighting is enabled + supports_extended_colors: bool, // Terminal supports 256-color output auto_scroll: AutoScroll, // Auto-scroll state for message rendering thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel viewport_height: usize, // Track the height of the messages viewport @@ -217,43 +220,59 @@ pub struct ConsentDialogState { struct MessageCacheEntry { theme_name: String, wrap_width: usize, - show_role_labels: bool, + role_label_mode: RoleLabelDisplay, + syntax_highlighting: bool, content_hash: u64, lines: Vec>, } pub(crate) struct MessageRenderContext<'a> { formatter: &'a mut owlen_core::formatting::MessageFormatter, - show_role_labels: bool, + role_label_mode: RoleLabelDisplay, content_width: usize, is_last_message: bool, is_streaming: bool, loading_indicator: &'a str, theme: &'a Theme, + syntax_highlighting: bool, } impl<'a> MessageRenderContext<'a> { + #[allow(clippy::too_many_arguments)] pub(crate) fn new( formatter: &'a mut owlen_core::formatting::MessageFormatter, - show_role_labels: bool, + role_label_mode: RoleLabelDisplay, content_width: usize, is_last_message: bool, is_streaming: bool, loading_indicator: &'a str, theme: &'a Theme, + syntax_highlighting: bool, ) -> Self { Self { formatter, - show_role_labels, + role_label_mode, content_width, is_last_message, is_streaming, loading_indicator, theme, + syntax_highlighting, } } } +#[derive(Debug, Clone)] +enum MessageSegment { + Text { + lines: Vec, + }, + CodeBlock { + language: Option, + lines: Vec, + }, +} + impl ChatApp { pub async fn new( controller: SessionController, @@ -268,6 +287,7 @@ impl ChatApp { let current_provider = config_guard.general.default_provider.clone(); let show_onboarding = config_guard.ui.show_onboarding; let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert; + let syntax_highlighting = config_guard.ui.syntax_highlighting; drop(config_guard); let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| { eprintln!("Warning: Theme '{}' not found, using default", theme_name); @@ -342,6 +362,8 @@ impl ChatApp { operating_mode: owlen_core::mode::Mode::default(), new_message_alert: false, show_cursor_outside_insert, + syntax_highlighting, + supports_extended_colors: detect_extended_color_support(), }; app.update_command_palette_catalog(); @@ -837,11 +859,18 @@ impl ChatApp { } fn sync_ui_preferences_from_config(&mut self) { - let show_cursor = { + let (show_cursor, role_label_mode, syntax_highlighting) = { let guard = self.controller.config(); - guard.ui.show_cursor_outside_insert + ( + guard.ui.show_cursor_outside_insert, + guard.ui.role_label_mode, + guard.ui.syntax_highlighting, + ) }; self.show_cursor_outside_insert = show_cursor; + self.syntax_highlighting = syntax_highlighting; + self.controller.set_role_label_mode(role_label_mode); + self.message_line_cache.clear(); } pub fn cursor_should_be_visible(&self) -> bool { @@ -852,6 +881,10 @@ impl ChatApp { } } + pub fn should_highlight_code(&self) -> bool { + self.syntax_highlighting && self.supports_extended_colors + } + pub(crate) fn render_message_lines_cached( &mut self, message_index: usize, @@ -859,12 +892,13 @@ impl ChatApp { ) -> Vec> { let MessageRenderContext { formatter, - show_role_labels, + role_label_mode, content_width, is_last_message, is_streaming, loading_indicator, theme, + syntax_highlighting, } = ctx; let (message_id, role, raw_content) = { let conversation = self.conversation(); @@ -880,18 +914,17 @@ impl ChatApp { raw_content }; - let formatted_lines: Vec = display_content - .trim() - .lines() - .map(|s| s.to_string()) - .collect(); - let content = formatted_lines.join("\n"); + let normalized_content = display_content.replace("\r\n", "\n"); + let trimmed = normalized_content.trim(); + let content = trimmed.to_string(); + let segments = parse_message_segments(trimmed); let content_hash = Self::message_content_hash(&role, &content); if !is_streaming && let Some(entry) = self.message_line_cache.get(&message_id) && entry.wrap_width == content_width - && entry.show_role_labels == show_role_labels + && entry.role_label_mode == role_label_mode + && entry.syntax_highlighting == syntax_highlighting && entry.theme_name == theme.name && entry.content_hash == content_hash { @@ -900,57 +933,214 @@ impl ChatApp { let mut rendered: Vec> = Vec::new(); let content_style = Self::content_style(theme, &role); + let mut indicator_target: Option = None; - if show_role_labels { - let (emoji, name) = match role { - Role::User => ("👤 ", "You: "), - Role::Assistant => ("🤖 ", "Assistant: "), - Role::System => ("⚙️ ", "System: "), - Role::Tool => ("🔧 ", "Tool: "), - }; - - let mut role_line_spans = vec![ - Span::raw(emoji), - Span::styled( - name.to_string(), - Self::role_style(theme, &role).add_modifier(Modifier::BOLD), - ), - ]; - - if matches!(role, Role::Assistant) - && is_streaming - && is_last_message - && !loading_indicator.is_empty() - { - role_line_spans.push(Span::styled( - format!(" {}", loading_indicator), - Style::default().fg(theme.info), - )); - } - - rendered.push(Line::from(role_line_spans)); - - let indent = " "; - let available_width = content_width.saturating_sub(2); - let chunks = wrap_unicode(content.as_str(), available_width); - - let last_index = chunks.len().saturating_sub(1); - for (chunk_idx, seg) in chunks.into_iter().enumerate() { - let mut spans = vec![Span::styled(format!("{indent}{seg}"), content_style)]; - if chunk_idx == last_index && is_streaming { - spans.push(Span::styled(" ▌", Style::default().fg(theme.cursor))); - } - rendered.push(Line::from(spans)); - } + let indicator_span = if is_streaming { + Some(Span::styled( + format!(" {}", streaming_indicator_symbol(loading_indicator)), + Style::default().fg(theme.cursor), + )) } else { - let chunks = wrap_unicode(content.as_str(), content_width); - let last_index = chunks.len().saturating_sub(1); - for (chunk_idx, seg) in chunks.into_iter().enumerate() { - let mut spans = vec![Span::styled(seg, content_style)]; - if chunk_idx == last_index && is_streaming { - spans.push(Span::styled(" ▌", Style::default().fg(theme.cursor))); + None + }; + + match role_label_mode { + RoleLabelDisplay::Above => { + let (emoji, title) = role_label_parts(&role); + let mut role_line_spans = vec![ + Span::raw(format!("{emoji} ")), + Span::styled( + format!("{title}:"), + Self::role_style(theme, &role).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]; + + if matches!(role, Role::Assistant) + && is_streaming + && is_last_message + && !loading_indicator.is_empty() + { + role_line_spans.push(Span::styled( + format!(" {}", loading_indicator), + Style::default().fg(theme.info), + )); } - rendered.push(Line::from(spans)); + + rendered.push(Line::from(role_line_spans)); + + let indent = " "; + let indent_width = UnicodeWidthStr::width(indent); + let available_width = content_width.saturating_sub(indent_width).max(1); + + if segments.is_empty() { + rendered.push(Line::from(vec![Span::styled( + indent.to_string(), + content_style, + )])); + indicator_target = Some(rendered.len() - 1); + } else { + for segment in &segments { + match segment { + MessageSegment::Text { lines } => { + for line in lines { + let mut chunks = wrap_unicode(line.as_str(), available_width); + if chunks.is_empty() { + chunks.push(String::new()); + } + for chunk in chunks { + let text = format!("{indent}{chunk}"); + rendered.push(Line::from(vec![Span::styled( + text, + content_style, + )])); + indicator_target = Some(rendered.len() - 1); + } + } + } + MessageSegment::CodeBlock { language, lines } => { + append_code_block_lines( + &mut rendered, + indent, + content_width.saturating_sub(indent_width), + language.as_deref(), + lines, + theme, + syntax_highlighting, + &mut indicator_target, + ); + } + } + } + } + } + RoleLabelDisplay::Inline => { + let (emoji, title) = role_label_parts(&role); + let label_style = Self::role_style(theme, &role).add_modifier(Modifier::BOLD); + let inline_label = format!("{emoji} {title}:"); + let label_width = UnicodeWidthStr::width(inline_label.as_str()); + let max_label_width = max_inline_label_width(); + let padding = max_label_width.saturating_sub(label_width); + let indent_columns = max_label_width + 1; + let inline_indent = " ".repeat(indent_columns); + let available_width = content_width.saturating_sub(indent_columns).max(1); + + let mut label_spans = vec![ + Span::raw(format!("{emoji} ")), + Span::styled(format!("{title}:"), label_style), + ]; + if padding > 0 { + label_spans.push(Span::raw(" ".repeat(padding))); + } + label_spans.push(Span::raw(" ")); + + let mut pending_label = Some(label_spans); + + if segments.is_empty() { + if let Some(spans) = pending_label.take() { + rendered.push(Line::from(spans)); + indicator_target = Some(rendered.len() - 1); + } + } else { + for segment in &segments { + match segment { + MessageSegment::Text { lines } => { + for line in lines { + let mut chunks = wrap_unicode(line.as_str(), available_width); + if chunks.is_empty() { + chunks.push(String::new()); + } + for chunk in chunks { + if let Some(mut spans) = pending_label.take() { + spans.push(Span::styled(chunk.clone(), content_style)); + rendered.push(Line::from(spans)); + } else { + let text = format!("{inline_indent}{chunk}"); + rendered.push(Line::from(vec![Span::styled( + text, + content_style, + )])); + } + indicator_target = Some(rendered.len() - 1); + } + } + } + MessageSegment::CodeBlock { language, lines } => { + if let Some(spans) = pending_label.take() { + rendered.push(Line::from(spans)); + indicator_target = Some(rendered.len() - 1); + } + append_code_block_lines( + &mut rendered, + inline_indent.as_str(), + content_width.saturating_sub(indent_columns), + language.as_deref(), + lines, + theme, + syntax_highlighting, + &mut indicator_target, + ); + } + } + } + } + + if let Some(spans) = pending_label.take() { + rendered.push(Line::from(spans)); + indicator_target = Some(rendered.len() - 1); + } + } + RoleLabelDisplay::None => { + let available_width = content_width.max(1); + + if segments.is_empty() { + rendered.push(Line::from(vec![Span::styled(String::new(), content_style)])); + indicator_target = Some(rendered.len() - 1); + } else { + for segment in &segments { + match segment { + MessageSegment::Text { lines } => { + for line in lines { + let mut chunks = wrap_unicode(line.as_str(), available_width); + if chunks.is_empty() { + chunks.push(String::new()); + } + for chunk in chunks { + rendered.push(Line::from(vec![Span::styled( + chunk.clone(), + content_style, + )])); + indicator_target = Some(rendered.len() - 1); + } + } + } + MessageSegment::CodeBlock { language, lines } => { + append_code_block_lines( + &mut rendered, + "", + content_width, + language.as_deref(), + lines, + theme, + syntax_highlighting, + &mut indicator_target, + ); + } + } + } + } + } + } + + if let Some(indicator) = indicator_span { + if let Some(idx) = indicator_target { + if let Some(line) = rendered.get_mut(idx) { + line.spans.push(indicator); + } else { + rendered.push(Line::from(vec![indicator])); + } + } else { + rendered.push(Line::from(vec![indicator])); } } @@ -960,7 +1150,8 @@ impl ChatApp { MessageCacheEntry { theme_name: theme.name.clone(), wrap_width: content_width, - show_role_labels, + role_label_mode, + syntax_highlighting, content_hash, lines: rendered.clone(), }, @@ -1297,12 +1488,13 @@ impl ChatApp { } } - if matches!(key.code, KeyCode::F(1)) - || matches!( - (key.code, key.modifiers), - (KeyCode::Char('?'), KeyModifiers::NONE | KeyModifiers::SHIFT) - ) - { + let is_help_key = matches!(key.code, KeyCode::F(1)); + let is_question_mark = matches!( + (key.code, key.modifiers), + (KeyCode::Char('?'), KeyModifiers::NONE | KeyModifiers::SHIFT) + ); + + if is_help_key || (is_question_mark && matches!(self.mode, InputMode::Normal)) { self.mode = InputMode::Help; self.status = "Help".to_string(); return Ok(AppState::Running); @@ -4396,18 +4588,12 @@ impl ChatApp { let mut formatter = self.formatter().clone(); let wrap_width = self.content_width.max(20); formatter.set_wrap_width(wrap_width); - let show_role_labels = formatter.show_role_labels(); + let role_label_mode = formatter.role_label_mode(); let mut lines = 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: "), - }; let content_to_display = if matches!(role, Role::Assistant) { let (content_without_think, _) = @@ -4425,35 +4611,194 @@ impl ChatApp { .and_then(|v| v.as_bool()) .unwrap_or(false); - let formatted: Vec = content_to_display - .trim() - .lines() - .map(|s| s.to_string()) - .collect(); - let content = formatted.join("\n"); + let normalized_content = content_to_display.replace("\r\n", "\n"); + let trimmed = normalized_content.trim(); + let segments = parse_message_segments(trimmed); + let mut indicator_target: Option = None; + let spinner_symbol = streaming_indicator_symbol(self.get_loading_indicator()); + let is_last_message = message_index + 1 == conversation.messages.len(); - if show_role_labels { - lines.push(format!("{}{}", emoji, name)); - let indent = " "; - let available_width = wrap_width.saturating_sub(2); - let chunks = wrap_unicode(content.as_str(), available_width); - let last_index = chunks.len().saturating_sub(1); - for (chunk_idx, seg) in chunks.into_iter().enumerate() { - let mut line = format!("{indent}{seg}"); - if chunk_idx == last_index && is_streaming { - line.push_str(" ▌"); + match role_label_mode { + RoleLabelDisplay::Above => { + let (emoji, title) = role_label_parts(role); + let mut label_line = format!("{emoji} {title}: "); + if matches!(role, Role::Assistant) + && is_streaming + && is_last_message + && !self.get_loading_indicator().is_empty() + { + label_line.push(' '); + label_line.push_str(self.get_loading_indicator()); + } + lines.push(label_line); + + let indent = " "; + let indent_width = UnicodeWidthStr::width(indent); + let available_width = wrap_width.saturating_sub(indent_width).max(1); + + if segments.is_empty() { + lines.push(indent.to_string()); + indicator_target = Some(lines.len() - 1); + } else { + for segment in &segments { + match segment { + MessageSegment::Text { lines: seg_lines } => { + for line_text in seg_lines { + let mut chunks = wrap_unicode( + line_text.as_str(), + available_width, + ); + if chunks.is_empty() { + chunks.push(String::new()); + } + for chunk in chunks { + lines.push(format!("{indent}{chunk}")); + indicator_target = Some(lines.len() - 1); + } + } + } + MessageSegment::CodeBlock { + language, + lines: code_lines, + } => { + append_code_block_lines_plain( + &mut lines, + indent, + wrap_width.saturating_sub(indent_width), + language.as_deref(), + code_lines, + &mut indicator_target, + ); + } + } + } } - lines.push(line); } - } else { - let chunks = wrap_unicode(content.as_str(), wrap_width); - let last_index = chunks.len().saturating_sub(1); - for (chunk_idx, seg) in chunks.into_iter().enumerate() { - let mut line = seg; - if chunk_idx == last_index && is_streaming { - line.push_str(" ▌"); + RoleLabelDisplay::Inline => { + let (emoji, title) = role_label_parts(role); + let inline_label = format!("{emoji} {title}:"); + let label_width = UnicodeWidthStr::width(inline_label.as_str()); + let max_label_width = max_inline_label_width(); + let padding = max_label_width.saturating_sub(label_width); + let indent_columns = max_label_width + 1; + let inline_indent = " ".repeat(indent_columns); + let available_width = wrap_width.saturating_sub(indent_columns).max(1); + + let mut label_line = inline_label.clone(); + if padding > 0 { + label_line.push_str(&" ".repeat(padding)); } - lines.push(line); + label_line.push(' '); + + let mut pending_label = Some(label_line); + + if segments.is_empty() { + if let Some(line) = pending_label.take() { + lines.push(line); + indicator_target = Some(lines.len() - 1); + } + } else { + for segment in &segments { + match segment { + MessageSegment::Text { lines: seg_lines } => { + for line_text in seg_lines { + let mut chunks = wrap_unicode( + line_text.as_str(), + available_width, + ); + if chunks.is_empty() { + chunks.push(String::new()); + } + for chunk in chunks { + if let Some(mut line) = pending_label.take() { + line.push_str(&chunk); + lines.push(line); + } else { + lines.push(format!( + "{inline_indent}{chunk}" + )); + } + indicator_target = Some(lines.len() - 1); + } + } + } + MessageSegment::CodeBlock { + language, + lines: code_lines, + } => { + if let Some(line) = pending_label.take() { + lines.push(line); + indicator_target = Some(lines.len() - 1); + } + append_code_block_lines_plain( + &mut lines, + inline_indent.as_str(), + wrap_width.saturating_sub(indent_columns), + language.as_deref(), + code_lines, + &mut indicator_target, + ); + } + } + } + if let Some(line) = pending_label.take() { + lines.push(line); + indicator_target = Some(lines.len() - 1); + } + } + } + RoleLabelDisplay::None => { + let available_width = wrap_width.max(1); + + if segments.is_empty() { + lines.push(String::new()); + indicator_target = Some(lines.len() - 1); + } else { + for segment in &segments { + match segment { + MessageSegment::Text { lines: seg_lines } => { + for line_text in seg_lines { + let mut chunks = wrap_unicode( + line_text.as_str(), + available_width, + ); + if chunks.is_empty() { + chunks.push(String::new()); + } + for chunk in chunks { + lines.push(chunk.clone()); + indicator_target = Some(lines.len() - 1); + } + } + } + MessageSegment::CodeBlock { + language, + lines: code_lines, + } => { + append_code_block_lines_plain( + &mut lines, + "", + wrap_width, + language.as_deref(), + code_lines, + &mut indicator_target, + ); + } + } + } + } + } + } + + if is_streaming && !spinner_symbol.is_empty() { + if let Some(idx) = indicator_target { + if let Some(line) = lines.get_mut(idx) { + line.push(' '); + line.push_str(spinner_symbol); + } + } else if let Some(line) = lines.last_mut() { + line.push(' '); + line.push_str(spinner_symbol); } } @@ -4469,7 +4814,24 @@ impl ChatApp { .unwrap_or(true); if self.get_loading_indicator() != "" && last_message_is_user { - lines.push(format!("🤖 Assistant: {}", self.get_loading_indicator())); + match role_label_mode { + RoleLabelDisplay::Inline => { + let (emoji, title) = role_label_parts(&Role::Assistant); + let inline_label = format!("{emoji} {title}:"); + let label_width = UnicodeWidthStr::width(inline_label.as_str()); + let padding = max_inline_label_width().saturating_sub(label_width); + let mut line = inline_label; + if padding > 0 { + line.push_str(&" ".repeat(padding)); + } + line.push(' '); + line.push_str(self.get_loading_indicator()); + lines.push(line); + } + _ => { + lines.push(format!("🤖 Assistant: {}", self.get_loading_indicator())); + } + } } if self.chat_line_offset > 0 { @@ -4602,6 +4964,642 @@ impl ChatApp { } } +fn detect_extended_color_support() -> bool { + let term = std::env::var("TERM").unwrap_or_default(); + if term.contains("256") || term.contains("direct") || term.contains("truecolor") { + return true; + } + + let colorterm = std::env::var("COLORTERM").unwrap_or_default(); + colorterm.contains("24bit") || colorterm.contains("truecolor") +} + +pub(crate) fn role_label_parts(role: &Role) -> (&'static str, &'static str) { + match role { + Role::User => ("👤", "You"), + Role::Assistant => ("🤖", "Assistant"), + Role::System => ("⚙️", "System"), + Role::Tool => ("🔧", "Tool"), + } +} + +pub(crate) fn max_inline_label_width() -> usize { + [ + ("👤", "You"), + ("🤖", "Assistant"), + ("⚙️", "System"), + ("🔧", "Tool"), + ] + .iter() + .map(|(emoji, title)| { + let measure = format!("{emoji} {title}:"); + UnicodeWidthStr::width(measure.as_str()) + }) + .max() + .unwrap_or(0) +} + +pub(crate) fn streaming_indicator_symbol(indicator: &str) -> &str { + if indicator.is_empty() { + "▌" + } else { + indicator + } +} + +fn parse_message_segments(content: &str) -> Vec { + let mut segments = Vec::new(); + let mut text_lines: Vec = Vec::new(); + let mut lines = content.lines(); + + while let Some(line) = lines.next() { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") { + if !text_lines.is_empty() { + segments.push(MessageSegment::Text { + lines: std::mem::take(&mut text_lines), + }); + } + + let language = trimmed + .trim_start_matches("```") + .split_whitespace() + .next() + .unwrap_or("") + .to_string(); + + let mut code_lines = Vec::new(); + for code_line in lines.by_ref() { + if code_line.trim_start().starts_with("```") { + break; + } + code_lines.push(code_line.to_string()); + } + + segments.push(MessageSegment::CodeBlock { + language: if language.is_empty() { + None + } else { + Some(language) + }, + lines: code_lines, + }); + } else { + text_lines.push(line.to_string()); + } + } + + if !text_lines.is_empty() { + segments.push(MessageSegment::Text { lines: text_lines }); + } + + segments +} + +fn wrap_code(text: &str, width: usize) -> Vec { + if width == 0 { + return vec![String::new()]; + } + + let options = Options::new(width) + .word_separator(WordSeparator::UnicodeBreakProperties) + .break_words(true); + + let mut wrapped: Vec = wrap(text, options) + .into_iter() + .map(|segment| segment.into_owned()) + .collect(); + + if wrapped.is_empty() { + wrapped.push(String::new()); + } + + wrapped +} + +#[derive(Clone, Copy)] +enum CommentMarker { + DoubleSlash, + Hash, +} + +#[derive(Clone, Copy)] +enum CodeState { + Normal, + String { delimiter: char, escaped: bool }, +} + +const RUST_KEYWORDS: &[&str] = &[ + "as", "async", "await", "break", "const", "crate", "dyn", "else", "enum", "extern", "false", + "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", + "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", + "where", "while", +]; + +const PYTHON_KEYWORDS: &[&str] = &[ + "and", "as", "assert", "break", "class", "continue", "def", "del", "elif", "else", "except", + "false", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "none", + "nonlocal", "not", "or", "pass", "raise", "return", "true", "try", "while", "with", "yield", +]; + +const JS_KEYWORDS: &[&str] = &[ + "async", + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "export", + "extends", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "let", + "new", + "return", + "switch", + "this", + "throw", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", +]; + +const GO_KEYWORDS: &[&str] = &[ + "break", + "case", + "chan", + "const", + "continue", + "default", + "defer", + "else", + "fallthrough", + "for", + "func", + "go", + "goto", + "if", + "import", + "interface", + "map", + "package", + "range", + "return", + "select", + "struct", + "switch", + "type", + "var", +]; + +const C_KEYWORDS: &[&str] = &[ + "auto", "break", "case", "char", "const", "continue", "default", "do", "double", "else", + "enum", "extern", "float", "for", "goto", "if", "inline", "int", "long", "register", + "restrict", "return", "short", "signed", "sizeof", "static", "struct", "switch", "typedef", + "union", "unsigned", "void", "volatile", "while", +]; + +const BASH_KEYWORDS: &[&str] = &[ + "case", "do", "done", "elif", "else", "esac", "fi", "for", "function", "if", "in", "select", + "then", "until", "while", +]; + +const JSON_KEYWORDS: &[&str] = &["false", "null", "true"]; + +const YAML_KEYWORDS: &[&str] = &["false", "no", "null", "true", "yes"]; + +const TOML_KEYWORDS: &[&str] = &["false", "inf", "nan", "true"]; + +fn keyword_list(language: &str) -> Option<&'static [&'static str]> { + match language { + "rust" | "rs" => Some(RUST_KEYWORDS), + "python" | "py" => Some(PYTHON_KEYWORDS), + "javascript" | "js" => Some(JS_KEYWORDS), + "typescript" | "ts" => Some(JS_KEYWORDS), + "go" | "golang" => Some(GO_KEYWORDS), + "c" | "cpp" | "c++" => Some(C_KEYWORDS), + "bash" | "sh" | "shell" => Some(BASH_KEYWORDS), + "json" => Some(JSON_KEYWORDS), + "yaml" | "yml" => Some(YAML_KEYWORDS), + "toml" => Some(TOML_KEYWORDS), + _ => None, + } +} + +fn comment_marker(language: &str) -> Option { + match language { + "rust" | "rs" | "javascript" | "js" | "typescript" | "ts" | "go" | "golang" | "c" + | "cpp" | "c++" | "java" => Some(CommentMarker::DoubleSlash), + "python" | "py" | "bash" | "sh" | "shell" | "yaml" | "yml" | "toml" => { + Some(CommentMarker::Hash) + } + _ => None, + } +} + +fn flush_normal_buffer( + buffer: &mut String, + keywords: Option<&[&str]>, + spans: &mut Vec>, + base_style: Style, + keyword_style: Style, +) { + if buffer.is_empty() { + return; + } + + if let Some(keys) = keywords { + let mut token = String::new(); + + for ch in buffer.chars() { + if ch.is_alphanumeric() || ch == '_' { + token.push(ch); + } else { + if !token.is_empty() { + let lower = token.to_ascii_lowercase(); + let style = if keys.binary_search(&lower.as_str()).is_ok() { + keyword_style + } else { + base_style + }; + spans.push(Span::styled(token.clone(), style)); + token.clear(); + } + + let mut punct = String::new(); + punct.push(ch); + spans.push(Span::styled(punct, base_style)); + } + } + + if !token.is_empty() { + let lower = token.to_ascii_lowercase(); + let style = if keys.binary_search(&lower.as_str()).is_ok() { + keyword_style + } else { + base_style + }; + spans.push(Span::styled(token.clone(), style)); + } + } else { + spans.push(Span::styled(buffer.clone(), base_style)); + } + + buffer.clear(); +} + +fn highlight_code_spans( + chunk: &str, + language: Option<&str>, + theme: &Theme, + syntax_highlighting: bool, +) -> Vec> { + let base_style = Style::default() + .fg(theme.code_block_text) + .bg(theme.code_block_background); + + if !syntax_highlighting { + return vec![Span::styled(chunk.to_string(), base_style)]; + } + + let normalized = language.map(|lang| lang.trim().to_ascii_lowercase()); + let lang_ref = normalized.as_deref(); + let keywords = lang_ref.and_then(keyword_list); + let comment = lang_ref.and_then(comment_marker); + + let keyword_style = Style::default() + .fg(theme.code_block_keyword) + .bg(theme.code_block_background) + .add_modifier(Modifier::BOLD); + let string_style = Style::default() + .fg(theme.code_block_string) + .bg(theme.code_block_background); + let comment_style = Style::default() + .fg(theme.code_block_comment) + .bg(theme.code_block_background) + .add_modifier(Modifier::ITALIC); + + let mut spans = Vec::new(); + let mut buffer = String::new(); + let chars: Vec = chunk.chars().collect(); + let mut idx = 0; + let mut state = CodeState::Normal; + + while idx < chars.len() { + match state { + CodeState::Normal => { + if let Some(marker) = comment { + let is_comment = match marker { + CommentMarker::DoubleSlash => { + chars[idx] == '/' && idx + 1 < chars.len() && chars[idx + 1] == '/' + } + CommentMarker::Hash => chars[idx] == '#', + }; + + if is_comment { + flush_normal_buffer( + &mut buffer, + keywords, + &mut spans, + base_style, + keyword_style, + ); + let comment_text: String = chars[idx..].iter().collect(); + spans.push(Span::styled(comment_text, comment_style)); + return spans; + } + } + + let ch = chars[idx]; + if ch == '"' || ch == '\'' { + flush_normal_buffer( + &mut buffer, + keywords, + &mut spans, + base_style, + keyword_style, + ); + buffer.push(ch); + state = CodeState::String { + delimiter: ch, + escaped: false, + }; + } else { + buffer.push(ch); + } + idx += 1; + } + CodeState::String { delimiter, escaped } => { + let ch = chars[idx]; + buffer.push(ch); + + let mut next_state = CodeState::String { + delimiter, + escaped: false, + }; + + if escaped { + next_state = CodeState::String { + delimiter, + escaped: false, + }; + } else if ch == '\\' { + next_state = CodeState::String { + delimiter, + escaped: true, + }; + } else if ch == delimiter { + spans.push(Span::styled(buffer.clone(), string_style)); + buffer.clear(); + next_state = CodeState::Normal; + } + + state = next_state; + idx += 1; + } + } + } + + match state { + CodeState::String { .. } => { + spans.push(Span::styled(buffer.clone(), string_style)); + } + CodeState::Normal => { + flush_normal_buffer(&mut buffer, keywords, &mut spans, base_style, keyword_style); + } + } + + spans +} + +#[allow(clippy::too_many_arguments)] +fn append_code_block_lines( + rendered: &mut Vec>, + indent: &str, + body_width: usize, + language: Option<&str>, + code_lines: &[String], + theme: &Theme, + syntax_highlighting: bool, + indicator_target: &mut Option, +) { + let body_width = body_width.max(4); + let inner_width = body_width.saturating_sub(2); + let code_width = inner_width.max(1); + + let border_style = Style::default() + .fg(theme.code_block_border) + .bg(theme.code_block_background); + let label_style = Style::default() + .fg(theme.code_block_text) + .bg(theme.code_block_background) + .add_modifier(Modifier::BOLD); + let text_style = Style::default() + .fg(theme.code_block_text) + .bg(theme.code_block_background); + + let mut top_spans = Vec::new(); + top_spans.push(Span::styled(indent.to_string(), border_style)); + top_spans.push(Span::styled("╭", border_style)); + + let language_label = language + .and_then(|lang| { + let trimmed = lang.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) + .map(|label| format!(" {} ", label)); + + if inner_width > 0 { + if let Some(label) = language_label { + let label_width = UnicodeWidthStr::width(label.as_str()); + if label_width < inner_width { + let left = (inner_width - label_width) / 2; + let right = inner_width - label_width - left; + if left > 0 { + top_spans.push(Span::styled("─".repeat(left), border_style)); + } + top_spans.push(Span::styled(label, label_style)); + if right > 0 { + top_spans.push(Span::styled("─".repeat(right), border_style)); + } + } else { + top_spans.push(Span::styled("─".repeat(inner_width), border_style)); + } + } else { + top_spans.push(Span::styled("─".repeat(inner_width), border_style)); + } + } + + top_spans.push(Span::styled("╮", border_style)); + rendered.push(Line::from(top_spans)); + + if code_lines.is_empty() { + let chunks = wrap_code("", code_width); + for chunk in chunks { + let mut spans = Vec::new(); + spans.push(Span::styled(indent.to_string(), border_style)); + spans.push(Span::styled("│", border_style)); + + let mut code_spans = highlight_code_spans(&chunk, language, theme, syntax_highlighting); + spans.append(&mut code_spans); + + let display_width = UnicodeWidthStr::width(chunk.as_str()); + if display_width < code_width { + spans.push(Span::styled( + " ".repeat(code_width - display_width), + text_style, + )); + } + + spans.push(Span::styled("│", border_style)); + rendered.push(Line::from(spans)); + *indicator_target = Some(rendered.len() - 1); + } + } else { + for line in code_lines { + let chunks = wrap_code(line.as_str(), code_width); + for chunk in chunks { + let mut spans = Vec::new(); + spans.push(Span::styled(indent.to_string(), border_style)); + spans.push(Span::styled("│", border_style)); + + let mut code_spans = + highlight_code_spans(&chunk, language, theme, syntax_highlighting); + spans.append(&mut code_spans); + + let display_width = UnicodeWidthStr::width(chunk.as_str()); + if display_width < code_width { + spans.push(Span::styled( + " ".repeat(code_width - display_width), + text_style, + )); + } + + spans.push(Span::styled("│", border_style)); + rendered.push(Line::from(spans)); + *indicator_target = Some(rendered.len() - 1); + } + } + } + + let mut bottom_spans = Vec::new(); + bottom_spans.push(Span::styled(indent.to_string(), border_style)); + bottom_spans.push(Span::styled("╰", border_style)); + if inner_width > 0 { + bottom_spans.push(Span::styled("─".repeat(inner_width), border_style)); + } + bottom_spans.push(Span::styled("╯", border_style)); + rendered.push(Line::from(bottom_spans)); +} + +fn append_code_block_lines_plain( + output: &mut Vec, + indent: &str, + body_width: usize, + language: Option<&str>, + code_lines: &[String], + indicator_target: &mut Option, +) { + let body_width = body_width.max(4); + let inner_width = body_width.saturating_sub(2); + let code_width = inner_width.max(1); + + let mut top_line = String::new(); + top_line.push_str(indent); + top_line.push('╭'); + + if inner_width > 0 { + if let Some(label) = language.and_then(|lang| { + let trimmed = lang.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) { + let label_text = format!(" {} ", label); + let label_width = UnicodeWidthStr::width(label_text.as_str()); + if label_width < inner_width { + let left = (inner_width - label_width) / 2; + let right = inner_width - label_width - left; + top_line.push_str(&"─".repeat(left)); + top_line.push_str(&label_text); + top_line.push_str(&"─".repeat(right)); + } else { + top_line.push_str(&"─".repeat(inner_width)); + } + } else { + top_line.push_str(&"─".repeat(inner_width)); + } + } + + top_line.push('╮'); + output.push(top_line); + + if code_lines.is_empty() { + let chunks = wrap_code("", code_width); + for chunk in chunks { + let mut line = String::new(); + line.push_str(indent); + line.push('│'); + line.push_str(&chunk); + let display_width = UnicodeWidthStr::width(chunk.as_str()); + if display_width < code_width { + line.push_str(&" ".repeat(code_width - display_width)); + } + line.push('│'); + output.push(line); + *indicator_target = Some(output.len() - 1); + } + } else { + for line_text in code_lines { + let chunks = wrap_code(line_text.as_str(), code_width); + for chunk in chunks { + let mut line = String::new(); + line.push_str(indent); + line.push('│'); + line.push_str(&chunk); + let display_width = UnicodeWidthStr::width(chunk.as_str()); + if display_width < code_width { + line.push_str(&" ".repeat(code_width - display_width)); + } + line.push('│'); + output.push(line); + *indicator_target = Some(output.len() - 1); + } + } + } + + let mut bottom_line = String::new(); + bottom_line.push_str(indent); + bottom_line.push('╰'); + if inner_width > 0 { + bottom_line.push_str(&"─".repeat(inner_width)); + } + bottom_line.push('╯'); + output.push(bottom_line); +} + pub(crate) fn wrap_unicode(text: &str, width: usize) -> Vec { if width == 0 { return Vec::new(); diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 47e5c74..88058db 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -12,7 +12,7 @@ use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelect use owlen_core::model::DetailedModelInfo; use owlen_core::theme::Theme; use owlen_core::types::{ModelInfo, Role}; -use owlen_core::ui::{FocusedPanel, InputMode}; +use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay}; const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1; @@ -687,7 +687,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // Build the lines for messages using cached rendering let mut lines: Vec> = Vec::new(); - let show_role_labels = formatter.show_role_labels(); + let role_label_mode = formatter.role_label_mode(); for message_index in 0..total_messages { let is_streaming = { let conversation = app.conversation(); @@ -701,12 +701,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { message_index, MessageRenderContext::new( &mut formatter, - show_role_labels, + role_label_mode, content_width as usize, message_index + 1 == total_messages, is_streaming, app.get_loading_indicator(), &theme, + app.should_highlight_code(), ), ); lines.extend(message_lines); @@ -729,20 +730,53 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { }; if app.get_loading_indicator() != "" && last_message_is_user { - let loading_spans = vec![ - Span::raw("🤖 "), - Span::styled( - "Assistant:", - Style::default() - .fg(theme.assistant_message_role) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {}", app.get_loading_indicator()), - Style::default().fg(theme.info), - ), - ]; - lines.push(Line::from(loading_spans)); + match role_label_mode { + RoleLabelDisplay::Inline => { + let (emoji, title) = crate::chat_app::role_label_parts(&Role::Assistant); + let inline_label = format!("{emoji} {title}:"); + let label_width = UnicodeWidthStr::width(inline_label.as_str()); + let max_label_width = crate::chat_app::max_inline_label_width(); + let padding = max_label_width.saturating_sub(label_width); + + let mut loading_spans = vec![ + Span::raw(format!("{emoji} ")), + Span::styled( + format!("{title}:"), + Style::default() + .fg(theme.assistant_message_role) + .add_modifier(Modifier::BOLD), + ), + ]; + + if padding > 0 { + loading_spans.push(Span::raw(" ".repeat(padding))); + } + + loading_spans.push(Span::raw(" ")); + loading_spans.push(Span::styled( + app.get_loading_indicator().to_string(), + Style::default().fg(theme.info), + )); + + lines.push(Line::from(loading_spans)); + } + _ => { + let loading_spans = vec![ + Span::raw("🤖 "), + Span::styled( + "Assistant:", + Style::default() + .fg(theme.assistant_message_role) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {}", app.get_loading_indicator()), + Style::default().fg(theme.info), + ), + ]; + lines.push(Line::from(loading_spans)); + } + } } if lines.is_empty() { diff --git a/docs/configuration.md b/docs/configuration.md index adf46c3..63506f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,8 +58,8 @@ These settings customize the look and feel of the terminal interface. - `max_history_lines` (integer, default: `2000`) The maximum number of lines to keep in the scrollback buffer for the chat history. -- `show_role_labels` (boolean, default: `true`) - Whether to show the `user` and `bot` role labels next to messages. +- `role_label` (string, default: `"above"`) + Controls how sender labels are rendered next to messages. Valid values are `"above"` (label on its own line), `"inline"` (label shares the first line of the message), and `"none"` (no label). - `wrap_column` (integer, default: `100`) The column at which to wrap text if `word_wrap` is enabled. @@ -70,6 +70,9 @@ These settings customize the look and feel of the terminal interface. - `scrollback_lines` (integer, default: `2000`) The maximum number of rendered lines the chat view keeps in memory. Set to `0` to disable trimming entirely if you prefer unlimited history. +- `syntax_highlighting` (boolean, default: `false`) + Enables lightweight syntax highlighting inside fenced code blocks when the terminal supports 256-color output. + ## Storage Settings (`[storage]`) These settings control how conversations are saved and loaded. diff --git a/themes/README.md b/themes/README.md index 9ec9ce4..1cb25e0 100644 --- a/themes/README.md +++ b/themes/README.md @@ -55,6 +55,14 @@ selection_bg = "#0000ff" # Selection background selection_fg = "#ffffff" # Selection foreground cursor = "#ff0080" # Cursor color +# Code block styling +code_block_background = "#111111" +code_block_border = "#ff00ff" +code_block_text = "#ffffff" +code_block_keyword = "#ffff00" +code_block_string = "#00ff00" +code_block_comment = "#808080" + # Status colors error = "#ff0000" # Error messages info = "#00ff00" # Info/success messages diff --git a/themes/ansi-basic.toml b/themes/ansi-basic.toml index b2870a7..cbf5165 100644 --- a/themes/ansi-basic.toml +++ b/themes/ansi-basic.toml @@ -19,6 +19,12 @@ mode_command = "yellow" selection_bg = "blue" selection_fg = "white" cursor = "white" +code_block_background = "black" +code_block_border = "cyan" +code_block_text = "white" +code_block_keyword = "yellow" +code_block_string = "green" +code_block_comment = "darkgray" placeholder = "darkgray" error = "red" info = "green" diff --git a/themes/default_dark.toml b/themes/default_dark.toml index 346c007..90308df 100644 --- a/themes/default_dark.toml +++ b/themes/default_dark.toml @@ -19,6 +19,12 @@ mode_command = "yellow" selection_bg = "lightblue" selection_fg = "black" cursor = "magenta" +code_block_background = "#191919" +code_block_border = "lightmagenta" +code_block_text = "white" +code_block_keyword = "yellow" +code_block_string = "lightgreen" +code_block_comment = "gray" placeholder = "darkgray" error = "red" info = "lightgreen" diff --git a/themes/default_light.toml b/themes/default_light.toml index 63b20d3..eaba091 100644 --- a/themes/default_light.toml +++ b/themes/default_light.toml @@ -19,6 +19,12 @@ mode_command = "#b58900" selection_bg = "#a4c8f0" selection_fg = "black" cursor = "#d95f02" +code_block_background = "#f5f5f5" +code_block_border = "#009688" +code_block_text = "black" +code_block_keyword = "#b58900" +code_block_string = "#388e3c" +code_block_comment = "#90a4ae" placeholder = "gray" error = "#c0392b" info = "green" diff --git a/themes/dracula.toml b/themes/dracula.toml index fe47e1d..2537f11 100644 --- a/themes/dracula.toml +++ b/themes/dracula.toml @@ -19,6 +19,12 @@ mode_command = "#f1fa8c" selection_bg = "#44475a" selection_fg = "#f8f8f2" cursor = "#ff79c6" +code_block_background = "#44475a" +code_block_border = "#bd93f9" +code_block_text = "#f8f8f2" +code_block_keyword = "#ff79c6" +code_block_string = "#50fa7b" +code_block_comment = "#6272a4" placeholder = "#6272a4" error = "#ff5555" info = "#50fa7b" diff --git a/themes/grayscale-high-contrast.toml b/themes/grayscale-high-contrast.toml index bdf8c35..74b434d 100644 --- a/themes/grayscale-high-contrast.toml +++ b/themes/grayscale-high-contrast.toml @@ -19,6 +19,12 @@ mode_command = "#d0d0d0" selection_bg = "#f0f0f0" selection_fg = "#000000" cursor = "#ffffff" +code_block_background = "#0f0f0f" +code_block_border = "#ffffff" +code_block_text = "#f7f7f7" +code_block_keyword = "#cccccc" +code_block_string = "#d6d6d6" +code_block_comment = "#7a7a7a" placeholder = "#7a7a7a" error = "#ffffff" info = "#c8c8c8" diff --git a/themes/gruvbox.toml b/themes/gruvbox.toml index b5467fd..e8bd806 100644 --- a/themes/gruvbox.toml +++ b/themes/gruvbox.toml @@ -19,6 +19,12 @@ mode_command = "#fabd2f" selection_bg = "#504945" selection_fg = "#ebdbb2" cursor = "#fe8019" +code_block_background = "#3c3836" +code_block_border = "#7c6f64" +code_block_text = "#ebdbb2" +code_block_keyword = "#fabd2f" +code_block_string = "#8ec07c" +code_block_comment = "#7c6f64" placeholder = "#665c54" error = "#fb4934" info = "#b8bb26" diff --git a/themes/material-dark.toml b/themes/material-dark.toml index 1b9c3bd..da082f8 100644 --- a/themes/material-dark.toml +++ b/themes/material-dark.toml @@ -19,6 +19,12 @@ mode_command = "#ffcb6b" selection_bg = "#546e7a" selection_fg = "#eeffff" cursor = "#ffcc00" +code_block_background = "#212b30" +code_block_border = "#80cbc4" +code_block_text = "#eeffff" +code_block_keyword = "#ffcb6b" +code_block_string = "#c3e88d" +code_block_comment = "#546e7a" placeholder = "#546e7a" error = "#f07178" info = "#c3e88d" diff --git a/themes/material-light.toml b/themes/material-light.toml index 5da41be..25bab66 100644 --- a/themes/material-light.toml +++ b/themes/material-light.toml @@ -19,6 +19,12 @@ mode_command = "#f57c00" selection_bg = "#b0bec5" selection_fg = "#212121" cursor = "#c2185b" +code_block_background = "#f8f9fa" +code_block_border = "#009688" +code_block_text = "#212121" +code_block_keyword = "#f57c00" +code_block_string = "#388e3c" +code_block_comment = "#90a4ae" placeholder = "#90a4ae" error = "#d32f2f" info = "#388e3c" diff --git a/themes/midnight-ocean.toml b/themes/midnight-ocean.toml index 5b72566..331deef 100644 --- a/themes/midnight-ocean.toml +++ b/themes/midnight-ocean.toml @@ -19,6 +19,12 @@ mode_command = "#ffd43b" selection_bg = "#388bfd" selection_fg = "#0d1117" cursor = "#f68cf5" +code_block_background = "#161b22" +code_block_border = "#58a6ff" +code_block_text = "#c0caf5" +code_block_keyword = "#ffd43b" +code_block_string = "#9ece6a" +code_block_comment = "#6e7681" placeholder = "#6e7681" error = "#f85149" info = "#9ece6a" diff --git a/themes/monokai.toml b/themes/monokai.toml index 6a5226d..11889ed 100644 --- a/themes/monokai.toml +++ b/themes/monokai.toml @@ -19,6 +19,12 @@ mode_command = "#e6db74" selection_bg = "#75715e" selection_fg = "#f8f8f2" cursor = "#f92672" +code_block_background = "#32332e" +code_block_border = "#f92672" +code_block_text = "#f8f8f2" +code_block_keyword = "#e6db74" +code_block_string = "#a6e22e" +code_block_comment = "#75715e" placeholder = "#75715e" error = "#f92672" info = "#a6e22e" diff --git a/themes/rose-pine.toml b/themes/rose-pine.toml index cf8b1bb..a6161ad 100644 --- a/themes/rose-pine.toml +++ b/themes/rose-pine.toml @@ -19,6 +19,12 @@ mode_command = "#f6c177" selection_bg = "#403d52" selection_fg = "#e0def4" cursor = "#eb6f92" +code_block_background = "#26233a" +code_block_border = "#eb6f92" +code_block_text = "#e0def4" +code_block_keyword = "#f6c177" +code_block_string = "#9ccfd8" +code_block_comment = "#6e6a86" placeholder = "#6e6a86" error = "#eb6f92" info = "#9ccfd8" diff --git a/themes/solarized.toml b/themes/solarized.toml index 2b8b8fe..cd66e93 100644 --- a/themes/solarized.toml +++ b/themes/solarized.toml @@ -19,6 +19,12 @@ mode_command = "#b58900" selection_bg = "#073642" selection_fg = "#93a1a1" cursor = "#d33682" +code_block_background = "#073642" +code_block_border = "#268bd2" +code_block_text = "#93a1a1" +code_block_keyword = "#b58900" +code_block_string = "#859900" +code_block_comment = "#586e75" placeholder = "#586e75" error = "#dc322f" info = "#859900"