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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user