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

@@ -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(),
}

View File

@@ -58,9 +58,14 @@ impl ConsentManager {
/// Load consent records from vault storage
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> 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::<HashMap<String, ConsentRecord>>(consent_data.clone())
if let Some(permanent_records) =
guard
.settings()
.get("consent_records")
.and_then(|consent_data| {
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
.ok()
})
{
return Self {
permanent_records,
@@ -90,15 +95,19 @@ impl ConsentManager {
endpoints: Vec<String>,
) -> Result<ConsentScope> {
// 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);
}

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
//!
//! This crate provides the foundational abstractions for building

View File

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

View File

@@ -42,7 +42,7 @@ impl ModelManager {
F: FnOnce() -> Fut,
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);
}

View File

@@ -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"]);

View File

@@ -71,16 +71,19 @@ impl Router {
fn find_provider_for_model(&self, model: &str) -> Result<Arc<dyn Provider>> {
// 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);
}

View File

@@ -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::<u32>(), minor.parse::<u32>())
{
// --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::<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)

View File

@@ -53,8 +53,8 @@ fn extract_resource_content(value: &Value) -> Option<String> {
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<String> {
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);
}
}

View File

@@ -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<Self> {
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);