Add word wrapping and cursor mapping utilities to core library; integrate advanced text input support in TUI. Update dependencies accordingly.
This commit is contained in:
@@ -5,20 +5,20 @@ edition = "2021"
|
||||
description = "Core traits and types for OWLEN LLM client"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
textwrap = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
shellexpand = { workspace = true }
|
||||
regex = "1"
|
||||
once_cell = "1.21.3"
|
||||
anyhow = "1.0.75"
|
||||
log = "0.4.20"
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
serde_json = "1.0.105"
|
||||
thiserror = "1.0.48"
|
||||
tokio = { version = "1.32.0", features = ["full"] }
|
||||
unicode-segmentation = "1.11"
|
||||
unicode-width = "0.1"
|
||||
uuid = { version = "1.4.1", features = ["v4", "serde"] }
|
||||
textwrap = "0.16.0"
|
||||
futures = "0.3.28"
|
||||
async-trait = "0.1.73"
|
||||
toml = "0.8.0"
|
||||
shellexpand = "3.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -49,11 +49,11 @@ impl MessageFormatter {
|
||||
// 2) Collapse: remove whitespace-only lines; keep exactly one '\n' between content lines
|
||||
let mut content = normalized
|
||||
.split('\n')
|
||||
.map(|l| l.trim_end()) // trim trailing spaces per line
|
||||
.filter(|l| !l.trim().is_empty()) // drop blank/whitespace-only lines
|
||||
.map(|l| l.trim_end()) // trim trailing spaces per line
|
||||
.filter(|l| !l.trim().is_empty()) // drop blank/whitespace-only lines
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.trim() // trim leading/trailing whitespace
|
||||
.trim() // trim leading/trailing whitespace
|
||||
.to_string();
|
||||
|
||||
if content.is_empty() && self.preserve_empty_lines {
|
||||
@@ -73,8 +73,12 @@ impl MessageFormatter {
|
||||
.collect();
|
||||
|
||||
// 5) Belt & suspenders: remove leading/trailing blanks if any survived
|
||||
while lines.first().map_or(false, |s| s.trim().is_empty()) { lines.remove(0); }
|
||||
while lines.last().map_or(false, |s| s.trim().is_empty()) { lines.pop(); }
|
||||
while lines.first().map_or(false, |s| s.trim().is_empty()) {
|
||||
lines.remove(0);
|
||||
}
|
||||
while lines.last().map_or(false, |s| s.trim().is_empty()) {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub use model::*;
|
||||
pub use provider::*;
|
||||
pub use router::*;
|
||||
pub use session::*;
|
||||
pub use types::*;
|
||||
pub mod wrap_cursor;
|
||||
|
||||
/// Result type used throughout the OWLEN ecosystem
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
92
crates/owlen-core/src/wrap_cursor.rs
Normal file
92
crates/owlen-core/src/wrap_cursor.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
#![allow(clippy::cast_possible_truncation)]
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ScreenPos {
|
||||
pub row: u16,
|
||||
pub col: u16,
|
||||
}
|
||||
|
||||
pub fn build_cursor_map(text: &str, width: u16) -> Vec<ScreenPos> {
|
||||
assert!(width > 0);
|
||||
let width = width as usize;
|
||||
let mut pos_map = vec![ScreenPos { row: 0, col: 0 }; text.len() + 1];
|
||||
let mut row = 0;
|
||||
let mut col = 0;
|
||||
|
||||
let mut word_start_idx = 0;
|
||||
let mut word_start_col = 0;
|
||||
|
||||
for (byte_offset, grapheme) in text.grapheme_indices(true) {
|
||||
let grapheme_width = UnicodeWidthStr::width(grapheme);
|
||||
|
||||
if grapheme == "\n" {
|
||||
row += 1;
|
||||
col = 0;
|
||||
word_start_col = 0;
|
||||
word_start_idx = byte_offset + grapheme.len();
|
||||
// Set position for the end of this grapheme and any intermediate bytes
|
||||
let end_pos = ScreenPos {
|
||||
row: row as u16,
|
||||
col: col as u16,
|
||||
};
|
||||
for i in 1..=grapheme.len() {
|
||||
if byte_offset + i < pos_map.len() {
|
||||
pos_map[byte_offset + i] = end_pos;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if grapheme.chars().all(char::is_whitespace) {
|
||||
if col + grapheme_width > width {
|
||||
// Whitespace causes wrap
|
||||
row += 1;
|
||||
col = 1; // Position after wrapping space
|
||||
word_start_col = 1;
|
||||
word_start_idx = byte_offset + grapheme.len();
|
||||
} else {
|
||||
col += grapheme_width;
|
||||
word_start_col = col;
|
||||
word_start_idx = byte_offset + grapheme.len();
|
||||
}
|
||||
} else {
|
||||
if col + grapheme_width > width {
|
||||
if word_start_col > 0 && byte_offset == word_start_idx {
|
||||
// This is the first character of a new word that won't fit, wrap it
|
||||
row += 1;
|
||||
col = grapheme_width;
|
||||
} else if word_start_col == 0 {
|
||||
// No previous word boundary, hard break
|
||||
row += 1;
|
||||
col = grapheme_width;
|
||||
} else {
|
||||
// This is part of a word already on the line, let it extend beyond width
|
||||
col += grapheme_width;
|
||||
}
|
||||
} else {
|
||||
col += grapheme_width;
|
||||
}
|
||||
}
|
||||
|
||||
// Set position for the end of this grapheme and any intermediate bytes
|
||||
let end_pos = ScreenPos {
|
||||
row: row as u16,
|
||||
col: col as u16,
|
||||
};
|
||||
for i in 1..=grapheme.len() {
|
||||
if byte_offset + i < pos_map.len() {
|
||||
pos_map[byte_offset + i] = end_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos_map
|
||||
}
|
||||
|
||||
pub fn byte_to_screen_pos(text: &str, byte_idx: usize, width: u16) -> ScreenPos {
|
||||
let pos_map = build_cursor_map(text, width);
|
||||
pos_map[byte_idx.min(text.len())]
|
||||
}
|
||||
115
crates/owlen-core/tests/long_word_debug.rs
Normal file
115
crates/owlen-core/tests/long_word_debug.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use owlen_core::wrap_cursor::build_cursor_map;
|
||||
|
||||
#[test]
|
||||
fn debug_long_word_wrapping() {
|
||||
// Test the exact scenario from the user's issue
|
||||
let text = "asdnklasdnaklsdnkalsdnaskldaskldnaskldnaskldnaskldnaskldnaskldnaskld asdnklska dnskadl dasnksdl asdn";
|
||||
let width = 50; // Approximate width from the user's example
|
||||
|
||||
println!("Testing long word text with width {}", width);
|
||||
println!("Text: '{}'", text);
|
||||
|
||||
// Check what the cursor map shows
|
||||
let cursor_map = build_cursor_map(text, width);
|
||||
|
||||
println!("\nCursor map for key positions:");
|
||||
let long_word_end = text.find(' ').unwrap_or(text.len());
|
||||
for i in [
|
||||
0,
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
40,
|
||||
50,
|
||||
60,
|
||||
70,
|
||||
long_word_end,
|
||||
long_word_end + 1,
|
||||
text.len(),
|
||||
] {
|
||||
if i <= text.len() {
|
||||
let pos = cursor_map[i];
|
||||
let char_at = if i < text.len() {
|
||||
format!("'{}'", text.chars().nth(i).unwrap_or('?'))
|
||||
} else {
|
||||
"END".to_string()
|
||||
};
|
||||
println!(
|
||||
" Byte {}: {} -> row {}, col {}",
|
||||
i, char_at, pos.row, pos.col
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test what my formatting function produces
|
||||
let lines = format_text_with_word_wrap_debug(text, width);
|
||||
|
||||
println!("\nFormatted lines:");
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
println!(" Line {}: '{}' (length: {})", i, line, line.len());
|
||||
}
|
||||
|
||||
// The long word should be broken up, not kept on one line
|
||||
assert!(
|
||||
lines[0].len() <= width as usize + 5,
|
||||
"First line is too long: {} chars",
|
||||
lines[0].len()
|
||||
);
|
||||
}
|
||||
|
||||
fn format_text_with_word_wrap_debug(text: &str, width: u16) -> Vec<String> {
|
||||
if text.is_empty() {
|
||||
return vec!["".to_string()];
|
||||
}
|
||||
|
||||
// Use the cursor map to determine where line breaks should occur
|
||||
let cursor_map = build_cursor_map(text, width);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut current_line = String::new();
|
||||
let mut current_row = 0;
|
||||
|
||||
for (byte_idx, ch) in text.char_indices() {
|
||||
let pos_before = if byte_idx > 0 {
|
||||
cursor_map[byte_idx]
|
||||
} else {
|
||||
cursor_map[0]
|
||||
};
|
||||
let pos_after = cursor_map[byte_idx + ch.len_utf8()];
|
||||
|
||||
println!(
|
||||
"Processing '{}' at byte {}: before=({},{}) after=({},{})",
|
||||
ch, byte_idx, pos_before.row, pos_before.col, pos_after.row, pos_after.col
|
||||
);
|
||||
|
||||
// If the row changed, we need to start a new line
|
||||
if pos_after.row > current_row {
|
||||
println!(
|
||||
" Row changed from {} to {}! Finishing line: '{}'",
|
||||
current_row, pos_after.row, current_line
|
||||
);
|
||||
if !current_line.is_empty() {
|
||||
lines.push(current_line.clone());
|
||||
current_line.clear();
|
||||
}
|
||||
current_row = pos_after.row;
|
||||
|
||||
// If this character is a space that caused the wrap, don't include it
|
||||
if ch.is_whitespace() && pos_before.row < pos_after.row {
|
||||
println!(" Skipping wrapping space");
|
||||
continue; // Skip the wrapping space
|
||||
}
|
||||
}
|
||||
|
||||
current_line.push(ch);
|
||||
}
|
||||
|
||||
// Add the final line
|
||||
if !current_line.is_empty() {
|
||||
lines.push(current_line);
|
||||
} else if lines.is_empty() {
|
||||
lines.push("".to_string());
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
96
crates/owlen-core/tests/wrap_cursor_tests.rs
Normal file
96
crates/owlen-core/tests/wrap_cursor_tests.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use owlen_core::wrap_cursor::{build_cursor_map, ScreenPos};
|
||||
|
||||
fn assert_cursor_pos(map: &[ScreenPos], byte_idx: usize, expected: ScreenPos) {
|
||||
assert_eq!(map[byte_idx], expected, "Mismatch at byte {}", byte_idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_wrap_at_spaces() {
|
||||
let text = "hello world";
|
||||
let width = 5;
|
||||
let map = build_cursor_map(text, width);
|
||||
|
||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
||||
assert_cursor_pos(&map, 5, ScreenPos { row: 0, col: 5 }); // after "hello"
|
||||
assert_cursor_pos(&map, 6, ScreenPos { row: 1, col: 1 }); // after "hello "
|
||||
assert_cursor_pos(&map, 11, ScreenPos { row: 1, col: 6 }); // after "world"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hard_line_break() {
|
||||
let text = "a\nb";
|
||||
let width = 10;
|
||||
let map = build_cursor_map(text, width);
|
||||
|
||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
||||
assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "a"
|
||||
assert_cursor_pos(&map, 2, ScreenPos { row: 1, col: 0 }); // after "\n"
|
||||
assert_cursor_pos(&map, 3, ScreenPos { row: 1, col: 1 }); // after "b"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_word_split() {
|
||||
let text = "abcdefgh";
|
||||
let width = 3;
|
||||
let map = build_cursor_map(text, width);
|
||||
|
||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
||||
assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 });
|
||||
assert_cursor_pos(&map, 2, ScreenPos { row: 0, col: 2 });
|
||||
assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 3 });
|
||||
assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 1 });
|
||||
assert_cursor_pos(&map, 5, ScreenPos { row: 1, col: 2 });
|
||||
assert_cursor_pos(&map, 6, ScreenPos { row: 1, col: 3 });
|
||||
assert_cursor_pos(&map, 7, ScreenPos { row: 2, col: 1 });
|
||||
assert_cursor_pos(&map, 8, ScreenPos { row: 2, col: 2 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trailing_spaces_preserved() {
|
||||
let text = "x y";
|
||||
let width = 2;
|
||||
let map = build_cursor_map(text, width);
|
||||
|
||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
||||
assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "x"
|
||||
assert_cursor_pos(&map, 2, ScreenPos { row: 0, col: 2 }); // after "x "
|
||||
assert_cursor_pos(&map, 3, ScreenPos { row: 1, col: 1 }); // after "x "
|
||||
assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 2 }); // after "y"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graphemes_emoji() {
|
||||
let text = "🙂🙂a";
|
||||
let width = 3;
|
||||
let map = build_cursor_map(text, width);
|
||||
|
||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
||||
assert_cursor_pos(&map, 4, ScreenPos { row: 0, col: 2 }); // after first emoji
|
||||
assert_cursor_pos(&map, 8, ScreenPos { row: 1, col: 2 }); // after second emoji
|
||||
assert_cursor_pos(&map, 9, ScreenPos { row: 1, col: 3 }); // after "a"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graphemes_combining() {
|
||||
let text = "e\u{0301}";
|
||||
let width = 10;
|
||||
let map = build_cursor_map(text, width);
|
||||
|
||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
||||
assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "e"
|
||||
assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 1 }); // after combining mark
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_edge() {
|
||||
let text = "abc def";
|
||||
let width = 3;
|
||||
let map = build_cursor_map(text, width);
|
||||
|
||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
||||
assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 3 }); // after "abc"
|
||||
assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 1 }); // after " "
|
||||
assert_cursor_pos(&map, 7, ScreenPos { row: 1, col: 4 }); // after "def"
|
||||
}
|
||||
@@ -10,6 +10,9 @@ owlen-core = { path = "../owlen-core" }
|
||||
# TUI framework
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
tui-textarea = { workspace = true }
|
||||
textwrap = { workspace = true }
|
||||
unicode-width = "0.1"
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -105,7 +105,7 @@ pub struct App {
|
||||
pub model_selection_index: usize,
|
||||
/// Ollama client for making API requests
|
||||
ollama_client: OllamaClient,
|
||||
/// Currently active requests (for tracking streaming responses)
|
||||
/// Currently active requests ( for tracking streaming responses)
|
||||
active_requests: HashMap<Uuid, usize>, // UUID -> message index
|
||||
/// Status message to show at the bottom
|
||||
pub status_message: String,
|
||||
@@ -907,4 +907,4 @@ impl App {
|
||||
pub fn is_session_input(&self) -> bool {
|
||||
self.input_mode == InputMode::SessionInput
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ use owlen_core::{
|
||||
session::{SessionController, SessionOutcome},
|
||||
types::{ChatParameters, ChatResponse, Conversation, ModelInfo},
|
||||
};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use tokio::sync::mpsc;
|
||||
use tui_textarea::{Input, TextArea};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config;
|
||||
@@ -64,11 +66,15 @@ pub struct ChatApp {
|
||||
scroll: usize,
|
||||
session_tx: mpsc::UnboundedSender<SessionEvent>,
|
||||
streaming: HashSet<Uuid>,
|
||||
textarea: TextArea<'static>, // Advanced text input widget
|
||||
}
|
||||
|
||||
impl ChatApp {
|
||||
pub fn new(controller: SessionController) -> (Self, mpsc::UnboundedReceiver<SessionEvent>) {
|
||||
let (session_tx, session_rx) = mpsc::unbounded_channel();
|
||||
let mut textarea = TextArea::default();
|
||||
configure_textarea_defaults(&mut textarea);
|
||||
|
||||
let app = Self {
|
||||
controller,
|
||||
mode: InputMode::Normal,
|
||||
@@ -82,6 +88,7 @@ impl ChatApp {
|
||||
scroll: 0,
|
||||
session_tx,
|
||||
streaming: std::collections::HashSet::new(),
|
||||
textarea,
|
||||
};
|
||||
|
||||
(app, session_rx)
|
||||
@@ -146,6 +153,28 @@ impl ChatApp {
|
||||
self.controller.input_buffer_mut()
|
||||
}
|
||||
|
||||
pub fn textarea(&self) -> &TextArea<'static> {
|
||||
&self.textarea
|
||||
}
|
||||
|
||||
pub fn textarea_mut(&mut self) -> &mut TextArea<'static> {
|
||||
&mut self.textarea
|
||||
}
|
||||
|
||||
/// Sync textarea content to input buffer
|
||||
fn sync_textarea_to_buffer(&mut self) {
|
||||
let text = self.textarea.lines().join("\n");
|
||||
self.input_buffer_mut().set_text(text);
|
||||
}
|
||||
|
||||
/// Sync input buffer content to textarea
|
||||
fn sync_buffer_to_textarea(&mut self) {
|
||||
let text = self.input_buffer().text().to_string();
|
||||
let lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
|
||||
self.textarea = TextArea::new(lines);
|
||||
configure_textarea_defaults(&mut self.textarea);
|
||||
}
|
||||
|
||||
pub async fn initialize_models(&mut self) -> Result<()> {
|
||||
let config_model_name = self.controller.config().general.default_model.clone();
|
||||
let config_model_provider = self.controller.config().general.default_provider.clone();
|
||||
@@ -227,6 +256,7 @@ impl ChatApp {
|
||||
(KeyCode::Enter, KeyModifiers::NONE)
|
||||
| (KeyCode::Char('i'), KeyModifiers::NONE) => {
|
||||
self.mode = InputMode::Editing;
|
||||
self.sync_buffer_to_textarea();
|
||||
}
|
||||
(KeyCode::Up, KeyModifiers::NONE) => {
|
||||
self.scroll = self.scroll.saturating_add(1);
|
||||
@@ -241,56 +271,41 @@ impl ChatApp {
|
||||
},
|
||||
InputMode::Editing => match key.code {
|
||||
KeyCode::Esc if key.modifiers.is_empty() => {
|
||||
// Sync textarea content to input buffer before leaving edit mode
|
||||
self.sync_textarea_to_buffer();
|
||||
self.mode = InputMode::Normal;
|
||||
self.reset_status();
|
||||
}
|
||||
KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||
self.input_buffer_mut().insert_char('\n');
|
||||
KeyCode::Char('j' | 'J') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.textarea.insert_newline();
|
||||
}
|
||||
KeyCode::Enter if key.modifiers.is_empty() => {
|
||||
// Send message and return to normal mode
|
||||
self.sync_textarea_to_buffer();
|
||||
self.try_send_message().await?;
|
||||
// Clear the textarea by setting it to empty
|
||||
self.textarea = TextArea::default();
|
||||
configure_textarea_defaults(&mut self.textarea);
|
||||
self.mode = InputMode::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.input_buffer_mut().insert_char('\n');
|
||||
// Any Enter with modifiers keeps editing and inserts a newline via tui-textarea
|
||||
self.textarea.input(Input::from(key));
|
||||
}
|
||||
KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.input_buffer_mut().insert_char('\n');
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.input_buffer_mut().backspace();
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
self.input_buffer_mut().delete();
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.input_buffer_mut().move_left();
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.input_buffer_mut().move_right();
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.input_buffer_mut().move_home();
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.input_buffer_mut().move_end();
|
||||
}
|
||||
KeyCode::Up => {
|
||||
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
// Navigate through input history
|
||||
self.input_buffer_mut().history_previous();
|
||||
self.sync_buffer_to_textarea();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
// Navigate through input history
|
||||
self.input_buffer_mut().history_next();
|
||||
self.sync_buffer_to_textarea();
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if key.modifiers.is_empty()
|
||||
|| key.modifiers.contains(KeyModifiers::SHIFT) =>
|
||||
{
|
||||
self.input_buffer_mut().insert_char(c);
|
||||
_ => {
|
||||
// Let tui-textarea handle all other input
|
||||
self.textarea.input(Input::from(key));
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.input_buffer_mut().insert_tab();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
InputMode::ProviderSelection => match key.code {
|
||||
KeyCode::Esc => {
|
||||
@@ -381,7 +396,11 @@ impl ChatApp {
|
||||
message_id,
|
||||
response,
|
||||
} => {
|
||||
let at_bottom = self.scroll == 0;
|
||||
self.controller.apply_stream_chunk(message_id, &response)?;
|
||||
if at_bottom {
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
if response.is_final {
|
||||
self.streaming.remove(&message_id);
|
||||
self.status = "Response complete".to_string();
|
||||
@@ -458,6 +477,8 @@ impl ChatApp {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.scroll_to_bottom();
|
||||
|
||||
let message = self.controller.input_buffer_mut().commit_to_history();
|
||||
let mut parameters = ChatParameters::default();
|
||||
parameters.stream = self.controller.config().general.enable_streaming;
|
||||
@@ -538,6 +559,10 @@ impl ChatApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_to_bottom(&mut self) {
|
||||
self.scroll = 0;
|
||||
}
|
||||
|
||||
fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) {
|
||||
let sender = self.session_tx.clone();
|
||||
self.streaming.insert(message_id);
|
||||
@@ -569,3 +594,17 @@ impl ChatApp {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_textarea_defaults(textarea: &mut TextArea<'static>) {
|
||||
textarea.set_placeholder_text("Type your message here...");
|
||||
textarea.set_tab_length(4);
|
||||
|
||||
textarea.set_style(
|
||||
Style::default()
|
||||
.remove_modifier(Modifier::UNDERLINED)
|
||||
.remove_modifier(Modifier::ITALIC)
|
||||
.remove_modifier(Modifier::BOLD),
|
||||
);
|
||||
textarea.set_cursor_style(Style::default());
|
||||
textarea.set_cursor_line_style(Style::default());
|
||||
}
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
use textwrap::{wrap, Options};
|
||||
use tui_textarea::TextArea;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::chat_app::{ChatApp, InputMode};
|
||||
use owlen_core::types::Role;
|
||||
|
||||
pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
// Calculate dynamic input height based on textarea content
|
||||
let available_width = frame.area().width;
|
||||
let input_height = if matches!(app.mode(), InputMode::Editing) {
|
||||
let visual_lines = calculate_wrapped_line_count(
|
||||
app.textarea().lines().iter().map(|s| s.as_str()),
|
||||
available_width,
|
||||
);
|
||||
(visual_lines as u16).min(10) + 2 // +2 for borders
|
||||
} else {
|
||||
let buffer_text = app.input_buffer().text();
|
||||
let lines: Vec<&str> = if buffer_text.is_empty() {
|
||||
vec![""]
|
||||
} else {
|
||||
buffer_text.lines().collect()
|
||||
};
|
||||
let visual_lines = calculate_wrapped_line_count(lines.into_iter(), available_width);
|
||||
(visual_lines as u16).min(10) + 2 // +2 for borders
|
||||
};
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(4), // Header
|
||||
Constraint::Min(8), // Messages
|
||||
Constraint::Length(3), // Input
|
||||
Constraint::Length(3), // Status
|
||||
Constraint::Length(4), // Header
|
||||
Constraint::Min(8), // Messages
|
||||
Constraint::Length(input_height), // Input
|
||||
Constraint::Length(3), // Status
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
@@ -31,6 +53,271 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_editable_textarea(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
textarea: &mut TextArea<'static>,
|
||||
wrap_lines: bool,
|
||||
) {
|
||||
let block = textarea.block().cloned();
|
||||
let inner = block.as_ref().map(|b| b.inner(area)).unwrap_or(area);
|
||||
let base_style = textarea.style();
|
||||
let cursor_line_style = textarea.cursor_line_style();
|
||||
let selection_style = textarea.selection_style();
|
||||
let selection_range = textarea.selection_range();
|
||||
let cursor = textarea.cursor();
|
||||
let mask_char = textarea.mask_char();
|
||||
let is_empty = textarea.is_empty();
|
||||
let placeholder_text = textarea.placeholder_text().to_string();
|
||||
let placeholder_style = textarea.placeholder_style();
|
||||
let lines_slice = textarea.lines();
|
||||
|
||||
let mut render_lines: Vec<Line> = Vec::new();
|
||||
|
||||
if is_empty {
|
||||
if !placeholder_text.is_empty() {
|
||||
let style = placeholder_style.unwrap_or_else(|| Style::default().fg(Color::DarkGray));
|
||||
render_lines.push(Line::from(vec![Span::styled(placeholder_text, style)]));
|
||||
} else {
|
||||
render_lines.push(Line::default());
|
||||
}
|
||||
} else {
|
||||
for (row_idx, raw_line) in lines_slice.iter().enumerate() {
|
||||
let display_line = mask_char
|
||||
.map(|mask| mask_line(raw_line, mask))
|
||||
.unwrap_or_else(|| raw_line.clone());
|
||||
|
||||
let spans = build_line_spans(&display_line, row_idx, selection_range, selection_style);
|
||||
|
||||
let mut line = Line::from(spans);
|
||||
if row_idx == cursor.0 {
|
||||
line = line.patch_style(cursor_line_style);
|
||||
}
|
||||
render_lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if render_lines.is_empty() {
|
||||
render_lines.push(Line::default());
|
||||
}
|
||||
|
||||
let mut paragraph = Paragraph::new(render_lines).style(base_style);
|
||||
|
||||
if wrap_lines {
|
||||
paragraph = paragraph.wrap(Wrap { trim: false });
|
||||
}
|
||||
|
||||
let metrics = compute_cursor_metrics(lines_slice, cursor, mask_char, inner, wrap_lines);
|
||||
|
||||
if let Some(ref metrics) = metrics {
|
||||
if metrics.scroll_top > 0 {
|
||||
paragraph = paragraph.scroll((metrics.scroll_top, 0));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(block) = block {
|
||||
paragraph = paragraph.block(block);
|
||||
}
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
if let Some(metrics) = metrics {
|
||||
frame.set_cursor_position((metrics.cursor_x, metrics.cursor_y));
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_line(line: &str, mask: char) -> String {
|
||||
line.chars().map(|_| mask).collect()
|
||||
}
|
||||
|
||||
fn build_line_spans(
|
||||
display_line: &str,
|
||||
row_idx: usize,
|
||||
selection: Option<((usize, usize), (usize, usize))>,
|
||||
selection_style: Style,
|
||||
) -> Vec<Span<'static>> {
|
||||
if let Some(((start_row, start_col), (end_row, end_col))) = selection {
|
||||
if row_idx < start_row || row_idx > end_row {
|
||||
return vec![Span::raw(display_line.to_string())];
|
||||
}
|
||||
|
||||
let char_count = display_line.chars().count();
|
||||
let start = if row_idx == start_row {
|
||||
start_col.min(char_count)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = if row_idx == end_row {
|
||||
end_col.min(char_count)
|
||||
} else {
|
||||
char_count
|
||||
};
|
||||
|
||||
if start >= end {
|
||||
return vec![Span::raw(display_line.to_string())];
|
||||
}
|
||||
|
||||
let start_byte = char_to_byte_idx(display_line, start);
|
||||
let end_byte = char_to_byte_idx(display_line, end);
|
||||
|
||||
let mut spans = Vec::new();
|
||||
if start_byte > 0 {
|
||||
spans.push(Span::raw(display_line[..start_byte].to_string()));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
display_line[start_byte..end_byte].to_string(),
|
||||
selection_style,
|
||||
));
|
||||
if end_byte < display_line.len() {
|
||||
spans.push(Span::raw(display_line[end_byte..].to_string()));
|
||||
}
|
||||
if spans.is_empty() {
|
||||
spans.push(Span::raw(String::new()));
|
||||
}
|
||||
spans
|
||||
} else {
|
||||
vec![Span::raw(display_line.to_string())]
|
||||
}
|
||||
}
|
||||
|
||||
fn char_to_byte_idx(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()
|
||||
}
|
||||
|
||||
struct CursorMetrics {
|
||||
cursor_x: u16,
|
||||
cursor_y: u16,
|
||||
scroll_top: u16,
|
||||
}
|
||||
|
||||
fn compute_cursor_metrics(
|
||||
lines: &[String],
|
||||
cursor: (usize, usize),
|
||||
mask_char: Option<char>,
|
||||
inner: Rect,
|
||||
wrap_lines: bool,
|
||||
) -> Option<CursorMetrics> {
|
||||
if inner.width == 0 || inner.height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content_width = inner.width as usize;
|
||||
let visible_height = inner.height as usize;
|
||||
if content_width == 0 || visible_height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cursor_row = cursor.0.min(lines.len().saturating_sub(1));
|
||||
let cursor_col = cursor.1;
|
||||
|
||||
let mut total_visual_rows = 0usize;
|
||||
let mut cursor_visual_row = 0usize;
|
||||
let mut cursor_col_width = 0usize;
|
||||
let mut cursor_found = false;
|
||||
|
||||
for (row_idx, line) in lines.iter().enumerate() {
|
||||
let display_owned = mask_char.map(|mask| mask_line(line, mask));
|
||||
let display_line = display_owned.as_deref().unwrap_or_else(|| line.as_str());
|
||||
|
||||
let mut segments = if wrap_lines {
|
||||
wrap_line_segments(display_line, content_width)
|
||||
} else {
|
||||
vec![display_line.to_string()]
|
||||
};
|
||||
|
||||
if segments.is_empty() {
|
||||
segments.push(String::new());
|
||||
}
|
||||
|
||||
if row_idx == cursor_row && !cursor_found {
|
||||
let mut remaining = cursor_col;
|
||||
for (segment_idx, segment) in segments.iter().enumerate() {
|
||||
let segment_len = segment.chars().count();
|
||||
let is_last_segment = segment_idx + 1 == segments.len();
|
||||
|
||||
if remaining > segment_len {
|
||||
remaining -= segment_len;
|
||||
continue;
|
||||
}
|
||||
|
||||
if remaining == segment_len && !is_last_segment {
|
||||
cursor_visual_row = total_visual_rows + segment_idx + 1;
|
||||
cursor_col_width = 0;
|
||||
cursor_found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let prefix: String = segment.chars().take(remaining).collect();
|
||||
cursor_visual_row = total_visual_rows + segment_idx;
|
||||
cursor_col_width = UnicodeWidthStr::width(prefix.as_str());
|
||||
cursor_found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if !cursor_found {
|
||||
if let Some(last_segment) = segments.last() {
|
||||
cursor_visual_row = total_visual_rows + segments.len().saturating_sub(1);
|
||||
cursor_col_width = UnicodeWidthStr::width(last_segment.as_str());
|
||||
cursor_found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total_visual_rows += segments.len();
|
||||
}
|
||||
|
||||
if !cursor_found {
|
||||
cursor_visual_row = total_visual_rows.saturating_sub(1);
|
||||
cursor_col_width = 0;
|
||||
}
|
||||
|
||||
let mut scroll_top = 0usize;
|
||||
if cursor_visual_row + 1 > visible_height {
|
||||
scroll_top = cursor_visual_row + 1 - visible_height;
|
||||
}
|
||||
|
||||
let max_scroll = total_visual_rows.saturating_sub(visible_height);
|
||||
if scroll_top > max_scroll {
|
||||
scroll_top = max_scroll;
|
||||
}
|
||||
|
||||
let cursor_visible_row = cursor_visual_row.saturating_sub(scroll_top);
|
||||
let cursor_y = inner.y + cursor_visible_row.min(visible_height.saturating_sub(1)) as u16;
|
||||
let cursor_x = inner.x + cursor_col_width.min(content_width.saturating_sub(1)) as u16;
|
||||
|
||||
Some(CursorMetrics {
|
||||
cursor_x,
|
||||
cursor_y,
|
||||
scroll_top: scroll_top as u16,
|
||||
})
|
||||
}
|
||||
|
||||
fn wrap_line_segments(line: &str, width: usize) -> Vec<String> {
|
||||
if width == 0 {
|
||||
return vec![String::new()];
|
||||
}
|
||||
|
||||
let wrapped = wrap(line, Options::new(width).break_words(false));
|
||||
if wrapped.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
wrapped
|
||||
.into_iter()
|
||||
.map(|segment| segment.into_owned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
let title_span = Span::styled(
|
||||
" 🦉 OWLEN - AI Assistant ",
|
||||
@@ -116,7 +403,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
||||
)
|
||||
.wrap(ratatui::widgets::Wrap { trim: false });
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
let scroll = app.scroll().min(u16::MAX as usize) as u16;
|
||||
paragraph = paragraph.scroll((scroll, 0));
|
||||
@@ -140,22 +427,58 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135)));
|
||||
|
||||
let input_text = app.input_buffer().text().to_string();
|
||||
let paragraph = Paragraph::new(input_text.clone())
|
||||
.block(input_block)
|
||||
.wrap(ratatui::widgets::Wrap { trim: false });
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
if matches!(app.mode(), InputMode::Editing) {
|
||||
let cursor_index = app.input_buffer().cursor();
|
||||
let (cursor_line, cursor_col) = cursor_position(&input_text, cursor_index);
|
||||
let x = area.x + 1 + cursor_col as u16;
|
||||
let y = area.y + 1 + cursor_line as u16;
|
||||
frame.set_cursor_position((
|
||||
x.min(area.right().saturating_sub(1)),
|
||||
y.min(area.bottom().saturating_sub(1)),
|
||||
));
|
||||
let mut textarea = app.textarea().clone();
|
||||
textarea.set_block(input_block.clone());
|
||||
render_editable_textarea(frame, area, &mut textarea, true);
|
||||
} else {
|
||||
// In non-editing mode, show the current input buffer content as read-only
|
||||
let input_text = app.input_buffer().text();
|
||||
let lines: Vec<Line> = if input_text.is_empty() {
|
||||
vec![Line::from("Press 'i' to start typing")]
|
||||
} else {
|
||||
input_text.lines().map(|line| Line::from(line)).collect()
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.block(input_block)
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
let content_width = available_width.saturating_sub(2); // subtract block borders
|
||||
if content_width == 0 {
|
||||
let mut count = 0;
|
||||
for _ in lines.into_iter() {
|
||||
count += 1;
|
||||
}
|
||||
return count.max(1);
|
||||
}
|
||||
|
||||
let options = Options::new(content_width as usize).break_words(false);
|
||||
|
||||
let mut total = 0usize;
|
||||
let mut seen = false;
|
||||
for line in lines.into_iter() {
|
||||
seen = true;
|
||||
if line.is_empty() {
|
||||
total += 1;
|
||||
continue;
|
||||
}
|
||||
let wrapped = wrap(line, &options);
|
||||
total += wrapped.len().max(1);
|
||||
}
|
||||
|
||||
if !seen {
|
||||
1
|
||||
} else {
|
||||
total.max(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,20 +695,3 @@ fn role_color(role: &Role) -> Style {
|
||||
Role::System => Style::default().fg(Color::Cyan),
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_position(text: &str, cursor: usize) -> (usize, usize) {
|
||||
let mut line = 0;
|
||||
let mut col = 0;
|
||||
for (idx, ch) in text.char_indices() {
|
||||
if idx >= cursor {
|
||||
break;
|
||||
}
|
||||
if ch == '\n' {
|
||||
line += 1;
|
||||
col = 0;
|
||||
} else {
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
(line, col)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user