From c24cb7734f0b15b49d621b47050edae09cd51947 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Fri, 31 Jul 2026 13:08:30 +0200 Subject: [PATCH 1/4] enhance(md, config, cli): Mark wrapped table rows and truncate headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapped table cells used to render as several plain rows, so a reader could not tell where one logical row ended and the next began. A continuation line now opens with `┆` instead of `|`, making it clear that it belongs to the row above. This is controlled by the new `style.markdown.table_continuation_edge` config option (default `true`); setting it to `false` restores every line opening with `|`, which keeps the output splitting into the right columns if pasted into a markdown document, since GFM treats a row's leading `|` as optional. Header cells that exceed their column width are now truncated with an `…` marker instead of being word-wrapped. Wrapping a header pushed the separator line down, and a markdown parser reading that output would promote the header's own wrapped tail into the header row. Truncating keeps the separator on the second line. `jp_md::table::TableOptions::continuation_edge` and `jp_md::format::Formatter::table_continuation_edge` expose the new behavior to callers, and `jp_cli`'s chat renderer wires the config value through when building the formatter. Signed-off-by: Jean Mertz --- crates/jp_cli/src/render/chat.rs | 1 + crates/jp_cli/src/render/chat_tests.rs | 41 +++- .../jp_config__tests__app_config_fields.snap | 1 + ...ig__tests__partial_app_config_default.snap | 1 + ...ts__partial_app_config_default_values.snap | 3 + ...s__partial_app_config_empty_serialize.snap | 1 + crates/jp_config/src/style/markdown.rs | 29 +++ ...ompletion_stream__conversation_stream.snap | 1 + ...tool_soft_forces__conversation_stream.snap | 1 + ...image_attachment__conversation_stream.snap | 1 + ...urn_conversation__conversation_stream.snap | 1 + ...daptive_thinking__conversation_stream.snap | 1 + ...s_4_6_max_effort__conversation_stream.snap | 1 + ...edacted_thinking__conversation_stream.snap | 1 + ...request_chaining__conversation_stream.snap | 1 + ...tructured_output__conversation_stream.snap | 1 + ...t_tool_call_auto__conversation_stream.snap | 1 + ...ol_call_function__conversation_stream.snap | 1 + ...l_call_reasoning__conversation_stream.snap | 1 + ...red_no_reasoning__conversation_stream.snap | 1 + ...quired_reasoning__conversation_stream.snap | 1 + ...tool_call_stream__conversation_stream.snap | 1 + ...ompletion_stream__conversation_stream.snap | 1 + ...urn_conversation__conversation_stream.snap | 1 + ...tructured_output__conversation_stream.snap | 1 + ...t_tool_call_auto__conversation_stream.snap | 1 + ...ol_call_function__conversation_stream.snap | 1 + ...l_call_reasoning__conversation_stream.snap | 1 + ...red_no_reasoning__conversation_stream.snap | 1 + ...quired_reasoning__conversation_stream.snap | 1 + ...tool_call_stream__conversation_stream.snap | 1 + ...uto_omits_effort__conversation_stream.snap | 1 + ...l_off_sends_none__conversation_stream.snap | 1 + ...ompletion_stream__conversation_stream.snap | 1 + ...mini_3_reasoning__conversation_stream.snap | 1 + ...image_attachment__conversation_stream.snap | 1 + ...urn_conversation__conversation_stream.snap | 1 + ...tructured_output__conversation_stream.snap | 1 + ...t_tool_call_auto__conversation_stream.snap | 1 + ...ol_call_function__conversation_stream.snap | 1 + ...l_call_reasoning__conversation_stream.snap | 1 + ...red_no_reasoning__conversation_stream.snap | 1 + ...quired_reasoning__conversation_stream.snap | 1 + ...tool_call_stream__conversation_stream.snap | 1 + ...d_thinking_level__conversation_stream.snap | 1 + ...ompletion_stream__conversation_stream.snap | 1 + ...image_attachment__conversation_stream.snap | 1 + ...urn_conversation__conversation_stream.snap | 1 + ...tructured_output__conversation_stream.snap | 1 + ...t_tool_call_auto__conversation_stream.snap | 1 + ...ol_call_function__conversation_stream.snap | 1 + ...l_call_reasoning__conversation_stream.snap | 1 + ...red_no_reasoning__conversation_stream.snap | 1 + ...quired_reasoning__conversation_stream.snap | 1 + ...tool_call_stream__conversation_stream.snap | 1 + ...ompletion_stream__conversation_stream.snap | 1 + ...image_attachment__conversation_stream.snap | 1 + ...urn_conversation__conversation_stream.snap | 1 + ...tructured_output__conversation_stream.snap | 1 + ...t_tool_call_auto__conversation_stream.snap | 1 + ...ol_call_function__conversation_stream.snap | 1 + ...l_call_reasoning__conversation_stream.snap | 1 + ...red_no_reasoning__conversation_stream.snap | 1 + ...quired_reasoning__conversation_stream.snap | 1 + ...tool_call_stream__conversation_stream.snap | 1 + ...ompletion_stream__conversation_stream.snap | 1 + ..._explicit_optout__conversation_stream.snap | 1 + ...explicit_caching__conversation_stream.snap | 1 + ...read_after_write__conversation_stream.snap | 1 + ...image_attachment__conversation_stream.snap | 1 + ...urn_conversation__conversation_stream.snap | 1 + ...nsupported_model__conversation_stream.snap | 1 + ...tructured_output__conversation_stream.snap | 1 + ...t_tool_call_auto__conversation_stream.snap | 1 + ...ol_call_function__conversation_stream.snap | 1 + ...l_call_reasoning__conversation_stream.snap | 1 + ...red_no_reasoning__conversation_stream.snap | 1 + ...quired_reasoning__conversation_stream.snap | 1 + ...tool_call_stream__conversation_stream.snap | 1 + ...r_event_metadata__conversation_stream.snap | 1 + ...r_event_metadata__conversation_stream.snap | 1 + ...r_event_metadata__conversation_stream.snap | 1 + ...ompletion_stream__conversation_stream.snap | 1 + ...image_attachment__conversation_stream.snap | 1 + ...urn_conversation__conversation_stream.snap | 1 + ...tructured_output__conversation_stream.snap | 1 + ...t_tool_call_auto__conversation_stream.snap | 1 + ...ol_call_function__conversation_stream.snap | 1 + ...l_call_reasoning__conversation_stream.snap | 1 + ...red_no_reasoning__conversation_stream.snap | 1 + ...quired_reasoning__conversation_stream.snap | 1 + ...tool_call_stream__conversation_stream.snap | 1 + ...r_event_metadata__conversation_stream.snap | 1 + crates/jp_md/src/format.rs | 16 +- crates/jp_md/src/format_tests.rs | 6 +- crates/jp_md/src/table.rs | 108 ++++++++- crates/jp_md/src/table_tests.rs | 209 ++++++++++++++++-- 97 files changed, 467 insertions(+), 35 deletions(-) diff --git a/crates/jp_cli/src/render/chat.rs b/crates/jp_cli/src/render/chat.rs index 6442e31a..cd8785a5 100644 --- a/crates/jp_cli/src/render/chat.rs +++ b/crates/jp_cli/src/render/chat.rs @@ -974,6 +974,7 @@ fn formatter_from_config( Formatter::with_width(wrap_width(config, terminal_width)) .terminal_width(terminal_width.map_or(0, usize::from)) .table_max_column_width(config.markdown.table_max_column_width) + .table_continuation_edge(config.markdown.table_continuation_edge) .theme(theme_name) .pretty_hr(pretty && config.markdown.hr_style.is_line()) .inline_code_bg( diff --git a/crates/jp_cli/src/render/chat_tests.rs b/crates/jp_cli/src/render/chat_tests.rs index a49357c1..a3340707 100644 --- a/crates/jp_cli/src/render/chat_tests.rs +++ b/crates/jp_cli/src/render/chat_tests.rs @@ -150,7 +150,11 @@ fn test_table_is_fitted_to_the_printers_terminal_width() { renderer.printer.flush(); let rendered = strip_ansi(&out.lock()); - let rows: Vec<&str> = rendered.lines().filter(|l| l.starts_with('|')).collect(); + // Wrapped rows continue on a line opening with `┆` rather than `|`. + let rows: Vec<&str> = rendered + .lines() + .filter(|l| l.starts_with('|') || l.starts_with('┆')) + .collect(); assert!(rows.len() > 3, "expected wrapped rows:\n{rendered}"); for row in rows { assert_eq!( @@ -161,6 +165,41 @@ fn test_table_is_fitted_to_the_printers_terminal_width() { } } +/// The continuation edge is configurable, so the rendered table has to follow +/// `style.markdown.table_continuation_edge` rather than a hardcoded default. +#[test] +fn test_table_continuation_edge_follows_the_config() { + let mut config = AppConfig::new_test(); + config.style.markdown.wrap_width = 80; + config.style.markdown.table_max_column_width = 40; + config.style.markdown.table_continuation_edge = false; + + let (printer, out, _err) = Printer::memory(OutputFormat::Text); + let mut renderer = ChatRenderer::new( + Arc::new(printer.with_output_width(OutputWidth::Terminal(30))), + config.style, + ); + + renderer.render_response(&ChatResponse::Message { + message: "| Alpha heading | Beta heading |\n| --- | --- |\n| first cell content | second \ + cell content |\n\n" + .into(), + }); + renderer.flush(); + renderer.printer.flush(); + + // The fourth line means the data row wrapped, so there is a continuation + // line for the setting to act on. + let rendered = strip_ansi(&out.lock()); + let rows: Vec<&str> = rendered.lines().filter(|l| l.contains('|')).collect(); + assert_eq!(rows, vec![ + "| Alpha headi… | Beta headi… |", + "|--------------|-------------|", + "| first cell | second cell |", + "| content | content |", + ]); +} + #[test] fn test_renders_message() { let (mut renderer, out, _err) = create_renderer(); diff --git a/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap b/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap index f123dbc3..0ec8e6b0 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap @@ -26,6 +26,7 @@ expression: "AppConfig::fields()" "style.reasoning.extend_across_tool_calls", "style.reasoning.summary_model", "style.markdown.hr_style", + "style.markdown.table_continuation_edge", "style.markdown.table_max_column_width", "style.markdown.theme", "style.markdown.wrap_width", diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap index 3d86a796..7ce60390 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap @@ -102,6 +102,7 @@ PartialAppConfig { markdown: PartialMarkdownConfig { wrap_width: None, table_max_column_width: None, + table_continuation_edge: None, theme: None, hr_style: None, }, diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap index d3b2b008..92c8747c 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap @@ -198,6 +198,9 @@ Ok( table_max_column_width: Some( 40, ), + table_continuation_edge: Some( + true, + ), theme: Some( "gruvbox-dark", ), diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap index 7a5dae78..66c22abf 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap @@ -102,6 +102,7 @@ PartialAppConfig { markdown: PartialMarkdownConfig { wrap_width: None, table_max_column_width: None, + table_continuation_edge: None, theme: None, hr_style: None, }, diff --git a/crates/jp_config/src/style/markdown.rs b/crates/jp_config/src/style/markdown.rs index 02ea501e..76088578 100644 --- a/crates/jp_config/src/style/markdown.rs +++ b/crates/jp_config/src/style/markdown.rs @@ -53,6 +53,8 @@ pub struct MarkdownConfig { /// Set to `0` to leave columns as wide as their content. /// /// Cells exceeding their column's width are wrapped over multiple lines. + /// A line continuing the row above opens with `┆` instead of `|`, so a + /// wrapped row reads as one row rather than several. /// A column can end up narrower than this: a table wider than the terminal /// has its widest columns narrowed until it fits, so the terminal does not /// break the rows apart. @@ -64,6 +66,19 @@ pub struct MarkdownConfig { #[setting(default = 40)] pub table_max_column_width: usize, + /// Whether the continuation lines of a wrapped table row open with `┆`. + /// + /// Defaults to `true`. + /// Set to `false` to open every line with `|`. + /// + /// A cell wrapped over several lines otherwise reads as several one-line + /// rows, since nothing distinguishes the start of a row from the middle of + /// one. + /// Only the line's opening delimiter changes, so a table copied out of the + /// terminal into a markdown document still splits into the right columns. + #[setting(default = true)] + pub table_continuation_edge: bool, + /// Syntax highlighting theme for code blocks. /// /// Uses `bat` / `syntect` theme names (e.g. `"Monokai Extended"`, @@ -88,6 +103,9 @@ impl AssignKeyValue for PartialMarkdownConfig { "table_max_column_width" => { self.table_max_column_width = kv.try_some_from_str()?; } + "table_continuation_edge" => { + self.table_continuation_edge = kv.try_some_from_str()?; + } "theme" => self.theme = kv.try_some_from_str()?, "hr_style" => self.hr_style = kv.try_some_from_str()?, _ => return missing_key(&kv), @@ -105,6 +123,10 @@ impl PartialConfigDelta for PartialMarkdownConfig { self.table_max_column_width.as_ref(), next.table_max_column_width, ), + table_continuation_edge: delta_opt( + self.table_continuation_edge.as_ref(), + next.table_continuation_edge, + ), theme: delta_opt(self.theme.as_ref(), next.theme), hr_style: delta_opt(self.hr_style.as_ref(), next.hr_style), } @@ -118,6 +140,9 @@ impl FillDefaults for PartialMarkdownConfig { table_max_column_width: self .table_max_column_width .or(defaults.table_max_column_width), + table_continuation_edge: self + .table_continuation_edge + .or(defaults.table_continuation_edge), theme: self.theme.or(defaults.theme), hr_style: self.hr_style.or(defaults.hr_style), } @@ -134,6 +159,10 @@ impl ToPartial for MarkdownConfig { &self.table_max_column_width, defaults.table_max_column_width, ), + table_continuation_edge: partial_opt( + &self.table_continuation_edge, + defaults.table_continuation_edge, + ), theme: partial_opts(self.theme.as_ref(), defaults.theme), hr_style: partial_opt(&self.hr_style, defaults.hr_style), } diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap index f982c177..25c5f697 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap index 33b8e6d1..8c3c3df0 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap index 86019fd7..3a5143fb 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap index 8145d87e..e74c7820 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap index 21a37d72..d22550d4 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap index d7af195b..c447779f 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap index deb659e7..adad1881 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap index f7c9ce84..dafb4b7a 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap @@ -101,6 +101,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap index 7d215e97..a2a160ee 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap index 66268450..682fa840 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap index 7f21e823..e59ff0a5 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap index d9fe2752..b16056cf 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap index 3ea618f3..d2804109 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap index c167ff1d..82c07817 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap index ebcd1f99..57f10707 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap index 946fca8b..0f069c74 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap index 4e443ae3..4fb8f621 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap index 621abb44..6ac32506 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap index 39754b73..cf53d05f 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap index ad8f8f98..1354bb36 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap index 3a43383e..59175955 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap index 0298c446..40d4f027 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap index 88ed7dcb..849a4584 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap index 2731458a..77d1b1ca 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap index c5a80173..b12a27d8 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap index e6e43424..5957f27d 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap index b7573c83..de735a2e 100644 --- a/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap index 68dbba02..0b85ab75 100644 --- a/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap index c71a5e6f..cc6e8e3d 100644 --- a/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap index cf0082b2..f53ccb6b 100644 --- a/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap index d7c4bdc1..fe81529a 100644 --- a/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap index cbe83e65..1435dc0a 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap index 2ab1be2c..6cf231f5 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap index 6296de24..79c761bf 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap index 43fa99c7..2bcef9c1 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap index e5d3cf7f..a6a36c15 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap index 5b5a1f73..3645a7b4 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap index bfb999ae..99bb4ec7 100644 --- a/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap index 1ea674ba..f8c57b85 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap index 7b94ace2..99f2bba6 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap index 046b7f93..698dc9d3 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap index accd0aff..e5dcd215 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap index ed25220c..f7f52db6 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap index 2cfe43fc..35733979 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap index fe65e74d..ccb58ab6 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap index 5edfa1ea..d0de08ef 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap index d83d732d..50c43bd8 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap index f1c85a1b..067d19e3 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap index 50bbb747..74035e0b 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap index 9bf3c44d..71a7b069 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap index e2e8e8ed..5f5d1b59 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap index 4651b8af..ce257b4a 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap index 063655e8..a1855276 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap index 723c1a17..aeacce92 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap index d29a9df9..38b36d85 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap index 5fa599d4..2c2ecd46 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap index 2fdd8b5e..de600c9f 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap index 01a022ac..98225380 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap index ca81b0c3..92b649ca 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap index c9d2b1d9..9a2ade54 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap index f7f34cd7..cf7b790a 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap @@ -101,6 +101,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap index 9530ff5a..e95a5fe7 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap index 580aefa8..f15335f3 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap index 52975aeb..25a4237f 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap index 3fabe9b6..08f0d703 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap index 63612fd6..64b50f1c 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap index 59706744..b60c313f 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap index 715f4343..d5681c00 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap index f70c44ab..57ac9bff 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap index 31e273cf..7971cbc7 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap index b8a0ec80..286fa32a 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap index 1a22fee1..a038aab5 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap index b29793a3..bf695548 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap index 92f75ec5..9915898a 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap index 16ed7e03..b5feb724 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap index 5a911405..8ff53200 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap index d821617f..64329aed 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap index f563f292..d5727ae6 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap index 12b4ab10..b2996a85 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap index 1dff10ca..401161b1 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap index bc5164fc..ba87b782 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap index 4d0184b7..00c83cb7 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap index e6cb6dcf..e0a7cdd8 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap index 73c549d5..d4f5ead8 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap index af20aabe..2cc86480 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap @@ -96,6 +96,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap index 7501f44c..6f175b68 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap @@ -99,6 +99,7 @@ expression: v "markdown": { "wrap_width": 80, "table_max_column_width": 40, + "table_continuation_edge": true, "theme": "gruvbox-dark", "hr_style": "line" }, diff --git a/crates/jp_md/src/format.rs b/crates/jp_md/src/format.rs index 2f984149..01b9ee02 100644 --- a/crates/jp_md/src/format.rs +++ b/crates/jp_md/src/format.rs @@ -116,6 +116,9 @@ pub struct Formatter { /// `0` = unlimited. table_max_column_width: usize, + /// Whether the continuation lines of a wrapped table row are marked. + table_continuation_edge: bool, + /// Resolved syntax highlighting theme. theme: Theme, @@ -142,6 +145,7 @@ impl fmt::Debug for Formatter { f.debug_struct("Formatter") .field("width", &self.width) .field("table_max_column_width", &self.table_max_column_width) + .field("table_continuation_edge", &self.table_continuation_edge) .field("theme", &"") .field("hr_style", &self.hr_style) .field("terminal_width", &self.terminal_width) @@ -163,6 +167,7 @@ impl Formatter { Self { width: DEFAULT_WIDTH, table_max_column_width: DEFAULT_TABLE_MAX_COL_WIDTH, + table_continuation_edge: true, theme: theme::resolve(None), hr_style: HrStyle::default(), terminal_width: None, @@ -178,6 +183,7 @@ impl Formatter { Self { width, table_max_column_width: DEFAULT_TABLE_MAX_COL_WIDTH, + table_continuation_edge: true, theme: theme::resolve(None), hr_style: HrStyle::default(), terminal_width: None, @@ -192,6 +198,13 @@ impl Formatter { self } + /// Set whether the continuation lines of a wrapped table row are marked. + #[must_use] + pub const fn table_continuation_edge(mut self, enabled: bool) -> Self { + self.table_continuation_edge = enabled; + self + } + /// Set the theme. #[must_use] pub fn theme(mut self, theme: Option<&str>) -> Self { @@ -286,7 +299,8 @@ impl Formatter { let comrak_options = self.parse_options(); let arena = Arena::new(); let ast = comrak::parse_document(&arena, text, &comrak_options); - let table_options = TableOptions::new(self.table_max_column_width); + let table_options = TableOptions::new(self.table_max_column_width) + .continuation_edge(self.table_continuation_edge); let hr_options = HrOptions { style: self.hr_style, }; diff --git a/crates/jp_md/src/format_tests.rs b/crates/jp_md/src/format_tests.rs index 6004b23e..3b434ebb 100644 --- a/crates/jp_md/src/format_tests.rs +++ b/crates/jp_md/src/format_tests.rs @@ -1352,7 +1352,11 @@ fn test_table_is_fitted_to_the_terminal_width() { let actual = formatter.format_terminal(input).unwrap(); - let rows: Vec<&str> = actual.lines().filter(|l| l.starts_with('|')).collect(); + // Wrapped rows continue on a line opening with `┆` rather than `|`. + let rows: Vec<&str> = actual + .lines() + .filter(|l| l.starts_with('|') || l.starts_with('┆')) + .collect(); assert!(rows.len() > 3, "expected wrapped rows:\n{actual}"); for row in rows { assert_eq!( diff --git a/crates/jp_md/src/table.rs b/crates/jp_md/src/table.rs index 6e776be1..66985254 100644 --- a/crates/jp_md/src/table.rs +++ b/crates/jp_md/src/table.rs @@ -9,7 +9,9 @@ //! that fits keeps its natural widths, and one that does not has its widest //! columns narrowed until the whole table fits. //! Cell content that exceeds its fitted column width is word-wrapped across -//! multiple visual rows, preserving ANSI formatting state across line breaks. +//! multiple visual lines, preserving ANSI formatting state across line breaks. +//! A line that continues the row above opens with [`CONTINUATION_EDGE`] rather +//! than `|`, so a wrapped row reads as one row instead of several. //! //! # Usage //! @@ -39,6 +41,18 @@ const MIN_COLUMN_WIDTH: usize = 3; /// space on either side of the cell. const COLUMN_CHROME: usize = 3; +/// Opens a line that continues the row above rather than starting a new one. +/// +/// Only the row's opening delimiter takes this glyph; the inner and trailing +/// `|` stay put. +/// GFM treats a row's leading `|` as optional, so a continuation line pasted +/// into a markdown document still splits into the right columns on the pipes +/// that remain. +const CONTINUATION_EDGE: char = '┆'; + +/// Marks a header cell cut short because it did not fit its column. +const TRUNCATION_MARKER: char = '…'; + /// Options for table formatting. pub struct TableOptions { /// Upper bound on the visual width of any single column. @@ -48,12 +62,27 @@ pub struct TableOptions { /// A column can still end up narrower than this, when the table would /// otherwise not fit the available width. pub max_column_width: usize, + + /// Whether a line continuing the row above opens with [`CONTINUATION_EDGE`] + /// instead of `|`. + pub continuation_edge: bool, } impl TableOptions { - /// Create a new `TableOptions` with the given column width. + /// Create a new `TableOptions` with the given column width, marking the + /// continuation lines of wrapped rows. pub const fn new(max_column_width: usize) -> Self { - Self { max_column_width } + Self { + max_column_width, + continuation_edge: true, + } + } + + /// Set whether continuation lines are marked. + #[must_use] + pub const fn continuation_edge(mut self, enabled: bool) -> Self { + self.continuation_edge = enabled; + self } } @@ -73,7 +102,8 @@ impl TableOptions { /// `width: 0` to disable wrapping inside cells). /// 3. Computes natural visual column widths (ignoring ANSI bytes) and fits them /// to `budget`. -/// 4. Word-wraps cells that exceed their fitted column width. +/// 4. Word-wraps cells that exceed their fitted column width, opening each +/// continuation line with [`CONTINUATION_EDGE`]. /// 5. Pads and aligns cells according to the table's alignment markers. pub fn format_table( node: Node<'_>, @@ -101,18 +131,31 @@ pub fn format_table( let mut out = String::new(); for (row_idx, row) in rows.iter().enumerate() { - // Wrap each cell's content into lines that fit the column width. + let is_header = row_idx == 0; + + // Wrap each cell's content into lines that fit the column width. The + // header is truncated to a single line instead: wrapping it would push + // the separator off the second line, and a markdown parser reading this + // output then promotes the header's own tail to the header row. let wrapped: Vec> = (0..num_cols) .map(|col| { let content = row.get(col).map_or("", |c| c.rendered.as_str()); - wrap_to_visual_width(content, col_widths[col]) + if is_header { + vec![truncate_to_visual_width(content, col_widths[col])] + } else { + wrap_to_visual_width(content, col_widths[col]) + } }) .collect(); let max_lines = wrapped.iter().map(Vec::len).max().unwrap_or(1); for line_idx in 0..max_lines { - out.push('|'); + if line_idx == 0 || !options.table_options.continuation_edge { + out.push('|'); + } else { + out.push(CONTINUATION_EDGE); + } for (col, col_lines) in wrapped.iter().enumerate() { if col >= num_cols { break; @@ -126,7 +169,7 @@ pub fn format_table( } // Separator line after header row. - if row_idx == 0 { + if is_header { out.push('|'); for (col, align) in alignments.iter().enumerate() { let w = col_widths[col]; @@ -323,6 +366,55 @@ fn pad_cell(content: &str, target_width: usize, alignment: TableAlignment) -> St } } +/// Truncate a string (possibly containing ANSI escapes) to a maximum visual +/// width, marking the cut with [`TRUNCATION_MARKER`]. +/// +/// Content that already fits, and any content at all when `max_width` is `0`, +/// is returned unchanged. +/// The marker takes the last column, so `max_width` still bounds the result. +/// ANSI state left open at the cut is closed, so styling does not leak into the +/// rest of the line. +fn truncate_to_visual_width(content: &str, max_width: usize) -> String { + if max_width == 0 || ansi::visual_width(content) <= max_width { + return content.to_string(); + } + + let keep = max_width - 1; + let mut out = String::new(); + let mut state = AnsiState::default(); + + 'segments: for segment in ansi::segments(content) { + let text = match segment { + Segment::Escape(escape) => { + state.update(escape); + out.push_str(escape); + continue; + } + Segment::Text(text) => text, + }; + + for c in text.chars() { + out.push(c); + if ansi::visual_width(&out) > keep { + out.pop(); + break 'segments; + } + } + } + + // An escape never ends in a space, so this only trims visible padding. + while out.ends_with(' ') { + out.pop(); + } + + out.push(TRUNCATION_MARKER); + if state.is_active() { + out.push_str(RESET); + } + + out +} + /// Word-wrap a string (possibly containing ANSI escapes) to a maximum visual /// width. /// diff --git a/crates/jp_md/src/table_tests.rs b/crates/jp_md/src/table_tests.rs index bf1378be..6fe715d6 100644 --- a/crates/jp_md/src/table_tests.rs +++ b/crates/jp_md/src/table_tests.rs @@ -1,6 +1,19 @@ use super::*; use crate::format::HrStyle; +/// Character positions of the column edges on a rendered line. +/// +/// A continuation line opens with [`CONTINUATION_EDGE`] instead of `|`, and +/// that glyph is three bytes wide, so edges are counted in characters rather +/// than byte offsets. +fn column_edges(line: &str) -> Vec { + line.chars() + .enumerate() + .filter(|(_, c)| *c == '|' || *c == CONTINUATION_EDGE) + .map(|(i, _)| i) + .collect() +} + #[test] fn test_pad_cell_left() { assert_eq!(pad_cell("hi", 10, TableAlignment::Left), "hi "); @@ -90,6 +103,36 @@ fn test_wrap_ansi_state_continues() { ); } +#[test] +fn test_truncate_fits_unchanged() { + assert_eq!(truncate_to_visual_width("hello", 10), "hello"); + assert_eq!(truncate_to_visual_width("hello", 5), "hello"); +} + +#[test] +fn test_truncate_unlimited() { + assert_eq!(truncate_to_visual_width("hello world", 0), "hello world"); +} + +#[test] +fn test_truncate_marks_the_cut() { + // The marker takes the last column, so the result still fits the width. + assert_eq!(truncate_to_visual_width("abcdefghij", 5), "abcd…"); +} + +#[test] +fn test_truncate_drops_the_space_before_the_marker() { + assert_eq!(truncate_to_visual_width("ab cdef", 4), "ab…"); +} + +#[test] +fn test_truncate_closes_open_ansi_state() { + // Bold opens before the cut, so it has to be closed or it bleeds into the + // rest of the line. + let input = "\x1b[1m**bold text**\x1b[22m"; + assert_eq!(truncate_to_visual_width(input, 6), "\x1b[1m**bol…\x1b[0m"); +} + #[test] fn test_fit_columns_unbounded_keeps_natural_widths() { assert_eq!(fit_columns(&[5, 10], 0, None), vec![5, 10]); @@ -245,20 +288,15 @@ fn test_format_simple_table() { assert!(line.ends_with('|'), "line should end with |: {line}"); } - // Pipe positions should be consistent across all content rows. - let pipe_positions: Vec> = lines + // Column edges should be consistent across all content rows. + let edges: Vec> = lines .iter() .enumerate() .filter(|(i, _)| *i != 1) // skip separator - .map(|(_, line)| { - line.char_indices() - .filter(|(_, c)| *c == '|') - .map(|(i, _)| i) - .collect() - }) + .map(|(_, line)| column_edges(line)) .collect(); - for (i, pos) in pipe_positions.iter().enumerate().skip(1) { - assert_eq!(*pos, pipe_positions[0], "pipe positions differ at row {i}"); + for (i, row_edges) in edges.iter().enumerate().skip(1) { + assert_eq!(*row_edges, edges[0], "column edges differ at row {i}"); } } @@ -317,7 +355,7 @@ fn test_format_table_with_wrapping() { let plain: String = result .lines() .flat_map(|l| l.chars()) - .filter(|c| !c.is_control() && *c != '|') + .filter(|c| !c.is_control() && *c != '|' && *c != CONTINUATION_EDGE) .collect(); let normalized: String = plain.split_whitespace().collect::>().join(" "); assert!( @@ -361,27 +399,19 @@ fn test_format_table_wrapping_respects_alignment() { ) .expect("should format"); - // All data lines should have consistent pipe positions. + // All data lines should have consistent column edge positions. let data_lines: Vec<&str> = result .lines() .enumerate() .filter(|(i, _)| *i != 1) // skip separator .map(|(_, l)| l) .collect(); - let first_pipes: Vec = data_lines[0] - .char_indices() - .filter(|(_, c)| *c == '|') - .map(|(i, _)| i) - .collect(); + let first_edges = column_edges(data_lines[0]); for (i, line) in data_lines.iter().enumerate().skip(1) { - let pipes: Vec = line - .char_indices() - .filter(|(_, c)| *c == '|') - .map(|(i, _)| i) - .collect(); assert_eq!( - pipes, first_pipes, - "pipe positions differ at data line {i}: {line:?}" + column_edges(line), + first_edges, + "column edges differ at data line {i}: {line:?}" ); } @@ -401,6 +431,137 @@ fn test_format_table_wrapping_respects_alignment() { ); } +/// A line that continues the row above opens with the continuation edge instead +/// of `|`, so a reader can tell a wrapped row from the next one. +/// Only the opening delimiter changes: GFM makes a row's leading `|` optional, +/// so a continuation line pasted into a markdown document still splits into the +/// right columns on the pipes that remain. +#[test] +fn test_wrapped_rows_open_with_the_continuation_edge() { + let arena = comrak::Arena::new(); + let options = comrak::Options { + extension: comrak::options::Extension { + table: true, + ..Default::default() + }, + ..Default::default() + }; + let input = "| Name | Desc |\n| --- | --- |\n| a | one two three |\n"; + let root = comrak::parse_document(&arena, input, &options); + let table_node = root.first_child().expect("should have table"); + let theme = crate::theme::resolve(None); + let opts = TableOptions::new(9); + let hr_opts = crate::render::HrOptions { + style: HrStyle::Markdown, + }; + let result = format_table( + table_node, + RenderOptions { + width: 0, + terminal_width: None, + table_options: &opts, + hr_options: &hr_opts, + theme: &theme, + default_background: None, + inline_code_bg: None, + indent: 0, + }, + None, + ) + .expect("should format"); + + assert_eq!( + result, + "| Name | Desc |\n|------|-----------|\n| a | one two |\n┆ | three |\n" + ); +} + +/// Opting out returns every line to `|`, for output that has to stay a valid +/// markdown table row by row. +#[test] +fn test_continuation_edge_can_be_disabled() { + let arena = comrak::Arena::new(); + let options = comrak::Options { + extension: comrak::options::Extension { + table: true, + ..Default::default() + }, + ..Default::default() + }; + let input = "| Name | Desc |\n| --- | --- |\n| a | one two three |\n"; + let root = comrak::parse_document(&arena, input, &options); + let table_node = root.first_child().expect("should have table"); + let theme = crate::theme::resolve(None); + let opts = TableOptions::new(9).continuation_edge(false); + let hr_opts = crate::render::HrOptions { + style: HrStyle::Markdown, + }; + let result = format_table( + table_node, + RenderOptions { + width: 0, + terminal_width: None, + table_options: &opts, + hr_options: &hr_opts, + theme: &theme, + default_background: None, + inline_code_bg: None, + indent: 0, + }, + None, + ) + .expect("should format"); + + assert_eq!( + result, + "| Name | Desc |\n|------|-----------|\n| a | one two |\n| | three |\n" + ); +} + +/// A header cell wider than its column is truncated, not wrapped, so the +/// separator stays on the second line. +/// A wrapped header pushes the separator down, and a markdown parser reading +/// that output promotes the header's own tail to the header row. +#[test] +fn test_wide_header_is_truncated_not_wrapped() { + let arena = comrak::Arena::new(); + let options = comrak::Options { + extension: comrak::options::Extension { + table: true, + ..Default::default() + }, + ..Default::default() + }; + let input = "| Alpha heading | Beta |\n| --- | --- |\n| a | b |\n"; + let root = comrak::parse_document(&arena, input, &options); + let table_node = root.first_child().expect("should have table"); + let theme = crate::theme::resolve(None); + let opts = TableOptions::new(9); + let hr_opts = crate::render::HrOptions { + style: HrStyle::Markdown, + }; + let result = format_table( + table_node, + RenderOptions { + width: 0, + terminal_width: None, + table_options: &opts, + hr_options: &hr_opts, + theme: &theme, + default_background: None, + inline_code_bg: None, + indent: 0, + }, + None, + ) + .expect("should format"); + + assert_eq!( + result, + "| Alpha he… | Beta |\n|-----------|------|\n| a | b |\n" + ); +} + #[test] fn test_format_aligned_table() { let arena = comrak::Arena::new(); From bc8998864f7d921d94eeca4b8346141a3a1f2d31 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Fri, 31 Jul 2026 14:02:08 +0200 Subject: [PATCH 2/4] review feedback Signed-off-by: Jean Mertz --- Cargo.lock | 1 + crates/jp_config/src/style/markdown.rs | 6 +- crates/jp_md/Cargo.toml | 1 + crates/jp_md/src/ansi.rs | 9 +++ crates/jp_md/src/shade.rs | 7 +- crates/jp_md/src/table.rs | 103 ++++++++++++++++++++----- crates/jp_md/src/table_tests.rs | 49 ++++++++++++ 7 files changed, 151 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b563f035..e47613db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2467,6 +2467,7 @@ dependencies = [ "proptest", "syntect", "two-face", + "unicode-segmentation", "unicode-width", ] diff --git a/crates/jp_config/src/style/markdown.rs b/crates/jp_config/src/style/markdown.rs index 76088578..d287d708 100644 --- a/crates/jp_config/src/style/markdown.rs +++ b/crates/jp_config/src/style/markdown.rs @@ -52,9 +52,13 @@ pub struct MarkdownConfig { /// Defaults to `40`. /// Set to `0` to leave columns as wide as their content. /// - /// Cells exceeding their column's width are wrapped over multiple lines. + /// Body cells exceeding their column's width are wrapped over multiple + /// lines. /// A line continuing the row above opens with `┆` instead of `|`, so a /// wrapped row reads as one row rather than several. + /// A header cell is cut short with `…` rather than wrapped, so the row of + /// dashes stays directly beneath the header and the table survives being + /// copied out of the terminal into a markdown document. /// A column can end up narrower than this: a table wider than the terminal /// has its widest columns narrowed until it fits, so the terminal does not /// break the rows apart. diff --git a/crates/jp_md/Cargo.toml b/crates/jp_md/Cargo.toml index 12c59c44..d5ed2805 100644 --- a/crates/jp_md/Cargo.toml +++ b/crates/jp_md/Cargo.toml @@ -16,6 +16,7 @@ version.workspace = true comrak = { workspace = true } syntect = { workspace = true, features = ["regex-fancy", "default-themes", "default-syntaxes"] } two-face = { workspace = true, features = ["syntect-fancy"] } +unicode-segmentation = { workspace = true } unicode-width = { workspace = true } [dev-dependencies] diff --git a/crates/jp_md/src/ansi.rs b/crates/jp_md/src/ansi.rs index f3678e05..bb33fddc 100644 --- a/crates/jp_md/src/ansi.rs +++ b/crates/jp_md/src/ansi.rs @@ -227,6 +227,15 @@ fn consume_color<'a, I: Iterator>(prefix: &str, tokens: &mut I) } } +/// Whether `esc` is an SGR sequence (`\x1b[…m`). +/// +/// SGR is the only family [`AnsiState`] tracks, so this doubles as the test for +/// whether an escape's effect can be closed with [`RESET`] or re-opened after a +/// line break. +pub fn is_sgr(esc: &str) -> bool { + esc.starts_with("\x1b[") && esc.ends_with('m') +} + /// A lexical segment of a string that may contain ANSI escape sequences. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Segment<'a> { diff --git a/crates/jp_md/src/shade.rs b/crates/jp_md/src/shade.rs index b69b74dc..bb41ae22 100644 --- a/crates/jp_md/src/shade.rs +++ b/crates/jp_md/src/shade.rs @@ -17,7 +17,7 @@ use std::fmt::{self, Write}; use crate::{ - ansi::{self, AnsiState, Segment}, + ansi::{self, AnsiState, Segment, is_sgr}, format::{self, BackgroundFill, DefaultBackground}, }; @@ -245,11 +245,6 @@ pub fn shade(text: &str, background: &DefaultBackground) -> String { buffer } -/// Whether `esc` is an SGR sequence (`\x1b[…m`). -fn is_sgr(esc: &str) -> bool { - esc.starts_with("\x1b[") && esc.ends_with('m') -} - /// Whether `esc` is a CSI erase-in-line (`\x1b[K`, `\x1b[2K`, …). fn is_erase(esc: &str) -> bool { esc.starts_with("\x1b[") && esc.ends_with('K') diff --git a/crates/jp_md/src/table.rs b/crates/jp_md/src/table.rs index 66985254..dcf88c97 100644 --- a/crates/jp_md/src/table.rs +++ b/crates/jp_md/src/table.rs @@ -22,6 +22,8 @@ use std::{cmp::min, fmt::Write as _}; use comrak::nodes::{NodeValue, TableAlignment}; +use unicode_segmentation::UnicodeSegmentation as _; +use unicode_width::UnicodeWidthStr as _; use crate::{ ansi::{self, AnsiState, RESET, Segment}, @@ -366,14 +368,61 @@ fn pad_cell(content: &str, target_width: usize, alignment: TableAlignment) -> St } } +/// Append as many of `text`'s grapheme clusters to `out` as keep its visual +/// width within `limit`, returning the text left over and the number of +/// whole-buffer measurements taken. +/// +/// An empty remainder means all of `text` fit. +/// Clusters are appended whole: `❤` is one column on its own and two when a +/// variation selector follows it, so stopping between the two would change +/// which character the terminal draws. +/// `out` is measured in full, so escapes it already holds cost nothing and +/// visible content it already holds counts against `limit`. +/// +/// The measurement count is returned so tests can pin it: measuring is the +/// expensive step, and its count has to follow `limit` rather than the length +/// of `text`. +fn push_clusters_within<'a>(out: &mut String, text: &'a str, limit: usize) -> (&'a str, usize) { + // The running sum of cluster widths bounds the true width from above: the + // interactions that render a string narrower than its parts (ZWJ emoji, + // Arabic Lam-Alef, Tifinagh joiners) span cluster boundaries, while the ones + // that render it wider stay inside a single cluster and so are already + // counted by measuring the cluster whole. A buffer under the sum therefore + // fits for certain, at O(1) per cluster. + let mut sum = ansi::visual_width(out); + let mut measurements = 0; + + for (offset, cluster) in text.grapheme_indices(true) { + sum += cluster.width(); + out.push_str(cluster); + + // Only once the sum passes the limit does the exact width decide, and + // that is the measurement worth counting. + if sum <= limit { + continue; + } + + measurements += 1; + if ansi::visual_width(out) > limit { + out.truncate(out.len() - cluster.len()); + return (&text[offset..], measurements); + } + } + + ("", measurements) +} + /// Truncate a string (possibly containing ANSI escapes) to a maximum visual /// width, marking the cut with [`TRUNCATION_MARKER`]. /// /// Content that already fits, and any content at all when `max_width` is `0`, /// is returned unchanged. /// The marker takes the last column, so `max_width` still bounds the result. -/// ANSI state left open at the cut is closed, so styling does not leak into the -/// rest of the line. +/// The cut falls between grapheme clusters, so a retained character keeps the +/// marks that modify it. +/// SGR state left open at the cut is closed; escapes of any other kind are +/// dropped rather than kept, since nothing here can close one whose terminator +/// sits in the discarded suffix. fn truncate_to_visual_width(content: &str, max_width: usize) -> String { if max_width == 0 || ansi::visual_width(content) <= max_width { return content.to_string(); @@ -383,22 +432,24 @@ fn truncate_to_visual_width(content: &str, max_width: usize) -> String { let mut out = String::new(); let mut state = AnsiState::default(); - 'segments: for segment in ansi::segments(content) { + for segment in ansi::segments(content) { let text = match segment { + // A non-SGR escape is dropped: the reset below closes SGR only, so + // keeping an OSC 8 opener whose terminator is about to be discarded + // would leave the rest of the output linked. Segment::Escape(escape) => { - state.update(escape); - out.push_str(escape); + if ansi::is_sgr(escape) { + state.update(escape); + out.push_str(escape); + } continue; } Segment::Text(text) => text, }; - for c in text.chars() { - out.push(c); - if ansi::visual_width(&out) > keep { - out.pop(); - break 'segments; - } + let (rest, _) = push_clusters_within(&mut out, text, keep); + if !rest.is_empty() { + break; } } @@ -556,8 +607,9 @@ fn finalize_line(lines: &mut Vec, current: &mut String, state: &AnsiStat /// Hard-break a word that exceeds `max_width` across multiple lines, preserving /// ANSI escape state. /// -/// Uses `visual_width` on the accumulated line to decide break points, so -/// multi-codepoint emoji sequences are measured correctly. +/// Breaks fall between grapheme clusters, so a cluster wider than the remaining +/// room moves to the next line whole rather than leaving its combining marks +/// behind. fn hard_break_into( lines: &mut Vec, current: &mut String, @@ -575,14 +627,29 @@ fn hard_break_into( Segment::Text(text) => text, }; - for c in text.chars() { - current.push(c); - if ansi::visual_width(current) > max_width { - current.pop(); + let mut rest = text; + loop { + let (remaining, _) = push_clusters_within(current, rest, max_width); + rest = remaining; + if rest.is_empty() { + break; + } + + // Nothing to break when the line is still empty, which is the case + // for a cluster wider than the whole column. + if ansi::visual_width(current) > 0 { finalize_line(lines, current, state); *current = state.restore_sequence(); - current.push(c); } + + // The cluster that did not fit opens the fresh line whether or not + // it fits there either: one wider than the whole column can only be + // broken up by changing what it renders as. + let Some(cluster) = rest.graphemes(true).next() else { + break; + }; + current.push_str(cluster); + rest = &rest[cluster.len()..]; } } } diff --git a/crates/jp_md/src/table_tests.rs b/crates/jp_md/src/table_tests.rs index 6fe715d6..1d9ff21b 100644 --- a/crates/jp_md/src/table_tests.rs +++ b/crates/jp_md/src/table_tests.rs @@ -125,6 +125,55 @@ fn test_truncate_drops_the_space_before_the_marker() { assert_eq!(truncate_to_visual_width("ab cdef", 4), "ab…"); } +#[test] +fn test_cluster_scan_measures_the_limit_not_the_input() { + // Measuring the whole buffer is the expensive step, so its count has to + // follow the limit rather than the size of the cell. The running sum of + // cluster widths bounds the width from above, so ASCII takes exactly one + // exact measurement: the one that confirms the overshoot. + let mut out = String::new(); + let (_, short) = push_clusters_within(&mut out, &"x".repeat(500), 9); + + out.clear(); + let (_, long) = push_clusters_within(&mut out, &"x".repeat(100_000), 9); + + assert_eq!(short, long, "measurement count grew with the input"); + assert_eq!(long, 1, "{long} measurements for a 9-column limit"); +} + +#[test] +fn test_truncate_drops_a_hyperlink_it_cannot_close() { + // OSC 8 is not an SGR sequence, so `RESET` cannot close it. Keeping the + // opener while its terminator falls in the dropped suffix would leave every + // following cell, and the rest of the output, linked. + let input = "\x1b]8;;url\x1b\\abcdef\x1b]8;;\x1b\\"; + assert_eq!(truncate_to_visual_width(input, 4), "abc…"); +} + +#[test] +fn test_truncate_keeps_grapheme_clusters_whole() { + // U+2764 is one column alone and two with the U+FE0F that follows it, so + // cutting between the two would render a different character than the input + // asked for. Written as escapes because the selector is invisible. + assert_eq!(truncate_to_visual_width("A\u{2764}\u{FE0F}x", 3), "A…"); +} + +#[test] +fn test_hard_break_keeps_grapheme_clusters_whole() { + // The cluster does not fit the tail of the first line, so it moves whole to + // the second rather than leaving its selector orphaned. + let lines = wrap_to_visual_width("ab\u{2764}\u{FE0F}cd", 3); + assert_eq!(lines, vec!["ab", "\u{2764}\u{FE0F}c", "d"]); +} + +#[test] +fn test_hard_break_lets_an_oversized_cluster_overflow() { + // A cluster wider than the whole column cannot be split without changing + // what it renders as, so each one takes a line and overflows it. + let lines = wrap_to_visual_width("\u{2764}\u{FE0F}\u{2764}\u{FE0F}", 1); + assert_eq!(lines, vec!["\u{2764}\u{FE0F}", "\u{2764}\u{FE0F}"]); +} + #[test] fn test_truncate_closes_open_ansi_state() { // Bold opens before the cut, so it has to be closed or it bleeds into the From 3721b0db61b24080d60d16bd660fc8e0ce590ca8 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Fri, 31 Jul 2026 14:39:47 +0200 Subject: [PATCH 3/4] review feedback Signed-off-by: Jean Mertz --- crates/jp_md/src/ansi.rs | 5 +- crates/jp_md/src/table.rs | 173 ++++++++++++++++++++++---------- crates/jp_md/src/table_tests.rs | 39 +++++-- 3 files changed, 153 insertions(+), 64 deletions(-) diff --git a/crates/jp_md/src/ansi.rs b/crates/jp_md/src/ansi.rs index bb33fddc..224185f2 100644 --- a/crates/jp_md/src/ansi.rs +++ b/crates/jp_md/src/ansi.rs @@ -325,7 +325,10 @@ fn osc_terminator_end(body: &str) -> Option { /// string is what lets multi-codepoint sequences (emoji presentation via VS16, /// ZWJ sequences, script-specific ligatures) measure correctly even when an /// escape sits between a base character and its combining mark. -fn visible_text(s: &str) -> String { +/// Grapheme cluster boundaries are a property of this text, not of the escape +/// separated runs it was built from, so anything measuring or cutting on +/// cluster boundaries has to work from here. +pub fn visible_text(s: &str) -> String { let mut plain = String::new(); for segment in segments(s) { if let Segment::Text(text) = segment { diff --git a/crates/jp_md/src/table.rs b/crates/jp_md/src/table.rs index dcf88c97..63a9f34d 100644 --- a/crates/jp_md/src/table.rs +++ b/crates/jp_md/src/table.rs @@ -368,48 +368,99 @@ fn pad_cell(content: &str, target_width: usize, alignment: TableAlignment) -> St } } -/// Append as many of `text`'s grapheme clusters to `out` as keep its visual -/// width within `limit`, returning the text left over and the number of -/// whole-buffer measurements taken. +/// Byte offset in `text` just past the longest prefix whose visual width, added +/// to `base`, stays within `limit`, and the number of exact measurements taken. /// -/// An empty remainder means all of `text` fit. -/// Clusters are appended whole: `❤` is one column on its own and two when a -/// variation selector follows it, so stopping between the two would change -/// which character the terminal draws. -/// `out` is measured in full, so escapes it already holds cost nothing and -/// visible content it already holds counts against `limit`. +/// `text` must be free of ANSI escapes. +/// Grapheme clusters and visual width are properties of the visible text, so an +/// escape sitting between a base character and its combining mark would hide +/// their boundary and under-count the width; pass [`ansi::visible_text`] rather +/// than a single escape-separated run. +/// +/// `base` is the width already occupied ahead of `text`. +/// The cut lands on a cluster boundary, so a retained character keeps the marks +/// that modify it. /// /// The measurement count is returned so tests can pin it: measuring is the /// expensive step, and its count has to follow `limit` rather than the length /// of `text`. -fn push_clusters_within<'a>(out: &mut String, text: &'a str, limit: usize) -> (&'a str, usize) { +fn longest_fitting_prefix(text: &str, base: usize, limit: usize) -> (usize, usize) { // The running sum of cluster widths bounds the true width from above: the // interactions that render a string narrower than its parts (ZWJ emoji, // Arabic Lam-Alef, Tifinagh joiners) span cluster boundaries, while the ones // that render it wider stay inside a single cluster and so are already - // counted by measuring the cluster whole. A buffer under the sum therefore + // counted by measuring the cluster whole. A prefix under the sum therefore // fits for certain, at O(1) per cluster. - let mut sum = ansi::visual_width(out); + let mut end = 0; + let mut sum = base; let mut measurements = 0; for (offset, cluster) in text.grapheme_indices(true) { + let candidate = offset + cluster.len(); sum += cluster.width(); - out.push_str(cluster); + if sum <= limit { + end = candidate; + continue; + } // Only once the sum passes the limit does the exact width decide, and // that is the measurement worth counting. - if sum <= limit { + measurements += 1; + let exact = base + text[..candidate].width(); + if exact > limit { + break; + } + + // The exact width supersedes the estimate, so a cluster that renders + // narrower than its parts cannot leave the sum over-counting. + end = candidate; + sum = exact; + } + + (end, measurements) +} + +/// Visible byte offsets at which `text` has to break so that no line exceeds +/// `limit` columns, given `base` columns already occupied on the first line. +/// +/// `text` must be free of ANSI escapes, for the reason +/// [`longest_fitting_prefix`] gives. +/// A cluster wider than `limit` takes a line of its own and overflows it, since +/// splitting it would change what it renders as. +fn break_offsets(text: &str, base: usize, limit: usize) -> Vec { + let mut cuts = Vec::new(); + let mut start = 0; + let mut base = base; + + while start < text.len() { + let (fit, _) = longest_fitting_prefix(&text[start..], base, limit); + + // Nothing fits beside what the line already holds, so break and try + // again on an empty one. + if fit == 0 && base > 0 { + cuts.push(start); + base = 0; continue; } - measurements += 1; - if ansi::visual_width(out) > limit { - out.truncate(out.len() - cluster.len()); - return (&text[offset..], measurements); + let advance = if fit == 0 { + text[start..].graphemes(true).next().map_or(0, str::len) + } else { + fit + }; + if advance == 0 { + break; } + + start += advance; + if start >= text.len() { + break; + } + cuts.push(start); + base = 0; } - ("", measurements) + cuts } /// Truncate a string (possibly containing ANSI escapes) to a maximum visual @@ -420,36 +471,45 @@ fn push_clusters_within<'a>(out: &mut String, text: &'a str, limit: usize) -> (& /// The marker takes the last column, so `max_width` still bounds the result. /// The cut falls between grapheme clusters, so a retained character keeps the /// marks that modify it. -/// SGR state left open at the cut is closed; escapes of any other kind are -/// dropped rather than kept, since nothing here can close one whose terminator -/// sits in the discarded suffix. +/// +/// A retained SGR escape is followed by a reset, since its own closer may have +/// been cut and [`AnsiState`] recognizes only some attributes. +/// Escapes of any other kind are dropped rather than kept: nothing here can +/// close one whose terminator sits in the discarded suffix. fn truncate_to_visual_width(content: &str, max_width: usize) -> String { - if max_width == 0 || ansi::visual_width(content) <= max_width { + let visible = ansi::visible_text(content); + if max_width == 0 || visible.width() <= max_width { return content.to_string(); } - let keep = max_width - 1; + // The marker takes the last column. + let (visible_end, _) = longest_fitting_prefix(&visible, 0, max_width - 1); + let mut out = String::new(); - let mut state = AnsiState::default(); + let mut retained_sgr = false; + let mut visible_pos = 0; for segment in ansi::segments(content) { - let text = match segment { + match segment { // A non-SGR escape is dropped: the reset below closes SGR only, so // keeping an OSC 8 opener whose terminator is about to be discarded // would leave the rest of the output linked. Segment::Escape(escape) => { if ansi::is_sgr(escape) { - state.update(escape); out.push_str(escape); + retained_sgr = true; + } + } + Segment::Text(text) => { + // Visible offsets advance one-for-one with the bytes of a run, + // so the cut needs no lookup back into the source. + let take = min(text.len(), visible_end - visible_pos); + out.push_str(&text[..take]); + visible_pos += take; + if visible_pos == visible_end { + break; } - continue; } - Segment::Text(text) => text, - }; - - let (rest, _) = push_clusters_within(&mut out, text, keep); - if !rest.is_empty() { - break; } } @@ -459,7 +519,10 @@ fn truncate_to_visual_width(content: &str, max_width: usize) -> String { } out.push(TRUNCATION_MARKER); - if state.is_active() { + // `AnsiState` tracks only some attributes, so whether a reset is owed cannot + // depend on what it understood: an untracked one (inverse video, dim) would + // otherwise leak past the cell with its closer discarded. + if retained_sgr { out.push_str(RESET); } @@ -610,6 +673,8 @@ fn finalize_line(lines: &mut Vec, current: &mut String, state: &AnsiStat /// Breaks fall between grapheme clusters, so a cluster wider than the remaining /// room moves to the next line whole rather than leaving its combining marks /// behind. +/// Every escape is kept: nothing is discarded when wrapping, so a pair whose +/// halves land on different lines stays balanced. fn hard_break_into( lines: &mut Vec, current: &mut String, @@ -617,6 +682,14 @@ fn hard_break_into( word: &str, max_width: usize, ) { + // Break points are decided over the visible text, where cluster boundaries + // are visible, then applied to the source below. + let visible = ansi::visible_text(word); + let mut cuts = break_offsets(&visible, ansi::visual_width(current), max_width) + .into_iter() + .peekable(); + let mut visible_pos = 0; + for segment in ansi::segments(word) { let text = match segment { Segment::Escape(escape) => { @@ -628,29 +701,23 @@ fn hard_break_into( }; let mut rest = text; - loop { - let (remaining, _) = push_clusters_within(current, rest, max_width); - rest = remaining; - if rest.is_empty() { + while let Some(&cut) = cuts.peek() { + if cut > visible_pos + rest.len() { break; } - // Nothing to break when the line is still empty, which is the case - // for a cluster wider than the whole column. - if ansi::visual_width(current) > 0 { - finalize_line(lines, current, state); - *current = state.restore_sequence(); - } + let take = cut - visible_pos; + current.push_str(&rest[..take]); + visible_pos += take; + rest = &rest[take..]; + cuts.next(); - // The cluster that did not fit opens the fresh line whether or not - // it fits there either: one wider than the whole column can only be - // broken up by changing what it renders as. - let Some(cluster) = rest.graphemes(true).next() else { - break; - }; - current.push_str(cluster); - rest = &rest[cluster.len()..]; + finalize_line(lines, current, state); + *current = state.restore_sequence(); } + + current.push_str(rest); + visible_pos += rest.len(); } } diff --git a/crates/jp_md/src/table_tests.rs b/crates/jp_md/src/table_tests.rs index 1d9ff21b..89deebd8 100644 --- a/crates/jp_md/src/table_tests.rs +++ b/crates/jp_md/src/table_tests.rs @@ -126,16 +126,13 @@ fn test_truncate_drops_the_space_before_the_marker() { } #[test] -fn test_cluster_scan_measures_the_limit_not_the_input() { - // Measuring the whole buffer is the expensive step, so its count has to - // follow the limit rather than the size of the cell. The running sum of - // cluster widths bounds the width from above, so ASCII takes exactly one - // exact measurement: the one that confirms the overshoot. - let mut out = String::new(); - let (_, short) = push_clusters_within(&mut out, &"x".repeat(500), 9); - - out.clear(); - let (_, long) = push_clusters_within(&mut out, &"x".repeat(100_000), 9); +fn test_prefix_scan_measures_the_limit_not_the_input() { + // Measuring is the expensive step, so its count has to follow the limit + // rather than the size of the cell. The running sum of cluster widths bounds + // the width from above, so ASCII takes exactly one exact measurement: the + // one that confirms the overshoot. + let (_, short) = longest_fitting_prefix(&"x".repeat(500), 0, 9); + let (_, long) = longest_fitting_prefix(&"x".repeat(100_000), 0, 9); assert_eq!(short, long, "measurement count grew with the input"); assert_eq!(long, 1, "{long} measurements for a 9-column limit"); @@ -150,6 +147,28 @@ fn test_truncate_drops_a_hyperlink_it_cannot_close() { assert_eq!(truncate_to_visual_width(input, 4), "abc…"); } +#[test] +fn test_truncate_measures_a_cluster_split_across_an_escape() { + // The escape hides the variation selector from the run it would have been + // segmented with, but the visible text is a two-column emoji heart, not a + // one-column text heart plus a zero-width selector. Trusting the runs kept + // nine columns against a budget of eight, pushing the header row out of + // alignment with the rest of the table. + let truncated = truncate_to_visual_width("A\u{2764}\x1b[31m\u{FE0F}xyz", 3); + assert_eq!(truncated, "A…"); + assert_eq!(ansi::visual_width(&truncated), 2); +} + +#[test] +fn test_truncate_closes_an_untracked_sgr_attribute() { + // `AnsiState` tracks neither inverse video nor its `27` closer, so whether a + // reset is owed cannot depend on what it recognized: the closer here is in + // the dropped suffix, and without the reset the attribute runs on through + // every following cell. + let input = "\x1b[7mabcdefghij\x1b[27m"; + assert_eq!(truncate_to_visual_width(input, 6), "\x1b[7mabcde…\x1b[0m"); +} + #[test] fn test_truncate_keeps_grapheme_clusters_whole() { // U+2764 is one column alone and two with the U+FE0F that follows it, so From 0018293bf03791f8673e475fffc2eda60e1bc162 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Fri, 31 Jul 2026 15:51:14 +0200 Subject: [PATCH 4/4] review feedback Signed-off-by: Jean Mertz --- crates/jp_md/src/table.rs | 29 ++++++++++++++-- crates/jp_md/src/table_tests.rs | 60 ++++++++++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/crates/jp_md/src/table.rs b/crates/jp_md/src/table.rs index 63a9f34d..92066217 100644 --- a/crates/jp_md/src/table.rs +++ b/crates/jp_md/src/table.rs @@ -55,6 +55,16 @@ const CONTINUATION_EDGE: char = '┆'; /// Marks a header cell cut short because it did not fit its column. const TRUNCATION_MARKER: char = '…'; +/// How many clusters past the limit to keep measuring for a sequence that +/// narrows back under it. +/// +/// Every width-collapsing rule in `unicode-width` reduces a run of two or three +/// clusters, so a prefix that has overshot recovers within a cluster or two if +/// it recovers at all. +/// Probing a bounded distance keeps a long cell from turning the scan +/// quadratic. +const MAX_LIGATURE_PROBES: usize = 8; + /// Options for table formatting. pub struct TableOptions { /// Upper bound on the visual width of any single column. @@ -391,8 +401,15 @@ fn longest_fitting_prefix(text: &str, base: usize, limit: usize) -> (usize, usiz // that render it wider stay inside a single cluster and so are already // counted by measuring the cluster whole. A prefix under the sum therefore // fits for certain, at O(1) per cluster. + // + // Past the limit the exact width decides, and it is not monotonic: a + // Tifinagh joiner costs a column on its own and none once the consonant + // after it completes the ligature. So an overshooting prefix is not the end + // of the scan, it is the start of a bounded probe for one that narrows back + // under. let mut end = 0; let mut sum = base; + let mut probes = 0; let mut measurements = 0; for (offset, cluster) in text.grapheme_indices(true) { @@ -403,18 +420,24 @@ fn longest_fitting_prefix(text: &str, base: usize, limit: usize) -> (usize, usiz continue; } - // Only once the sum passes the limit does the exact width decide, and - // that is the measurement worth counting. + if probes == MAX_LIGATURE_PROBES { + break; + } + probes += 1; measurements += 1; + let exact = base + text[..candidate].width(); if exact > limit { - break; + continue; } // The exact width supersedes the estimate, so a cluster that renders // narrower than its parts cannot leave the sum over-counting. end = candidate; sum = exact; + // A sequence closed and brought the prefix back under the limit; allow a + // fresh run of probes for the next one. + probes = 0; } (end, measurements) diff --git a/crates/jp_md/src/table_tests.rs b/crates/jp_md/src/table_tests.rs index 89deebd8..441aaa57 100644 --- a/crates/jp_md/src/table_tests.rs +++ b/crates/jp_md/src/table_tests.rs @@ -1,6 +1,14 @@ use super::*; use crate::format::HrStyle; +/// Tifinagh consonant, consonant joiner, consonant: a ligature that renders in +/// one column, having cost two before the final consonant closed it. +const TIFINAGH_LIGATURE: &str = "\u{2D4F}\u{2D7F}\u{2D3E}"; + +/// Arabic Lam followed by Alef: two grapheme clusters that ligate into a single +/// column. +const LAM_ALEF: &str = "\u{0644}\u{0627}"; + /// Character positions of the column edges on a rendered line. /// /// A continuation line opens with [`CONTINUATION_EDGE`] instead of `|`, and @@ -125,17 +133,58 @@ fn test_truncate_drops_the_space_before_the_marker() { assert_eq!(truncate_to_visual_width("ab cdef", 4), "ab…"); } +#[test] +fn test_tifinagh_ligature_narrows_once_completed() { + // The premise the prefix scan depends on. The joiner extends the first + // consonant's cluster and costs a column there, and completing the ligature + // drops the total to one, so prefix width is not monotonic. If this changes + // upstream, the test below stops testing what it claims to. + assert_eq!(TIFINAGH_LIGATURE.graphemes(true).collect::>(), [ + "\u{2D4F}\u{2D7F}", + "\u{2D3E}" + ]); + assert_eq!(ansi::visual_width("\u{2D4F}\u{2D7F}"), 2); + assert_eq!(ansi::visual_width(TIFINAGH_LIGATURE), 1); +} + +#[test] +fn test_truncate_keeps_a_narrowing_sequence_that_fits() { + // The ligature's first cluster is two columns and the completed ligature is + // one, so the prefix dips back under the budget as the sequence closes. + // Stopping at the first overshoot keeps `A` alone and drops content the + // column had room for. + let input = format!("A{TIFINAGH_LIGATURE}xy"); + assert_eq!(ansi::visual_width(&input), 4); + + let truncated = truncate_to_visual_width(&input, 3); + assert_eq!(truncated, format!("A{TIFINAGH_LIGATURE}\u{2026}")); + assert_eq!(ansi::visual_width(&truncated), 3); +} + #[test] fn test_prefix_scan_measures_the_limit_not_the_input() { // Measuring is the expensive step, so its count has to follow the limit - // rather than the size of the cell. The running sum of cluster widths bounds - // the width from above, so ASCII takes exactly one exact measurement: the - // one that confirms the overshoot. + // rather than the size of the cell. ASCII never narrows, so every probe past + // the overshoot is wasted and the cap is what ends the scan. let (_, short) = longest_fitting_prefix(&"x".repeat(500), 0, 9); let (_, long) = longest_fitting_prefix(&"x".repeat(100_000), 0, 9); assert_eq!(short, long, "measurement count grew with the input"); - assert_eq!(long, 1, "{long} measurements for a 9-column limit"); + assert!( + long <= MAX_LIGATURE_PROBES, + "{long} measurements for a 9-column limit" + ); +} + +#[test] +fn test_prefix_scan_measurements_stay_bounded_across_ligatures() { + // Ligatures are why the exact measurement exists at all, so the bound has to + // hold for an input made entirely of them, where a probe that finds a + // fitting prefix starts a fresh run. + let (_, short) = longest_fitting_prefix(&LAM_ALEF.repeat(250), 0, 2); + let (_, long) = longest_fitting_prefix(&LAM_ALEF.repeat(50_000), 0, 2); + + assert_eq!(short, long, "measurement count grew with the input"); } #[test] @@ -152,7 +201,8 @@ fn test_truncate_measures_a_cluster_split_across_an_escape() { // The escape hides the variation selector from the run it would have been // segmented with, but the visible text is a two-column emoji heart, not a // one-column text heart plus a zero-width selector. Trusting the runs kept - // nine columns against a budget of eight, pushing the header row out of + // `A` plus the heart at three columns and then added the marker: four + // against a `max_width` of three, which pushed the header row out of // alignment with the rest of the table. let truncated = truncate_to_visual_width("A\u{2764}\x1b[31m\u{FE0F}xyz", 3); assert_eq!(truncated, "A…");