diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index c354a7d..c7a03fa 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -1389,7 +1389,7 @@ impl UiSettings { } const fn default_syntax_highlighting() -> bool { - false + true } const fn default_show_timestamps() -> bool { diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 832dc47..ef2c552 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -23,10 +23,10 @@ use tokio::{ task::{self, JoinHandle}, }; use tui_textarea::{CursorMove, Input, TextArea}; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use uuid::Uuid; -use crate::commands; use crate::config; use crate::events::Event; use crate::model_info_panel::ModelInfoPanel; @@ -39,6 +39,7 @@ use crate::state::{ }; use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::ui::format_tool_output; +use crate::{commands, highlight}; // Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly // imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`. use std::collections::hash_map::DefaultHasher; @@ -211,7 +212,6 @@ pub struct ChatApp { show_cursor_outside_insert: bool, // Configurable cursor visibility flag syntax_highlighting: bool, // Whether syntax highlighting is enabled show_message_timestamps: bool, // Whether to render timestamps in chat headers - 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 @@ -516,7 +516,6 @@ impl ChatApp { new_message_alert: false, show_cursor_outside_insert, syntax_highlighting, - supports_extended_colors: detect_extended_color_support(), show_message_timestamps: show_timestamps, }; @@ -1910,7 +1909,7 @@ impl ChatApp { } pub fn should_highlight_code(&self) -> bool { - self.syntax_highlighting && self.supports_extended_colors + true } pub(crate) fn render_message_lines_cached( @@ -2019,12 +2018,16 @@ impl ChatApp { chunks.push(String::new()); } for chunk in chunks { - let text = if indent.is_empty() { - chunk.clone() - } else { - format!("{indent}{chunk}") - }; - rendered.push(Line::from(vec![Span::styled(text, content_style)])); + let mut spans: Vec> = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled(indent.to_string(), content_style)); + } + + let inline_spans = + inline_code_spans_from_text(&chunk, theme, content_style); + spans.extend(inline_spans); + + rendered.push(Line::from(spans)); *indicator_target = Some(rendered.len() - 1); } } @@ -8334,16 +8337,6 @@ 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"), @@ -8447,312 +8440,124 @@ fn wrap_code(text: &str, width: usize) -> Vec { 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>, +fn wrap_highlight_segments( + segments: Vec<(Style, String)>, + code_width: usize, theme: &Theme, - syntax_highlighting: bool, -) -> Vec> { - let base_style = Style::default() - .fg(theme.code_block_text) - .bg(theme.code_block_background); +) -> Vec> { + let mut rows: Vec> = Vec::new(); + let mut current: Vec<(Style, String)> = Vec::new(); + let mut current_width: usize = 0; - if !syntax_highlighting { - return vec![Span::styled(chunk.to_string(), base_style)]; + let push_row = |rows: &mut Vec>, + current: &mut Vec<(Style, String)>, + current_width: &mut usize| { + rows.push(std::mem::take(current)); + *current_width = 0; + }; + + for (style_raw, text) in segments { + let mut remaining = text.as_str(); + if remaining.is_empty() { + continue; + } + + while !remaining.is_empty() { + if current_width >= code_width { + push_row(&mut rows, &mut current, &mut current_width); + } + + let available = code_width.saturating_sub(current_width); + if available == 0 { + push_row(&mut rows, &mut current, &mut current_width); + continue; + } + + let mut take_bytes = 0; + let mut take_width = 0; + + for grapheme in remaining.graphemes(true) { + let grapheme_width = UnicodeWidthStr::width(grapheme); + if take_width + grapheme_width > available { + break; + } + take_bytes += grapheme.len(); + take_width += grapheme_width; + if take_width == available { + break; + } + } + + if take_bytes == 0 { + push_row(&mut rows, &mut current, &mut current_width); + continue; + } + + let chunk = &remaining[..take_bytes]; + remaining = &remaining[take_bytes..]; + + let mut style = style_raw; + if style.fg.is_none() { + style = style.fg(theme.code_block_text); + } + style = style.bg(theme.code_block_background); + + current.push((style, chunk.to_string())); + current_width += take_width; + } } - 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); + if !current.is_empty() { + rows.push(current); + } else if rows.is_empty() { + rows.push(Vec::new()); + } - let keyword_style = Style::default() - .fg(theme.code_block_keyword) + rows +} + +fn inline_code_spans_from_text(text: &str, theme: &Theme, base_style: Style) -> Vec> { + let tick_count = text.matches('`').count(); + if tick_count < 2 || (tick_count & 1) != 0 { + return vec![Span::styled(text.to_string(), base_style)]; + } + + let code_style = Style::default() + .fg(theme.code_block_text) .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; + let mut in_code = false; - 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)); + for ch in text.chars() { + if ch == '`' { + if in_code { + if !buffer.is_empty() { + spans.push(Span::styled(buffer.clone(), code_style)); buffer.clear(); - next_state = CodeState::Normal; } - - state = next_state; - idx += 1; + } else if !buffer.is_empty() { + spans.push(Span::styled(buffer.clone(), base_style)); + buffer.clear(); } + in_code = !in_code; + } else { + buffer.push(ch); } } - 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); - } + if in_code { + return vec![Span::styled(text.to_string(), base_style)]; + } + + if !buffer.is_empty() { + spans.push(Span::styled(buffer, base_style)); + } + + if spans.is_empty() { + spans.push(Span::styled(String::new(), base_style)); } spans @@ -8823,52 +8628,60 @@ fn append_code_block_lines( 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 highlighter = if syntax_highlighting { + Some(highlight::build_highlighter_for_language(language)) + } else { + None + }; + + let mut process_line = |line: &str| { + let segments = if let Some(highlighter) = highlighter.as_mut() { + let mut segments = highlight::highlight_line(highlighter, line); + if segments.is_empty() { + segments.push((Style::default(), String::new())); + } + segments + } else { + vec![(Style::default(), line.to_string())] + }; + + let has_content = segments.iter().any(|(_, text)| !text.is_empty()); + let rows = if has_content { + wrap_highlight_segments(segments, code_width, theme) + } else { + vec![Vec::new()] + }; + + for row in rows { 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, - )); + let mut row_width = 0; + if row.is_empty() { + spans.push(Span::styled(" ".repeat(code_width), text_style)); + } else { + for (style, piece) in row { + let width = UnicodeWidthStr::width(piece.as_str()); + row_width += width; + spans.push(Span::styled(piece, style)); + } + if row_width < code_width { + spans.push(Span::styled(" ".repeat(code_width - row_width), text_style)); + } } spans.push(Span::styled("│", border_style)); rendered.push(Line::from(spans)); *indicator_target = Some(rendered.len() - 1); } + }; + + if code_lines.is_empty() { + process_line(""); } 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); - } + process_line(line); } } diff --git a/crates/owlen-tui/src/highlight.rs b/crates/owlen-tui/src/highlight.rs index c642e0d..67fa0fb 100644 --- a/crates/owlen-tui/src/highlight.rs +++ b/crates/owlen-tui/src/highlight.rs @@ -36,6 +36,42 @@ fn select_syntax(path_hint: Option<&Path>) -> &'static SyntaxReference { SYNTAX_SET.find_syntax_plain_text() } +fn select_syntax_for_language(language: Option<&str>) -> &'static SyntaxReference { + let token = language + .map(|lang| lang.trim().to_ascii_lowercase()) + .filter(|lang| !lang.is_empty()); + + if let Some(token) = token { + let mut attempts: Vec<&str> = vec![token.as_str()]; + match token.as_str() { + "c++" => attempts.extend(["cpp", "c"]), + "c#" | "cs" => attempts.extend(["csharp", "cs"]), + "shell" => attempts.extend(["bash", "sh"]), + "typescript" | "ts" => attempts.extend(["typescript", "ts", "tsx"]), + "javascript" | "js" => attempts.extend(["javascript", "js", "jsx"]), + "py" => attempts.push("python"), + "rs" => attempts.push("rust"), + "yml" => attempts.push("yaml"), + other => { + if let Some(stripped) = other.strip_prefix('.') { + attempts.push(stripped); + } + } + } + + for candidate in attempts { + if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(candidate) { + return syntax; + } + if let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(candidate) { + return syntax; + } + } + } + + SYNTAX_SET.find_syntax_plain_text() +} + fn path_hint_from_components(absolute: Option<&Path>, display: Option<&str>) -> Option { if let Some(abs) = absolute { return Some(abs.to_path_buf()); @@ -100,3 +136,25 @@ pub fn highlight_line( segments } + +pub fn build_highlighter_for_language(language: Option<&str>) -> HighlightLines<'static> { + let syntax = select_syntax_for_language(language); + HighlightLines::new(syntax, &THEME) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rust_highlighting_produces_colored_segment() { + let mut highlighter = build_highlighter_for_language(Some("rust")); + let segments = highlight_line(&mut highlighter, "fn main() {}"); + assert!( + segments + .iter() + .any(|(style, text)| style.fg.is_some() && !text.trim().is_empty()), + "Expected at least one colored segment" + ); + } +}