diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c16f5d..3c44c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tabbed model selector that separates local and cloud providers, including cloud indicators in the UI. - Footer status line includes provider connectivity/credential summaries (e.g., cloud auth failures, missing API keys). - Secure credential vault integration for Ollama Cloud API keys when `privacy.encrypt_local_data = true`. +- Input panel respects a new `ui.input_max_rows` setting so long prompts expand predictably before scrolling kicks in. ### Changed - The main `README.md` has been updated to be more concise and link to the new documentation. @@ -31,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Configuration loading performs structural validation and fails fast on missing default providers or invalid MCP definitions. - Ollama provider error handling now distinguishes timeouts, missing models, and authentication failures. - `owlen` warns when the active terminal likely lacks 256-color support. -- `config.toml` now carries a schema version (`1.1.0`) and is migrated automatically; deprecated keys such as `agent.max_tool_calls` trigger warnings instead of hard failures. +- `config.toml` now carries a schema version (`1.2.0`) and is migrated automatically; deprecated keys such as `agent.max_tool_calls` trigger warnings instead of hard failures. - Model selector navigation (Tab/Shift-Tab) now switches between local and cloud tabs while preserving selection state. --- diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index 3c9ed7c..cdf2e21 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -11,7 +11,7 @@ use std::time::Duration; pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml"; /// Current schema version written to `config.toml`. -pub const CONFIG_SCHEMA_VERSION: &str = "1.1.0"; +pub const CONFIG_SCHEMA_VERSION: &str = "1.2.0"; /// Core configuration shared by all OWLEN clients #[derive(Debug, Clone, Serialize, Deserialize)] @@ -706,6 +706,8 @@ pub struct UiSettings { pub wrap_column: u16, #[serde(default = "UiSettings::default_show_onboarding")] pub show_onboarding: bool, + #[serde(default = "UiSettings::default_input_max_rows")] + pub input_max_rows: u16, } impl UiSettings { @@ -732,6 +734,10 @@ impl UiSettings { const fn default_show_onboarding() -> bool { true } + + const fn default_input_max_rows() -> u16 { + 5 + } } impl Default for UiSettings { @@ -743,6 +749,7 @@ impl Default for UiSettings { show_role_labels: Self::default_show_role_labels(), wrap_column: Self::default_wrap_column(), show_onboarding: Self::default_show_onboarding(), + input_max_rows: Self::default_input_max_rows(), } } } diff --git a/crates/owlen-tui/Cargo.toml b/crates/owlen-tui/Cargo.toml index ae9367e..0480980 100644 --- a/crates/owlen-tui/Cargo.toml +++ b/crates/owlen-tui/Cargo.toml @@ -18,6 +18,7 @@ crossterm = { workspace = true } tui-textarea = { workspace = true } textwrap = { workspace = true } unicode-width = "0.1" +unicode-segmentation = "1.11" async-trait = "0.1" # Async runtime diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index a6f131e..fd186db 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -679,6 +679,11 @@ impl ChatApp { &self.theme } + pub fn input_max_rows(&self) -> u16 { + let config = self.controller.config(); + config.ui.input_max_rows.max(1) + } + pub fn set_theme(&mut self, theme: Theme) { self.theme = theme; } @@ -867,18 +872,9 @@ impl ChatApp { } Event::Paste(text) => { // Handle paste events - insert text directly without triggering sends - if matches!(self.mode, InputMode::Editing) { - // In editing mode, insert the pasted text directly into textarea - let lines: Vec<&str> = text.lines().collect(); - for (i, line) in lines.iter().enumerate() { - for ch in line.chars() { - self.textarea.insert_char(ch); - } - // Insert newline between lines (but not after the last line) - if i < lines.len() - 1 { - self.textarea.insert_newline(); - } - } + if matches!(self.mode, InputMode::Editing | InputMode::Visual) + && self.textarea.insert_str(&text) + { self.sync_textarea_to_buffer(); } // Ignore paste events in other modes diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 14814ad..33cb306 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -4,8 +4,9 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use serde_json; -use textwrap::{Options, wrap}; +use textwrap::wrap; use tui_textarea::TextArea; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::chat_app::{ChatApp, HELP_TAB_COUNT, ModelSelectorItemKind}; @@ -37,22 +38,23 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { // Calculate dynamic input height based on textarea content let available_width = chat_area.width; - let input_height = if matches!(app.mode(), InputMode::Editing) { - let visual_lines = calculate_wrapped_line_count( + let max_input_rows = usize::from(app.input_max_rows()).max(1); + let visual_lines = if matches!(app.mode(), InputMode::Editing | InputMode::Visual) { + 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() + buffer_text.split('\n').collect() }; - let visual_lines = calculate_wrapped_line_count(lines, available_width); - (visual_lines as u16).min(10) + 2 // +2 for borders + calculate_wrapped_line_count(lines, available_width) }; + let visible_rows = visual_lines.max(1).min(max_input_rows); + let input_height = visible_rows as u16 + 2; // +2 for borders // Calculate thinking section height let thinking_height = if let Some(thinking) = app.current_thinking() { @@ -233,10 +235,11 @@ fn render_editable_textarea( let metrics = compute_cursor_metrics(lines_slice, cursor, mask_char, inner, wrap_lines); - if let Some(ref metrics) = metrics - && metrics.scroll_top > 0 + if let Some(metrics) = metrics + .as_ref() + .filter(|metrics| metrics.scroll_top > 0 || metrics.scroll_left > 0) { - paragraph = paragraph.scroll((metrics.scroll_top, 0)); + paragraph = paragraph.scroll((metrics.scroll_top, metrics.scroll_left)); } if let Some(block) = block { @@ -322,6 +325,7 @@ struct CursorMetrics { cursor_x: u16, cursor_y: u16, scroll_top: u16, + scroll_left: u16, } fn compute_cursor_metrics( @@ -348,6 +352,7 @@ fn compute_cursor_metrics( let mut cursor_visual_row = 0usize; let mut cursor_col_width = 0usize; let mut cursor_found = false; + let mut cursor_line_total_width = 0usize; for (row_idx, line) in lines.iter().enumerate() { let display_owned = mask_char.map(|mask| mask_line(line, mask)); @@ -364,32 +369,40 @@ fn compute_cursor_metrics( } if row_idx == cursor_row && !cursor_found { + cursor_line_total_width = segments + .iter() + .map(|segment| UnicodeWidthStr::width(segment.as_str())) + .sum(); + let mut remaining = cursor_col; + let mut segment_base_row = total_visual_rows; 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; + segment_base_row += 1; continue; } if remaining == segment_len && !is_last_segment { - cursor_visual_row = total_visual_rows + segment_idx + 1; + cursor_visual_row = segment_base_row + 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()); + let prefix_byte = char_to_byte_idx(segment, remaining); + let prefix = &segment[..prefix_byte]; + cursor_visual_row = segment_base_row; + cursor_col_width = UnicodeWidthStr::width(prefix); cursor_found = true; break; } if !cursor_found && let Some(last_segment) = segments.last() { - cursor_visual_row = total_visual_rows + segments.len().saturating_sub(1); + cursor_visual_row = segment_base_row + segments.len().saturating_sub(1); cursor_col_width = UnicodeWidthStr::width(last_segment.as_str()); cursor_found = true; } @@ -413,14 +426,28 @@ fn compute_cursor_metrics( scroll_top = max_scroll; } + let mut scroll_left = 0usize; + if !wrap_lines && content_width > 0 { + let max_scroll_left = cursor_line_total_width.saturating_sub(content_width); + if cursor_col_width + 1 > content_width { + scroll_left = cursor_col_width + 1 - content_width; + } + if scroll_left > max_scroll_left { + scroll_left = max_scroll_left; + } + } + + let visible_cursor_col = cursor_col_width.saturating_sub(scroll_left); let cursor_visible_row = cursor_visual_row.saturating_sub(scroll_top); + let max_x = content_width.saturating_sub(1); 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; + let cursor_x = inner.x + visible_cursor_col.min(max_x) as u16; Some(CursorMetrics { cursor_x, cursor_y, scroll_top: scroll_top as u16, + scroll_left: scroll_left.min(u16::MAX as usize) as u16, }) } @@ -438,28 +465,24 @@ fn wrap_line_segments(line: &str, width: usize) -> Vec { let mut current = String::new(); let mut current_width = 0usize; - for ch in line.chars() { - let ch_width = UnicodeWidthStr::width(ch.to_string().as_str()); + for grapheme in line.graphemes(true) { + let grapheme_width = UnicodeWidthStr::width(grapheme); // If adding this character would exceed width, wrap to next line - if current_width + ch_width > width { - if !current.is_empty() { - result.push(current); - current = String::new(); - current_width = 0; - } - // If even a single character is too wide, add it anyway to avoid infinite loop - if ch_width > width { - current.push(ch); - result.push(current); - current = String::new(); - current_width = 0; - continue; - } + if current_width + grapheme_width > width && !current.is_empty() { + result.push(current); + current = String::new(); + current_width = 0; } - current.push(ch); - current_width += ch_width; + // If even a single grapheme is too wide, add it as its own line + if grapheme_width > width { + result.push(grapheme.to_string()); + continue; + } + + current.push_str(grapheme); + current_width += grapheme_width; } if !current.is_empty() { @@ -1268,27 +1291,17 @@ fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize where I: IntoIterator, { - 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 content_width = available_width.saturating_sub(2) as usize; // subtract block borders let mut total = 0usize; let mut seen = false; for line in lines.into_iter() { seen = true; - if line.is_empty() { + if content_width == 0 || line.is_empty() { total += 1; continue; } - let wrapped = wrap(line, &options); - total += wrapped.len().max(1); + total += wrap_line_segments(line, content_width).len().max(1); } if !seen { 1 } else { total.max(1) } diff --git a/docs/configuration.md b/docs/configuration.md index c3b86a3..5f4e223 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,6 +64,9 @@ These settings customize the look and feel of the terminal interface. - `wrap_column` (integer, default: `100`) The column at which to wrap text if `word_wrap` is enabled. +- `input_max_rows` (integer, default: `5`) + The maximum number of rows the input panel will expand to before it starts scrolling internally. Increase this value if you prefer to see more of long prompts while editing. + ## Storage Settings (`[storage]`) These settings control how conversations are saved and loaded. diff --git a/docs/migrations/README.md b/docs/migrations/README.md index 4a56d8c..c088385 100644 --- a/docs/migrations/README.md +++ b/docs/migrations/README.md @@ -2,6 +2,10 @@ Owlen is still in alpha, so configuration and storage formats may change between releases. This directory collects short guides that explain how to update a local environment when breaking changes land. +### Schema 1.2.0 (November 2025) + +`config.toml` now records `schema_version = "1.2.0"` and introduces the optional `ui.input_max_rows` setting. The new key defaults to `5`, so no manual edits are required unless you prefer a taller input panel. Existing files are updated automatically on load/save. + ### Schema 1.1.0 (October 2025) Owlen `config.toml` files now carry a `schema_version`. On startup the loader upgrades any existing file and warns when deprecated keys are present. No manual changes are required, but if you track the file in version control you may notice `schema_version = "1.1.0"` added near the top.