feat(ui): add show_cursor_outside_insert setting and Unicode‑aware wrapping; introduce grayscale‑high‑contrast theme

- 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.
This commit is contained in:
2025-10-12 15:47:22 +02:00
parent 0bd560b408
commit ae9c3af096
6 changed files with 182 additions and 40 deletions

View File

@@ -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(),
}
}
}

View File

@@ -347,6 +347,10 @@ pub fn built_in_themes() -> HashMap<String, Theme> {
"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<Theme> {
"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<Color, D::Error>
@@ -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"));
}
}

View File

@@ -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<String>, // Which provider group is currently expanded
current_provider: String, // Provider backing the active session
message_line_cache: HashMap<Uuid, MessageCacheEntry>, // 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<Cow<'_, str>> = 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<Cow<'_, str>> = 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<String> {
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);

View File

@@ -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<Line> = 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());

View File

@@ -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

View File

@@ -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"