From ae9c3af0960ccaef90b820b4ebe7b76957541f22 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 12 Oct 2025 15:47:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20add=20show=5Fcursor=5Foutside=5Fins?= =?UTF-8?q?ert=20setting=20and=20Unicode=E2=80=91aware=20wrapping;=20intro?= =?UTF-8?q?duce=20grayscale=E2=80=91high=E2=80=91contrast=20theme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added `show_cursor_outside_insert` (default false) to `UiSettings` and synced it from config. - Cursor rendering now follows `cursor_should_be_visible`, allowing visibility outside insert mode based on the new setting. - Replaced `textwrap::wrap` with `wrap_unicode`, which uses Unicode break properties for proper CJK and emoji handling. - Added `grayscale-high-contrast.toml` theme, registered it in theme loading, and updated README and tests. --- crates/owlen-core/src/config.rs | 7 +++ crates/owlen-core/src/theme.rs | 49 +++++++++++++++ crates/owlen-tui/src/chat_app.rs | 94 +++++++++++++++++++++-------- crates/owlen-tui/src/ui.rs | 34 ++++++----- themes/README.md | 1 + themes/grayscale-high-contrast.toml | 37 ++++++++++++ 6 files changed, 182 insertions(+), 40 deletions(-) create mode 100644 themes/grayscale-high-contrast.toml diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index 4e55d63..a180330 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -710,6 +710,8 @@ pub struct UiSettings { pub input_max_rows: u16, #[serde(default = "UiSettings::default_scrollback_lines")] pub scrollback_lines: usize, + #[serde(default = "UiSettings::default_show_cursor_outside_insert")] + pub show_cursor_outside_insert: bool, } impl UiSettings { @@ -744,6 +746,10 @@ impl UiSettings { const fn default_scrollback_lines() -> usize { 2000 } + + const fn default_show_cursor_outside_insert() -> bool { + false + } } impl Default for UiSettings { @@ -757,6 +763,7 @@ impl Default for UiSettings { 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(), } } } diff --git a/crates/owlen-core/src/theme.rs b/crates/owlen-core/src/theme.rs index a5baa00..3fb8083 100644 --- a/crates/owlen-core/src/theme.rs +++ b/crates/owlen-core/src/theme.rs @@ -347,6 +347,10 @@ pub fn built_in_themes() -> HashMap { "ansi_basic", include_str!("../../../themes/ansi-basic.toml"), ), + ( + "grayscale-high-contrast", + include_str!("../../../themes/grayscale-high-contrast.toml"), + ), ("gruvbox", include_str!("../../../themes/gruvbox.toml")), ("dracula", include_str!("../../../themes/dracula.toml")), ("solarized", include_str!("../../../themes/solarized.toml")), @@ -397,6 +401,7 @@ fn get_fallback_theme(name: &str) -> Option { "monokai" => Some(monokai()), "material-dark" => Some(material_dark()), "material-light" => Some(material_light()), + "grayscale-high-contrast" => Some(grayscale_high_contrast()), _ => None, } } @@ -831,6 +836,49 @@ fn material_light() -> Theme { } } +/// Grayscale high-contrast theme +fn grayscale_high_contrast() -> Theme { + Theme { + name: "grayscale_high_contrast".to_string(), + text: Color::Rgb(247, 247, 247), + background: Color::Black, + focused_panel_border: Color::White, + unfocused_panel_border: Color::Rgb(76, 76, 76), + user_message_role: Color::Rgb(240, 240, 240), + assistant_message_role: Color::Rgb(214, 214, 214), + tool_output: Color::Rgb(189, 189, 189), + thinking_panel_title: Color::Rgb(224, 224, 224), + command_bar_background: Color::Black, + status_background: Color::Rgb(15, 15, 15), + mode_normal: Color::White, + mode_editing: Color::Rgb(230, 230, 230), + mode_model_selection: Color::Rgb(204, 204, 204), + mode_provider_selection: Color::Rgb(179, 179, 179), + mode_help: Color::Rgb(153, 153, 153), + mode_visual: Color::Rgb(242, 242, 242), + mode_command: Color::Rgb(208, 208, 208), + selection_bg: Color::Rgb(240, 240, 240), + selection_fg: Color::Black, + cursor: Color::White, + placeholder: Color::Rgb(122, 122, 122), + error: Color::White, + info: Color::Rgb(200, 200, 200), + agent_thought: Color::Rgb(230, 230, 230), + agent_action: Color::Rgb(204, 204, 204), + agent_action_input: Color::Rgb(176, 176, 176), + agent_observation: Color::Rgb(153, 153, 153), + agent_final_answer: Color::White, + agent_badge_running_fg: Color::Black, + agent_badge_running_bg: Color::Rgb(247, 247, 247), + agent_badge_idle_fg: Color::Black, + agent_badge_idle_bg: Color::Rgb(189, 189, 189), + operating_chat_fg: Color::Black, + operating_chat_bg: Color::Rgb(242, 242, 242), + operating_code_fg: Color::Black, + operating_code_bg: Color::Rgb(191, 191, 191), + } +} + // Helper functions for color serialization/deserialization fn deserialize_color<'de, D>(deserializer: D) -> Result @@ -924,5 +972,6 @@ mod tests { assert!(themes.contains_key("default_dark")); assert!(themes.contains_key("gruvbox")); assert!(themes.contains_key("dracula")); + assert!(themes.contains_key("grayscale-high-contrast")); } } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 3afde6d..c630a43 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -11,7 +11,7 @@ use owlen_core::{ }; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use textwrap::wrap; +use textwrap::{Options, WordSeparator, wrap}; use tokio::{sync::mpsc, task::JoinHandle}; use tui_textarea::{Input, TextArea}; use uuid::Uuid; @@ -24,7 +24,6 @@ use crate::state::{CommandPalette, ModelPaletteEntry}; use crate::ui::format_tool_output; // 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::borrow::Cow; use std::collections::hash_map::DefaultHasher; use std::collections::{BTreeSet, HashMap, HashSet}; use std::hash::{Hash, Hasher}; @@ -156,6 +155,7 @@ pub struct ChatApp { expanded_provider: Option, // Which provider group is currently expanded 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 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 @@ -267,6 +267,7 @@ impl ChatApp { let theme_name = config_guard.ui.theme.clone(); 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; drop(config_guard); let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| { eprintln!("Warning: Theme '{}' not found, using default", theme_name); @@ -340,6 +341,7 @@ impl ChatApp { agent_running: false, operating_mode: owlen_core::mode::Mode::default(), new_message_alert: false, + show_cursor_outside_insert, }; app.update_command_palette_catalog(); @@ -834,6 +836,22 @@ impl ChatApp { self.message_line_cache.remove(id); } + fn sync_ui_preferences_from_config(&mut self) { + let show_cursor = { + let guard = self.controller.config(); + guard.ui.show_cursor_outside_insert + }; + self.show_cursor_outside_insert = show_cursor; + } + + pub fn cursor_should_be_visible(&self) -> bool { + if matches!(self.mode, InputMode::Editing) { + true + } else { + self.show_cursor_outside_insert + } + } + pub(crate) fn render_message_lines_cached( &mut self, message_index: usize, @@ -914,32 +932,21 @@ impl ChatApp { let indent = " "; let available_width = content_width.saturating_sub(2); - let chunks: Vec> = if available_width > 0 { - wrap(content.as_str(), available_width) - } else { - Vec::new() - }; + 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.into_owned()), - content_style, - )]; + 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)); } } else { - let chunks: Vec> = if content_width > 0 { - wrap(content.as_str(), content_width) - } else { - Vec::new() - }; + 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.into_owned(), content_style)]; + 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))); } @@ -2588,6 +2595,8 @@ impl ChatApp { self.status = "Configuration reloaded, but theme not found. Using current theme.".to_string(); } self.error = None; + self.sync_ui_preferences_from_config(); + self.update_command_palette_catalog(); } Err(e) => { self.error = @@ -4427,25 +4436,20 @@ impl ChatApp { lines.push(format!("{}{}", emoji, name)); let indent = " "; let available_width = wrap_width.saturating_sub(2); - let chunks = if available_width > 0 { - wrap(content.as_str(), available_width) - } else { - Vec::new() - }; + 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 seg_owned = seg.into_owned(); - let mut line = format!("{indent}{seg_owned}"); + let mut line = format!("{indent}{seg}"); if chunk_idx == last_index && is_streaming { line.push_str(" ▌"); } lines.push(line); } } else { - let chunks = wrap(content.as_str(), wrap_width); + 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.into_owned(); + let mut line = seg; if chunk_idx == last_index && is_streaming { line.push_str(" ▌"); } @@ -4598,6 +4602,44 @@ impl ChatApp { } } +pub(crate) fn wrap_unicode(text: &str, width: usize) -> Vec { + if width == 0 { + return Vec::new(); + } + + let options = Options::new(width) + .word_separator(WordSeparator::UnicodeBreakProperties) + .break_words(false); + + wrap(text, options) + .into_iter() + .map(|segment| segment.into_owned()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::wrap_unicode; + + #[test] + fn wrap_unicode_respects_cjk_display_width() { + let wrapped = wrap_unicode("你好世界", 4); + assert_eq!(wrapped, vec!["你好".to_string(), "世界".to_string()]); + } + + #[test] + fn wrap_unicode_handles_emoji_graphemes() { + let wrapped = wrap_unicode("🙂🙂🙂", 4); + assert_eq!(wrapped, vec!["🙂🙂".to_string(), "🙂".to_string()]); + } + + #[test] + fn wrap_unicode_zero_width_returns_empty() { + let wrapped = wrap_unicode("hello", 0); + assert!(wrapped.is_empty()); + } +} + fn configure_textarea_defaults(textarea: &mut TextArea<'static>) { textarea.set_placeholder_text("Type your message here..."); textarea.set_tab_length(4); diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 7596134..47e5c74 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -4,7 +4,6 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use serde_json; -use textwrap::wrap; use tui_textarea::TextArea; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -161,6 +160,7 @@ fn render_editable_textarea( area: Rect, textarea: &mut TextArea<'static>, mut wrap_lines: bool, + show_cursor: bool, theme: &Theme, ) { let block = textarea.block().cloned(); @@ -250,7 +250,7 @@ fn render_editable_textarea( frame.render_widget(paragraph, area); - if let Some(metrics) = metrics { + if let Some(metrics) = metrics.filter(|_| show_cursor) { frame.set_cursor_position((metrics.cursor_x, metrics.cursor_y)); } } @@ -818,7 +818,9 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { } // Render cursor if Chat panel is focused and in Normal mode - if matches!(app.focused_panel(), FocusedPanel::Chat) && matches!(app.mode(), InputMode::Normal) + if app.cursor_should_be_visible() + && matches!(app.focused_panel(), FocusedPanel::Chat) + && matches!(app.mode(), InputMode::Normal) { let cursor = app.chat_cursor(); let cursor_row = cursor.0; @@ -855,13 +857,13 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { app.set_thinking_viewport_height(viewport_height); - let chunks = wrap(&thinking, content_width as usize); + let chunks = crate::chat_app::wrap_unicode(&thinking, content_width as usize); let mut lines: Vec = chunks .into_iter() .map(|seg| { Line::from(Span::styled( - seg.into_owned(), + seg, Style::default() .fg(theme.placeholder) .add_modifier(Modifier::ITALIC), @@ -911,7 +913,8 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { frame.render_widget(paragraph, area); // Render cursor if Thinking panel is focused and in Normal mode - if matches!(app.focused_panel(), FocusedPanel::Thinking) + if app.cursor_should_be_visible() + && matches!(app.focused_panel(), FocusedPanel::Thinking) && matches!(app.mode(), InputMode::Normal) { let cursor = app.thinking_cursor(); @@ -958,7 +961,8 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { if line_trimmed.starts_with("THOUGHT:") { let thought_color = theme.agent_thought; let thought_content = line_trimmed.strip_prefix("THOUGHT:").unwrap_or("").trim(); - let wrapped = wrap(thought_content, content_width as usize); + let wrapped = + crate::chat_app::wrap_unicode(thought_content, content_width as usize); // First line with label if let Some(first) = wrapped.first() { @@ -1003,7 +1007,7 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { .strip_prefix("ACTION_INPUT:") .unwrap_or("") .trim(); - let wrapped = wrap(input_content, content_width as usize); + let wrapped = crate::chat_app::wrap_unicode(input_content, content_width as usize); if let Some(first) = wrapped.first() { lines.push(Line::from(vec![ @@ -1029,7 +1033,7 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { .strip_prefix("OBSERVATION:") .unwrap_or("") .trim(); - let wrapped = wrap(obs_content, content_width as usize); + let wrapped = crate::chat_app::wrap_unicode(obs_content, content_width as usize); if let Some(first) = wrapped.first() { lines.push(Line::from(vec![ @@ -1055,7 +1059,7 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { .strip_prefix("FINAL_ANSWER:") .unwrap_or("") .trim(); - let wrapped = wrap(answer_content, content_width as usize); + let wrapped = crate::chat_app::wrap_unicode(answer_content, content_width as usize); if let Some(first) = wrapped.first() { lines.push(Line::from(vec![ @@ -1082,10 +1086,10 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { } } else if !line_trimmed.is_empty() { // Regular text - let wrapped = wrap(line_trimmed, content_width as usize); + let wrapped = crate::chat_app::wrap_unicode(line_trimmed, content_width as usize); for chunk in wrapped { lines.push(Line::from(Span::styled( - chunk.into_owned(), + chunk, Style::default().fg(theme.text), ))); } @@ -1153,16 +1157,18 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { if matches!(app.mode(), InputMode::Editing) { // Use the textarea directly to preserve selection state + let show_cursor = app.cursor_should_be_visible(); let textarea = app.textarea_mut(); textarea.set_block(input_block.clone()); textarea.set_hard_tab_indent(false); - render_editable_textarea(frame, area, textarea, true, &theme); + render_editable_textarea(frame, area, textarea, true, show_cursor, &theme); } else if matches!(app.mode(), InputMode::Visual) { // In visual mode, render textarea in read-only mode with selection + let show_cursor = app.cursor_should_be_visible(); let textarea = app.textarea_mut(); textarea.set_block(input_block.clone()); textarea.set_hard_tab_indent(false); - render_editable_textarea(frame, area, textarea, true, &theme); + render_editable_textarea(frame, area, textarea, true, show_cursor, &theme); } else if matches!(app.mode(), InputMode::Command) { // In command mode, show the command buffer with : prefix let command_text = format!(":{}", app.command_buffer()); diff --git a/themes/README.md b/themes/README.md index 6714960..9ec9ce4 100644 --- a/themes/README.md +++ b/themes/README.md @@ -6,6 +6,7 @@ This directory contains the built-in themes that are embedded into the OWLEN bin - **default_dark** - High-contrast dark theme (default) - **default_light** - Clean light theme +- **grayscale-high-contrast** - Monochrome palette tuned for color-blind accessibility - **gruvbox** - Popular retro color scheme with warm tones - **dracula** - Dark theme with vibrant purple and cyan colors - **solarized** - Precision colors for optimal readability diff --git a/themes/grayscale-high-contrast.toml b/themes/grayscale-high-contrast.toml new file mode 100644 index 0000000..bdf8c35 --- /dev/null +++ b/themes/grayscale-high-contrast.toml @@ -0,0 +1,37 @@ +name = "grayscale_high_contrast" +text = "#f7f7f7" +background = "#000000" +focused_panel_border = "#ffffff" +unfocused_panel_border = "#4c4c4c" +user_message_role = "#f0f0f0" +assistant_message_role = "#d6d6d6" +tool_output = "#bdbdbd" +thinking_panel_title = "#e0e0e0" +command_bar_background = "#000000" +status_background = "#0f0f0f" +mode_normal = "#ffffff" +mode_editing = "#e6e6e6" +mode_model_selection = "#cccccc" +mode_provider_selection = "#b3b3b3" +mode_help = "#999999" +mode_visual = "#f2f2f2" +mode_command = "#d0d0d0" +selection_bg = "#f0f0f0" +selection_fg = "#000000" +cursor = "#ffffff" +placeholder = "#7a7a7a" +error = "#ffffff" +info = "#c8c8c8" +agent_thought = "#e6e6e6" +agent_action = "#cccccc" +agent_action_input = "#b0b0b0" +agent_observation = "#999999" +agent_final_answer = "#ffffff" +agent_badge_running_fg = "#000000" +agent_badge_running_bg = "#f7f7f7" +agent_badge_idle_fg = "#000000" +agent_badge_idle_bg = "#bdbdbd" +operating_chat_fg = "#000000" +operating_chat_bg = "#f2f2f2" +operating_code_fg = "#000000" +operating_code_bg = "#bfbfbf"