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

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