feat(ui): add configurable input panel max rows and horizontal scrolling
- Introduce `ui.input_max_rows` (default 5) to control how many rows the input panel expands before scrolling. - Bump `CONFIG_SCHEMA_VERSION` to **1.2.0** and update migration documentation. - Update `configuration.md` and migration guide to describe the new setting. - Adjust TUI height calculation to respect `input_max_rows` and add horizontal scrolling support for long lines. - Add `unicode-segmentation` dependency for proper grapheme handling.
This commit is contained in:
@@ -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.
|
- 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).
|
- 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`.
|
- 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
|
### Changed
|
||||||
- The main `README.md` has been updated to be more concise and link to the new documentation.
|
- 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.
|
- 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.
|
- Ollama provider error handling now distinguishes timeouts, missing models, and authentication failures.
|
||||||
- `owlen` warns when the active terminal likely lacks 256-color support.
|
- `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.
|
- Model selector navigation (Tab/Shift-Tab) now switches between local and cloud tabs while preserving selection state.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::time::Duration;
|
|||||||
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
|
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
|
||||||
|
|
||||||
/// Current schema version written to `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
|
/// Core configuration shared by all OWLEN clients
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -706,6 +706,8 @@ pub struct UiSettings {
|
|||||||
pub wrap_column: u16,
|
pub wrap_column: u16,
|
||||||
#[serde(default = "UiSettings::default_show_onboarding")]
|
#[serde(default = "UiSettings::default_show_onboarding")]
|
||||||
pub show_onboarding: bool,
|
pub show_onboarding: bool,
|
||||||
|
#[serde(default = "UiSettings::default_input_max_rows")]
|
||||||
|
pub input_max_rows: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UiSettings {
|
impl UiSettings {
|
||||||
@@ -732,6 +734,10 @@ impl UiSettings {
|
|||||||
const fn default_show_onboarding() -> bool {
|
const fn default_show_onboarding() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn default_input_max_rows() -> u16 {
|
||||||
|
5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiSettings {
|
impl Default for UiSettings {
|
||||||
@@ -743,6 +749,7 @@ impl Default for UiSettings {
|
|||||||
show_role_labels: Self::default_show_role_labels(),
|
show_role_labels: Self::default_show_role_labels(),
|
||||||
wrap_column: Self::default_wrap_column(),
|
wrap_column: Self::default_wrap_column(),
|
||||||
show_onboarding: Self::default_show_onboarding(),
|
show_onboarding: Self::default_show_onboarding(),
|
||||||
|
input_max_rows: Self::default_input_max_rows(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ crossterm = { workspace = true }
|
|||||||
tui-textarea = { workspace = true }
|
tui-textarea = { workspace = true }
|
||||||
textwrap = { workspace = true }
|
textwrap = { workspace = true }
|
||||||
unicode-width = "0.1"
|
unicode-width = "0.1"
|
||||||
|
unicode-segmentation = "1.11"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
|
|||||||
@@ -679,6 +679,11 @@ impl ChatApp {
|
|||||||
&self.theme
|
&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) {
|
pub fn set_theme(&mut self, theme: Theme) {
|
||||||
self.theme = theme;
|
self.theme = theme;
|
||||||
}
|
}
|
||||||
@@ -867,18 +872,9 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
Event::Paste(text) => {
|
Event::Paste(text) => {
|
||||||
// Handle paste events - insert text directly without triggering sends
|
// Handle paste events - insert text directly without triggering sends
|
||||||
if matches!(self.mode, InputMode::Editing) {
|
if matches!(self.mode, InputMode::Editing | InputMode::Visual)
|
||||||
// In editing mode, insert the pasted text directly into textarea
|
&& self.textarea.insert_str(&text)
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.sync_textarea_to_buffer();
|
self.sync_textarea_to_buffer();
|
||||||
}
|
}
|
||||||
// Ignore paste events in other modes
|
// Ignore paste events in other modes
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ use ratatui::style::{Color, Modifier, Style};
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use textwrap::{Options, wrap};
|
use textwrap::wrap;
|
||||||
use tui_textarea::TextArea;
|
use tui_textarea::TextArea;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, ModelSelectorItemKind};
|
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
|
// Calculate dynamic input height based on textarea content
|
||||||
let available_width = chat_area.width;
|
let available_width = chat_area.width;
|
||||||
let input_height = if matches!(app.mode(), InputMode::Editing) {
|
let max_input_rows = usize::from(app.input_max_rows()).max(1);
|
||||||
let visual_lines = calculate_wrapped_line_count(
|
let visual_lines = if matches!(app.mode(), InputMode::Editing | InputMode::Visual) {
|
||||||
|
calculate_wrapped_line_count(
|
||||||
app.textarea().lines().iter().map(|s| s.as_str()),
|
app.textarea().lines().iter().map(|s| s.as_str()),
|
||||||
available_width,
|
available_width,
|
||||||
);
|
)
|
||||||
(visual_lines as u16).min(10) + 2 // +2 for borders
|
|
||||||
} else {
|
} else {
|
||||||
let buffer_text = app.input_buffer().text();
|
let buffer_text = app.input_buffer().text();
|
||||||
let lines: Vec<&str> = if buffer_text.is_empty() {
|
let lines: Vec<&str> = if buffer_text.is_empty() {
|
||||||
vec![""]
|
vec![""]
|
||||||
} else {
|
} else {
|
||||||
buffer_text.lines().collect()
|
buffer_text.split('\n').collect()
|
||||||
};
|
};
|
||||||
let visual_lines = calculate_wrapped_line_count(lines, available_width);
|
calculate_wrapped_line_count(lines, available_width)
|
||||||
(visual_lines as u16).min(10) + 2 // +2 for borders
|
|
||||||
};
|
};
|
||||||
|
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
|
// Calculate thinking section height
|
||||||
let thinking_height = if let Some(thinking) = app.current_thinking() {
|
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);
|
let metrics = compute_cursor_metrics(lines_slice, cursor, mask_char, inner, wrap_lines);
|
||||||
|
|
||||||
if let Some(ref metrics) = metrics
|
if let Some(metrics) = metrics
|
||||||
&& metrics.scroll_top > 0
|
.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 {
|
if let Some(block) = block {
|
||||||
@@ -322,6 +325,7 @@ struct CursorMetrics {
|
|||||||
cursor_x: u16,
|
cursor_x: u16,
|
||||||
cursor_y: u16,
|
cursor_y: u16,
|
||||||
scroll_top: u16,
|
scroll_top: u16,
|
||||||
|
scroll_left: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_cursor_metrics(
|
fn compute_cursor_metrics(
|
||||||
@@ -348,6 +352,7 @@ fn compute_cursor_metrics(
|
|||||||
let mut cursor_visual_row = 0usize;
|
let mut cursor_visual_row = 0usize;
|
||||||
let mut cursor_col_width = 0usize;
|
let mut cursor_col_width = 0usize;
|
||||||
let mut cursor_found = false;
|
let mut cursor_found = false;
|
||||||
|
let mut cursor_line_total_width = 0usize;
|
||||||
|
|
||||||
for (row_idx, line) in lines.iter().enumerate() {
|
for (row_idx, line) in lines.iter().enumerate() {
|
||||||
let display_owned = mask_char.map(|mask| mask_line(line, mask));
|
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 {
|
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 remaining = cursor_col;
|
||||||
|
let mut segment_base_row = total_visual_rows;
|
||||||
for (segment_idx, segment) in segments.iter().enumerate() {
|
for (segment_idx, segment) in segments.iter().enumerate() {
|
||||||
let segment_len = segment.chars().count();
|
let segment_len = segment.chars().count();
|
||||||
let is_last_segment = segment_idx + 1 == segments.len();
|
let is_last_segment = segment_idx + 1 == segments.len();
|
||||||
|
|
||||||
if remaining > segment_len {
|
if remaining > segment_len {
|
||||||
remaining -= segment_len;
|
remaining -= segment_len;
|
||||||
|
segment_base_row += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if remaining == segment_len && !is_last_segment {
|
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_col_width = 0;
|
||||||
cursor_found = true;
|
cursor_found = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let prefix: String = segment.chars().take(remaining).collect();
|
let prefix_byte = char_to_byte_idx(segment, remaining);
|
||||||
cursor_visual_row = total_visual_rows + segment_idx;
|
let prefix = &segment[..prefix_byte];
|
||||||
cursor_col_width = UnicodeWidthStr::width(prefix.as_str());
|
cursor_visual_row = segment_base_row;
|
||||||
|
cursor_col_width = UnicodeWidthStr::width(prefix);
|
||||||
cursor_found = true;
|
cursor_found = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cursor_found && let Some(last_segment) = segments.last() {
|
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_col_width = UnicodeWidthStr::width(last_segment.as_str());
|
||||||
cursor_found = true;
|
cursor_found = true;
|
||||||
}
|
}
|
||||||
@@ -413,14 +426,28 @@ fn compute_cursor_metrics(
|
|||||||
scroll_top = max_scroll;
|
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 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_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 {
|
Some(CursorMetrics {
|
||||||
cursor_x,
|
cursor_x,
|
||||||
cursor_y,
|
cursor_y,
|
||||||
scroll_top: scroll_top as u16,
|
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<String> {
|
|||||||
let mut current = String::new();
|
let mut current = String::new();
|
||||||
let mut current_width = 0usize;
|
let mut current_width = 0usize;
|
||||||
|
|
||||||
for ch in line.chars() {
|
for grapheme in line.graphemes(true) {
|
||||||
let ch_width = UnicodeWidthStr::width(ch.to_string().as_str());
|
let grapheme_width = UnicodeWidthStr::width(grapheme);
|
||||||
|
|
||||||
// If adding this character would exceed width, wrap to next line
|
// If adding this character would exceed width, wrap to next line
|
||||||
if current_width + ch_width > width {
|
if current_width + grapheme_width > width && !current.is_empty() {
|
||||||
if !current.is_empty() {
|
result.push(current);
|
||||||
result.push(current);
|
current = String::new();
|
||||||
current = String::new();
|
current_width = 0;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
current.push(ch);
|
// If even a single grapheme is too wide, add it as its own line
|
||||||
current_width += ch_width;
|
if grapheme_width > width {
|
||||||
|
result.push(grapheme.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current.push_str(grapheme);
|
||||||
|
current_width += grapheme_width;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !current.is_empty() {
|
if !current.is_empty() {
|
||||||
@@ -1268,27 +1291,17 @@ fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize
|
|||||||
where
|
where
|
||||||
I: IntoIterator<Item = &'a str>,
|
I: IntoIterator<Item = &'a str>,
|
||||||
{
|
{
|
||||||
let content_width = available_width.saturating_sub(2); // subtract block borders
|
let content_width = available_width.saturating_sub(2) as usize; // 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 total = 0usize;
|
||||||
let mut seen = false;
|
let mut seen = false;
|
||||||
for line in lines.into_iter() {
|
for line in lines.into_iter() {
|
||||||
seen = true;
|
seen = true;
|
||||||
if line.is_empty() {
|
if content_width == 0 || line.is_empty() {
|
||||||
total += 1;
|
total += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let wrapped = wrap(line, &options);
|
total += wrap_line_segments(line, content_width).len().max(1);
|
||||||
total += wrapped.len().max(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !seen { 1 } else { total.max(1) }
|
if !seen { 1 } else { total.max(1) }
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ These settings customize the look and feel of the terminal interface.
|
|||||||
- `wrap_column` (integer, default: `100`)
|
- `wrap_column` (integer, default: `100`)
|
||||||
The column at which to wrap text if `word_wrap` is enabled.
|
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]`)
|
## Storage Settings (`[storage]`)
|
||||||
|
|
||||||
These settings control how conversations are saved and loaded.
|
These settings control how conversations are saved and loaded.
|
||||||
|
|||||||
@@ -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.
|
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)
|
### 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.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user