From 498e6e61b6f9f7b7a05da3018605fa506e9a8c2c Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 14 Oct 2025 01:35:13 +0200 Subject: [PATCH] feat(tui): add markdown rendering support and toggle command - Introduce new `owlen-markdown` crate that converts Markdown strings to `ratatui::Text` with headings, lists, bold/italic, and inline code. - Add `render_markdown` config option (default true) and expose it via `app.render_markdown_enabled()`. - Implement `:markdown [on|off]` command to toggle markdown rendering. - Update help overlay to document the new markdown toggle. - Adjust UI rendering to conditionally apply markdown styling based on the markdown flag and code mode. - Wire the new crate into `owlen-tui` Cargo.toml. --- Cargo.toml | 1 + crates/owlen-cli/src/cloud.rs | 42 +- crates/owlen-cli/src/main.rs | 2 + crates/owlen-cli/src/mcp.rs | 10 +- crates/owlen-core/src/config.rs | 7 + crates/owlen-core/src/consent.rs | 23 +- crates/owlen-core/src/lib.rs | 2 + crates/owlen-core/src/mcp/permission.rs | 5 +- crates/owlen-core/src/model.rs | 2 +- crates/owlen-core/src/providers/ollama.rs | 6 +- crates/owlen-core/src/router.rs | 13 +- crates/owlen-core/src/sandbox.rs | 22 +- crates/owlen-core/src/session.rs | 36 +- crates/owlen-core/src/storage.rs | 30 +- crates/owlen-markdown/Cargo.toml | 10 + crates/owlen-markdown/src/lib.rs | 270 ++++++++++++ crates/owlen-tui/Cargo.toml | 1 + crates/owlen-tui/src/chat_app.rs | 500 ++++++++++++++++++---- crates/owlen-tui/src/commands/mod.rs | 4 + crates/owlen-tui/src/highlight.rs | 16 +- crates/owlen-tui/src/lib.rs | 2 + crates/owlen-tui/src/state/file_tree.rs | 24 +- crates/owlen-tui/src/state/search.rs | 14 +- crates/owlen-tui/src/ui.rs | 116 ++--- 24 files changed, 911 insertions(+), 247 deletions(-) create mode 100644 crates/owlen-markdown/Cargo.toml create mode 100644 crates/owlen-markdown/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index b75161b..0a88754 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/owlen-mcp-client", "crates/owlen-mcp-code-server", "crates/owlen-mcp-prompt-server", + "crates/owlen-markdown", ] exclude = [] diff --git a/crates/owlen-cli/src/cloud.rs b/crates/owlen-cli/src/cloud.rs index 8e31a63..a1f00e9 100644 --- a/crates/owlen-cli/src/cloud.rs +++ b/crates/owlen-cli/src/cloud.rs @@ -221,10 +221,11 @@ fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) { if provider == "ollama" && config.providers.contains_key("ollama-cloud") && !config.providers.contains_key("ollama") - && let Some(mut legacy) = config.providers.remove("ollama-cloud") { - legacy.provider_type = "ollama".to_string(); - config.providers.insert("ollama".to_string(), legacy); + if let Some(mut legacy) = config.providers.remove("ollama-cloud") { + legacy.provider_type = "ollama".to_string(); + config.providers.insert("ollama".to_string(), legacy); + } } core_config::ensure_provider_config(config, provider); @@ -315,8 +316,10 @@ fn unlock_vault(path: &Path) -> Result { use std::env; if path.exists() { - if let Ok(password) = env::var("OWLEN_MASTER_PASSWORD") - && !password.trim().is_empty() + if let Some(password) = env::var("OWLEN_MASTER_PASSWORD") + .ok() + .map(|value| value.trim().to_string()) + .filter(|password| !password.is_empty()) { return encryption::unlock_with_password(path.to_path_buf(), &password) .context("Failed to unlock vault with OWLEN_MASTER_PASSWORD"); @@ -356,30 +359,31 @@ async fn hydrate_api_key( config: &mut Config, manager: Option<&Arc>, ) -> Result> { - if let Some(manager) = manager - && let Some(credentials) = manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await? - { + let credentials = match manager { + Some(manager) => manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await?, + None => None, + }; + + if let Some(credentials) = credentials { let key = credentials.api_key.trim().to_string(); if !key.is_empty() { set_env_if_missing("OLLAMA_API_KEY", &key); set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key); } - if let Some(cfg) = provider_entry_mut(config) - && cfg.base_url.is_none() - && !credentials.endpoint.trim().is_empty() - { - cfg.base_url = Some(credentials.endpoint); + let Some(cfg) = provider_entry_mut(config) else { + return Ok(Some(key)); + }; + if cfg.base_url.is_none() && !credentials.endpoint.trim().is_empty() { + cfg.base_url = Some(credentials.endpoint.clone()); } return Ok(Some(key)); } - if let Some(cfg) = provider_entry(config) - && let Some(key) = cfg - .api_key - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) + if let Some(key) = provider_entry(config) + .and_then(|cfg| cfg.api_key.as_ref()) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) { set_env_if_missing("OLLAMA_API_KEY", key); set_env_if_missing("OLLAMA_CLOUD_API_KEY", key); diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index 48f7577..9dd8653 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -1,3 +1,5 @@ +#![allow(clippy::collapsible_if)] // TODO: Remove once Rust 2024 let-chains are available + //! OWLEN CLI - Chat TUI client mod cloud; diff --git a/crates/owlen-cli/src/mcp.rs b/crates/owlen-cli/src/mcp.rs index 34410eb..eddcdbe 100644 --- a/crates/owlen-cli/src/mcp.rs +++ b/crates/owlen-cli/src/mcp.rs @@ -151,8 +151,9 @@ fn handle_list(args: ListArgs) -> Result<()> { "", "Scope", "Name", "Transport" ); for entry in scoped { - if let Some(target_scope) = filter_scope - && entry.scope != target_scope + if filter_scope + .as_ref() + .is_some_and(|target_scope| entry.scope != *target_scope) { continue; } @@ -186,8 +187,9 @@ fn handle_list(args: ListArgs) -> Result<()> { .collect(); for entry in scoped_resources { - if let Some(target_scope) = filter_scope - && entry.scope != target_scope + if filter_scope + .as_ref() + .is_some_and(|target_scope| entry.scope != *target_scope) { continue; } diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index c7a03fa..eb8d835 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -1332,6 +1332,8 @@ pub struct UiSettings { pub show_cursor_outside_insert: bool, #[serde(default = "UiSettings::default_syntax_highlighting")] pub syntax_highlighting: bool, + #[serde(default = "UiSettings::default_render_markdown")] + pub render_markdown: bool, #[serde(default = "UiSettings::default_show_timestamps")] pub show_timestamps: bool, #[serde(default = "UiSettings::default_icon_mode")] @@ -1392,6 +1394,10 @@ impl UiSettings { true } + const fn default_render_markdown() -> bool { + true + } + const fn default_show_timestamps() -> bool { true } @@ -1466,6 +1472,7 @@ impl Default for UiSettings { scrollback_lines: Self::default_scrollback_lines(), show_cursor_outside_insert: Self::default_show_cursor_outside_insert(), syntax_highlighting: Self::default_syntax_highlighting(), + render_markdown: Self::default_render_markdown(), show_timestamps: Self::default_show_timestamps(), icon_mode: Self::default_icon_mode(), } diff --git a/crates/owlen-core/src/consent.rs b/crates/owlen-core/src/consent.rs index e45fcf7..f851bf9 100644 --- a/crates/owlen-core/src/consent.rs +++ b/crates/owlen-core/src/consent.rs @@ -58,9 +58,14 @@ impl ConsentManager { /// Load consent records from vault storage pub fn from_vault(vault: &Arc>) -> Self { let guard = vault.lock().expect("Vault mutex poisoned"); - if let Some(consent_data) = guard.settings().get("consent_records") - && let Ok(permanent_records) = - serde_json::from_value::>(consent_data.clone()) + if let Some(permanent_records) = + guard + .settings() + .get("consent_records") + .and_then(|consent_data| { + serde_json::from_value::>(consent_data.clone()) + .ok() + }) { return Self { permanent_records, @@ -90,15 +95,19 @@ impl ConsentManager { endpoints: Vec, ) -> Result { // Check if already granted permanently - if let Some(existing) = self.permanent_records.get(tool_name) - && existing.scope == ConsentScope::Permanent + if self + .permanent_records + .get(tool_name) + .is_some_and(|existing| existing.scope == ConsentScope::Permanent) { return Ok(ConsentScope::Permanent); } // Check if granted for session - if let Some(existing) = self.session_records.get(tool_name) - && existing.scope == ConsentScope::Session + if self + .session_records + .get(tool_name) + .is_some_and(|existing| existing.scope == ConsentScope::Session) { return Ok(ConsentScope::Session); } diff --git a/crates/owlen-core/src/lib.rs b/crates/owlen-core/src/lib.rs index 9507ddd..2b0fb6b 100644 --- a/crates/owlen-core/src/lib.rs +++ b/crates/owlen-core/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::collapsible_if)] // TODO: Remove once we can rely on Rust 2024 let-chains + //! Core traits and types for OWLEN LLM client //! //! This crate provides the foundational abstractions for building diff --git a/crates/owlen-core/src/mcp/permission.rs b/crates/owlen-core/src/mcp/permission.rs index 8642ac4..732f27b 100644 --- a/crates/owlen-core/src/mcp/permission.rs +++ b/crates/owlen-core/src/mcp/permission.rs @@ -156,13 +156,14 @@ mod tests { use super::*; use crate::mcp::LocalMcpClient; use crate::tools::registry::ToolRegistry; + use crate::ui::NoOpUiController; use crate::validation::SchemaValidator; use std::sync::atomic::{AtomicBool, Ordering}; #[tokio::test] async fn test_permission_layer_filters_dangerous_tools() { let config = Arc::new(Config::default()); - let ui = Arc::new(crate::ui::NoOpUiController); + let ui = Arc::new(NoOpUiController); let registry = Arc::new(ToolRegistry::new( Arc::new(tokio::sync::Mutex::new((*config).clone())), ui, @@ -186,7 +187,7 @@ mod tests { #[tokio::test] async fn test_consent_callback_is_invoked() { let config = Arc::new(Config::default()); - let ui = Arc::new(crate::ui::NoOpUiController); + let ui = Arc::new(NoOpUiController); let registry = Arc::new(ToolRegistry::new( Arc::new(tokio::sync::Mutex::new((*config).clone())), ui, diff --git a/crates/owlen-core/src/model.rs b/crates/owlen-core/src/model.rs index 9f5aebe..f822e7a 100644 --- a/crates/owlen-core/src/model.rs +++ b/crates/owlen-core/src/model.rs @@ -42,7 +42,7 @@ impl ModelManager { F: FnOnce() -> Fut, Fut: Future>>, { - if !force_refresh && let Some(models) = self.cached_if_fresh().await { + if let (false, Some(models)) = (force_refresh, self.cached_if_fresh().await) { return Ok(models); } diff --git a/crates/owlen-core/src/providers/ollama.rs b/crates/owlen-core/src/providers/ollama.rs index 90bae4a..564cdc6 100644 --- a/crates/owlen-core/src/providers/ollama.rs +++ b/crates/owlen-core/src/providers/ollama.rs @@ -378,10 +378,8 @@ impl OllamaProvider { let family = pick_first_string(map, &["family", "model_family"]); let mut families = pick_string_list(map, &["families", "model_families"]); - if families.is_empty() - && let Some(single) = family.clone() - { - families.push(single); + if families.is_empty() { + families.extend(family.clone()); } let system = pick_first_string(map, &["system"]); diff --git a/crates/owlen-core/src/router.rs b/crates/owlen-core/src/router.rs index 2060abf..cea9206 100644 --- a/crates/owlen-core/src/router.rs +++ b/crates/owlen-core/src/router.rs @@ -71,16 +71,19 @@ impl Router { fn find_provider_for_model(&self, model: &str) -> Result> { // Check routing rules first for rule in &self.routing_rules { - if self.matches_pattern(&rule.model_pattern, model) - && let Some(provider) = self.registry.get(&rule.provider) - { + if !self.matches_pattern(&rule.model_pattern, model) { + continue; + } + if let Some(provider) = self.registry.get(&rule.provider) { return Ok(provider); } } // Fall back to default provider - if let Some(default) = &self.default_provider - && let Some(provider) = self.registry.get(default) + if let Some(provider) = self + .default_provider + .as_ref() + .and_then(|default| self.registry.get(default)) { return Ok(provider); } diff --git a/crates/owlen-core/src/sandbox.rs b/crates/owlen-core/src/sandbox.rs index 4643d4a..94f00f4 100644 --- a/crates/owlen-core/src/sandbox.rs +++ b/crates/owlen-core/src/sandbox.rs @@ -185,14 +185,20 @@ impl SandboxedProcess { if let Ok(output) = output { let version_str = String::from_utf8_lossy(&output.stdout); // Parse version like "bubblewrap 0.11.0" or "0.11.0" - if let Some(version_part) = version_str.split_whitespace().last() - && let Some((major, rest)) = version_part.split_once('.') - && let Some((minor, _patch)) = rest.split_once('.') - && let (Ok(maj), Ok(min)) = (major.parse::(), minor.parse::()) - { - // --rlimit-as was added in 0.12.0 - return maj > 0 || (maj == 0 && min >= 12); - } + return version_str + .split_whitespace() + .last() + .and_then(|part| { + part.split_once('.').and_then(|(major, rest)| { + rest.split_once('.').and_then(|(minor, _)| { + let maj = major.parse::().ok()?; + let min = minor.parse::().ok()?; + Some((maj, min)) + }) + }) + }) + .map(|(maj, min)| maj > 0 || (maj == 0 && min >= 12)) + .unwrap_or(false); } // If we can't determine the version, assume it doesn't support it (safer default) diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs index bbdf1a9..b695a68 100644 --- a/crates/owlen-core/src/session.rs +++ b/crates/owlen-core/src/session.rs @@ -53,8 +53,8 @@ fn extract_resource_content(value: &Value) -> Option { Value::Array(items) => { let mut segments = Vec::new(); for item in items { - if let Some(segment) = extract_resource_content(item) - && !segment.is_empty() + if let Some(segment) = + extract_resource_content(item).filter(|segment| !segment.is_empty()) { segments.push(segment); } @@ -69,17 +69,19 @@ fn extract_resource_content(value: &Value) -> Option { const PREFERRED_FIELDS: [&str; 6] = ["content", "contents", "text", "value", "body", "data"]; for key in PREFERRED_FIELDS.iter() { - if let Some(inner) = map.get(*key) - && let Some(text) = extract_resource_content(inner) - && !text.is_empty() + if let Some(text) = map + .get(*key) + .and_then(extract_resource_content) + .filter(|text| !text.is_empty()) { return Some(text); } } - if let Some(inner) = map.get("chunks") - && let Some(text) = extract_resource_content(inner) - && !text.is_empty() + if let Some(text) = map + .get("chunks") + .and_then(extract_resource_content) + .filter(|text| !text.is_empty()) { return Some(text); } @@ -566,9 +568,10 @@ impl SessionController { .expect("Consent manager mutex poisoned"); consent.grant_consent(tool_name, data_types, endpoints); - if let Some(vault) = &self.vault - && let Err(e) = consent.persist_to_vault(vault) - { + let Some(vault) = &self.vault else { + return; + }; + if let Err(e) = consent.persist_to_vault(vault) { eprintln!("Warning: Failed to persist consent to vault: {}", e); } } @@ -588,10 +591,13 @@ impl SessionController { consent.grant_consent_with_scope(tool_name, data_types, endpoints, scope); // Only persist to vault for permanent consent - if is_permanent - && let Some(vault) = &self.vault - && let Err(e) = consent.persist_to_vault(vault) - { + if !is_permanent { + return; + } + let Some(vault) = &self.vault else { + return; + }; + if let Err(e) = consent.persist_to_vault(vault) { eprintln!("Warning: Failed to persist consent to vault: {}", e); } } diff --git a/crates/owlen-core/src/storage.rs b/crates/owlen-core/src/storage.rs index 9b12efc..a12706a 100644 --- a/crates/owlen-core/src/storage.rs +++ b/crates/owlen-core/src/storage.rs @@ -50,14 +50,14 @@ impl StorageManager { /// Create a storage manager using the provided database path pub async fn with_database_path(database_path: PathBuf) -> Result { - if let Some(parent) = database_path.parent() - && !parent.exists() - { - std::fs::create_dir_all(parent).map_err(|e| { - Error::Storage(format!( - "Failed to create database directory {parent:?}: {e}" - )) - })?; + if let Some(parent) = database_path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent).map_err(|e| { + Error::Storage(format!( + "Failed to create database directory {parent:?}: {e}" + )) + })?; + } } let options = SqliteConnectOptions::from_str(&format!( @@ -431,13 +431,13 @@ impl StorageManager { } } - if migrated > 0 - && let Err(err) = archive_legacy_directory(&legacy_dir) - { - println!( - "Warning: migrated sessions but failed to archive legacy directory: {}", - err - ); + if migrated > 0 { + if let Err(err) = archive_legacy_directory(&legacy_dir) { + println!( + "Warning: migrated sessions but failed to archive legacy directory: {}", + err + ); + } } println!("Migrated {} legacy sessions.", migrated); diff --git a/crates/owlen-markdown/Cargo.toml b/crates/owlen-markdown/Cargo.toml new file mode 100644 index 0000000..b117a32 --- /dev/null +++ b/crates/owlen-markdown/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "owlen-markdown" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Lightweight markdown to ratatui::Text renderer for OWLEN" + +[dependencies] +ratatui = { workspace = true } +unicode-width = "0.1" diff --git a/crates/owlen-markdown/src/lib.rs b/crates/owlen-markdown/src/lib.rs new file mode 100644 index 0000000..9bef678 --- /dev/null +++ b/crates/owlen-markdown/src/lib.rs @@ -0,0 +1,270 @@ +use ratatui::prelude::*; +use ratatui::text::{Line, Span, Text}; +use unicode_width::UnicodeWidthStr; + +/// Convert a markdown string into a `ratatui::Text`. +/// +/// This lightweight renderer supports common constructs (headings, lists, bold, +/// italics, and inline code) and is designed to keep dependencies minimal for +/// the OWLEN project. +pub fn from_str(input: &str) -> Text<'static> { + let mut lines = Vec::new(); + let mut in_code_block = false; + + for raw_line in input.lines() { + let line = raw_line.trim_end_matches('\r'); + let trimmed = line.trim_start(); + let indent = &line[..line.len() - trimmed.len()]; + + if trimmed.starts_with("```") { + in_code_block = !in_code_block; + continue; + } + + if in_code_block { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::raw(indent.to_string())); + } + spans.push(Span::styled( + trimmed.to_string(), + Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::DIM), + )); + lines.push(Line::from(spans)); + continue; + } + + if trimmed.is_empty() { + lines.push(Line::from(Vec::>::new())); + continue; + } + + if trimmed.starts_with('#') { + let level = trimmed.chars().take_while(|c| *c == '#').count().min(6); + let content = trimmed[level..].trim_start(); + let mut style = Style::default().add_modifier(Modifier::BOLD); + style = match level { + 1 => style.fg(Color::LightCyan), + 2 => style.fg(Color::Cyan), + _ => style.fg(Color::LightBlue), + }; + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::raw(indent.to_string())); + } + spans.push(Span::styled(content.to_string(), style)); + lines.push(Line::from(spans)); + continue; + } + + if let Some(rest) = trimmed.strip_prefix("- ") { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::raw(indent.to_string())); + } + spans.push(Span::styled( + "• ".to_string(), + Style::default().fg(Color::LightGreen), + )); + spans.extend(parse_inline(rest)); + lines.push(Line::from(spans)); + continue; + } + + if let Some(rest) = trimmed.strip_prefix("* ") { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::raw(indent.to_string())); + } + spans.push(Span::styled( + "• ".to_string(), + Style::default().fg(Color::LightGreen), + )); + spans.extend(parse_inline(rest)); + lines.push(Line::from(spans)); + continue; + } + + if let Some((number, rest)) = parse_ordered_item(trimmed) { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::raw(indent.to_string())); + } + spans.push(Span::styled( + format!("{number}. "), + Style::default().fg(Color::LightGreen), + )); + spans.extend(parse_inline(rest)); + lines.push(Line::from(spans)); + continue; + } + + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::raw(indent.to_string())); + } + spans.extend(parse_inline(trimmed)); + lines.push(Line::from(spans)); + } + + if input.is_empty() { + lines.push(Line::from(Vec::>::new())); + } + + Text::from(lines) +} + +fn parse_ordered_item(line: &str) -> Option<(u32, &str)> { + let mut parts = line.splitn(2, '.'); + let number = parts.next()?.trim(); + let rest = parts.next()?; + if number.chars().all(|c| c.is_ascii_digit()) { + let value = number.parse().ok()?; + let rest = rest.trim_start(); + Some((value, rest)) + } else { + None + } +} + +fn parse_inline(text: &str) -> Vec> { + let mut spans = Vec::new(); + let bytes = text.as_bytes(); + let mut i = 0; + let len = bytes.len(); + let mut plain_start = 0; + + while i < len { + if bytes[i] == b'`' { + if let Some(offset) = text[i + 1..].find('`') { + if i > plain_start { + spans.push(Span::raw(text[plain_start..i].to_string())); + } + let content = &text[i + 1..i + 1 + offset]; + spans.push(Span::styled( + content.to_string(), + Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + )); + i += offset + 2; + plain_start = i; + continue; + } else { + break; + } + } + + if bytes[i] == b'*' { + if i + 1 < len && bytes[i + 1] == b'*' { + if let Some(offset) = text[i + 2..].find("**") { + if i > plain_start { + spans.push(Span::raw(text[plain_start..i].to_string())); + } + let content = &text[i + 2..i + 2 + offset]; + spans.push(Span::styled( + content.to_string(), + Style::default().add_modifier(Modifier::BOLD), + )); + i += offset + 4; + plain_start = i; + continue; + } + } else if let Some(offset) = text[i + 1..].find('*') { + if i > plain_start { + spans.push(Span::raw(text[plain_start..i].to_string())); + } + let content = &text[i + 1..i + 1 + offset]; + spans.push(Span::styled( + content.to_string(), + Style::default().add_modifier(Modifier::ITALIC), + )); + i += offset + 2; + plain_start = i; + continue; + } + } + + if bytes[i] == b'_' { + if i + 1 < len && bytes[i + 1] == b'_' { + if let Some(offset) = text[i + 2..].find("__") { + if i > plain_start { + spans.push(Span::raw(text[plain_start..i].to_string())); + } + let content = &text[i + 2..i + 2 + offset]; + spans.push(Span::styled( + content.to_string(), + Style::default().add_modifier(Modifier::BOLD), + )); + i += offset + 4; + plain_start = i; + continue; + } + } else if let Some(offset) = text[i + 1..].find('_') { + if i > plain_start { + spans.push(Span::raw(text[plain_start..i].to_string())); + } + let content = &text[i + 1..i + 1 + offset]; + spans.push(Span::styled( + content.to_string(), + Style::default().add_modifier(Modifier::ITALIC), + )); + i += offset + 2; + plain_start = i; + continue; + } + } + + i += 1; + } + + if plain_start < len { + spans.push(Span::raw(text[plain_start..].to_string())); + } + + if spans.is_empty() { + spans.push(Span::raw(String::new())); + } + + spans +} + +#[allow(dead_code)] +fn visual_length(spans: &[Span<'_>]) -> usize { + spans + .iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headings_are_bold() { + let text = from_str("# Heading"); + assert_eq!(text.lines.len(), 1); + let line = &text.lines[0]; + assert!( + line.spans + .iter() + .any(|span| span.style.contains(Modifier::BOLD)) + ); + } + + #[test] + fn inline_code_styled() { + let text = from_str("Use `code` inline."); + let styled = text + .lines + .iter() + .flat_map(|line| &line.spans) + .find(|span| span.content.as_ref() == "code") + .cloned() + .unwrap(); + assert!(styled.style.contains(Modifier::BOLD)); + } +} diff --git a/crates/owlen-tui/Cargo.toml b/crates/owlen-tui/Cargo.toml index 17332e8..a3dd14e 100644 --- a/crates/owlen-tui/Cargo.toml +++ b/crates/owlen-tui/Cargo.toml @@ -29,6 +29,7 @@ dirs = { workspace = true } toml = { workspace = true } syntect = "5.3" once_cell = "1.19" +owlen-markdown = { path = "../owlen-markdown" } # Async runtime tokio = { workspace = true } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index ef2c552..2097b7a 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -14,6 +14,7 @@ use owlen_core::{ types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role}, ui::{AppState, AutoScroll, FocusedPanel, InputMode, RoleLabelDisplay}, }; +use owlen_markdown::from_str; use pathdiff::diff_paths; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -211,6 +212,7 @@ pub struct ChatApp { message_line_cache: HashMap, // Cached rendered lines per message show_cursor_outside_insert: bool, // Configurable cursor visibility flag syntax_highlighting: bool, // Whether syntax highlighting is enabled + render_markdown: bool, // Whether markdown rendering is enabled show_message_timestamps: bool, // Whether to render timestamps in chat headers auto_scroll: AutoScroll, // Auto-scroll state for message rendering thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel @@ -293,6 +295,7 @@ struct MessageCacheEntry { wrap_width: usize, role_label_mode: RoleLabelDisplay, syntax_highlighting: bool, + render_markdown: bool, show_timestamps: bool, content_hash: u64, lines: Vec>, @@ -315,6 +318,7 @@ pub(crate) struct MessageRenderContext<'a> { loading_indicator: &'a str, theme: &'a Theme, syntax_highlighting: bool, + render_markdown: bool, } impl<'a> MessageRenderContext<'a> { @@ -328,6 +332,7 @@ impl<'a> MessageRenderContext<'a> { loading_indicator: &'a str, theme: &'a Theme, syntax_highlighting: bool, + render_markdown: bool, ) -> Self { Self { formatter, @@ -338,6 +343,7 @@ impl<'a> MessageRenderContext<'a> { loading_indicator, theme, syntax_highlighting, + render_markdown, } } } @@ -416,6 +422,7 @@ impl ChatApp { let show_onboarding = config_guard.ui.show_onboarding; let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert; let syntax_highlighting = config_guard.ui.syntax_highlighting; + let render_markdown = config_guard.ui.render_markdown; let show_timestamps = config_guard.ui.show_timestamps; let icon_mode = config_guard.ui.icon_mode; drop(config_guard); @@ -516,6 +523,7 @@ impl ChatApp { new_message_alert: false, show_cursor_outside_insert, syntax_highlighting, + render_markdown, show_message_timestamps: show_timestamps, }; @@ -596,6 +604,10 @@ impl ChatApp { .and_then(|pane| pane.display_path()) } + pub fn is_code_mode(&self) -> bool { + matches!(self.operating_mode, owlen_core::mode::Mode::Code) + } + pub fn code_view_lines(&self) -> &[String] { self.code_workspace .active_pane() @@ -767,6 +779,12 @@ impl ChatApp { return Ok(()); }; + if !self.is_code_mode() { + self.status = "Switch to code mode to open repository matches".to_string(); + self.error = None; + return Ok(()); + } + let (absolute, display, line_number, column) = { let file = &self.repo_search.files()[file_index]; let m = &file.matches[match_index]; @@ -826,6 +844,12 @@ impl ChatApp { return Ok(()); } + if !self.is_code_mode() { + self.status = "Switch to code mode to open repository matches".to_string(); + self.error = None; + return Ok(()); + } + let mut buffer = String::new(); for file in self.repo_search.files() { if file.matches.is_empty() { @@ -1054,6 +1078,11 @@ impl ChatApp { } pub fn expand_file_panel(&mut self) { + if !self.is_code_mode() { + self.status = "Switch to code mode to use the file explorer".to_string(); + self.error = None; + return; + } if self.file_panel_collapsed { self.file_panel_collapsed = false; self.focused_panel = FocusedPanel::Files; @@ -1072,6 +1101,11 @@ impl ChatApp { } pub fn toggle_file_panel(&mut self) { + if !self.is_code_mode() { + self.status = "File explorer is available in code mode".to_string(); + self.error = None; + return; + } if self.file_panel_collapsed { self.expand_file_panel(); } else { @@ -1102,6 +1136,7 @@ impl ChatApp { } if !matches!(mode, owlen_core::mode::Mode::Code) { + self.collapse_file_panel(); self.close_code_view(); self.set_system_status(String::new()); } @@ -1229,9 +1264,10 @@ impl ChatApp { .model_info_panel .current_model_name() .map(|s| s.to_string()) - && let Some(updated) = self.model_details_cache.get(¤t).cloned() { - self.model_info_panel.set_model_info(updated); + if let Some(updated) = self.model_details_cache.get(¤t).cloned() { + self.model_info_panel.set_model_info(updated); + } } let total = self.model_details_cache.len(); self.status = format!("Cached model details for {} model(s)", total); @@ -1575,20 +1611,20 @@ impl ChatApp { const CANDIDATES: [&str; 6] = ["rendered", "text", "content", "value", "message", "body"]; for key in CANDIDATES { - if let Some(Value::String(text)) = map.get(key) - && !text.trim().is_empty() - { - return Some(text.clone()); + if let Some(Value::String(text)) = map.get(key) { + if !text.trim().is_empty() { + return Some(text.clone()); + } } } if let Some(Value::Array(items)) = map.get("lines") { let mut collected = Vec::new(); for item in items { - if let Some(segment) = item.as_str() - && !segment.trim().is_empty() - { - collected.push(segment.trim()); + if let Some(segment) = item.as_str() { + if !segment.trim().is_empty() { + collected.push(segment.trim()); + } } } if !collected.is_empty() { @@ -1601,11 +1637,12 @@ impl ChatApp { } fn extract_mcp_error(value: &Value) -> Option { - if let Value::Object(map) = value - && let Some(Value::String(message)) = map.get("error") - && !message.trim().is_empty() - { - return Some(message.clone()); + if let Value::Object(map) = value { + if let Some(Value::String(message)) = map.get("error") { + if !message.trim().is_empty() { + return Some(message.clone()); + } + } } None } @@ -1884,17 +1921,19 @@ impl ChatApp { } fn sync_ui_preferences_from_config(&mut self) { - let (show_cursor, role_label_mode, syntax_highlighting, show_timestamps) = { + let (show_cursor, role_label_mode, syntax_highlighting, render_markdown, show_timestamps) = { let guard = self.controller.config(); ( guard.ui.show_cursor_outside_insert, guard.ui.role_label_mode, guard.ui.syntax_highlighting, + guard.ui.render_markdown, guard.ui.show_timestamps, ) }; self.show_cursor_outside_insert = show_cursor; self.syntax_highlighting = syntax_highlighting; + self.render_markdown = render_markdown; self.show_message_timestamps = show_timestamps; self.controller.set_role_label_mode(role_label_mode); self.message_line_cache.clear(); @@ -1912,6 +1951,42 @@ impl ChatApp { true } + pub fn render_markdown_enabled(&self) -> bool { + self.render_markdown + } + + pub fn set_render_markdown(&mut self, enabled: bool) { + if self.render_markdown == enabled { + self.status = if enabled { + "Markdown rendering already enabled".to_string() + } else { + "Markdown rendering already disabled".to_string() + }; + self.error = None; + return; + } + + self.render_markdown = enabled; + self.message_line_cache.clear(); + + { + let mut guard = self.controller.config_mut(); + guard.ui.render_markdown = enabled; + } + + if let Err(err) = config::save_config(&self.controller.config()) { + self.error = Some(format!("Failed to save config: {}", err)); + } else { + self.error = None; + } + + self.status = if enabled { + "Markdown rendering enabled".to_string() + } else { + "Markdown rendering disabled".to_string() + }; + } + pub(crate) fn render_message_lines_cached( &mut self, message_index: usize, @@ -1926,6 +2001,7 @@ impl ChatApp { loading_indicator, theme, syntax_highlighting, + render_markdown, } = ctx; let (message_id, role, raw_content, timestamp, tool_calls, tool_result_id) = { let conversation = self.conversation(); @@ -1955,7 +2031,7 @@ impl ChatApp { let normalized_content = display_content.replace("\r\n", "\n"); let trimmed = normalized_content.trim(); let content = trimmed.to_string(); - let segments = parse_message_segments(trimmed); + let segments = parse_message_segments(trimmed, render_markdown); let tool_signature = tool_calls .as_ref() .map(|calls| { @@ -1966,18 +2042,21 @@ impl ChatApp { .unwrap_or_default(); let content_hash = Self::message_content_hash(&role, &content, &tool_signature); - if !is_streaming - && let Some(entry) = self.message_line_cache.get(&message_id) - && entry.wrap_width == card_width - && entry.role_label_mode == role_label_mode - && entry.syntax_highlighting == syntax_highlighting - && entry.theme_name == theme.name - && entry.show_timestamps == self.show_message_timestamps - && entry.metrics.body_width == body_width - && entry.metrics.card_width == card_width - && entry.content_hash == content_hash - { - return entry.lines.clone(); + if !is_streaming { + if let Some(entry) = self.message_line_cache.get(&message_id) { + if entry.wrap_width == card_width + && entry.role_label_mode == role_label_mode + && entry.syntax_highlighting == syntax_highlighting + && entry.render_markdown == render_markdown + && entry.theme_name == theme.name + && entry.show_timestamps == self.show_message_timestamps + && entry.metrics.body_width == body_width + && entry.metrics.card_width == card_width + && entry.content_hash == content_hash + { + return entry.lines.clone(); + } + } } let mut rendered: Vec> = Vec::new(); @@ -2012,24 +2091,38 @@ impl ChatApp { for segment in segments { match segment { MessageSegment::Text { lines } => { - for line_text in lines { - let mut chunks = wrap_unicode(line_text.as_str(), available_width); - if chunks.is_empty() { - chunks.push(String::new()); - } - for chunk in chunks { - let mut spans: Vec> = Vec::new(); - if !indent.is_empty() { - spans.push(Span::styled(indent.to_string(), content_style)); - } - - let inline_spans = - inline_code_spans_from_text(&chunk, theme, content_style); - spans.extend(inline_spans); - - rendered.push(Line::from(spans)); + if render_markdown { + let block = lines.join("\n"); + let markdown_lines = render_markdown_lines( + &block, + indent, + available_width, + content_style, + ); + for line in markdown_lines { + rendered.push(line); *indicator_target = Some(rendered.len() - 1); } + } else { + for line_text in lines { + let mut chunks = wrap_unicode(line_text.as_str(), available_width); + if chunks.is_empty() { + chunks.push(String::new()); + } + for chunk in chunks { + let mut spans: Vec> = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled(indent.to_string(), content_style)); + } + + let inline_spans = + inline_code_spans_from_text(&chunk, theme, content_style); + spans.extend(inline_spans); + + rendered.push(Line::from(spans)); + *indicator_target = Some(rendered.len() - 1); + } + } } } MessageSegment::CodeBlock { language, lines } => { @@ -2116,6 +2209,7 @@ impl ChatApp { wrap_width: card_width, role_label_mode, syntax_highlighting, + render_markdown, show_timestamps: self.show_message_timestamps, content_hash, lines: card_lines.clone(), @@ -3204,6 +3298,12 @@ impl ChatApp { &mut self, disposition: FileOpenDisposition, ) -> Result<()> { + if !self.is_code_mode() { + self.status = "Switch to code mode to open files".to_string(); + self.error = None; + return Ok(()); + } + let selected_opt = { let tree = self.file_tree(); tree.selected_node().cloned() @@ -3230,10 +3330,6 @@ impl ChatApp { return Ok(()); } - if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { - self.set_mode(owlen_core::mode::Mode::Code).await; - } - let relative_display = self.relative_tree_display(&selected.path); let absolute_path = self.absolute_tree_path(&selected.path); let request_path = if selected.path.is_absolute() { @@ -3390,6 +3486,9 @@ impl ChatApp { } fn create_file_from_command(&mut self, path: &str) -> Result { + if !self.is_code_mode() { + return Err(anyhow!("File creation is only available in code mode")); + } let trimmed = path.trim(); if trimmed.is_empty() { return Err(anyhow!("File path cannot be empty")); @@ -3462,6 +3561,11 @@ impl ChatApp { } fn begin_file_action(&mut self, kind: FileActionKind, initial: impl Into) { + if !self.is_code_mode() { + self.status = "Switch to code mode to manage files".to_string(); + self.error = None; + return; + } let prompt = FileActionPrompt::new(kind, initial); self.status = self.describe_file_action_prompt(&prompt); self.error = None; @@ -3557,6 +3661,11 @@ impl ChatApp { } async fn launch_external_editor(&mut self) -> Result<()> { + if !self.is_code_mode() { + self.status = "Switch to code mode to launch the external editor".to_string(); + self.error = None; + return Ok(()); + } let Some(selected) = self.selected_file_node() else { self.status = "No file selected".to_string(); return Ok(()); @@ -3617,6 +3726,9 @@ impl ChatApp { } fn perform_file_action(&mut self, prompt: FileActionPrompt) -> Result { + if !self.is_code_mode() { + return Err(anyhow!("File actions are only available in code mode")); + } match prompt.kind { FileActionKind::CreateFile { base } => { let name = prompt.buffer.trim(); @@ -3805,6 +3917,11 @@ impl ChatApp { } fn reveal_path_in_file_tree(&mut self, path: &Path) { + if !self.is_code_mode() { + self.status = "Switch to code mode to reveal files".to_string(); + self.error = None; + return; + } let absolute = self.absolute_tree_path(path); self.expand_file_panel(); self.file_tree_mut().reveal(&absolute); @@ -3836,6 +3953,10 @@ impl ChatApp { async fn handle_file_panel_key(&mut self, key: &crossterm::event::KeyEvent) -> Result { use crossterm::event::{KeyCode, KeyModifiers}; + if !self.is_code_mode() { + return Ok(false); + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let shift = key.modifiers.contains(KeyModifiers::SHIFT); let alt = key.modifiers.contains(KeyModifiers::ALT); @@ -5294,11 +5415,12 @@ impl ChatApp { } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection forward by word - if let Some((row, col)) = self.visual_end - && let Some(new_col) = + if let Some((row, col)) = self.visual_end { + if let Some(new_col) = self.find_next_word_boundary(row, col) - { - self.visual_end = Some((row, new_col)); + { + self.visual_end = Some((row, new_col)); + } } } FocusedPanel::Files => {} @@ -5313,11 +5435,12 @@ impl ChatApp { } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection backward by word - if let Some((row, col)) = self.visual_end - && let Some(new_col) = + if let Some((row, col)) = self.visual_end { + if let Some(new_col) = self.find_prev_word_boundary(row, col) - { - self.visual_end = Some((row, new_col)); + { + self.visual_end = Some((row, new_col)); + } } } FocusedPanel::Files => {} @@ -5346,11 +5469,11 @@ impl ChatApp { } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection to end of line - if let Some((row, _)) = self.visual_end - && let Some(line) = self.get_line_at_row(row) - { - let line_len = line.chars().count(); - self.visual_end = Some((row, line_len)); + if let Some((row, _)) = self.visual_end { + if let Some(line) = self.get_line_at_row(row) { + let line_len = line.chars().count(); + self.visual_end = Some((row, line_len)); + } } } FocusedPanel::Files => {} @@ -5438,6 +5561,14 @@ impl ChatApp { } } "create" => { + if !self.is_code_mode() { + self.status = "File operations are available in code mode" + .to_string(); + self.error = None; + self.set_input_mode(InputMode::Normal); + self.command_palette.clear(); + return Ok(AppState::Running); + } if args.is_empty() { self.error = Some("Usage: :create ".to_string()); } else { @@ -5455,6 +5586,14 @@ impl ChatApp { } } "files" | "explorer" => { + if !self.is_code_mode() { + self.status = + "File explorer is available in code mode".to_string(); + self.error = None; + self.set_input_mode(InputMode::Normal); + self.command_palette.clear(); + return Ok(AppState::Running); + } let was_collapsed = self.is_file_panel_collapsed(); self.toggle_file_panel(); let now_collapsed = self.is_file_panel_collapsed(); @@ -5468,6 +5607,35 @@ impl ChatApp { self.status = "Files panel unchanged".to_string(); } } + "markdown" => { + let desired = if let Some(arg) = args.first() { + match arg.to_ascii_lowercase().as_str() { + "on" | "enable" | "enabled" | "true" => Some(true), + "off" | "disable" | "disabled" | "false" => Some(false), + "toggle" => None, + other => { + self.error = Some(format!( + "Unknown markdown option '{}'. Use on, off, or toggle.", + other + )); + self.status = + "Usage: :markdown [on|off|toggle]".to_string(); + self.set_input_mode(InputMode::Normal); + self.command_palette.clear(); + return Ok(AppState::Running); + } + } + } else { + None + }; + + let enable = + desired.unwrap_or_else(|| !self.render_markdown_enabled()); + self.set_render_markdown(enable); + self.set_input_mode(InputMode::Normal); + self.command_palette.clear(); + return Ok(AppState::Running); + } "c" | "clear" => { self.controller.clear(); self.chat_line_offset = 0; @@ -5787,14 +5955,15 @@ impl ChatApp { self.status = "Usage: :provider ".to_string(); } else { let filter = args.join(" "); - if self.available_providers.is_empty() - && let Err(err) = self.refresh_models().await - { - self.error = Some(format!( - "Failed to refresh providers: {}", - err - )); - self.status = "Unable to refresh providers".to_string(); + if self.available_providers.is_empty() { + if let Err(err) = self.refresh_models().await { + self.error = Some(format!( + "Failed to refresh providers: {}", + err + )); + self.status = + "Unable to refresh providers".to_string(); + } } if let Some(provider) = self.best_provider_match(&filter) { @@ -6404,20 +6573,23 @@ impl ChatApp { } } KeyCode::Char(' ') => { - if let Some(item) = self.current_model_selector_item() - && let ModelSelectorItemKind::Header { provider, expanded } = + if let Some(item) = self.current_model_selector_item() { + if let ModelSelectorItemKind::Header { provider, expanded } = item.kind() - { - if *expanded { - let provider_name = provider.clone(); - self.collapse_provider(&provider_name); - self.status = format!("Collapsed provider: {}", provider_name); - } else { - let provider_name = provider.clone(); - self.expand_provider(&provider_name, true); - self.status = format!("Expanded provider: {}", provider_name); + { + if *expanded { + let provider_name = provider.clone(); + self.collapse_provider(&provider_name); + self.status = + format!("Collapsed provider: {}", provider_name); + } else { + let provider_name = provider.clone(); + self.expand_provider(&provider_name, true); + self.status = + format!("Expanded provider: {}", provider_name); + } + self.error = None; } - self.error = None; } } _ => {} @@ -6441,11 +6613,10 @@ impl ChatApp { } } KeyCode::Char(ch) if ch.is_ascii_digit() => { - if let Some(idx) = ch.to_digit(10) - && idx >= 1 - && (idx as usize) <= HELP_TAB_COUNT - { - self.help_tab_index = (idx - 1) as usize; + if let Some(idx) = ch.to_digit(10) { + if idx >= 1 && (idx as usize) <= HELP_TAB_COUNT { + self.help_tab_index = (idx - 1) as usize; + } } } _ => {} @@ -7543,11 +7714,11 @@ impl ChatApp { )); } - if let Some(idx) = self.best_model_match_index(query) - && let Some(model) = self.models.get(idx).cloned() - { - self.apply_model_selection(model).await?; - return Ok(()); + if let Some(idx) = self.best_model_match_index(query) { + if let Some(model) = self.models.get(idx).cloned() { + self.apply_model_selection(model).await?; + return Ok(()); + } } Err(anyhow!(format!( @@ -8060,7 +8231,7 @@ impl ChatApp { let normalized_content = content_to_display.replace("\r\n", "\n"); let trimmed = normalized_content.trim(); - let segments = parse_message_segments(trimmed); + let segments = parse_message_segments(trimmed, self.render_markdown); let mut body_lines: Vec = Vec::new(); let mut indicator_target: Option = None; @@ -8370,7 +8541,15 @@ pub(crate) fn streaming_indicator_symbol(indicator: &str) -> &str { } } -fn parse_message_segments(content: &str) -> Vec { +fn parse_message_segments(content: &str, markdown_enabled: bool) -> Vec { + if !markdown_enabled { + let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); + if lines.is_empty() { + lines.push(String::new()); + } + return vec![MessageSegment::Text { lines }]; + } + let mut segments = Vec::new(); let mut text_lines: Vec = Vec::new(); let mut lines = content.lines(); @@ -8414,6 +8593,10 @@ fn parse_message_segments(content: &str) -> Vec { if !text_lines.is_empty() { segments.push(MessageSegment::Text { lines: text_lines }); + } else if segments.is_empty() { + segments.push(MessageSegment::Text { + lines: vec![String::new()], + }); } segments @@ -8440,6 +8623,135 @@ fn wrap_code(text: &str, width: usize) -> Vec { wrapped } +fn render_markdown_lines( + markdown: &str, + indent: &str, + available_width: usize, + base_style: Style, +) -> Vec> { + let width = available_width.max(1); + let mut text = from_str(markdown); + let mut output: Vec> = Vec::new(); + + if text.lines.is_empty() { + let wrapped = wrap_markdown_spans(Vec::new(), indent, width, base_style); + output.extend(wrapped); + } else { + for line in text.lines.drain(..) { + let spans_owned = line + .spans + .into_iter() + .map(|span| { + let owned = span.content.into_owned(); + Span::styled(owned, span.style) + }) + .collect::>(); + let wrapped = wrap_markdown_spans(spans_owned, indent, width, base_style); + output.extend(wrapped); + } + } + + if output.is_empty() { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled(indent.to_string(), base_style)); + } + spans.push(Span::styled(String::new(), base_style)); + output.push(Line::from(spans)); + } + + output +} + +fn wrap_markdown_spans( + spans: Vec>, + indent: &str, + available_width: usize, + base_style: Style, +) -> Vec> { + let width = available_width.max(1); + if spans.is_empty() { + let mut line_spans = Vec::new(); + if !indent.is_empty() { + line_spans.push(Span::styled(indent.to_string(), base_style)); + } + line_spans.push(Span::styled(String::new(), base_style)); + return vec![Line::from(line_spans)]; + } + + let mut result: Vec> = Vec::new(); + let mut current: Vec> = Vec::new(); + let mut remaining = width; + if !indent.is_empty() { + current.push(Span::styled(indent.to_string(), base_style)); + remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent)); + } + + for span in spans { + let mut content = span.content.into_owned(); + let style = span.style; + if content.is_empty() { + continue; + } + + while !content.is_empty() { + if remaining == 0 { + result.push(Line::from(std::mem::take(&mut current))); + if !indent.is_empty() { + current.push(Span::styled(indent.to_string(), base_style)); + } + remaining = width; + if !indent.is_empty() { + remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent)); + } + } + + let available = remaining; + let mut take_bytes = 0; + let mut take_width = 0; + + for grapheme in content.graphemes(true) { + let grapheme_width = UnicodeWidthStr::width(grapheme); + if take_width + grapheme_width > available { + break; + } + take_bytes += grapheme.len(); + take_width += grapheme_width; + if take_width == available { + break; + } + } + + if take_bytes == 0 { + result.push(Line::from(std::mem::take(&mut current))); + if !indent.is_empty() { + current.push(Span::styled(indent.to_string(), base_style)); + } + remaining = width; + if !indent.is_empty() { + remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent)); + } + continue; + } + + let chunk = content[..take_bytes].to_string(); + content = content[take_bytes..].to_string(); + current.push(Span::styled(chunk, style)); + remaining = remaining.saturating_sub(take_width); + } + } + + if current.is_empty() { + if !indent.is_empty() { + current.push(Span::styled(indent.to_string(), base_style)); + } + current.push(Span::styled(String::new(), base_style)); + } + + result.push(Line::from(current)); + result +} + fn wrap_highlight_segments( segments: Vec<(Style, String)>, code_width: usize, diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index 43e79c9..064b073 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -148,6 +148,10 @@ const COMMANDS: &[CommandSpec] = &[ keyword: "reload", description: "Reload configuration and themes", }, + CommandSpec { + keyword: "markdown", + description: "Toggle markdown rendering", + }, CommandSpec { keyword: "e", description: "Edit a file", diff --git a/crates/owlen-tui/src/highlight.rs b/crates/owlen-tui/src/highlight.rs index 67fa0fb..ceec9fa 100644 --- a/crates/owlen-tui/src/highlight.rs +++ b/crates/owlen-tui/src/highlight.rs @@ -21,15 +21,15 @@ fn select_syntax(path_hint: Option<&Path>) -> &'static SyntaxReference { if let Ok(Some(syntax)) = SYNTAX_SET.find_syntax_for_file(path) { return syntax; } - if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) - && let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(ext) - { - return syntax; + if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) { + if let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(ext) { + return syntax; + } } - if let Some(name) = path.file_name().and_then(|name| name.to_str()) - && let Some(syntax) = SYNTAX_SET.find_syntax_by_token(name) - { - return syntax; + if let Some(name) = path.file_name().and_then(|name| name.to_str()) { + if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(name) { + return syntax; + } } } diff --git a/crates/owlen-tui/src/lib.rs b/crates/owlen-tui/src/lib.rs index d770d61..647bcd2 100644 --- a/crates/owlen-tui/src/lib.rs +++ b/crates/owlen-tui/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::collapsible_if)] // TODO: Remove once Rust 2024 let-chains are available + //! # Owlen TUI //! //! This crate contains all the logic for the terminal user interface (TUI) of Owlen. diff --git a/crates/owlen-tui/src/state/file_tree.rs b/crates/owlen-tui/src/state/file_tree.rs index ec632dc..b9a2dca 100644 --- a/crates/owlen-tui/src/state/file_tree.rs +++ b/crates/owlen-tui/src/state/file_tree.rs @@ -302,16 +302,18 @@ impl FileTreeState { return; } - if let Some(rel) = diff_paths(path, &self.root) - && let Some(index) = self + if let Some(rel) = diff_paths(path, &self.root) { + if let Some(index) = self .nodes .iter() .position(|node| node.path == rel || node.path == path) - { - self.expand_to(index); - if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index) { - self.cursor = cursor_pos; - self.ensure_cursor_in_view(); + { + self.expand_to(index); + if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index) + { + self.cursor = cursor_pos; + self.ensure_cursor_in_view(); + } } } } @@ -561,10 +563,10 @@ fn build_nodes( node.is_expanded = node.should_default_expand(); let index = nodes.len(); - if let Some(parent_idx) = parent - && let Some(parent_node) = nodes.get_mut(parent_idx) - { - parent_node.children.push(index); + if let Some(parent_idx) = parent { + if let Some(parent_node) = nodes.get_mut(parent_idx) { + parent_node.children.push(index); + } } index_by_path.insert(relative, index); diff --git a/crates/owlen-tui/src/state/search.rs b/crates/owlen-tui/src/state/search.rs index 6eb9e9e..c7c3053 100644 --- a/crates/owlen-tui/src/state/search.rs +++ b/crates/owlen-tui/src/state/search.rs @@ -182,12 +182,14 @@ impl RepoSearchState { if matches!( self.rows[self.selected_row].kind, RepoSearchRowKind::FileHeader - ) && let Some(idx) = self - .rows - .iter() - .position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. })) - { - self.selected_row = idx; + ) { + if let Some(idx) = self + .rows + .iter() + .position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. })) + { + self.selected_row = idx; + } } self.ensure_selection_visible(); } diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index ae8f595..e41a76a 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -209,7 +209,14 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { return; } - let (file_area, main_area) = if app.is_file_panel_collapsed() || content_area.width < 40 { + if !app.is_code_mode() && !app.is_file_panel_collapsed() { + app.set_file_panel_collapsed(true); + } + + let show_file_panel = + app.is_code_mode() && !app.is_file_panel_collapsed() && content_area.width >= 40; + + let (file_area, main_area) = if !show_file_panel { (None, content_area) } else { let max_sidebar = content_area.width.saturating_sub(30).max(10); @@ -524,11 +531,11 @@ fn collect_unsaved_relative_paths(app: &ChatApp, root: &Path) -> HashSet HashSet String { let mut parts = vec![repo_name.to_string()]; for component in path.components() { - if let Component::Normal(segment) = component - && !segment.is_empty() - { - parts.push(segment.to_string_lossy().into_owned()); + if let Component::Normal(segment) = component { + if !segment.is_empty() { + parts.push(segment.to_string_lossy().into_owned()); + } } } parts.join(" > ") @@ -620,6 +627,7 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let unsaved_paths = collect_unsaved_relative_paths(app, &root_path); let tree = app.file_tree(); + let git_enabled = app.is_code_mode(); let entries = tree.visible_entries(); let render_info = compute_tree_line_info(entries, tree.nodes()); let icon_resolver = app.file_icons(); @@ -697,27 +705,30 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { }; spans.push(Span::styled(toggle_symbol.to_string(), guide_style)); - let mut git_color: Option = match node.git.badge { - Some('D') => Some(Color::LightRed), - Some('A') => Some(Color::LightGreen), - Some('R') | Some('C') => Some(Color::Yellow), - Some('U') => Some(Color::Magenta), - Some('M') => Some(Color::Yellow), - _ => None, - }; + let mut git_color: Option = None; let mut git_modifiers = Modifier::empty(); - if let Some('D') = node.git.badge { - git_modifiers |= Modifier::ITALIC; - } - if let Some('U') = node.git.badge { - git_modifiers |= Modifier::BOLD; - } - if git_color.is_none() { - git_color = match node.git.cleanliness { - '○' => Some(Color::LightYellow), - '●' => Some(Color::Yellow), + if git_enabled { + git_color = match node.git.badge { + Some('D') => Some(Color::LightRed), + Some('A') => Some(Color::LightGreen), + Some('R') | Some('C') => Some(Color::Yellow), + Some('U') => Some(Color::Magenta), + Some('M') => Some(Color::Yellow), _ => None, }; + if let Some('D') = node.git.badge { + git_modifiers |= Modifier::ITALIC; + } + if let Some('U') = node.git.badge { + git_modifiers |= Modifier::BOLD; + } + if git_color.is_none() { + git_color = match node.git.cleanliness { + '○' => Some(Color::LightYellow), + '●' => Some(Color::Yellow), + _ => None, + }; + } } let mut icon_style = if node.is_dir { @@ -758,11 +769,7 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { spans.push(Span::styled(node.name.clone(), name_style)); let mut marker_spans: Vec> = Vec::new(); - let marker_color = git_color.unwrap_or(theme.info); - if node.git.cleanliness != '✓' { - marker_spans.push(Span::styled("*", Style::default().fg(marker_color))); - } - if is_unsaved { + if git_enabled && is_unsaved { marker_spans.push(Span::styled( "~", Style::default() @@ -778,11 +785,18 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { .add_modifier(Modifier::DIM | Modifier::ITALIC), )); } - if let Some(badge) = node.git.badge { - marker_spans.push(Span::styled( - badge.to_string(), - Style::default().fg(marker_color), - )); + if git_enabled { + if node.git.cleanliness != '✓' { + let marker_color = git_color.unwrap_or(theme.info); + marker_spans.push(Span::styled("*", Style::default().fg(marker_color))); + } + if let Some(badge) = node.git.badge { + let marker_color = git_color.unwrap_or(theme.info); + marker_spans.push(Span::styled( + badge.to_string(), + Style::default().fg(marker_color), + )); + } } if !marker_spans.is_empty() { @@ -1071,10 +1085,12 @@ fn compute_cursor_metrics( break; } - if !cursor_found && let Some(last_segment) = segments.last() { - cursor_visual_row = segment_base_row + segments.len().saturating_sub(1); - cursor_col_width = UnicodeWidthStr::width(last_segment.as_str()); - cursor_found = true; + if !cursor_found { + if let Some(last_segment) = segments.last() { + cursor_visual_row = segment_base_row + segments.len().saturating_sub(1); + cursor_col_width = UnicodeWidthStr::width(last_segment.as_str()); + cursor_found = true; + } } } @@ -1337,6 +1353,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { app.get_loading_indicator(), &theme, app.should_highlight_code(), + app.render_markdown_enabled(), ), ); lines.extend(message_lines); @@ -1419,11 +1436,11 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { } // 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) - && let Some(selection) = app.visual_selection() + if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Chat) { - lines = apply_visual_selection(lines, Some(selection), &theme); + if let Some(selection) = app.visual_selection() { + lines = apply_visual_selection(lines, Some(selection), &theme); + } } // Update AutoScroll state with accurate content length @@ -1535,9 +1552,10 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // 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) - && let Some(selection) = app.visual_selection() { - lines = apply_visual_selection(lines, Some(selection), &theme); + if let Some(selection) = app.visual_selection() { + lines = apply_visual_selection(lines, Some(selection), &theme); + } } // Update AutoScroll state with accurate content length @@ -3312,6 +3330,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" F1 / ? → toggle help overlay"), Line::from(" :h, :help → open help from command mode"), Line::from(" :files, :explorer → toggle files panel"), + Line::from(" :markdown [on|off] → toggle markdown rendering"), Line::from(" Ctrl+←/→ → resize files panel"), Line::from(" Ctrl+↑/↓ → resize chat/thinking split"), Line::from(vec![Span::styled( @@ -3431,6 +3450,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" :h, :help → show this help"), Line::from(" F1 or ? → toggle help overlay"), Line::from(" :files, :explorer → toggle files panel"), + Line::from(" :markdown [on|off] → toggle markdown rendering"), Line::from(" Ctrl+←/→ → resize files panel"), Line::from(" Ctrl+↑/↓ → resize chat/thinking split"), Line::from(" :quit → quit application"),