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:
File diff suppressed because it is too large
Load Diff
@@ -91,7 +91,7 @@ fn render_editable_textarea(
|
|||||||
frame: &mut Frame<'_>,
|
frame: &mut Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
textarea: &mut TextArea<'static>,
|
textarea: &mut TextArea<'static>,
|
||||||
wrap_lines: bool,
|
mut wrap_lines: bool,
|
||||||
) {
|
) {
|
||||||
let block = textarea.block().cloned();
|
let block = textarea.block().cloned();
|
||||||
let inner = block.as_ref().map(|b| b.inner(area)).unwrap_or(area);
|
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 placeholder_style = textarea.placeholder_style();
|
||||||
let lines_slice = textarea.lines();
|
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();
|
let mut render_lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
if is_empty {
|
if is_empty {
|
||||||
@@ -136,7 +141,6 @@ fn render_editable_textarea(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If wrapping is enabled, we need to manually wrap the lines
|
// 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
|
// This ensures consistency with cursor calculation
|
||||||
if wrap_lines {
|
if wrap_lines {
|
||||||
let content_width = inner.width as usize;
|
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);
|
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) {
|
fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
||||||
// Calculate viewport dimensions for autoscroll calculations
|
// Calculate viewport dimensions for autoscroll calculations
|
||||||
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
|
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."));
|
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
|
// Update AutoScroll state with accurate content length
|
||||||
let auto_scroll = app.auto_scroll_mut();
|
let auto_scroll = app.auto_scroll_mut();
|
||||||
auto_scroll.content_len = lines.len();
|
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;
|
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)
|
let paragraph = Paragraph::new(lines)
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
.border_style(Style::default().fg(border_color)),
|
||||||
)
|
)
|
||||||
.scroll((scroll_position, 0));
|
.scroll((scroll_position, 0));
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
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) {
|
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 chunks = wrap(&thinking, content_width as usize);
|
||||||
|
|
||||||
let 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(
|
||||||
@@ -595,6 +741,13 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
|||||||
})
|
})
|
||||||
.collect();
|
.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
|
// Update AutoScroll state with accurate content length
|
||||||
let thinking_scroll = app.thinking_scroll_mut();
|
let thinking_scroll = app.thinking_scroll_mut();
|
||||||
thinking_scroll.content_len = lines.len();
|
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;
|
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)
|
let paragraph = Paragraph::new(lines)
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
@@ -612,21 +772,53 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
|||||||
.add_modifier(Modifier::ITALIC),
|
.add_modifier(Modifier::ITALIC),
|
||||||
))
|
))
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Color::DarkGray)),
|
.border_style(Style::default().fg(border_color)),
|
||||||
)
|
)
|
||||||
.scroll((scroll_position, 0))
|
.scroll((scroll_position, 0))
|
||||||
.wrap(Wrap { trim: false });
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
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() {
|
let title = match app.mode() {
|
||||||
InputMode::Editing => " Input (Enter=send · Ctrl+J=newline · Esc=exit input 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) ",
|
_ => " 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()
|
let input_block = Block::default()
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
title,
|
title,
|
||||||
@@ -635,13 +827,35 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
))
|
))
|
||||||
.borders(Borders::ALL)
|
.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) {
|
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_block(input_block.clone());
|
||||||
textarea.set_hard_tab_indent(false);
|
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 {
|
} else {
|
||||||
// In non-editing mode, show the current input buffer content as read-only
|
// In non-editing mode, show the current input buffer content as read-only
|
||||||
let input_text = app.input_buffer().text();
|
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::ModelSelection => (" MODEL", Color::LightYellow),
|
||||||
InputMode::ProviderSelection => (" PROVIDER", Color::LightCyan),
|
InputMode::ProviderSelection => (" PROVIDER", Color::LightCyan),
|
||||||
InputMode::Help => (" HELP", Color::LightMagenta),
|
InputMode::Help => (" HELP", Color::LightMagenta),
|
||||||
|
InputMode::Visual => (" VISUAL", Color::Magenta),
|
||||||
|
InputMode::Command => (" COMMAND", Color::Yellow),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status_message = if app.streaming_count() > 0 {
|
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()
|
"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![
|
let left_spans = vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
@@ -845,18 +1061,74 @@ fn render_help(frame: &mut Frame<'_>) {
|
|||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
let help_text = vec![
|
let help_text = vec![
|
||||||
Line::from("Controls:"),
|
Line::from("MODES:"),
|
||||||
Line::from(" i / Enter → start typing"),
|
Line::from(" Normal → default mode for navigation"),
|
||||||
Line::from(" Enter → send message"),
|
Line::from(" Insert → editing input text"),
|
||||||
Line::from(" Ctrl+J → newline"),
|
Line::from(" Visual → selecting text"),
|
||||||
Line::from(" j / ↓ → scroll down"),
|
Line::from(" Command → executing commands (: prefix)"),
|
||||||
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(""),
|
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(
|
let paragraph = Paragraph::new(help_text).block(
|
||||||
|
|||||||
Reference in New Issue
Block a user