Skip to content

Commit 414da9a

Browse files
committed
fix(tui): show Mermaid source without image support
1 parent 4d77991 commit 414da9a

10 files changed

Lines changed: 152 additions & 40 deletions

File tree

crates/jcode-tui-markdown/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ pub use jcode_render_core::reasoning_summary_line_markup;
150150
use render_support::{
151151
highlight_code_cached, line_plain_text, placeholder_code_block, ranges_overlap, render_table,
152152
};
153+
154+
fn should_render_mermaid_block(lang: Option<&str>) -> bool {
155+
mermaid_rendering_enabled()
156+
&& lang.map(mermaid::is_mermaid_lang).unwrap_or(false)
157+
&& mermaid::native_image_protocol_available()
158+
}
153159
pub use render_support::{highlight_file_lines, highlight_line, render_table_with_width};
154160

155161
// Syntax highlighting resources (loaded once)

crates/jcode-tui-markdown/src/markdown_latex_image.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ mod tests {
508508
assert_ne!(cache_key("x", false, 240), cache_key("x", false, 312));
509509
}
510510

511+
#[cfg(feature = "mermaid-renderer")]
511512
#[test]
512513
fn image_placeholder_extracts_math_copy_target_with_source_delimiters() {
513514
let hash = 0x1a7e_c0de_u64;

crates/jcode-tui-markdown/src/markdown_mermaid_fallback.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ pub fn image_protocol_available() -> bool {
2020
false
2121
}
2222

23+
pub fn native_image_protocol_available() -> bool {
24+
false
25+
}
26+
27+
#[cfg(test)]
28+
pub fn with_image_protocol_override<T>(_enabled: Option<bool>, f: impl FnOnce() -> T) -> T {
29+
f()
30+
}
31+
2332
pub fn get_font_size() -> Option<(u16, u16)> {
2433
None
2534
}

crates/jcode-tui-markdown/src/markdown_render_full.rs

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -408,26 +408,13 @@ pub fn render_markdown_with_width(text: &str, max_width: Option<usize>) -> Vec<L
408408
}
409409
Event::End(TagEnd::CodeBlock) => {
410410
// Check if this is a mermaid diagram
411-
let is_mermaid = mermaid_rendering_enabled()
412-
&& code_block_lang
413-
.as_ref()
414-
.map(|l| mermaid::is_mermaid_lang(l))
415-
.unwrap_or(false);
411+
let is_mermaid = should_render_mermaid_block(code_block_lang.as_deref());
416412

417413
if is_mermaid {
418414
dbg_mermaid_blocks += 1;
419415
// Render mermaid diagram.
420416
// In streaming mode this updates only the ephemeral preview entry.
421417
let terminal_width = max_width.and_then(|w| u16::try_from(w).ok());
422-
if !streaming_mode
423-
&& !mermaid_should_register_active()
424-
&& !mermaid::image_protocol_available()
425-
{
426-
lines.push(mermaid_sidebar_placeholder(
427-
"↗ mermaid diagram (image protocols unavailable)",
428-
));
429-
continue;
430-
}
431418
let result = if streaming_mode {
432419
mermaid::render_mermaid_deferred_with_stream_scope(
433420
&code_block_content,
@@ -982,10 +969,7 @@ pub fn render_markdown_with_width(text: &str, max_width: Option<usize>) -> Vec<L
982969
// Handle incomplete code block (streaming case)
983970
// If we're still inside a code block, render what we have so far
984971
if in_code_block && !code_block_content.is_empty() {
985-
let is_mermaid = code_block_lang
986-
.as_ref()
987-
.map(|l| mermaid::is_mermaid_lang(l))
988-
.unwrap_or(false);
972+
let is_mermaid = should_render_mermaid_block(code_block_lang.as_deref());
989973

990974
if is_mermaid {
991975
// For mermaid, show "rendering..." placeholder while streaming

crates/jcode-tui-markdown/src/markdown_render_lazy.rs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -371,19 +371,9 @@ pub fn render_markdown_lazy(
371371
code_block_content.clear();
372372
}
373373
Event::End(TagEnd::CodeBlock) => {
374-
let is_mermaid = mermaid_rendering_enabled()
375-
&& code_block_lang
376-
.as_ref()
377-
.map(|l| mermaid::is_mermaid_lang(l))
378-
.unwrap_or(false);
374+
let is_mermaid = should_render_mermaid_block(code_block_lang.as_deref());
379375

380376
if is_mermaid {
381-
if !mermaid_should_register_active() && !mermaid::image_protocol_available() {
382-
lines.push(mermaid_sidebar_placeholder(
383-
"↗ mermaid diagram (image protocols unavailable)",
384-
));
385-
continue;
386-
}
387377
let terminal_width = max_width.and_then(|w| u16::try_from(w).ok());
388378
let result = if deferred_mermaid_mode {
389379
mermaid::render_mermaid_deferred_with_registration(

crates/jcode-tui-markdown/src/markdown_tests/cases/rendering.rs

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,12 @@ fn test_table_cjk_alignment() {
293293

294294
#[test]
295295
fn test_mermaid_block_detection() {
296-
// Mermaid rendering is temporarily disabled by default, so Mermaid fences
297-
// should safely fall back to normal code blocks unless explicitly opted in.
296+
// Without a native image protocol, Mermaid fences stay useful as readable
297+
// source code instead of becoming a half-block image or a text stub.
298298
let md = "```mermaid\nflowchart LR\n A --> B\n```";
299-
let lines = render_markdown(md);
299+
let lines = with_mermaid_rendering_override(Some(true), || {
300+
mermaid::with_image_protocol_override(Some(false), || render_markdown(md))
301+
});
300302
let text: String = lines
301303
.iter()
302304
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
@@ -310,6 +312,64 @@ fn test_mermaid_block_detection() {
310312
text.contains("flowchart LR"),
311313
"Expected raw Mermaid source: {text}"
312314
);
315+
assert!(
316+
lines.iter().all(|line| {
317+
mermaid::parse_image_placeholder(line).is_none()
318+
&& mermaid::parse_inline_image_placeholder(line).is_none()
319+
}),
320+
"Unsupported terminals must not receive a Mermaid image placeholder: {text}"
321+
);
322+
}
323+
324+
#[cfg(feature = "mermaid-renderer")]
325+
#[test]
326+
fn lazy_mermaid_without_native_protocol_also_renders_source() {
327+
let md = "```mermaid\nflowchart TD\n Start --> Finish\n```";
328+
let lines = with_mermaid_rendering_override(Some(true), || {
329+
mermaid::with_image_protocol_override(Some(false), || {
330+
render_markdown_lazy(md, Some(80), 0..usize::MAX)
331+
})
332+
});
333+
let text = lines_to_string(&lines);
334+
335+
assert!(text.contains("┌─ mermaid"), "missing source header: {text}");
336+
assert!(text.contains("Start --> Finish"), "missing source: {text}");
337+
assert!(lines.iter().all(|line| {
338+
mermaid::parse_image_placeholder(line).is_none()
339+
&& mermaid::parse_inline_image_placeholder(line).is_none()
340+
}));
341+
}
342+
343+
#[test]
344+
fn streaming_mermaid_without_native_protocol_keeps_source_visible() {
345+
let md = "```mermaid\nflowchart TD\n Stream --> Source\n```";
346+
let lines = with_mermaid_rendering_override(Some(true), || {
347+
mermaid::with_image_protocol_override(Some(false), || {
348+
with_streaming_render_context(|| render_markdown_with_width(md, Some(80)))
349+
})
350+
});
351+
let text = lines_to_string(&lines);
352+
353+
assert!(text.contains("┌─ mermaid"), "missing source header: {text}");
354+
assert!(text.contains("Stream --> Source"), "missing source: {text}");
355+
assert!(!text.contains("rendering mermaid diagram"), "{text}");
356+
assert!(lines.iter().all(|line| {
357+
mermaid::parse_image_placeholder(line).is_none()
358+
&& mermaid::parse_inline_image_placeholder(line).is_none()
359+
}));
360+
}
361+
362+
#[cfg(feature = "mermaid-renderer")]
363+
#[test]
364+
fn mermaid_gate_accepts_native_protocol_and_rejects_halfblock_fallback() {
365+
with_mermaid_rendering_override(Some(true), || {
366+
mermaid::with_image_protocol_override(Some(false), || {
367+
assert!(!should_render_mermaid_block(Some("mermaid")));
368+
});
369+
mermaid::with_image_protocol_override(Some(true), || {
370+
assert!(should_render_mermaid_block(Some("mermaid")));
371+
});
372+
});
313373
}
314374

315375
#[test]

crates/jcode-tui-markdown/src/markdown_tests/cases/streaming_cache.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,8 @@ fn test_incremental_renderer_defers_mermaid_render_until_background_ready() {
341341

342342
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
343343
let text = "Plan:\n\n```mermaid\nflowchart LR\n A[Start] --> B[End]\n```\n";
344-
let lines = renderer.update(text);
344+
let lines =
345+
jcode_tui_mermaid::with_image_protocol_override(Some(true), || renderer.update(text));
345346
let rendered = lines_to_string(&lines);
346347

347348
assert!(
@@ -395,7 +396,8 @@ fn test_incremental_renderer_rerenders_pending_mermaid_after_epoch_bump() {
395396
// Unique content so no earlier test populated the render cache for it.
396397
let text = "Plan:\n\n```mermaid\nflowchart LR\n E1[EpochBump] --> E2[FastPath]\n```\n";
397398

398-
let lines = renderer.update(text);
399+
let lines =
400+
jcode_tui_mermaid::with_image_protocol_override(Some(true), || renderer.update(text));
399401
if !lines.iter().any(line_is_mermaid_pending_placeholder) {
400402
// Cache already warm (render finished before this update); nothing to pin.
401403
return;
@@ -407,7 +409,7 @@ fn test_incremental_renderer_rerenders_pending_mermaid_after_epoch_bump() {
407409
jcode_tui_mermaid::debug_bump_deferred_render_epoch_for_tests();
408410

409411
let before = thread_render_count();
410-
let _ = renderer.update(text);
412+
let _ = jcode_tui_mermaid::with_image_protocol_override(Some(true), || renderer.update(text));
411413
assert!(
412414
thread_render_count() > before,
413415
"identical text with an advanced deferred epoch must re-render \

crates/jcode-tui-mermaid/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,9 @@ pub use inline_image::{
252252
pub use runtime::force_test_kitty_picker;
253253
pub use runtime::{
254254
error_lines_for, get_cached_png, get_font_size, image_protocol_available, init_picker,
255-
is_video_export_mode, protocol_type, register_external_image, register_inline_image,
256-
set_video_export_mode, uses_text_image_fallback, with_image_protocol_override,
255+
is_video_export_mode, native_image_protocol_available, protocol_type, register_external_image,
256+
register_inline_image, set_video_export_mode, uses_text_image_fallback,
257+
with_image_protocol_override,
257258
};
258259
pub use viewport_render::{
259260
InlineFitReadiness, inline_fit_readiness, invalidate_render_state, prewarm_inline_fit_state,

crates/jcode-tui-mermaid/src/mermaid_runtime.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,28 @@ pub fn image_protocol_available() -> bool {
361361
PICKER.get().and_then(|p| p.as_ref()).is_some() || VIDEO_EXPORT_MODE.load(Ordering::Relaxed)
362362
}
363363

364+
fn protocol_supports_native_images(
365+
protocol: Option<ProtocolType>,
366+
video_export_mode: bool,
367+
) -> bool {
368+
video_export_mode
369+
|| matches!(
370+
protocol,
371+
Some(ProtocolType::Kitty | ProtocolType::Iterm2 | ProtocolType::Sixel)
372+
)
373+
}
374+
375+
/// Whether Mermaid and other image-only content can use a real terminal image
376+
/// protocol. Unicode half-block rendering intentionally does not count: it is a
377+
/// useful raster fallback, but Mermaid source is more useful than a degraded
378+
/// diagram on terminals without Kitty, iTerm2, or Sixel support.
379+
pub fn native_image_protocol_available() -> bool {
380+
if let Some(enabled) = IMAGE_PROTOCOL_OVERRIDE.with(|cell| cell.get()) {
381+
return enabled;
382+
}
383+
protocol_supports_native_images(protocol_type(), VIDEO_EXPORT_MODE.load(Ordering::Relaxed))
384+
}
385+
364386
fn protocol_uses_text_image_fallback(
365387
protocol: Option<ProtocolType>,
366388
video_export_mode: bool,
@@ -686,4 +708,36 @@ mod tests {
686708
true
687709
));
688710
}
711+
712+
#[test]
713+
fn native_image_capability_excludes_halfblocks_but_allows_video_export() {
714+
assert!(protocol_supports_native_images(
715+
Some(ProtocolType::Kitty),
716+
false
717+
));
718+
assert!(protocol_supports_native_images(
719+
Some(ProtocolType::Iterm2),
720+
false
721+
));
722+
assert!(protocol_supports_native_images(
723+
Some(ProtocolType::Sixel),
724+
false
725+
));
726+
assert!(!protocol_supports_native_images(
727+
Some(ProtocolType::Halfblocks),
728+
false
729+
));
730+
assert!(!protocol_supports_native_images(None, false));
731+
assert!(protocol_supports_native_images(None, true));
732+
}
733+
734+
#[test]
735+
fn native_image_capability_respects_scoped_test_override() {
736+
with_image_protocol_override(Some(false), || {
737+
assert!(!native_image_protocol_available());
738+
});
739+
with_image_protocol_override(Some(true), || {
740+
assert!(native_image_protocol_available());
741+
});
742+
}
689743
}

crates/jcode-tui/src/tui/ui_pinned_tests.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,7 @@ fn render_side_panel_markdown_multiple_mermaids_create_ordered_placements() {
601601
}
602602

603603
#[test]
604-
fn render_side_panel_markdown_without_protocol_falls_back_to_text_placeholder() {
604+
fn render_side_panel_markdown_without_native_protocol_shows_mermaid_source() {
605605
let page = sample_mermaid_page("```mermaid\nflowchart TD\n A --> B\n```\n");
606606

607607
// Pin protocol availability OFF for this thread: PICKER is a
@@ -631,8 +631,13 @@ fn render_side_panel_markdown_without_protocol_falls_back_to_text_placeholder()
631631
rendered.image_placements.len()
632632
);
633633
assert!(
634-
text.iter().any(|line| line.contains("mermaid diagram")),
635-
"expected textual placeholder when image protocols are unavailable: {:?}",
634+
text.iter().any(|line| line.contains("┌─ mermaid")),
635+
"expected a Mermaid source-code header without a native image protocol: {:?}",
636+
text
637+
);
638+
assert!(
639+
text.iter().any(|line| line.contains("flowchart TD")),
640+
"expected readable Mermaid source without a native image protocol: {:?}",
636641
text
637642
);
638643
}

0 commit comments

Comments
 (0)