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