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:
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
37
themes/grayscale-high-contrast.toml
Normal file
37
themes/grayscale-high-contrast.toml
Normal 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"
|
||||||
Reference in New Issue
Block a user