Skip to content

Commit dd811c0

Browse files
committed
✨ Add syntax highlighting and improve markdown rendering in chat view
Integrate SyntaxHighlighter for code blocks to provide language-aware coloring instead of plain green text. Improve inline formatting by adding proper spacing before/after emphasis, strong, strikethrough, and links to prevent text from running together. Additional enhancements: - Support markdown tables with cell rendering and dividers - Underline links for visual distinction - Preserve leading/trailing whitespace during text wrapping - Add needs_space_before helper for clean span concatenation
1 parent 37a8a99 commit dd811c0

File tree

1 file changed

+163
-44
lines changed

1 file changed

+163
-44
lines changed

src/studio/render/chat.rs

Lines changed: 163 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use ratatui::prelude::Stylize;
55
use ratatui::style::{Modifier, Style};
66
use ratatui::text::{Line, Span};
77

8+
use crate::studio::components::syntax::SyntaxHighlighter;
89
use crate::studio::state::{ChatRole, ChatState};
910
use crate::studio::theme;
1011

@@ -200,6 +201,9 @@ pub fn format_markdown(content: &str, max_width: usize, base_style: Style) -> Ve
200201
let mut code_lang = String::new();
201202
let mut list_depth: usize = 0;
202203
let mut ordered_list_num: Option<u64> = None;
204+
let mut _in_link = false;
205+
let mut in_table = false;
206+
let mut table_row: Vec<String> = Vec::new();
203207

204208
// Enable all markdown options
205209
let options = Options::ENABLE_STRIKETHROUGH
@@ -256,14 +260,26 @@ pub fn format_markdown(content: &str, max_width: usize, base_style: Style) -> Ve
256260
}
257261
}
258262
Tag::Emphasis => {
263+
// Add space before if needed
264+
if needs_space_before(&current_spans) {
265+
current_spans.push(Span::styled(" ", base_style));
266+
}
259267
let current = style_stack.last().copied().unwrap_or(base_style);
260268
style_stack.push(current.add_modifier(Modifier::ITALIC));
261269
}
262270
Tag::Strong => {
271+
// Add space before if needed
272+
if needs_space_before(&current_spans) {
273+
current_spans.push(Span::styled(" ", base_style));
274+
}
263275
let current = style_stack.last().copied().unwrap_or(base_style);
264276
style_stack.push(current.add_modifier(Modifier::BOLD));
265277
}
266278
Tag::Strikethrough => {
279+
// Add space before if needed
280+
if needs_space_before(&current_spans) {
281+
current_spans.push(Span::styled(" ", base_style));
282+
}
267283
let current = style_stack.last().copied().unwrap_or(base_style);
268284
style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
269285
}
@@ -272,7 +288,22 @@ pub fn format_markdown(content: &str, max_width: usize, base_style: Style) -> Ve
272288
current_spans.push(Span::styled(" │ ", Style::default().fg(theme::TEXT_DIM)));
273289
}
274290
Tag::Link { .. } | Tag::Image { .. } => {
275-
style_stack.push(Style::default().fg(theme::NEON_CYAN));
291+
// Add space before link if needed
292+
if needs_space_before(&current_spans) {
293+
current_spans.push(Span::styled(" ", base_style));
294+
}
295+
_in_link = true;
296+
style_stack.push(
297+
Style::default()
298+
.fg(theme::NEON_CYAN)
299+
.add_modifier(Modifier::UNDERLINED),
300+
);
301+
}
302+
Tag::Table(_) | Tag::TableHead | Tag::TableRow => {
303+
in_table = true;
304+
}
305+
Tag::TableCell => {
306+
// Cell content handled in text
276307
}
277308
_ => {}
278309
},
@@ -304,53 +335,49 @@ pub fn format_markdown(content: &str, max_width: usize, base_style: Style) -> Ve
304335
}
305336
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
306337
style_stack.pop();
338+
// Add space after if next text might need it
339+
// (will be trimmed if next char is punctuation)
307340
}
308341
TagEnd::BlockQuote(_) => {
309342
flush_line(&mut lines, &mut current_spans, list_depth);
310343
}
311344
TagEnd::Link | TagEnd::Image => {
312345
style_stack.pop();
346+
_in_link = false;
347+
// Add space after link
348+
current_spans.push(Span::styled(" ", base_style));
349+
}
350+
TagEnd::Table => {
351+
in_table = false;
352+
flush_line(&mut lines, &mut current_spans, list_depth);
353+
lines.push(Line::from("")); // Space after table
354+
}
355+
TagEnd::TableHead | TagEnd::TableRow => {
356+
// Render the row
357+
if !table_row.is_empty() {
358+
let row_text = table_row.join(" │ ");
359+
flush_line(&mut lines, &mut current_spans, list_depth);
360+
current_spans.push(Span::styled(
361+
format!(" │ {} │", row_text),
362+
Style::default().fg(theme::TEXT_SECONDARY),
363+
));
364+
flush_line(&mut lines, &mut current_spans, list_depth);
365+
table_row.clear();
366+
}
367+
}
368+
TagEnd::TableCell => {
369+
// Cell completed - handled in text
313370
}
314371
_ => {}
315372
},
316373
Event::Text(text) => {
317374
if in_code_block {
318375
code_block_lines.extend(text.lines().map(String::from));
376+
} else if in_table {
377+
table_row.push(text.to_string());
319378
} else {
320379
let style = style_stack.last().copied().unwrap_or(base_style);
321-
322-
// Add space after inline code if text doesn't start with punctuation/space
323-
if let Some(last_span) = current_spans.last() {
324-
let last_content = last_span.content.as_ref();
325-
if last_content.ends_with('`') {
326-
let first_char = text.chars().next().unwrap_or(' ');
327-
if !first_char.is_whitespace()
328-
&& !matches!(
329-
first_char,
330-
'.' | ',' | ':' | ';' | ')' | ']' | '-' | '!' | '?'
331-
)
332-
{
333-
current_spans.push(Span::styled(" ", style));
334-
}
335-
}
336-
}
337-
338-
// Word wrap the text
339-
let effective_width = max_width.saturating_sub(4 + list_depth * 2);
340-
for chunk in wrap_text(&text, effective_width) {
341-
if !current_spans.is_empty() && !chunk.is_empty() {
342-
current_spans.push(Span::styled(chunk, style));
343-
} else if !chunk.is_empty() {
344-
// Base indent + list depth indent (aligns with bullet text)
345-
let indent = if list_depth > 0 {
346-
// 2 spaces per depth level + 2 for bullet alignment
347-
" ".repeat(list_depth) + " "
348-
} else {
349-
" ".to_string()
350-
};
351-
current_spans.push(Span::styled(format!("{}{}", indent, chunk), style));
352-
}
353-
}
380+
process_text(&text, style, &mut current_spans, list_depth, max_width);
354381
}
355382
}
356383
Event::Code(code) => {
@@ -417,6 +444,63 @@ fn flush_line(lines: &mut Vec<Line<'static>>, spans: &mut Vec<Span<'static>>, _l
417444
}
418445
}
419446

447+
/// Check if we need to add a space before a new styled element
448+
fn needs_space_before(spans: &[Span<'static>]) -> bool {
449+
if let Some(last_span) = spans.last() {
450+
let last_content = last_span.content.as_ref();
451+
!last_content.is_empty()
452+
&& !last_content.ends_with(' ')
453+
&& !last_content.ends_with('\n')
454+
&& !last_content.ends_with('(')
455+
&& !last_content.ends_with('[')
456+
&& !last_content.ends_with('"')
457+
&& !last_content.ends_with('\'')
458+
} else {
459+
false
460+
}
461+
}
462+
463+
/// Process text content with proper spacing and word wrapping
464+
fn process_text(
465+
text: &str,
466+
style: Style,
467+
current_spans: &mut Vec<Span<'static>>,
468+
list_depth: usize,
469+
max_width: usize,
470+
) {
471+
// Add space after inline code if text doesn't start with punctuation/space
472+
if let Some(last_span) = current_spans.last() {
473+
let last_content = last_span.content.as_ref();
474+
if last_content.ends_with('`') {
475+
let first_char = text.chars().next().unwrap_or(' ');
476+
if !first_char.is_whitespace()
477+
&& !matches!(
478+
first_char,
479+
'.' | ',' | ':' | ';' | ')' | ']' | '-' | '!' | '?'
480+
)
481+
{
482+
current_spans.push(Span::styled(" ", style));
483+
}
484+
}
485+
}
486+
487+
// Word wrap the text
488+
let effective_width = max_width.saturating_sub(4 + list_depth * 2);
489+
for chunk in wrap_text(text, effective_width) {
490+
if !current_spans.is_empty() && !chunk.is_empty() {
491+
current_spans.push(Span::styled(chunk, style));
492+
} else if !chunk.is_empty() {
493+
// Base indent + list depth indent (aligns with bullet text)
494+
let indent = if list_depth > 0 {
495+
" ".repeat(list_depth) + " "
496+
} else {
497+
" ".to_string()
498+
};
499+
current_spans.push(Span::styled(format!("{}{}", indent, chunk), style));
500+
}
501+
}
502+
}
503+
420504
fn render_code_block(
421505
lines: &mut Vec<Line<'static>>,
422506
code_lines: &[String],
@@ -429,16 +513,33 @@ fn render_code_block(
429513
Style::default().fg(theme::TEXT_DIM),
430514
)));
431515

516+
// Create syntax highlighter for the language
517+
let highlighter = SyntaxHighlighter::for_extension(lang);
518+
432519
for code_line in code_lines {
433520
let truncated = if code_line.len() > max_width.saturating_sub(4) {
434521
format!("{}…", &code_line[..max_width.saturating_sub(5)])
435522
} else {
436523
code_line.clone()
437524
};
438-
lines.push(Line::from(vec![
439-
Span::styled(" │ ", Style::default().fg(theme::TEXT_DIM)),
440-
Span::styled(truncated, Style::default().fg(theme::SUCCESS_GREEN)),
441-
]));
525+
526+
// Build spans for this line
527+
let mut line_spans = vec![Span::styled(" │ ", Style::default().fg(theme::TEXT_DIM))];
528+
529+
if highlighter.is_available() {
530+
// Syntax highlighted
531+
for (style, text) in highlighter.highlight_line(&truncated) {
532+
line_spans.push(Span::styled(text, style));
533+
}
534+
} else {
535+
// Fallback to plain green
536+
line_spans.push(Span::styled(
537+
truncated,
538+
Style::default().fg(theme::SUCCESS_GREEN),
539+
));
540+
}
541+
542+
lines.push(Line::from(line_spans));
442543
}
443544

444545
lines.push(Line::from(Span::styled(
@@ -453,19 +554,32 @@ fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
453554
return vec![text.to_string()];
454555
}
455556

557+
// Preserve leading/trailing whitespace
558+
let has_leading_space = text.starts_with(char::is_whitespace);
559+
let has_trailing_space = text.ends_with(char::is_whitespace);
560+
456561
let mut lines = Vec::new();
457562
let mut current_line = String::new();
563+
let mut first_word = true;
458564

459565
for word in text.split_whitespace() {
566+
// Add leading space to first word if original had it
567+
let word_to_add = if first_word && has_leading_space {
568+
first_word = false;
569+
format!(" {}", word)
570+
} else {
571+
first_word = false;
572+
word.to_string()
573+
};
460574
// Handle words longer than max_width by breaking them
461-
if word.len() > max_width {
575+
if word_to_add.len() > max_width {
462576
// Push current line if not empty
463577
if !current_line.is_empty() {
464578
lines.push(current_line);
465579
current_line = String::new();
466580
}
467581
// Break the long word into chunks
468-
let mut remaining = word;
582+
let mut remaining = word_to_add.as_str();
469583
while remaining.len() > max_width {
470584
let (chunk, rest) = remaining.split_at(max_width);
471585
lines.push(chunk.to_string());
@@ -475,16 +589,21 @@ fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
475589
current_line = remaining.to_string();
476590
}
477591
} else if current_line.is_empty() {
478-
current_line = word.to_string();
479-
} else if current_line.len() + 1 + word.len() <= max_width {
592+
current_line = word_to_add;
593+
} else if current_line.len() + 1 + word_to_add.len() <= max_width {
480594
current_line.push(' ');
481-
current_line.push_str(word);
595+
current_line.push_str(&word_to_add);
482596
} else {
483597
lines.push(current_line);
484-
current_line = word.to_string();
598+
current_line = word_to_add;
485599
}
486600
}
487601

602+
// Add trailing space if original had it
603+
if has_trailing_space && !current_line.is_empty() {
604+
current_line.push(' ');
605+
}
606+
488607
if !current_line.is_empty() {
489608
lines.push(current_line);
490609
}

0 commit comments

Comments
 (0)