diff --git a/crates/owlen-core/Cargo.toml b/crates/owlen-core/Cargo.toml index c63578e..a645122 100644 --- a/crates/owlen-core/Cargo.toml +++ b/crates/owlen-core/Cargo.toml @@ -17,6 +17,8 @@ async-trait = "0.1" textwrap = { workspace = true } toml = { workspace = true } shellexpand = { workspace = true } +regex = "1" +once_cell = "1.21.3" [dev-dependencies] tokio-test = { workspace = true } diff --git a/crates/owlen-core/src/formatting.rs b/crates/owlen-core/src/formatting.rs index 3f85972..2c7aa5c 100644 --- a/crates/owlen-core/src/formatting.rs +++ b/crates/owlen-core/src/formatting.rs @@ -15,7 +15,7 @@ impl MessageFormatter { Self { wrap_width: wrap_width.max(20), show_role_labels, - preserve_empty_lines: true, + preserve_empty_lines: false, } } @@ -25,36 +25,51 @@ impl MessageFormatter { self } - /// Render a message to a list of visual lines ready for display - pub fn format_message(&self, message: &Message) -> Vec { - let mut lines = Vec::new(); + /// Update the wrap width + pub fn set_wrap_width(&mut self, width: usize) { + self.wrap_width = width.max(20); + } + + pub fn format_message(&self, message: &Message) -> Vec { + // 1) Normalize line breaks to '\n' (handles CR, NEL, LS, PS) + let normalized: String = message + .content + .chars() + .map(|ch| match ch { + '\r' | '\u{0085}' | '\u{2028}' | '\u{2029}' => '\n', + _ => ch, + }) + .collect(); + + // 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 + .collect::>() + .join("\n") + .trim() // trim leading/trailing whitespace + .to_string(); - let mut content = message.content.trim_end().to_string(); if content.is_empty() && self.preserve_empty_lines { content.push(' '); } + // 3) Wrap let options = Options::new(self.wrap_width) .break_words(true) .word_separator(textwrap::WordSeparator::UnicodeBreakProperties); - let wrapped = wrap(&content, &options); + // 4) Post: rtrim each visual line; drop any whitespace-only lines + let mut lines: Vec = wrap(&content, &options) + .into_iter() + .map(|s| s.trim_end().to_string()) + .filter(|s| !s.trim().is_empty()) + .collect(); - if self.show_role_labels { - let label = format!("{}:", message.role.to_string().to_uppercase()); - if let Some(first) = wrapped.first() { - lines.push(format!("{label} {first}")); - for line in wrapped.iter().skip(1) { - lines.push(format!("{:width$} {line}", "", width = label.len())); - } - } else { - lines.push(label); - } - } else { - for line in wrapped { - lines.push(line.into_owned()); - } - } + // 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(); } lines } diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs index 36bb98c..c7bdb47 100644 --- a/crates/owlen-core/src/session.rs +++ b/crates/owlen-core/src/session.rs @@ -87,6 +87,11 @@ impl SessionController { &self.formatter } + /// Update the wrap width of the message formatter + pub fn set_formatter_wrap_width(&mut self, width: usize) { + self.formatter.set_wrap_width(width); + } + /// Access configuration pub fn config(&self) -> &Config { &self.config diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index dc1aa49..c85f9d1 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -56,11 +56,11 @@ pub struct ChatApp { pub mode: InputMode, pub status: String, pub error: Option, - models: Vec, // All models fetched + models: Vec, // All models fetched pub available_providers: Vec, // Unique providers from models - pub selected_provider: String, // The currently selected provider - pub selected_provider_index: usize, // Index into the available_providers list - pub selected_model: Option, // Index into the *filtered* models list + pub selected_provider: String, // The currently selected provider + pub selected_provider_index: usize, // Index into the available_providers list + pub selected_model: Option, // Index into the *filtered* models list scroll: usize, session_tx: mpsc::UnboundedSender, streaming: HashSet, @@ -104,7 +104,8 @@ impl ChatApp { } pub fn models(&self) -> Vec<&ModelInfo> { - self.models.iter() + self.models + .iter() .filter(|m| m.provider == self.selected_provider) .collect() } @@ -153,16 +154,24 @@ impl ChatApp { self.models = all_models; // Populate available_providers - let mut providers = self.models.iter().map(|m| m.provider.clone()).collect::>(); + let mut providers = self + .models + .iter() + .map(|m| m.provider.clone()) + .collect::>(); self.available_providers = providers.into_iter().collect(); self.available_providers.sort(); // Set selected_provider based on config, or default to "ollama" if not found - self.selected_provider = self.available_providers.iter() + self.selected_provider = self + .available_providers + .iter() .find(|&p| p == &config_model_provider) .cloned() .unwrap_or_else(|| "ollama".to_string()); - self.selected_provider_index = self.available_providers.iter() + self.selected_provider_index = self + .available_providers + .iter() .position(|p| p == &self.selected_provider) .unwrap_or(0); @@ -174,7 +183,9 @@ impl ChatApp { let current_model_name = self.controller.selected_model().to_string(); let current_model_provider = self.controller.config().general.default_provider.clone(); - if config_model_name.as_deref() != Some(¤t_model_name) || config_model_provider != current_model_provider { + if config_model_name.as_deref() != Some(¤t_model_name) + || config_model_provider != current_model_provider + { if let Err(err) = config::save_config(self.controller.config()) { self.error = Some(format!("Failed to save config: {err}")); } else { @@ -286,7 +297,9 @@ impl ChatApp { self.mode = InputMode::Normal; } KeyCode::Enter => { - if let Some(provider) = self.available_providers.get(self.selected_provider_index) { + if let Some(provider) = + self.available_providers.get(self.selected_provider_index) + { self.selected_provider = provider.clone(); self.sync_selected_model_index(); // Update model selection based on new provider self.mode = InputMode::ModelSelection; @@ -318,12 +331,15 @@ impl ChatApp { self.controller.set_model(model_id.clone()); self.status = format!("Using model: {}", model_name); // Save the selected provider and model to config - self.controller.config_mut().general.default_model = Some(model_id.clone()); - self.controller.config_mut().general.default_provider = self.selected_provider.clone(); + self.controller.config_mut().general.default_model = + Some(model_id.clone()); + self.controller.config_mut().general.default_provider = + self.selected_provider.clone(); match config::save_config(self.controller.config()) { Ok(_) => self.error = None, Err(err) => { - self.error = Some(format!("Failed to save config: {}", err)); + self.error = + Some(format!("Failed to save config: {}", err)); } } } @@ -394,16 +410,24 @@ impl ChatApp { self.models = all_models; // Populate available_providers - let mut providers = self.models.iter().map(|m| m.provider.clone()).collect::>(); + let mut providers = self + .models + .iter() + .map(|m| m.provider.clone()) + .collect::>(); self.available_providers = providers.into_iter().collect(); self.available_providers.sort(); // Set selected_provider based on config, or default to "ollama" if not found - self.selected_provider = self.available_providers.iter() + self.selected_provider = self + .available_providers + .iter() .find(|&p| p == &config_model_provider) .cloned() .unwrap_or_else(|| "ollama".to_string()); - self.selected_provider_index = self.available_providers.iter() + self.selected_provider_index = self + .available_providers + .iter() .position(|p| p == &self.selected_provider) .unwrap_or(0); @@ -413,7 +437,9 @@ impl ChatApp { let current_model_name = self.controller.selected_model().to_string(); let current_model_provider = self.controller.config().general.default_provider.clone(); - if config_model_name.as_deref() != Some(¤t_model_name) || config_model_provider != current_model_provider { + if config_model_name.as_deref() != Some(¤t_model_name) + || config_model_provider != current_model_provider + { if let Err(err) = config::save_config(self.controller.config()) { self.error = Some(format!("Failed to save config: {err}")); } else { @@ -480,7 +506,9 @@ impl ChatApp { fn sync_selected_model_index(&mut self) { let current_model_id = self.controller.selected_model().to_string(); - let filtered_models: Vec<&ModelInfo> = self.models.iter() + let filtered_models: Vec<&ModelInfo> = self + .models + .iter() .filter(|m| m.provider == self.selected_provider) .collect(); @@ -501,7 +529,8 @@ impl ChatApp { self.controller.set_model(model.id.clone()); // Also update the config with the new model and provider self.controller.config_mut().general.default_model = Some(model.id.clone()); - self.controller.config_mut().general.default_provider = self.selected_provider.clone(); + self.controller.config_mut().general.default_provider = + self.selected_provider.clone(); if let Err(err) = config::save_config(self.controller.config()) { self.error = Some(format!("Failed to save config: {err}")); } diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 9046a35..7fcd540 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -11,15 +11,17 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) { let layout = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(8), - Constraint::Length(5), - Constraint::Length(3), + Constraint::Length(4), // Header + Constraint::Min(8), // Messages + Constraint::Length(3), // Input + Constraint::Length(3), // Status ]) .split(frame.area()); - render_messages(frame, layout[0], app); - render_input(frame, layout[1], app); - render_status(frame, layout[2], app); + render_header(frame, layout[0], app); + render_messages(frame, layout[1], app); + render_input(frame, layout[2], app); + render_status(frame, layout[3], app); match app.mode() { InputMode::ProviderSelection => render_provider_selector(frame, app), @@ -29,13 +31,49 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) { } } +fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { + let title_span = Span::styled( + " ๐Ÿฆ‰ OWLEN - AI Assistant ", + Style::default() + .fg(Color::LightMagenta) + .add_modifier(Modifier::BOLD), + ); + let model_span = Span::styled( + format!("Model: {}", app.selected_model()), + Style::default().fg(Color::LightBlue), + ); + + let header_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Rgb(95, 20, 135))) + .title(Line::from(vec![title_span])); + + let inner_area = header_block.inner(area); + + let header_text = vec![Line::from(""), Line::from(format!(" {model_span} "))]; + + let paragraph = Paragraph::new(header_text).alignment(Alignment::Left); + + frame.render_widget(header_block, area); + frame.render_widget(paragraph, inner_area); +} + fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let conversation = app.conversation(); - let formatter = app.formatter(); + let mut formatter = app.formatter().clone(); + + // Reserve space for borders and the message indent so text fits within the block + let content_width = area.width.saturating_sub(4).max(20); + formatter.set_wrap_width(usize::from(content_width)); let mut lines: Vec = Vec::new(); - for message in &conversation.messages { - let color = role_color(message.role.clone()); + for (message_index, message) in conversation.messages.iter().enumerate() { + let (prefix, color) = match message.role { + Role::User => ("๐Ÿ‘ค You:", Color::LightBlue), + Role::Assistant => ("๐Ÿค– Assistant:", Color::LightMagenta), + Role::System => ("โš™๏ธ System:", Color::Cyan), + }; + let mut formatted = formatter.format_message(message); let is_streaming = message .metadata @@ -43,37 +81,23 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { .and_then(|v| v.as_bool()) .unwrap_or(false); - if let Some(first) = formatted.first_mut() { - if let Some((label, rest)) = first.split_once(':') { - let mut spans = Vec::new(); - spans.push(Span::styled( - format!("{label}:"), - color.add_modifier(Modifier::BOLD), - )); - if !rest.trim().is_empty() { - spans.push(Span::raw(format!(" {}", rest.trim_start()))); - } - if is_streaming { - spans.push(Span::styled(" โ–Œ", Style::default().fg(Color::Magenta))); - } - lines.push(Line::from(spans)); - } else { - let mut spans = vec![Span::raw(first.clone())]; - if is_streaming { - spans.push(Span::styled(" โ–Œ", Style::default().fg(Color::Magenta))); - } - lines.push(Line::from(spans)); - } - } + lines.push(Line::from(Span::styled( + prefix, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ))); - for line in formatted.into_iter().skip(1) { - let mut spans = vec![Span::raw(line)]; - if is_streaming { + for (i, line) in formatted.iter().enumerate() { + let mut spans = Vec::new(); + spans.push(Span::raw(format!(" {}", line.clone()))); + if i == formatted.len() - 1 && is_streaming { spans.push(Span::styled(" โ–Œ", Style::default().fg(Color::Magenta))); } lines.push(Line::from(spans)); } - lines.push(Line::from("")); + // Add an empty line after each message, except the last one + if message_index < conversation.messages.len() - 1 { + lines.push(Line::from("")); + } } if lines.is_empty() { @@ -83,12 +107,6 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let mut paragraph = Paragraph::new(lines) .block( Block::default() - .title(Span::styled( - "Conversation", - Style::default() - .fg(Color::LightMagenta) - .add_modifier(Modifier::BOLD), - )) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Rgb(95, 20, 135))), ) @@ -102,8 +120,8 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let title = match app.mode() { - InputMode::Editing => "Input (Enter=send ยท Shift+Enter/Ctrl+J=newline)", - _ => "Input", + InputMode::Editing => " Input (Enter=send ยท Shift+Enter/Ctrl+J=newline) ", + _ => " Input (Press 'i' to start typing) ", }; let input_block = Block::default() @@ -136,58 +154,46 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { } fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { - let mut spans = Vec::new(); - spans.push(Span::styled( - " OWLEN ", - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("Model {} ({})", app.selected_model(), app.selected_provider), - Style::default().fg(Color::LightMagenta), - )); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("Mode {}", app.mode()), - Style::default() - .fg(Color::LightBlue) - .add_modifier(Modifier::ITALIC), - )); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("Msgs {}", app.message_count()), - Style::default().fg(Color::Cyan), - )); + let (mode_text, mode_bg_color) = match app.mode() { + InputMode::Normal => (" NORMAL", Color::LightBlue), + InputMode::Editing => (" INPUT", Color::LightGreen), + InputMode::ModelSelection => (" MODEL", Color::LightYellow), + InputMode::ProviderSelection => (" PROVIDER", Color::LightCyan), + InputMode::Help => (" HELP", Color::LightMagenta), + }; - if app.streaming_count() > 0 { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("โŸณ {}", app.streaming_count()), + let status_message = if app.streaming_count() > 0 { + format!("Streaming... ({})", app.streaming_count()) + } else if let Some(error) = app.error_message() { + format!("Error: {}", error) + } else { + "Ready".to_string() + }; + + let help_text = "i:Input m:Model c:Clear q:Quit"; + + let left_spans = vec![ + Span::styled( + format!(" {} ", mode_text), Style::default() - .fg(Color::LightMagenta) + .fg(Color::Black) + .bg(mode_bg_color) .add_modifier(Modifier::BOLD), - )); - } + ), + Span::raw(format!(" | {} ", status_message)), + ]; - spans.push(Span::raw(" ")); - spans.push(Span::styled( - app.status_message(), - Style::default().fg(Color::LightBlue), - )); + let right_spans = vec![ + Span::raw(" Help: "), + Span::styled(help_text, Style::default().fg(Color::LightBlue)), + ]; - if let Some(error) = app.error_message() { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - error, - Style::default() - .fg(Color::LightRed) - .add_modifier(Modifier::BOLD), - )); - } + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); - let paragraph = Paragraph::new(Line::from(spans)) + let left_paragraph = Paragraph::new(Line::from(left_spans)) .alignment(Alignment::Left) .block( Block::default() @@ -195,7 +201,16 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { .border_style(Style::default().fg(Color::Rgb(95, 20, 135))), ); - frame.render_widget(paragraph, area); + let right_paragraph = Paragraph::new(Line::from(right_spans)) + .alignment(Alignment::Right) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Rgb(95, 20, 135))), + ); + + frame.render_widget(left_paragraph, layout[0]); + frame.render_widget(right_paragraph, layout[1]); } fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {