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, pub input_max_rows: u16,
#[serde(default = "UiSettings::default_scrollback_lines")] #[serde(default = "UiSettings::default_scrollback_lines")]
pub scrollback_lines: usize, pub scrollback_lines: usize,
#[serde(default = "UiSettings::default_show_cursor_outside_insert")]
pub show_cursor_outside_insert: bool,
} }
impl UiSettings { impl UiSettings {
@@ -744,6 +746,10 @@ impl UiSettings {
const fn default_scrollback_lines() -> usize { const fn default_scrollback_lines() -> usize {
2000 2000
} }
const fn default_show_cursor_outside_insert() -> bool {
false
}
} }
impl Default for UiSettings { impl Default for UiSettings {
@@ -757,6 +763,7 @@ impl Default for UiSettings {
show_onboarding: Self::default_show_onboarding(), show_onboarding: Self::default_show_onboarding(),
input_max_rows: Self::default_input_max_rows(), input_max_rows: Self::default_input_max_rows(),
scrollback_lines: Self::default_scrollback_lines(), 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", "ansi_basic",
include_str!("../../../themes/ansi-basic.toml"), include_str!("../../../themes/ansi-basic.toml"),
), ),
(
"grayscale-high-contrast",
include_str!("../../../themes/grayscale-high-contrast.toml"),
),
("gruvbox", include_str!("../../../themes/gruvbox.toml")), ("gruvbox", include_str!("../../../themes/gruvbox.toml")),
("dracula", include_str!("../../../themes/dracula.toml")), ("dracula", include_str!("../../../themes/dracula.toml")),
("solarized", include_str!("../../../themes/solarized.toml")), ("solarized", include_str!("../../../themes/solarized.toml")),
@@ -397,6 +401,7 @@ fn get_fallback_theme(name: &str) -> Option<Theme> {
"monokai" => Some(monokai()), "monokai" => Some(monokai()),
"material-dark" => Some(material_dark()), "material-dark" => Some(material_dark()),
"material-light" => Some(material_light()), "material-light" => Some(material_light()),
"grayscale-high-contrast" => Some(grayscale_high_contrast()),
_ => None, _ => 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 // Helper functions for color serialization/deserialization
fn deserialize_color<'de, D>(deserializer: D) -> Result<Color, D::Error> 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("default_dark"));
assert!(themes.contains_key("gruvbox")); assert!(themes.contains_key("gruvbox"));
assert!(themes.contains_key("dracula")); 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::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use textwrap::wrap; use textwrap::{Options, WordSeparator, wrap};
use tokio::{sync::mpsc, task::JoinHandle}; use tokio::{sync::mpsc, task::JoinHandle};
use tui_textarea::{Input, TextArea}; use tui_textarea::{Input, TextArea};
use uuid::Uuid; use uuid::Uuid;
@@ -24,7 +24,6 @@ use crate::state::{CommandPalette, ModelPaletteEntry};
use crate::ui::format_tool_output; use crate::ui::format_tool_output;
// 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::borrow::Cow;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeSet, HashMap, HashSet}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
@@ -156,6 +155,7 @@ pub struct ChatApp {
expanded_provider: Option<String>, // Which provider group is currently expanded expanded_provider: Option<String>, // Which provider group is currently expanded
current_provider: String, // Provider backing the active session current_provider: String, // Provider backing the active session
message_line_cache: HashMap<Uuid, MessageCacheEntry>, // Cached rendered lines per message 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 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
@@ -267,6 +267,7 @@ impl ChatApp {
let theme_name = config_guard.ui.theme.clone(); let theme_name = config_guard.ui.theme.clone();
let current_provider = config_guard.general.default_provider.clone(); let current_provider = config_guard.general.default_provider.clone();
let show_onboarding = config_guard.ui.show_onboarding; let show_onboarding = config_guard.ui.show_onboarding;
let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert;
drop(config_guard); drop(config_guard);
let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| { let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| {
eprintln!("Warning: Theme '{}' not found, using default", theme_name); eprintln!("Warning: Theme '{}' not found, using default", theme_name);
@@ -340,6 +341,7 @@ impl ChatApp {
agent_running: false, agent_running: false,
operating_mode: owlen_core::mode::Mode::default(), operating_mode: owlen_core::mode::Mode::default(),
new_message_alert: false, new_message_alert: false,
show_cursor_outside_insert,
}; };
app.update_command_palette_catalog(); app.update_command_palette_catalog();
@@ -834,6 +836,22 @@ impl ChatApp {
self.message_line_cache.remove(id); 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( pub(crate) fn render_message_lines_cached(
&mut self, &mut self,
message_index: usize, message_index: usize,
@@ -914,32 +932,21 @@ impl ChatApp {
let indent = " "; let indent = " ";
let available_width = content_width.saturating_sub(2); let available_width = content_width.saturating_sub(2);
let chunks: Vec<Cow<'_, str>> = if available_width > 0 { let chunks = wrap_unicode(content.as_str(), available_width);
wrap(content.as_str(), available_width)
} else {
Vec::new()
};
let last_index = chunks.len().saturating_sub(1); let last_index = chunks.len().saturating_sub(1);
for (chunk_idx, seg) in chunks.into_iter().enumerate() { for (chunk_idx, seg) in chunks.into_iter().enumerate() {
let mut spans = vec![Span::styled( let mut spans = vec![Span::styled(format!("{indent}{seg}"), content_style)];
format!("{indent}{}", seg.into_owned()),
content_style,
)];
if chunk_idx == last_index && is_streaming { if chunk_idx == last_index && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor))); spans.push(Span::styled("", Style::default().fg(theme.cursor)));
} }
rendered.push(Line::from(spans)); rendered.push(Line::from(spans));
} }
} else { } else {
let chunks: Vec<Cow<'_, str>> = if content_width > 0 { let chunks = wrap_unicode(content.as_str(), content_width);
wrap(content.as_str(), content_width)
} else {
Vec::new()
};
let last_index = chunks.len().saturating_sub(1); let last_index = chunks.len().saturating_sub(1);
for (chunk_idx, seg) in chunks.into_iter().enumerate() { 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 { if chunk_idx == last_index && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor))); 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.status = "Configuration reloaded, but theme not found. Using current theme.".to_string();
} }
self.error = None; self.error = None;
self.sync_ui_preferences_from_config();
self.update_command_palette_catalog();
} }
Err(e) => { Err(e) => {
self.error = self.error =
@@ -4427,25 +4436,20 @@ impl ChatApp {
lines.push(format!("{}{}", emoji, name)); lines.push(format!("{}{}", emoji, name));
let indent = " "; let indent = " ";
let available_width = wrap_width.saturating_sub(2); let available_width = wrap_width.saturating_sub(2);
let chunks = if available_width > 0 { let chunks = wrap_unicode(content.as_str(), available_width);
wrap(content.as_str(), available_width)
} else {
Vec::new()
};
let last_index = chunks.len().saturating_sub(1); let last_index = chunks.len().saturating_sub(1);
for (chunk_idx, seg) in chunks.into_iter().enumerate() { for (chunk_idx, seg) in chunks.into_iter().enumerate() {
let seg_owned = seg.into_owned(); let mut line = format!("{indent}{seg}");
let mut line = format!("{indent}{seg_owned}");
if chunk_idx == last_index && is_streaming { if chunk_idx == last_index && is_streaming {
line.push_str(""); line.push_str("");
} }
lines.push(line); lines.push(line);
} }
} else { } 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); let last_index = chunks.len().saturating_sub(1);
for (chunk_idx, seg) in chunks.into_iter().enumerate() { 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 { if chunk_idx == last_index && is_streaming {
line.push_str(""); 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>) { fn configure_textarea_defaults(textarea: &mut TextArea<'static>) {
textarea.set_placeholder_text("Type your message here..."); textarea.set_placeholder_text("Type your message here...");
textarea.set_tab_length(4); textarea.set_tab_length(4);

View File

@@ -4,7 +4,6 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use serde_json; use serde_json;
use textwrap::wrap;
use tui_textarea::TextArea; use tui_textarea::TextArea;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
@@ -161,6 +160,7 @@ fn render_editable_textarea(
area: Rect, area: Rect,
textarea: &mut TextArea<'static>, textarea: &mut TextArea<'static>,
mut wrap_lines: bool, mut wrap_lines: bool,
show_cursor: bool,
theme: &Theme, theme: &Theme,
) { ) {
let block = textarea.block().cloned(); let block = textarea.block().cloned();
@@ -250,7 +250,7 @@ fn render_editable_textarea(
frame.render_widget(paragraph, area); 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)); 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 // 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 = app.chat_cursor();
let cursor_row = cursor.0; 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); 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 let mut lines: Vec<Line> = chunks
.into_iter() .into_iter()
.map(|seg| { .map(|seg| {
Line::from(Span::styled( Line::from(Span::styled(
seg.into_owned(), seg,
Style::default() Style::default()
.fg(theme.placeholder) .fg(theme.placeholder)
.add_modifier(Modifier::ITALIC), .add_modifier(Modifier::ITALIC),
@@ -911,7 +913,8 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
// Render cursor if Thinking panel is focused and in Normal mode // 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) && matches!(app.mode(), InputMode::Normal)
{ {
let cursor = app.thinking_cursor(); 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:") { if line_trimmed.starts_with("THOUGHT:") {
let thought_color = theme.agent_thought; let thought_color = theme.agent_thought;
let thought_content = line_trimmed.strip_prefix("THOUGHT:").unwrap_or("").trim(); 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 // First line with label
if let Some(first) = wrapped.first() { 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:") .strip_prefix("ACTION_INPUT:")
.unwrap_or("") .unwrap_or("")
.trim(); .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() { if let Some(first) = wrapped.first() {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
@@ -1029,7 +1033,7 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
.strip_prefix("OBSERVATION:") .strip_prefix("OBSERVATION:")
.unwrap_or("") .unwrap_or("")
.trim(); .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() { if let Some(first) = wrapped.first() {
lines.push(Line::from(vec![ 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:") .strip_prefix("FINAL_ANSWER:")
.unwrap_or("") .unwrap_or("")
.trim(); .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() { if let Some(first) = wrapped.first() {
lines.push(Line::from(vec![ 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() { } else if !line_trimmed.is_empty() {
// Regular text // 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 { for chunk in wrapped {
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
chunk.into_owned(), chunk,
Style::default().fg(theme.text), 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) { if matches!(app.mode(), InputMode::Editing) {
// Use the textarea directly to preserve selection state // Use the textarea directly to preserve selection state
let show_cursor = app.cursor_should_be_visible();
let textarea = app.textarea_mut(); let textarea = app.textarea_mut();
textarea.set_block(input_block.clone()); textarea.set_block(input_block.clone());
textarea.set_hard_tab_indent(false); 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) { } else if matches!(app.mode(), InputMode::Visual) {
// In visual mode, render textarea in read-only mode with selection // In visual mode, render textarea in read-only mode with selection
let show_cursor = app.cursor_should_be_visible();
let textarea = app.textarea_mut(); let textarea = app.textarea_mut();
textarea.set_block(input_block.clone()); textarea.set_block(input_block.clone());
textarea.set_hard_tab_indent(false); 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) { } else if matches!(app.mode(), InputMode::Command) {
// In command mode, show the command buffer with : prefix // In command mode, show the command buffer with : prefix
let command_text = format!(":{}", app.command_buffer()); 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_dark** - High-contrast dark theme (default)
- **default_light** - Clean light theme - **default_light** - Clean light theme
- **grayscale-high-contrast** - Monochrome palette tuned for color-blind accessibility
- **gruvbox** - Popular retro color scheme with warm tones - **gruvbox** - Popular retro color scheme with warm tones
- **dracula** - Dark theme with vibrant purple and cyan colors - **dracula** - Dark theme with vibrant purple and cyan colors
- **solarized** - Precision colors for optimal readability - **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"