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:
@@ -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+J → newline"),
|
||||
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(" Insert → editing input text"),
|
||||
Line::from(" Visual → selecting 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(
|
||||
|
||||
Reference in New Issue
Block a user