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

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