feat(tui): enable syntax highlighting by default and refactor highlighting logic

- Set `default_syntax_highlighting` to true in core config.
- Added language‑aware syntax selector (`select_syntax_for_language`) and highlighter builder (`build_highlighter_for_language`) with unit test.
- Integrated new highlight module into `ChatApp`, using `UnicodeSegmentation` for proper grapheme handling.
- Simplified `should_highlight_code` to always return true and removed extended‑color detection logic.
- Reworked code rendering to use `inline_code_spans_from_text` and `wrap_highlight_segments` for accurate line wrapping and styling.
- Cleaned up removed legacy keyword/comment parsing and extended‑color detection code.
This commit is contained in:
2025-10-14 00:17:17 +02:00
parent ee58b0ac32
commit 99064b6c41
3 changed files with 215 additions and 344 deletions

View File

@@ -1389,7 +1389,7 @@ impl UiSettings {
} }
const fn default_syntax_highlighting() -> bool { const fn default_syntax_highlighting() -> bool {
false true
} }
const fn default_show_timestamps() -> bool { const fn default_show_timestamps() -> bool {

View File

@@ -23,10 +23,10 @@ use tokio::{
task::{self, JoinHandle}, task::{self, JoinHandle},
}; };
use tui_textarea::{CursorMove, Input, TextArea}; use tui_textarea::{CursorMove, Input, TextArea};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use uuid::Uuid; use uuid::Uuid;
use crate::commands;
use crate::config; use crate::config;
use crate::events::Event; use crate::events::Event;
use crate::model_info_panel::ModelInfoPanel; use crate::model_info_panel::ModelInfoPanel;
@@ -39,6 +39,7 @@ use crate::state::{
}; };
use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::toast::{Toast, ToastLevel, ToastManager};
use crate::ui::format_tool_output; use crate::ui::format_tool_output;
use crate::{commands, highlight};
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly // Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`. // imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
@@ -211,7 +212,6 @@ pub struct ChatApp {
show_cursor_outside_insert: bool, // Configurable cursor visibility flag show_cursor_outside_insert: bool, // Configurable cursor visibility flag
syntax_highlighting: bool, // Whether syntax highlighting is enabled syntax_highlighting: bool, // Whether syntax highlighting is enabled
show_message_timestamps: bool, // Whether to render timestamps in chat headers 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 auto_scroll: AutoScroll, // Auto-scroll state for message rendering
thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel
viewport_height: usize, // Track the height of the messages viewport viewport_height: usize, // Track the height of the messages viewport
@@ -516,7 +516,6 @@ impl ChatApp {
new_message_alert: false, new_message_alert: false,
show_cursor_outside_insert, show_cursor_outside_insert,
syntax_highlighting, syntax_highlighting,
supports_extended_colors: detect_extended_color_support(),
show_message_timestamps: show_timestamps, show_message_timestamps: show_timestamps,
}; };
@@ -1910,7 +1909,7 @@ impl ChatApp {
} }
pub fn should_highlight_code(&self) -> bool { pub fn should_highlight_code(&self) -> bool {
self.syntax_highlighting && self.supports_extended_colors true
} }
pub(crate) fn render_message_lines_cached( pub(crate) fn render_message_lines_cached(
@@ -2019,12 +2018,16 @@ impl ChatApp {
chunks.push(String::new()); chunks.push(String::new());
} }
for chunk in chunks { for chunk in chunks {
let text = if indent.is_empty() { let mut spans: Vec<Span<'static>> = Vec::new();
chunk.clone() if !indent.is_empty() {
} else { spans.push(Span::styled(indent.to_string(), content_style));
format!("{indent}{chunk}") }
};
rendered.push(Line::from(vec![Span::styled(text, 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); *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) { pub(crate) fn role_label_parts(role: &Role) -> (&'static str, &'static str) {
match role { match role {
Role::User => ("👤", "You"), Role::User => ("👤", "You"),
@@ -8447,312 +8440,124 @@ fn wrap_code(text: &str, width: usize) -> Vec<String> {
wrapped wrapped
} }
#[derive(Clone, Copy)] fn wrap_highlight_segments(
enum CommentMarker { segments: Vec<(Style, String)>,
DoubleSlash, code_width: usize,
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<CommentMarker> {
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<Span<'static>>,
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, theme: &Theme,
syntax_highlighting: bool, ) -> Vec<Vec<(Style, String)>> {
) -> Vec<Span<'static>> { let mut rows: Vec<Vec<(Style, String)>> = Vec::new();
let base_style = Style::default() let mut current: Vec<(Style, String)> = Vec::new();
.fg(theme.code_block_text) let mut current_width: usize = 0;
.bg(theme.code_block_background);
if !syntax_highlighting { let push_row = |rows: &mut Vec<Vec<(Style, String)>>,
return vec![Span::styled(chunk.to_string(), base_style)]; 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;
} }
let normalized = language.map(|lang| lang.trim().to_ascii_lowercase()); while !remaining.is_empty() {
let lang_ref = normalized.as_deref(); if current_width >= code_width {
let keywords = lang_ref.and_then(keyword_list); push_row(&mut rows, &mut current, &mut current_width);
let comment = lang_ref.and_then(comment_marker); }
let keyword_style = Style::default() let available = code_width.saturating_sub(current_width);
.fg(theme.code_block_keyword) 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;
}
}
if !current.is_empty() {
rows.push(current);
} else if rows.is_empty() {
rows.push(Vec::new());
}
rows
}
fn inline_code_spans_from_text(text: &str, theme: &Theme, base_style: Style) -> Vec<Span<'static>> {
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) .bg(theme.code_block_background)
.add_modifier(Modifier::BOLD); .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 spans = Vec::new();
let mut buffer = String::new(); let mut buffer = String::new();
let chars: Vec<char> = chunk.chars().collect(); let mut in_code = false;
let mut idx = 0;
let mut state = CodeState::Normal;
while idx < chars.len() { for ch in text.chars() {
match state { if ch == '`' {
CodeState::Normal => { if in_code {
if let Some(marker) = comment { if !buffer.is_empty() {
let is_comment = match marker { spans.push(Span::styled(buffer.clone(), code_style));
CommentMarker::DoubleSlash => { buffer.clear();
chars[idx] == '/' && idx + 1 < chars.len() && chars[idx + 1] == '/'
} }
CommentMarker::Hash => chars[idx] == '#', } else if !buffer.is_empty() {
}; spans.push(Span::styled(buffer.clone(), base_style));
buffer.clear();
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;
} }
} in_code = !in_code;
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 { } else {
buffer.push(ch); 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; if in_code {
idx += 1; return vec![Span::styled(text.to_string(), base_style)];
}
}
} }
match state { if !buffer.is_empty() {
CodeState::String { .. } => { spans.push(Span::styled(buffer, base_style));
spans.push(Span::styled(buffer.clone(), string_style));
}
CodeState::Normal => {
flush_normal_buffer(&mut buffer, keywords, &mut spans, base_style, keyword_style);
} }
if spans.is_empty() {
spans.push(Span::styled(String::new(), base_style));
} }
spans spans
@@ -8823,52 +8628,60 @@ fn append_code_block_lines(
top_spans.push(Span::styled("", border_style)); top_spans.push(Span::styled("", border_style));
rendered.push(Line::from(top_spans)); rendered.push(Line::from(top_spans));
if code_lines.is_empty() { let mut highlighter = if syntax_highlighting {
let chunks = wrap_code("", code_width); Some(highlight::build_highlighter_for_language(language))
for chunk in chunks { } 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(); let mut spans = Vec::new();
spans.push(Span::styled(indent.to_string(), border_style)); spans.push(Span::styled(indent.to_string(), border_style));
spans.push(Span::styled("", border_style)); spans.push(Span::styled("", border_style));
let mut code_spans = highlight_code_spans(&chunk, language, theme, syntax_highlighting); let mut row_width = 0;
spans.append(&mut code_spans); if row.is_empty() {
spans.push(Span::styled(" ".repeat(code_width), text_style));
let display_width = UnicodeWidthStr::width(chunk.as_str()); } else {
if display_width < code_width { for (style, piece) in row {
spans.push(Span::styled( let width = UnicodeWidthStr::width(piece.as_str());
" ".repeat(code_width - display_width), row_width += width;
text_style, 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)); spans.push(Span::styled("", border_style));
rendered.push(Line::from(spans)); rendered.push(Line::from(spans));
*indicator_target = Some(rendered.len() - 1); *indicator_target = Some(rendered.len() - 1);
} }
};
if code_lines.is_empty() {
process_line("");
} else { } else {
for line in code_lines { for line in code_lines {
let chunks = wrap_code(line.as_str(), code_width); process_line(line);
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);
}
} }
} }

View File

@@ -36,6 +36,42 @@ fn select_syntax(path_hint: Option<&Path>) -> &'static SyntaxReference {
SYNTAX_SET.find_syntax_plain_text() 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<PathBuf> { fn path_hint_from_components(absolute: Option<&Path>, display: Option<&str>) -> Option<PathBuf> {
if let Some(abs) = absolute { if let Some(abs) = absolute {
return Some(abs.to_path_buf()); return Some(abs.to_path_buf());
@@ -100,3 +136,25 @@ pub fn highlight_line(
segments 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"
);
}
}