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:
@@ -9,6 +9,7 @@ members = [
|
||||
"crates/owlen-mcp-client",
|
||||
"crates/owlen-mcp-code-server",
|
||||
"crates/owlen-mcp-prompt-server",
|
||||
"crates/owlen-markdown",
|
||||
]
|
||||
exclude = []
|
||||
|
||||
|
||||
@@ -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<encryption::VaultHandle> {
|
||||
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<CredentialManager>>,
|
||||
) -> Result<Option<String>> {
|
||||
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);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::collapsible_if)] // TODO: Remove once Rust 2024 let-chains are available
|
||||
|
||||
//! OWLEN CLI - Chat TUI client
|
||||
|
||||
mod cloud;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
10
crates/owlen-markdown/Cargo.toml
Normal file
10
crates/owlen-markdown/Cargo.toml
Normal 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"
|
||||
270
crates/owlen-markdown/src/lib.rs
Normal file
270
crates/owlen-markdown/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Uuid, MessageCacheEntry>, // 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<Line<'static>>,
|
||||
@@ -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<String> {
|
||||
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<Line<'static>> = 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<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));
|
||||
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<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 } => {
|
||||
@@ -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<String> {
|
||||
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<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);
|
||||
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<String> {
|
||||
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<bool> {
|
||||
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 <path>".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 <name>".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<String> = Vec::new();
|
||||
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 text_lines: Vec<String> = Vec::new();
|
||||
let mut lines = content.lines();
|
||||
@@ -8414,6 +8593,10 @@ fn parse_message_segments(content: &str) -> Vec<MessageSegment> {
|
||||
|
||||
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<String> {
|
||||
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(
|
||||
segments: Vec<(Style, String)>,
|
||||
code_width: usize,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<PathBuf
|
||||
if !pane.is_dirty {
|
||||
continue;
|
||||
}
|
||||
if let Some(abs) = pane.absolute_path()
|
||||
&& let Some(rel) = diff_paths(abs, root)
|
||||
{
|
||||
set.insert(rel);
|
||||
continue;
|
||||
if let Some(abs) = pane.absolute_path() {
|
||||
if let Some(rel) = diff_paths(abs, root) {
|
||||
set.insert(rel);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(display) = pane.display_path() {
|
||||
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 {
|
||||
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<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,
|
||||
};
|
||||
let mut git_color: Option<Color> = 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<Span<'static>> = 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"),
|
||||
|
||||
Reference in New Issue
Block a user