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.
This commit is contained in:
2025-10-14 01:35:13 +02:00
parent 99064b6c41
commit 498e6e61b6
24 changed files with 911 additions and 247 deletions

View File

@@ -9,6 +9,7 @@ members = [
"crates/owlen-mcp-client", "crates/owlen-mcp-client",
"crates/owlen-mcp-code-server", "crates/owlen-mcp-code-server",
"crates/owlen-mcp-prompt-server", "crates/owlen-mcp-prompt-server",
"crates/owlen-markdown",
] ]
exclude = [] exclude = []

View File

@@ -221,10 +221,11 @@ fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) {
if provider == "ollama" if provider == "ollama"
&& config.providers.contains_key("ollama-cloud") && config.providers.contains_key("ollama-cloud")
&& !config.providers.contains_key("ollama") && !config.providers.contains_key("ollama")
&& let Some(mut legacy) = config.providers.remove("ollama-cloud")
{ {
legacy.provider_type = "ollama".to_string(); if let Some(mut legacy) = config.providers.remove("ollama-cloud") {
config.providers.insert("ollama".to_string(), legacy); legacy.provider_type = "ollama".to_string();
config.providers.insert("ollama".to_string(), legacy);
}
} }
core_config::ensure_provider_config(config, provider); core_config::ensure_provider_config(config, provider);
@@ -315,8 +316,10 @@ fn unlock_vault(path: &Path) -> Result<encryption::VaultHandle> {
use std::env; use std::env;
if path.exists() { if path.exists() {
if let Ok(password) = env::var("OWLEN_MASTER_PASSWORD") if let Some(password) = env::var("OWLEN_MASTER_PASSWORD")
&& !password.trim().is_empty() .ok()
.map(|value| value.trim().to_string())
.filter(|password| !password.is_empty())
{ {
return encryption::unlock_with_password(path.to_path_buf(), &password) return encryption::unlock_with_password(path.to_path_buf(), &password)
.context("Failed to unlock vault with OWLEN_MASTER_PASSWORD"); .context("Failed to unlock vault with OWLEN_MASTER_PASSWORD");
@@ -356,30 +359,31 @@ async fn hydrate_api_key(
config: &mut Config, config: &mut Config,
manager: Option<&Arc<CredentialManager>>, manager: Option<&Arc<CredentialManager>>,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
if let Some(manager) = manager let credentials = match manager {
&& let Some(credentials) = manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await? 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(); let key = credentials.api_key.trim().to_string();
if !key.is_empty() { if !key.is_empty() {
set_env_if_missing("OLLAMA_API_KEY", &key); set_env_if_missing("OLLAMA_API_KEY", &key);
set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key); set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key);
} }
if let Some(cfg) = provider_entry_mut(config) let Some(cfg) = provider_entry_mut(config) else {
&& cfg.base_url.is_none() return Ok(Some(key));
&& !credentials.endpoint.trim().is_empty() };
{ if cfg.base_url.is_none() && !credentials.endpoint.trim().is_empty() {
cfg.base_url = Some(credentials.endpoint); cfg.base_url = Some(credentials.endpoint.clone());
} }
return Ok(Some(key)); return Ok(Some(key));
} }
if let Some(cfg) = provider_entry(config) if let Some(key) = provider_entry(config)
&& let Some(key) = cfg .and_then(|cfg| cfg.api_key.as_ref())
.api_key .map(|value| value.trim())
.as_ref() .filter(|value| !value.is_empty())
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{ {
set_env_if_missing("OLLAMA_API_KEY", key); set_env_if_missing("OLLAMA_API_KEY", key);
set_env_if_missing("OLLAMA_CLOUD_API_KEY", key); set_env_if_missing("OLLAMA_CLOUD_API_KEY", key);

View File

@@ -1,3 +1,5 @@
#![allow(clippy::collapsible_if)] // TODO: Remove once Rust 2024 let-chains are available
//! OWLEN CLI - Chat TUI client //! OWLEN CLI - Chat TUI client
mod cloud; mod cloud;

View File

@@ -151,8 +151,9 @@ fn handle_list(args: ListArgs) -> Result<()> {
"", "Scope", "Name", "Transport" "", "Scope", "Name", "Transport"
); );
for entry in scoped { for entry in scoped {
if let Some(target_scope) = filter_scope if filter_scope
&& entry.scope != target_scope .as_ref()
.is_some_and(|target_scope| entry.scope != *target_scope)
{ {
continue; continue;
} }
@@ -186,8 +187,9 @@ fn handle_list(args: ListArgs) -> Result<()> {
.collect(); .collect();
for entry in scoped_resources { for entry in scoped_resources {
if let Some(target_scope) = filter_scope if filter_scope
&& entry.scope != target_scope .as_ref()
.is_some_and(|target_scope| entry.scope != *target_scope)
{ {
continue; continue;
} }

View File

@@ -1332,6 +1332,8 @@ pub struct UiSettings {
pub show_cursor_outside_insert: bool, pub show_cursor_outside_insert: bool,
#[serde(default = "UiSettings::default_syntax_highlighting")] #[serde(default = "UiSettings::default_syntax_highlighting")]
pub syntax_highlighting: bool, pub syntax_highlighting: bool,
#[serde(default = "UiSettings::default_render_markdown")]
pub render_markdown: bool,
#[serde(default = "UiSettings::default_show_timestamps")] #[serde(default = "UiSettings::default_show_timestamps")]
pub show_timestamps: bool, pub show_timestamps: bool,
#[serde(default = "UiSettings::default_icon_mode")] #[serde(default = "UiSettings::default_icon_mode")]
@@ -1392,6 +1394,10 @@ impl UiSettings {
true true
} }
const fn default_render_markdown() -> bool {
true
}
const fn default_show_timestamps() -> bool { const fn default_show_timestamps() -> bool {
true true
} }
@@ -1466,6 +1472,7 @@ impl Default for UiSettings {
scrollback_lines: Self::default_scrollback_lines(), scrollback_lines: Self::default_scrollback_lines(),
show_cursor_outside_insert: Self::default_show_cursor_outside_insert(), show_cursor_outside_insert: Self::default_show_cursor_outside_insert(),
syntax_highlighting: Self::default_syntax_highlighting(), syntax_highlighting: Self::default_syntax_highlighting(),
render_markdown: Self::default_render_markdown(),
show_timestamps: Self::default_show_timestamps(), show_timestamps: Self::default_show_timestamps(),
icon_mode: Self::default_icon_mode(), icon_mode: Self::default_icon_mode(),
} }

View File

@@ -58,9 +58,14 @@ impl ConsentManager {
/// Load consent records from vault storage /// Load consent records from vault storage
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self { pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self {
let guard = vault.lock().expect("Vault mutex poisoned"); let guard = vault.lock().expect("Vault mutex poisoned");
if let Some(consent_data) = guard.settings().get("consent_records") if let Some(permanent_records) =
&& let Ok(permanent_records) = guard
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone()) .settings()
.get("consent_records")
.and_then(|consent_data| {
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
.ok()
})
{ {
return Self { return Self {
permanent_records, permanent_records,
@@ -90,15 +95,19 @@ impl ConsentManager {
endpoints: Vec<String>, endpoints: Vec<String>,
) -> Result<ConsentScope> { ) -> Result<ConsentScope> {
// Check if already granted permanently // Check if already granted permanently
if let Some(existing) = self.permanent_records.get(tool_name) if self
&& existing.scope == ConsentScope::Permanent .permanent_records
.get(tool_name)
.is_some_and(|existing| existing.scope == ConsentScope::Permanent)
{ {
return Ok(ConsentScope::Permanent); return Ok(ConsentScope::Permanent);
} }
// Check if granted for session // Check if granted for session
if let Some(existing) = self.session_records.get(tool_name) if self
&& existing.scope == ConsentScope::Session .session_records
.get(tool_name)
.is_some_and(|existing| existing.scope == ConsentScope::Session)
{ {
return Ok(ConsentScope::Session); return Ok(ConsentScope::Session);
} }

View File

@@ -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 //! Core traits and types for OWLEN LLM client
//! //!
//! This crate provides the foundational abstractions for building //! This crate provides the foundational abstractions for building

View File

@@ -156,13 +156,14 @@ mod tests {
use super::*; use super::*;
use crate::mcp::LocalMcpClient; use crate::mcp::LocalMcpClient;
use crate::tools::registry::ToolRegistry; use crate::tools::registry::ToolRegistry;
use crate::ui::NoOpUiController;
use crate::validation::SchemaValidator; use crate::validation::SchemaValidator;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
#[tokio::test] #[tokio::test]
async fn test_permission_layer_filters_dangerous_tools() { async fn test_permission_layer_filters_dangerous_tools() {
let config = Arc::new(Config::default()); let config = Arc::new(Config::default());
let ui = Arc::new(crate::ui::NoOpUiController); let ui = Arc::new(NoOpUiController);
let registry = Arc::new(ToolRegistry::new( let registry = Arc::new(ToolRegistry::new(
Arc::new(tokio::sync::Mutex::new((*config).clone())), Arc::new(tokio::sync::Mutex::new((*config).clone())),
ui, ui,
@@ -186,7 +187,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_consent_callback_is_invoked() { async fn test_consent_callback_is_invoked() {
let config = Arc::new(Config::default()); let config = Arc::new(Config::default());
let ui = Arc::new(crate::ui::NoOpUiController); let ui = Arc::new(NoOpUiController);
let registry = Arc::new(ToolRegistry::new( let registry = Arc::new(ToolRegistry::new(
Arc::new(tokio::sync::Mutex::new((*config).clone())), Arc::new(tokio::sync::Mutex::new((*config).clone())),
ui, ui,

View File

@@ -42,7 +42,7 @@ impl ModelManager {
F: FnOnce() -> Fut, F: FnOnce() -> Fut,
Fut: Future<Output = Result<Vec<ModelInfo>>>, Fut: Future<Output = Result<Vec<ModelInfo>>>,
{ {
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); return Ok(models);
} }

View File

@@ -378,10 +378,8 @@ impl OllamaProvider {
let family = pick_first_string(map, &["family", "model_family"]); let family = pick_first_string(map, &["family", "model_family"]);
let mut families = pick_string_list(map, &["families", "model_families"]); let mut families = pick_string_list(map, &["families", "model_families"]);
if families.is_empty() if families.is_empty() {
&& let Some(single) = family.clone() families.extend(family.clone());
{
families.push(single);
} }
let system = pick_first_string(map, &["system"]); let system = pick_first_string(map, &["system"]);

View File

@@ -71,16 +71,19 @@ impl Router {
fn find_provider_for_model(&self, model: &str) -> Result<Arc<dyn Provider>> { fn find_provider_for_model(&self, model: &str) -> Result<Arc<dyn Provider>> {
// Check routing rules first // Check routing rules first
for rule in &self.routing_rules { for rule in &self.routing_rules {
if self.matches_pattern(&rule.model_pattern, model) if !self.matches_pattern(&rule.model_pattern, model) {
&& let Some(provider) = self.registry.get(&rule.provider) continue;
{ }
if let Some(provider) = self.registry.get(&rule.provider) {
return Ok(provider); return Ok(provider);
} }
} }
// Fall back to default provider // Fall back to default provider
if let Some(default) = &self.default_provider if let Some(provider) = self
&& let Some(provider) = self.registry.get(default) .default_provider
.as_ref()
.and_then(|default| self.registry.get(default))
{ {
return Ok(provider); return Ok(provider);
} }

View File

@@ -185,14 +185,20 @@ impl SandboxedProcess {
if let Ok(output) = output { if let Ok(output) = output {
let version_str = String::from_utf8_lossy(&output.stdout); let version_str = String::from_utf8_lossy(&output.stdout);
// Parse version like "bubblewrap 0.11.0" or "0.11.0" // Parse version like "bubblewrap 0.11.0" or "0.11.0"
if let Some(version_part) = version_str.split_whitespace().last() return version_str
&& let Some((major, rest)) = version_part.split_once('.') .split_whitespace()
&& let Some((minor, _patch)) = rest.split_once('.') .last()
&& let (Ok(maj), Ok(min)) = (major.parse::<u32>(), minor.parse::<u32>()) .and_then(|part| {
{ part.split_once('.').and_then(|(major, rest)| {
// --rlimit-as was added in 0.12.0 rest.split_once('.').and_then(|(minor, _)| {
return maj > 0 || (maj == 0 && min >= 12); let maj = major.parse::<u32>().ok()?;
} let min = minor.parse::<u32>().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) // If we can't determine the version, assume it doesn't support it (safer default)

View File

@@ -53,8 +53,8 @@ fn extract_resource_content(value: &Value) -> Option<String> {
Value::Array(items) => { Value::Array(items) => {
let mut segments = Vec::new(); let mut segments = Vec::new();
for item in items { for item in items {
if let Some(segment) = extract_resource_content(item) if let Some(segment) =
&& !segment.is_empty() extract_resource_content(item).filter(|segment| !segment.is_empty())
{ {
segments.push(segment); segments.push(segment);
} }
@@ -69,17 +69,19 @@ fn extract_resource_content(value: &Value) -> Option<String> {
const PREFERRED_FIELDS: [&str; 6] = const PREFERRED_FIELDS: [&str; 6] =
["content", "contents", "text", "value", "body", "data"]; ["content", "contents", "text", "value", "body", "data"];
for key in PREFERRED_FIELDS.iter() { for key in PREFERRED_FIELDS.iter() {
if let Some(inner) = map.get(*key) if let Some(text) = map
&& let Some(text) = extract_resource_content(inner) .get(*key)
&& !text.is_empty() .and_then(extract_resource_content)
.filter(|text| !text.is_empty())
{ {
return Some(text); return Some(text);
} }
} }
if let Some(inner) = map.get("chunks") if let Some(text) = map
&& let Some(text) = extract_resource_content(inner) .get("chunks")
&& !text.is_empty() .and_then(extract_resource_content)
.filter(|text| !text.is_empty())
{ {
return Some(text); return Some(text);
} }
@@ -566,9 +568,10 @@ impl SessionController {
.expect("Consent manager mutex poisoned"); .expect("Consent manager mutex poisoned");
consent.grant_consent(tool_name, data_types, endpoints); consent.grant_consent(tool_name, data_types, endpoints);
if let Some(vault) = &self.vault let Some(vault) = &self.vault else {
&& let Err(e) = consent.persist_to_vault(vault) return;
{ };
if let Err(e) = consent.persist_to_vault(vault) {
eprintln!("Warning: Failed to persist consent to vault: {}", e); 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); consent.grant_consent_with_scope(tool_name, data_types, endpoints, scope);
// Only persist to vault for permanent consent // Only persist to vault for permanent consent
if is_permanent if !is_permanent {
&& let Some(vault) = &self.vault return;
&& 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); eprintln!("Warning: Failed to persist consent to vault: {}", e);
} }
} }

View File

@@ -50,14 +50,14 @@ impl StorageManager {
/// Create a storage manager using the provided database path /// Create a storage manager using the provided database path
pub async fn with_database_path(database_path: PathBuf) -> Result<Self> { pub async fn with_database_path(database_path: PathBuf) -> Result<Self> {
if let Some(parent) = database_path.parent() if let Some(parent) = database_path.parent() {
&& !parent.exists() if !parent.exists() {
{ std::fs::create_dir_all(parent).map_err(|e| {
std::fs::create_dir_all(parent).map_err(|e| { Error::Storage(format!(
Error::Storage(format!( "Failed to create database directory {parent:?}: {e}"
"Failed to create database directory {parent:?}: {e}" ))
)) })?;
})?; }
} }
let options = SqliteConnectOptions::from_str(&format!( let options = SqliteConnectOptions::from_str(&format!(
@@ -431,13 +431,13 @@ impl StorageManager {
} }
} }
if migrated > 0 if migrated > 0 {
&& let Err(err) = archive_legacy_directory(&legacy_dir) if let Err(err) = archive_legacy_directory(&legacy_dir) {
{ println!(
println!( "Warning: migrated sessions but failed to archive legacy directory: {}",
"Warning: migrated sessions but failed to archive legacy directory: {}", err
err );
); }
} }
println!("Migrated {} legacy sessions.", migrated); println!("Migrated {} legacy sessions.", migrated);

View File

@@ -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"

View File

@@ -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::<Span<'static>>::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::<Span<'static>>::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<Span<'static>> {
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));
}
}

View File

@@ -29,6 +29,7 @@ dirs = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
syntect = "5.3" syntect = "5.3"
once_cell = "1.19" once_cell = "1.19"
owlen-markdown = { path = "../owlen-markdown" }
# Async runtime # Async runtime
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -14,6 +14,7 @@ use owlen_core::{
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role}, types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
ui::{AppState, AutoScroll, FocusedPanel, InputMode, RoleLabelDisplay}, ui::{AppState, AutoScroll, FocusedPanel, InputMode, RoleLabelDisplay},
}; };
use owlen_markdown::from_str;
use pathdiff::diff_paths; use pathdiff::diff_paths;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
@@ -211,6 +212,7 @@ pub struct ChatApp {
message_line_cache: HashMap<Uuid, MessageCacheEntry>, // Cached rendered lines per message message_line_cache: HashMap<Uuid, MessageCacheEntry>, // Cached rendered lines per message
show_cursor_outside_insert: bool, // Configurable cursor visibility flag show_cursor_outside_insert: bool, // Configurable cursor visibility flag
syntax_highlighting: bool, // Whether syntax highlighting is enabled 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 show_message_timestamps: bool, // Whether to render timestamps in chat headers
auto_scroll: AutoScroll, // Auto-scroll state for message rendering auto_scroll: AutoScroll, // Auto-scroll state for message rendering
thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel
@@ -293,6 +295,7 @@ struct MessageCacheEntry {
wrap_width: usize, wrap_width: usize,
role_label_mode: RoleLabelDisplay, role_label_mode: RoleLabelDisplay,
syntax_highlighting: bool, syntax_highlighting: bool,
render_markdown: bool,
show_timestamps: bool, show_timestamps: bool,
content_hash: u64, content_hash: u64,
lines: Vec<Line<'static>>, lines: Vec<Line<'static>>,
@@ -315,6 +318,7 @@ pub(crate) struct MessageRenderContext<'a> {
loading_indicator: &'a str, loading_indicator: &'a str,
theme: &'a Theme, theme: &'a Theme,
syntax_highlighting: bool, syntax_highlighting: bool,
render_markdown: bool,
} }
impl<'a> MessageRenderContext<'a> { impl<'a> MessageRenderContext<'a> {
@@ -328,6 +332,7 @@ impl<'a> MessageRenderContext<'a> {
loading_indicator: &'a str, loading_indicator: &'a str,
theme: &'a Theme, theme: &'a Theme,
syntax_highlighting: bool, syntax_highlighting: bool,
render_markdown: bool,
) -> Self { ) -> Self {
Self { Self {
formatter, formatter,
@@ -338,6 +343,7 @@ impl<'a> MessageRenderContext<'a> {
loading_indicator, loading_indicator,
theme, theme,
syntax_highlighting, syntax_highlighting,
render_markdown,
} }
} }
} }
@@ -416,6 +422,7 @@ impl ChatApp {
let show_onboarding = config_guard.ui.show_onboarding; let show_onboarding = config_guard.ui.show_onboarding;
let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert; let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert;
let syntax_highlighting = config_guard.ui.syntax_highlighting; 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 show_timestamps = config_guard.ui.show_timestamps;
let icon_mode = config_guard.ui.icon_mode; let icon_mode = config_guard.ui.icon_mode;
drop(config_guard); drop(config_guard);
@@ -516,6 +523,7 @@ impl ChatApp {
new_message_alert: false, new_message_alert: false,
show_cursor_outside_insert, show_cursor_outside_insert,
syntax_highlighting, syntax_highlighting,
render_markdown,
show_message_timestamps: show_timestamps, show_message_timestamps: show_timestamps,
}; };
@@ -596,6 +604,10 @@ impl ChatApp {
.and_then(|pane| pane.display_path()) .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] { pub fn code_view_lines(&self) -> &[String] {
self.code_workspace self.code_workspace
.active_pane() .active_pane()
@@ -767,6 +779,12 @@ impl ChatApp {
return Ok(()); 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 (absolute, display, line_number, column) = {
let file = &self.repo_search.files()[file_index]; let file = &self.repo_search.files()[file_index];
let m = &file.matches[match_index]; let m = &file.matches[match_index];
@@ -826,6 +844,12 @@ impl ChatApp {
return Ok(()); 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(); let mut buffer = String::new();
for file in self.repo_search.files() { for file in self.repo_search.files() {
if file.matches.is_empty() { if file.matches.is_empty() {
@@ -1054,6 +1078,11 @@ impl ChatApp {
} }
pub fn expand_file_panel(&mut self) { 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 { if self.file_panel_collapsed {
self.file_panel_collapsed = false; self.file_panel_collapsed = false;
self.focused_panel = FocusedPanel::Files; self.focused_panel = FocusedPanel::Files;
@@ -1072,6 +1101,11 @@ impl ChatApp {
} }
pub fn toggle_file_panel(&mut self) { 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 { if self.file_panel_collapsed {
self.expand_file_panel(); self.expand_file_panel();
} else { } else {
@@ -1102,6 +1136,7 @@ impl ChatApp {
} }
if !matches!(mode, owlen_core::mode::Mode::Code) { if !matches!(mode, owlen_core::mode::Mode::Code) {
self.collapse_file_panel();
self.close_code_view(); self.close_code_view();
self.set_system_status(String::new()); self.set_system_status(String::new());
} }
@@ -1229,9 +1264,10 @@ impl ChatApp {
.model_info_panel .model_info_panel
.current_model_name() .current_model_name()
.map(|s| s.to_string()) .map(|s| s.to_string())
&& let Some(updated) = self.model_details_cache.get(&current).cloned()
{ {
self.model_info_panel.set_model_info(updated); if let Some(updated) = self.model_details_cache.get(&current).cloned() {
self.model_info_panel.set_model_info(updated);
}
} }
let total = self.model_details_cache.len(); let total = self.model_details_cache.len();
self.status = format!("Cached model details for {} model(s)", total); self.status = format!("Cached model details for {} model(s)", total);
@@ -1575,20 +1611,20 @@ impl ChatApp {
const CANDIDATES: [&str; 6] = const CANDIDATES: [&str; 6] =
["rendered", "text", "content", "value", "message", "body"]; ["rendered", "text", "content", "value", "message", "body"];
for key in CANDIDATES { for key in CANDIDATES {
if let Some(Value::String(text)) = map.get(key) if let Some(Value::String(text)) = map.get(key) {
&& !text.trim().is_empty() if !text.trim().is_empty() {
{ return Some(text.clone());
return Some(text.clone()); }
} }
} }
if let Some(Value::Array(items)) = map.get("lines") { if let Some(Value::Array(items)) = map.get("lines") {
let mut collected = Vec::new(); let mut collected = Vec::new();
for item in items { for item in items {
if let Some(segment) = item.as_str() if let Some(segment) = item.as_str() {
&& !segment.trim().is_empty() if !segment.trim().is_empty() {
{ collected.push(segment.trim());
collected.push(segment.trim()); }
} }
} }
if !collected.is_empty() { if !collected.is_empty() {
@@ -1601,11 +1637,12 @@ impl ChatApp {
} }
fn extract_mcp_error(value: &Value) -> Option<String> { fn extract_mcp_error(value: &Value) -> Option<String> {
if let Value::Object(map) = value if let Value::Object(map) = value {
&& let Some(Value::String(message)) = map.get("error") if let Some(Value::String(message)) = map.get("error") {
&& !message.trim().is_empty() if !message.trim().is_empty() {
{ return Some(message.clone());
return Some(message.clone()); }
}
} }
None None
} }
@@ -1884,17 +1921,19 @@ impl ChatApp {
} }
fn sync_ui_preferences_from_config(&mut self) { 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(); let guard = self.controller.config();
( (
guard.ui.show_cursor_outside_insert, guard.ui.show_cursor_outside_insert,
guard.ui.role_label_mode, guard.ui.role_label_mode,
guard.ui.syntax_highlighting, guard.ui.syntax_highlighting,
guard.ui.render_markdown,
guard.ui.show_timestamps, guard.ui.show_timestamps,
) )
}; };
self.show_cursor_outside_insert = show_cursor; self.show_cursor_outside_insert = show_cursor;
self.syntax_highlighting = syntax_highlighting; self.syntax_highlighting = syntax_highlighting;
self.render_markdown = render_markdown;
self.show_message_timestamps = show_timestamps; self.show_message_timestamps = show_timestamps;
self.controller.set_role_label_mode(role_label_mode); self.controller.set_role_label_mode(role_label_mode);
self.message_line_cache.clear(); self.message_line_cache.clear();
@@ -1912,6 +1951,42 @@ impl ChatApp {
true 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( pub(crate) fn render_message_lines_cached(
&mut self, &mut self,
message_index: usize, message_index: usize,
@@ -1926,6 +2001,7 @@ impl ChatApp {
loading_indicator, loading_indicator,
theme, theme,
syntax_highlighting, syntax_highlighting,
render_markdown,
} = ctx; } = ctx;
let (message_id, role, raw_content, timestamp, tool_calls, tool_result_id) = { let (message_id, role, raw_content, timestamp, tool_calls, tool_result_id) = {
let conversation = self.conversation(); let conversation = self.conversation();
@@ -1955,7 +2031,7 @@ impl ChatApp {
let normalized_content = display_content.replace("\r\n", "\n"); let normalized_content = display_content.replace("\r\n", "\n");
let trimmed = normalized_content.trim(); let trimmed = normalized_content.trim();
let content = trimmed.to_string(); let content = trimmed.to_string();
let segments = parse_message_segments(trimmed); let segments = parse_message_segments(trimmed, render_markdown);
let tool_signature = tool_calls let tool_signature = tool_calls
.as_ref() .as_ref()
.map(|calls| { .map(|calls| {
@@ -1966,18 +2042,21 @@ impl ChatApp {
.unwrap_or_default(); .unwrap_or_default();
let content_hash = Self::message_content_hash(&role, &content, &tool_signature); let content_hash = Self::message_content_hash(&role, &content, &tool_signature);
if !is_streaming if !is_streaming {
&& let Some(entry) = self.message_line_cache.get(&message_id) if let Some(entry) = self.message_line_cache.get(&message_id) {
&& entry.wrap_width == card_width if entry.wrap_width == card_width
&& entry.role_label_mode == role_label_mode && entry.role_label_mode == role_label_mode
&& entry.syntax_highlighting == syntax_highlighting && entry.syntax_highlighting == syntax_highlighting
&& entry.theme_name == theme.name && entry.render_markdown == render_markdown
&& entry.show_timestamps == self.show_message_timestamps && entry.theme_name == theme.name
&& entry.metrics.body_width == body_width && entry.show_timestamps == self.show_message_timestamps
&& entry.metrics.card_width == card_width && entry.metrics.body_width == body_width
&& entry.content_hash == content_hash && entry.metrics.card_width == card_width
{ && entry.content_hash == content_hash
return entry.lines.clone(); {
return entry.lines.clone();
}
}
} }
let mut rendered: Vec<Line<'static>> = Vec::new(); let mut rendered: Vec<Line<'static>> = Vec::new();
@@ -2012,24 +2091,38 @@ impl ChatApp {
for segment in segments { for segment in segments {
match segment { match segment {
MessageSegment::Text { lines } => { MessageSegment::Text { lines } => {
for line_text in lines { if render_markdown {
let mut chunks = wrap_unicode(line_text.as_str(), available_width); let block = lines.join("\n");
if chunks.is_empty() { let markdown_lines = render_markdown_lines(
chunks.push(String::new()); &block,
} indent,
for chunk in chunks { available_width,
let mut spans: Vec<Span<'static>> = Vec::new(); content_style,
if !indent.is_empty() { );
spans.push(Span::styled(indent.to_string(), content_style)); for line in markdown_lines {
} rendered.push(line);
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); *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<Span<'static>> = 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 } => { MessageSegment::CodeBlock { language, lines } => {
@@ -2116,6 +2209,7 @@ impl ChatApp {
wrap_width: card_width, wrap_width: card_width,
role_label_mode, role_label_mode,
syntax_highlighting, syntax_highlighting,
render_markdown,
show_timestamps: self.show_message_timestamps, show_timestamps: self.show_message_timestamps,
content_hash, content_hash,
lines: card_lines.clone(), lines: card_lines.clone(),
@@ -3204,6 +3298,12 @@ impl ChatApp {
&mut self, &mut self,
disposition: FileOpenDisposition, disposition: FileOpenDisposition,
) -> Result<()> { ) -> 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 selected_opt = {
let tree = self.file_tree(); let tree = self.file_tree();
tree.selected_node().cloned() tree.selected_node().cloned()
@@ -3230,10 +3330,6 @@ impl ChatApp {
return Ok(()); 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 relative_display = self.relative_tree_display(&selected.path);
let absolute_path = self.absolute_tree_path(&selected.path); let absolute_path = self.absolute_tree_path(&selected.path);
let request_path = if selected.path.is_absolute() { let request_path = if selected.path.is_absolute() {
@@ -3390,6 +3486,9 @@ impl ChatApp {
} }
fn create_file_from_command(&mut self, path: &str) -> Result<String> { fn create_file_from_command(&mut self, path: &str) -> Result<String> {
if !self.is_code_mode() {
return Err(anyhow!("File creation is only available in code mode"));
}
let trimmed = path.trim(); let trimmed = path.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Err(anyhow!("File path cannot be 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<String>) { fn begin_file_action(&mut self, kind: FileActionKind, initial: impl Into<String>) {
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); let prompt = FileActionPrompt::new(kind, initial);
self.status = self.describe_file_action_prompt(&prompt); self.status = self.describe_file_action_prompt(&prompt);
self.error = None; self.error = None;
@@ -3557,6 +3661,11 @@ impl ChatApp {
} }
async fn launch_external_editor(&mut self) -> Result<()> { 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 { let Some(selected) = self.selected_file_node() else {
self.status = "No file selected".to_string(); self.status = "No file selected".to_string();
return Ok(()); return Ok(());
@@ -3617,6 +3726,9 @@ impl ChatApp {
} }
fn perform_file_action(&mut self, prompt: FileActionPrompt) -> Result<String> { fn perform_file_action(&mut self, prompt: FileActionPrompt) -> Result<String> {
if !self.is_code_mode() {
return Err(anyhow!("File actions are only available in code mode"));
}
match prompt.kind { match prompt.kind {
FileActionKind::CreateFile { base } => { FileActionKind::CreateFile { base } => {
let name = prompt.buffer.trim(); let name = prompt.buffer.trim();
@@ -3805,6 +3917,11 @@ impl ChatApp {
} }
fn reveal_path_in_file_tree(&mut self, path: &Path) { 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); let absolute = self.absolute_tree_path(path);
self.expand_file_panel(); self.expand_file_panel();
self.file_tree_mut().reveal(&absolute); 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<bool> { async fn handle_file_panel_key(&mut self, key: &crossterm::event::KeyEvent) -> Result<bool> {
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyModifiers};
if !self.is_code_mode() {
return Ok(false);
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT); let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let alt = key.modifiers.contains(KeyModifiers::ALT); let alt = key.modifiers.contains(KeyModifiers::ALT);
@@ -5294,11 +5415,12 @@ impl ChatApp {
} }
FocusedPanel::Chat | FocusedPanel::Thinking => { FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection forward by word // Move selection forward by word
if let Some((row, col)) = self.visual_end if let Some((row, col)) = self.visual_end {
&& let Some(new_col) = if let Some(new_col) =
self.find_next_word_boundary(row, col) self.find_next_word_boundary(row, col)
{ {
self.visual_end = Some((row, new_col)); self.visual_end = Some((row, new_col));
}
} }
} }
FocusedPanel::Files => {} FocusedPanel::Files => {}
@@ -5313,11 +5435,12 @@ impl ChatApp {
} }
FocusedPanel::Chat | FocusedPanel::Thinking => { FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection backward by word // Move selection backward by word
if let Some((row, col)) = self.visual_end if let Some((row, col)) = self.visual_end {
&& let Some(new_col) = if let Some(new_col) =
self.find_prev_word_boundary(row, col) self.find_prev_word_boundary(row, col)
{ {
self.visual_end = Some((row, new_col)); self.visual_end = Some((row, new_col));
}
} }
} }
FocusedPanel::Files => {} FocusedPanel::Files => {}
@@ -5346,11 +5469,11 @@ impl ChatApp {
} }
FocusedPanel::Chat | FocusedPanel::Thinking => { FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection to end of line // Move selection to end of line
if let Some((row, _)) = self.visual_end if let Some((row, _)) = self.visual_end {
&& let Some(line) = self.get_line_at_row(row) if let Some(line) = self.get_line_at_row(row) {
{ let line_len = line.chars().count();
let line_len = line.chars().count(); self.visual_end = Some((row, line_len));
self.visual_end = Some((row, line_len)); }
} }
} }
FocusedPanel::Files => {} FocusedPanel::Files => {}
@@ -5438,6 +5561,14 @@ impl ChatApp {
} }
} }
"create" => { "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() { if args.is_empty() {
self.error = Some("Usage: :create <path>".to_string()); self.error = Some("Usage: :create <path>".to_string());
} else { } else {
@@ -5455,6 +5586,14 @@ impl ChatApp {
} }
} }
"files" | "explorer" => { "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(); let was_collapsed = self.is_file_panel_collapsed();
self.toggle_file_panel(); self.toggle_file_panel();
let now_collapsed = self.is_file_panel_collapsed(); let now_collapsed = self.is_file_panel_collapsed();
@@ -5468,6 +5607,35 @@ impl ChatApp {
self.status = "Files panel unchanged".to_string(); 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" => { "c" | "clear" => {
self.controller.clear(); self.controller.clear();
self.chat_line_offset = 0; self.chat_line_offset = 0;
@@ -5787,14 +5955,15 @@ impl ChatApp {
self.status = "Usage: :provider <name>".to_string(); self.status = "Usage: :provider <name>".to_string();
} else { } else {
let filter = args.join(" "); let filter = args.join(" ");
if self.available_providers.is_empty() if self.available_providers.is_empty() {
&& let Err(err) = self.refresh_models().await if let Err(err) = self.refresh_models().await {
{ self.error = Some(format!(
self.error = Some(format!( "Failed to refresh providers: {}",
"Failed to refresh providers: {}", err
err ));
)); self.status =
self.status = "Unable to refresh providers".to_string(); "Unable to refresh providers".to_string();
}
} }
if let Some(provider) = self.best_provider_match(&filter) { if let Some(provider) = self.best_provider_match(&filter) {
@@ -6404,20 +6573,23 @@ impl ChatApp {
} }
} }
KeyCode::Char(' ') => { KeyCode::Char(' ') => {
if let Some(item) = self.current_model_selector_item() if let Some(item) = self.current_model_selector_item() {
&& let ModelSelectorItemKind::Header { provider, expanded } = if let ModelSelectorItemKind::Header { provider, expanded } =
item.kind() item.kind()
{ {
if *expanded { if *expanded {
let provider_name = provider.clone(); let provider_name = provider.clone();
self.collapse_provider(&provider_name); self.collapse_provider(&provider_name);
self.status = format!("Collapsed provider: {}", provider_name); self.status =
} else { format!("Collapsed provider: {}", provider_name);
let provider_name = provider.clone(); } else {
self.expand_provider(&provider_name, true); let provider_name = provider.clone();
self.status = format!("Expanded provider: {}", provider_name); 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() => { KeyCode::Char(ch) if ch.is_ascii_digit() => {
if let Some(idx) = ch.to_digit(10) if let Some(idx) = ch.to_digit(10) {
&& idx >= 1 if idx >= 1 && (idx as usize) <= HELP_TAB_COUNT {
&& (idx as usize) <= HELP_TAB_COUNT self.help_tab_index = (idx - 1) as usize;
{ }
self.help_tab_index = (idx - 1) as usize;
} }
} }
_ => {} _ => {}
@@ -7543,11 +7714,11 @@ impl ChatApp {
)); ));
} }
if let Some(idx) = self.best_model_match_index(query) if let Some(idx) = self.best_model_match_index(query) {
&& let Some(model) = self.models.get(idx).cloned() if let Some(model) = self.models.get(idx).cloned() {
{ self.apply_model_selection(model).await?;
self.apply_model_selection(model).await?; return Ok(());
return Ok(()); }
} }
Err(anyhow!(format!( Err(anyhow!(format!(
@@ -8060,7 +8231,7 @@ impl ChatApp {
let normalized_content = content_to_display.replace("\r\n", "\n"); let normalized_content = content_to_display.replace("\r\n", "\n");
let trimmed = normalized_content.trim(); 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<String> = Vec::new(); let mut body_lines: Vec<String> = Vec::new();
let mut indicator_target: Option<usize> = None; let mut indicator_target: Option<usize> = None;
@@ -8370,7 +8541,15 @@ pub(crate) fn streaming_indicator_symbol(indicator: &str) -> &str {
} }
} }
fn parse_message_segments(content: &str) -> Vec<MessageSegment> { fn parse_message_segments(content: &str, markdown_enabled: bool) -> Vec<MessageSegment> {
if !markdown_enabled {
let mut lines: Vec<String> = 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 segments = Vec::new();
let mut text_lines: Vec<String> = Vec::new(); let mut text_lines: Vec<String> = Vec::new();
let mut lines = content.lines(); let mut lines = content.lines();
@@ -8414,6 +8593,10 @@ fn parse_message_segments(content: &str) -> Vec<MessageSegment> {
if !text_lines.is_empty() { if !text_lines.is_empty() {
segments.push(MessageSegment::Text { lines: text_lines }); segments.push(MessageSegment::Text { lines: text_lines });
} else if segments.is_empty() {
segments.push(MessageSegment::Text {
lines: vec![String::new()],
});
} }
segments segments
@@ -8440,6 +8623,135 @@ fn wrap_code(text: &str, width: usize) -> Vec<String> {
wrapped wrapped
} }
fn render_markdown_lines(
markdown: &str,
indent: &str,
available_width: usize,
base_style: Style,
) -> Vec<Line<'static>> {
let width = available_width.max(1);
let mut text = from_str(markdown);
let mut output: Vec<Line<'static>> = 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::<Vec<_>>();
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<Span<'static>>,
indent: &str,
available_width: usize,
base_style: Style,
) -> Vec<Line<'static>> {
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<Line<'static>> = Vec::new();
let mut current: Vec<Span<'static>> = 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( fn wrap_highlight_segments(
segments: Vec<(Style, String)>, segments: Vec<(Style, String)>,
code_width: usize, code_width: usize,

View File

@@ -148,6 +148,10 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "reload", keyword: "reload",
description: "Reload configuration and themes", description: "Reload configuration and themes",
}, },
CommandSpec {
keyword: "markdown",
description: "Toggle markdown rendering",
},
CommandSpec { CommandSpec {
keyword: "e", keyword: "e",
description: "Edit a file", description: "Edit a file",

View File

@@ -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) { if let Ok(Some(syntax)) = SYNTAX_SET.find_syntax_for_file(path) {
return syntax; return syntax;
} }
if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
&& let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(ext) if let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(ext) {
{ return syntax;
return syntax; }
} }
if let Some(name) = path.file_name().and_then(|name| name.to_str()) if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
&& let Some(syntax) = SYNTAX_SET.find_syntax_by_token(name) if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(name) {
{ return syntax;
return syntax; }
} }
} }

View File

@@ -1,3 +1,5 @@
#![allow(clippy::collapsible_if)] // TODO: Remove once Rust 2024 let-chains are available
//! # Owlen TUI //! # Owlen TUI
//! //!
//! This crate contains all the logic for the terminal user interface (TUI) of Owlen. //! This crate contains all the logic for the terminal user interface (TUI) of Owlen.

View File

@@ -302,16 +302,18 @@ impl FileTreeState {
return; return;
} }
if let Some(rel) = diff_paths(path, &self.root) if let Some(rel) = diff_paths(path, &self.root) {
&& let Some(index) = self if let Some(index) = self
.nodes .nodes
.iter() .iter()
.position(|node| node.path == rel || node.path == path) .position(|node| node.path == rel || node.path == path)
{ {
self.expand_to(index); self.expand_to(index);
if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index) { if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index)
self.cursor = cursor_pos; {
self.ensure_cursor_in_view(); self.cursor = cursor_pos;
self.ensure_cursor_in_view();
}
} }
} }
} }
@@ -561,10 +563,10 @@ fn build_nodes(
node.is_expanded = node.should_default_expand(); node.is_expanded = node.should_default_expand();
let index = nodes.len(); let index = nodes.len();
if let Some(parent_idx) = parent if let Some(parent_idx) = parent {
&& let Some(parent_node) = nodes.get_mut(parent_idx) if let Some(parent_node) = nodes.get_mut(parent_idx) {
{ parent_node.children.push(index);
parent_node.children.push(index); }
} }
index_by_path.insert(relative, index); index_by_path.insert(relative, index);

View File

@@ -182,12 +182,14 @@ impl RepoSearchState {
if matches!( if matches!(
self.rows[self.selected_row].kind, self.rows[self.selected_row].kind,
RepoSearchRowKind::FileHeader RepoSearchRowKind::FileHeader
) && let Some(idx) = self ) {
.rows if let Some(idx) = self
.iter() .rows
.position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. })) .iter()
{ .position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. }))
self.selected_row = idx; {
self.selected_row = idx;
}
} }
self.ensure_selection_visible(); self.ensure_selection_visible();
} }

View File

@@ -209,7 +209,14 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
return; 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) (None, content_area)
} else { } else {
let max_sidebar = content_area.width.saturating_sub(30).max(10); 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<PathBuf
if !pane.is_dirty { if !pane.is_dirty {
continue; continue;
} }
if let Some(abs) = pane.absolute_path() if let Some(abs) = pane.absolute_path() {
&& let Some(rel) = diff_paths(abs, root) if let Some(rel) = diff_paths(abs, root) {
{ set.insert(rel);
set.insert(rel); continue;
continue; }
} }
if let Some(display) = pane.display_path() { if let Some(display) = pane.display_path() {
let display_path = PathBuf::from(display); let display_path = PathBuf::from(display);
@@ -543,10 +550,10 @@ fn collect_unsaved_relative_paths(app: &ChatApp, root: &Path) -> HashSet<PathBuf
fn build_breadcrumbs(repo_name: &str, path: &Path) -> String { fn build_breadcrumbs(repo_name: &str, path: &Path) -> String {
let mut parts = vec![repo_name.to_string()]; let mut parts = vec![repo_name.to_string()];
for component in path.components() { for component in path.components() {
if let Component::Normal(segment) = component if let Component::Normal(segment) = component {
&& !segment.is_empty() if !segment.is_empty() {
{ parts.push(segment.to_string_lossy().into_owned());
parts.push(segment.to_string_lossy().into_owned()); }
} }
} }
parts.join(" > ") 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 unsaved_paths = collect_unsaved_relative_paths(app, &root_path);
let tree = app.file_tree(); let tree = app.file_tree();
let git_enabled = app.is_code_mode();
let entries = tree.visible_entries(); let entries = tree.visible_entries();
let render_info = compute_tree_line_info(entries, tree.nodes()); let render_info = compute_tree_line_info(entries, tree.nodes());
let icon_resolver = app.file_icons(); 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)); spans.push(Span::styled(toggle_symbol.to_string(), guide_style));
let mut git_color: Option<Color> = match node.git.badge { let mut git_color: Option<Color> = None;
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_modifiers = Modifier::empty(); let mut git_modifiers = Modifier::empty();
if let Some('D') = node.git.badge { if git_enabled {
git_modifiers |= Modifier::ITALIC; git_color = match node.git.badge {
} Some('D') => Some(Color::LightRed),
if let Some('U') = node.git.badge { Some('A') => Some(Color::LightGreen),
git_modifiers |= Modifier::BOLD; Some('R') | Some('C') => Some(Color::Yellow),
} Some('U') => Some(Color::Magenta),
if git_color.is_none() { Some('M') => Some(Color::Yellow),
git_color = match node.git.cleanliness {
'○' => Some(Color::LightYellow),
'●' => Some(Color::Yellow),
_ => None, _ => 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 { 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)); spans.push(Span::styled(node.name.clone(), name_style));
let mut marker_spans: Vec<Span<'static>> = Vec::new(); let mut marker_spans: Vec<Span<'static>> = Vec::new();
let marker_color = git_color.unwrap_or(theme.info); if git_enabled && is_unsaved {
if node.git.cleanliness != '✓' {
marker_spans.push(Span::styled("*", Style::default().fg(marker_color)));
}
if is_unsaved {
marker_spans.push(Span::styled( marker_spans.push(Span::styled(
"~", "~",
Style::default() Style::default()
@@ -778,11 +785,18 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
.add_modifier(Modifier::DIM | Modifier::ITALIC), .add_modifier(Modifier::DIM | Modifier::ITALIC),
)); ));
} }
if let Some(badge) = node.git.badge { if git_enabled {
marker_spans.push(Span::styled( if node.git.cleanliness != '✓' {
badge.to_string(), let marker_color = git_color.unwrap_or(theme.info);
Style::default().fg(marker_color), 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() { if !marker_spans.is_empty() {
@@ -1071,10 +1085,12 @@ fn compute_cursor_metrics(
break; break;
} }
if !cursor_found && let Some(last_segment) = segments.last() { if !cursor_found {
cursor_visual_row = segment_base_row + segments.len().saturating_sub(1); if let Some(last_segment) = segments.last() {
cursor_col_width = UnicodeWidthStr::width(last_segment.as_str()); cursor_visual_row = segment_base_row + segments.len().saturating_sub(1);
cursor_found = true; 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(), app.get_loading_indicator(),
&theme, &theme,
app.should_highlight_code(), app.should_highlight_code(),
app.render_markdown_enabled(),
), ),
); );
lines.extend(message_lines); 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 // Apply visual selection highlighting if in visual mode and Chat panel is focused
if matches!(app.mode(), InputMode::Visual) if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Chat)
&& matches!(app.focused_panel(), FocusedPanel::Chat)
&& 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 // 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 // Apply visual selection highlighting if in visual mode and Thinking panel is focused
if matches!(app.mode(), InputMode::Visual) if matches!(app.mode(), InputMode::Visual)
&& matches!(app.focused_panel(), FocusedPanel::Thinking) && 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 // 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(" F1 / ? → toggle help overlay"),
Line::from(" :h, :help → open help from command mode"), Line::from(" :h, :help → open help from command mode"),
Line::from(" :files, :explorer → toggle files panel"), 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 files panel"),
Line::from(" Ctrl+↑/↓ → resize chat/thinking split"), Line::from(" Ctrl+↑/↓ → resize chat/thinking split"),
Line::from(vec![Span::styled( 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(" :h, :help → show this help"),
Line::from(" F1 or ? → toggle help overlay"), Line::from(" F1 or ? → toggle help overlay"),
Line::from(" :files, :explorer → toggle files panel"), 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 files panel"),
Line::from(" Ctrl+↑/↓ → resize chat/thinking split"), Line::from(" Ctrl+↑/↓ → resize chat/thinking split"),
Line::from(" :quit → quit application"), Line::from(" :quit → quit application"),