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

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

View File

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

View File

@@ -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;
}

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);

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 }
syntect = "5.3"
once_cell = "1.19"
owlen-markdown = { path = "../owlen-markdown" }
# Async runtime
tokio = { workspace = true }

View File

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

View File

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

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) {
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;
}
}
}

View File

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

View File

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

View File

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

View File

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