Enhance TUI: implement visual and command modes, add selection highlighting, multi-panel focus management, and extend cursor/scrolling functionality. Update help instructions accordingly.

This commit is contained in:
2025-09-30 02:22:21 +02:00
parent 2731a3a878
commit 63ca71c6ae
2 changed files with 1361 additions and 69 deletions

View File

@@ -91,7 +91,7 @@ fn render_editable_textarea(
frame: &mut Frame<'_>,
area: Rect,
textarea: &mut TextArea<'static>,
wrap_lines: bool,
mut wrap_lines: bool,
) {
let block = textarea.block().cloned();
let inner = block.as_ref().map(|b| b.inner(area)).unwrap_or(area);
@@ -106,6 +106,11 @@ fn render_editable_textarea(
let placeholder_style = textarea.placeholder_style();
let lines_slice = textarea.lines();
// Disable wrapping when there's an active selection to preserve highlighting
if selection_range.is_some() {
wrap_lines = false;
}
let mut render_lines: Vec<Line> = Vec::new();
if is_empty {
@@ -136,7 +141,6 @@ fn render_editable_textarea(
}
// If wrapping is enabled, we need to manually wrap the lines
// For now, we'll convert to plain text, wrap, and lose styling
// This ensures consistency with cursor calculation
if wrap_lines {
let content_width = inner.width as usize;
@@ -432,6 +436,109 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
frame.render_widget(paragraph, inner_area);
}
use crate::chat_app::FocusedPanel;
fn apply_visual_selection(lines: Vec<Line>, selection: Option<((usize, usize), (usize, usize))>) -> Vec<Line> {
if let Some(((start_row, start_col), (end_row, end_col))) = selection {
// Normalize selection (ensure start is before end)
let ((start_r, start_c), (end_r, end_c)) = if start_row < end_row || (start_row == end_row && start_col <= end_col) {
((start_row, start_col), (end_row, end_col))
} else {
((end_row, end_col), (start_row, start_col))
};
lines.into_iter().enumerate().map(|(idx, line)| {
if idx < start_r || idx > end_r {
// Line not in selection
return line;
}
// Convert line to plain text for character indexing
let line_text = line.to_string();
let char_count = line_text.chars().count();
if idx == start_r && idx == end_r {
// Selection within single line
let sel_start = start_c.min(char_count);
let sel_end = end_c.min(char_count);
if sel_start >= sel_end {
return line;
}
let start_byte = char_to_byte_index(&line_text, sel_start);
let end_byte = char_to_byte_index(&line_text, sel_end);
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::raw(line_text[..start_byte].to_string()));
}
spans.push(Span::styled(
line_text[start_byte..end_byte].to_string(),
Style::default().bg(Color::LightBlue).fg(Color::Black)
));
if end_byte < line_text.len() {
spans.push(Span::raw(line_text[end_byte..].to_string()));
}
Line::from(spans)
} else if idx == start_r {
// First line of multi-line selection
let sel_start = start_c.min(char_count);
let start_byte = char_to_byte_index(&line_text, sel_start);
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::raw(line_text[..start_byte].to_string()));
}
spans.push(Span::styled(
line_text[start_byte..].to_string(),
Style::default().bg(Color::LightBlue).fg(Color::Black)
));
Line::from(spans)
} else if idx == end_r {
// Last line of multi-line selection
let sel_end = end_c.min(char_count);
let end_byte = char_to_byte_index(&line_text, sel_end);
let mut spans = Vec::new();
spans.push(Span::styled(
line_text[..end_byte].to_string(),
Style::default().bg(Color::LightBlue).fg(Color::Black)
));
if end_byte < line_text.len() {
spans.push(Span::raw(line_text[end_byte..].to_string()));
}
Line::from(spans)
} else {
// Middle line - fully selected
let styled_spans: Vec<Span> = line.spans.into_iter().map(|span| {
Span::styled(
span.content,
span.style.bg(Color::LightBlue).fg(Color::Black)
)
}).collect();
Line::from(styled_spans)
}
}).collect()
} else {
lines
}
}
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
if char_idx == 0 {
return 0;
}
let mut iter = s.char_indices();
for (i, (byte_idx, _)) in iter.by_ref().enumerate() {
if i == char_idx {
return byte_idx;
}
}
s.len()
}
fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
// Calculate viewport dimensions for autoscroll calculations
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
@@ -556,6 +663,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
lines.push(Line::from("No messages yet. Press 'i' to start typing."));
}
// Apply visual selection highlighting if in visual mode and Chat panel is focused
if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Chat) {
if let Some(selection) = app.visual_selection() {
lines = apply_visual_selection(lines, Some(selection));
}
}
// Update AutoScroll state with accurate content length
let auto_scroll = app.auto_scroll_mut();
auto_scroll.content_len = lines.len();
@@ -563,15 +677,47 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let scroll_position = app.scroll().min(u16::MAX as usize) as u16;
// Highlight border if this panel is focused
let border_color = if matches!(app.focused_panel(), FocusedPanel::Chat) {
Color::LightMagenta
} else {
Color::Rgb(95, 20, 135)
};
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
.border_style(Style::default().fg(border_color)),
)
.scroll((scroll_position, 0));
frame.render_widget(paragraph, area);
// Render cursor if Chat panel is focused and in Normal mode
if matches!(app.focused_panel(), FocusedPanel::Chat) && matches!(app.mode(), InputMode::Normal) {
let cursor = app.chat_cursor();
let cursor_row = cursor.0;
let cursor_col = cursor.1;
// Calculate visible cursor position (accounting for scroll)
if cursor_row >= scroll_position as usize && cursor_row < (scroll_position as usize + viewport_height) {
let visible_row = cursor_row - scroll_position as usize;
let cursor_y = area.y + 1 + visible_row as u16; // +1 for border
// Get the rendered line and calculate display width
let rendered_lines = app.get_rendered_lines();
if let Some(line_text) = rendered_lines.get(cursor_row) {
let chars: Vec<char> = line_text.chars().collect();
let text_before_cursor: String = chars.iter().take(cursor_col).collect();
let display_width = UnicodeWidthStr::width(text_before_cursor.as_str());
let cursor_x = area.x + 1 + display_width as u16; // +1 for border only
frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
@@ -583,7 +729,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let chunks = wrap(&thinking, content_width as usize);
let lines: Vec<Line> = chunks
let mut lines: Vec<Line> = chunks
.into_iter()
.map(|seg| {
Line::from(Span::styled(
@@ -595,6 +741,13 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
})
.collect();
// Apply visual selection highlighting if in visual mode and Thinking panel is focused
if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Thinking) {
if let Some(selection) = app.visual_selection() {
lines = apply_visual_selection(lines, Some(selection));
}
}
// Update AutoScroll state with accurate content length
let thinking_scroll = app.thinking_scroll_mut();
thinking_scroll.content_len = lines.len();
@@ -602,6 +755,13 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let scroll_position = app.thinking_scroll_position().min(u16::MAX as usize) as u16;
// Highlight border if this panel is focused
let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) {
Color::LightMagenta
} else {
Color::DarkGray
};
let paragraph = Paragraph::new(lines)
.block(
Block::default()
@@ -612,21 +772,53 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
.add_modifier(Modifier::ITALIC),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
.border_style(Style::default().fg(border_color)),
)
.scroll((scroll_position, 0))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
// Render cursor if Thinking panel is focused and in Normal mode
if matches!(app.focused_panel(), FocusedPanel::Thinking) && matches!(app.mode(), InputMode::Normal) {
let cursor = app.thinking_cursor();
let cursor_row = cursor.0;
let cursor_col = cursor.1;
// Calculate visible cursor position (accounting for scroll)
if cursor_row >= scroll_position as usize && cursor_row < (scroll_position as usize + viewport_height) {
let visible_row = cursor_row - scroll_position as usize;
let cursor_y = area.y + 1 + visible_row as u16; // +1 for border
// Calculate actual display width by measuring characters up to cursor
let line_text = thinking.lines().nth(cursor_row).unwrap_or("");
let chars: Vec<char> = line_text.chars().collect();
let text_before_cursor: String = chars.iter().take(cursor_col).collect();
let display_width = UnicodeWidthStr::width(text_before_cursor.as_str());
let cursor_x = area.x + 1 + display_width as u16; // +1 for border only
frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let title = match app.mode() {
InputMode::Editing => " Input (Enter=send · Ctrl+J=newline · Esc=exit input mode) ",
InputMode::Visual => " Visual Mode (y=yank · d=cut · Esc=cancel) ",
InputMode::Command => " Command Mode (Enter=execute · Esc=cancel) ",
_ => " Input (Press 'i' to start typing) ",
};
// Highlight border if this panel is focused
let border_color = if matches!(app.focused_panel(), FocusedPanel::Input) {
Color::LightMagenta
} else {
Color::Rgb(95, 20, 135)
};
let input_block = Block::default()
.title(Span::styled(
title,
@@ -635,13 +827,35 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(95, 20, 135)));
.border_style(Style::default().fg(border_color));
if matches!(app.mode(), InputMode::Editing) {
let mut textarea = app.textarea().clone();
// Use the textarea directly to preserve selection state
let textarea = app.textarea_mut();
textarea.set_block(input_block.clone());
textarea.set_hard_tab_indent(false);
render_editable_textarea(frame, area, &mut textarea, true);
render_editable_textarea(frame, area, textarea, true);
} else if matches!(app.mode(), InputMode::Visual) {
// In visual mode, render textarea in read-only mode with selection
let textarea = app.textarea_mut();
textarea.set_block(input_block.clone());
textarea.set_hard_tab_indent(false);
render_editable_textarea(frame, area, textarea, true);
} else if matches!(app.mode(), InputMode::Command) {
// In command mode, show the command buffer with : prefix
let command_text = format!(":{}", app.command_buffer());
let lines = vec![Line::from(Span::styled(
command_text,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))];
let paragraph = Paragraph::new(lines)
.block(input_block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
} else {
// In non-editing mode, show the current input buffer content as read-only
let input_text = app.input_buffer().text();
@@ -700,6 +914,8 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
InputMode::ModelSelection => (" MODEL", Color::LightYellow),
InputMode::ProviderSelection => (" PROVIDER", Color::LightCyan),
InputMode::Help => (" HELP", Color::LightMagenta),
InputMode::Visual => (" VISUAL", Color::Magenta),
InputMode::Command => (" COMMAND", Color::Yellow),
};
let status_message = if app.streaming_count() > 0 {
@@ -710,7 +926,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
"Ready".to_string()
};
let help_text = "i:Input m:Model c:Clear q:Quit";
let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit";
let left_spans = vec![
Span::styled(
@@ -845,18 +1061,74 @@ fn render_help(frame: &mut Frame<'_>) {
frame.render_widget(Clear, area);
let help_text = vec![
Line::from("Controls:"),
Line::from(" i / Enter → start typing"),
Line::from(" Enter send message"),
Line::from(" Ctrl+Jnewline"),
Line::from(" j / ↓ → scroll down"),
Line::from(" k / ↑ → scroll up"),
Line::from(" m → select model"),
Line::from(" n → new conversation"),
Line::from(" c → clear conversation"),
Line::from(" q → quit"),
Line::from("MODES:"),
Line::from(" Normal → default mode for navigation"),
Line::from(" Insertediting input text"),
Line::from(" Visualselecting text"),
Line::from(" Command → executing commands (: prefix)"),
Line::from(""),
Line::from("Press Esc to close this help."),
Line::from("PANEL NAVIGATION:"),
Line::from(" Tab → cycle panels forward"),
Line::from(" Shift+Tab → cycle panels backward"),
Line::from(" (Panels: Chat, Thinking, Input)"),
Line::from(""),
Line::from("CURSOR MOVEMENT (Normal mode, Chat/Thinking panels):"),
Line::from(" h/← l/→ → move left/right by character"),
Line::from(" j/↓ k/↑ → move down/up by line"),
Line::from(" w → forward to next word start"),
Line::from(" e → forward to word end"),
Line::from(" b → backward to previous word"),
Line::from(" 0 / Home → start of line"),
Line::from(" ^ → first non-blank character"),
Line::from(" $ / End → end of line"),
Line::from(" gg → jump to top"),
Line::from(" G → jump to bottom"),
Line::from(" Ctrl+d/u → scroll half page down/up"),
Line::from(" Ctrl+f/b → scroll full page down/up"),
Line::from(" PageUp/Down → scroll full page"),
Line::from(""),
Line::from("EDITING (Normal mode):"),
Line::from(" i / Enter → enter insert mode at cursor"),
Line::from(" a → append after cursor"),
Line::from(" A → append at end of line"),
Line::from(" I → insert at start of line"),
Line::from(" o → insert line below and enter insert mode"),
Line::from(" O → insert line above and enter insert mode"),
Line::from(" dd → clear input buffer"),
Line::from(" p → paste from clipboard to input"),
Line::from(" Esc → return to normal mode"),
Line::from(""),
Line::from("INSERT MODE:"),
Line::from(" Enter → send message"),
Line::from(" Ctrl+J → insert newline"),
Line::from(" Ctrl+↑/↓ → navigate input history"),
Line::from(" Ctrl+A → start of line"),
Line::from(" Ctrl+E → end of line"),
Line::from(" Ctrl+W → word forward"),
Line::from(" Ctrl+B → word backward"),
Line::from(" Esc → return to normal mode"),
Line::from(""),
Line::from("VISUAL MODE (all panels):"),
Line::from(" v → enter visual mode at cursor"),
Line::from(" h/j/k/l → extend selection left/down/up/right"),
Line::from(" w / e / b → extend by word (start/end/back)"),
Line::from(" 0 / ^ / $ → extend to line start/first char/end"),
Line::from(" y → yank (copy) selection"),
Line::from(" d → yank selection (delete in Input)"),
Line::from(" v / Esc → exit visual mode"),
Line::from(""),
Line::from("COMMANDS (press : then type):"),
Line::from(" :h, :help → show this help"),
Line::from(" :m, :model → select model"),
Line::from(" :n, :new → start new conversation"),
Line::from(" :c, :clear → clear current conversation"),
Line::from(" :q, :quit → quit application"),
Line::from(""),
Line::from("QUICK KEYS:"),
Line::from(" q → quit (from normal mode)"),
Line::from(" Ctrl+C → quit"),
Line::from(""),
Line::from("Press Esc or Enter to close this help."),
];
let paragraph = Paragraph::new(help_text).block(