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:
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user