Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2fb365d
fix(markdown): preserve korean spacing when wrapping text
iosif2 Apr 7, 2026
018bbf9
fix(markdown): harden cjk wrapping edge cases
iosif2 Apr 8, 2026
322d975
fix(markdown): preserve grapheme and ansi state during wrapping
iosif2 Apr 8, 2026
fb98366
fix(markdown): correct blockquote width regression coverage
iosif2 Apr 8, 2026
3a3cb96
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 16, 2026
0d052b7
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 16, 2026
ccfaa62
Merge branch 'main' into fix/korean-spacing-loss
amitksingh1490 Apr 16, 2026
2f0aebb
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 16, 2026
679c1d1
fix: fix markdown stream wrapping for safe slice handling
iosif2 Apr 16, 2026
24d321a
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 16, 2026
4f3b5d3
fix: fix markdown stream wrapping clippy violations
iosif2 Apr 16, 2026
d296d77
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 21, 2026
9e12f2c
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 22, 2026
3c52849
Merge branch 'main' into fix/korean-spacing-loss
amitksingh1490 Apr 22, 2026
23f5176
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 22, 2026
d9c035b
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 22, 2026
b48598b
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 22, 2026
004cada
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 23, 2026
489bc28
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 23, 2026
f554fc4
Merge branch 'main' into fix/korean-spacing-loss
iosif2 Apr 24, 2026
9d5c154
Merge branch 'main' into fix/korean-spacing-loss
amitksingh1490 Apr 24, 2026
20ef3f9
Merge branch 'main' into fix/korean-spacing-loss
amitksingh1490 Apr 24, 2026
a462169
fix(clippy): resolve string_slice warnings in markdown stream utils
amitksingh1490 Apr 24, 2026
ae19c14
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/forge_markdown_stream/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ streamdown-render = "0.1.4"
syntect.workspace = true
colored.workspace = true
unicode-width = "0.2"
unicode-segmentation = "1.12"
terminal-colorsaurus = "1.0.3"

[dev-dependencies]
insta.workspace = true
strip-ansi-escapes.workspace = true
pretty_assertions.workspace = true
28 changes: 25 additions & 3 deletions crates/forge_markdown_stream/src/heading.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
//! Heading rendering with theme-based styling.

use streamdown_render::simple_wrap;

use crate::inline::render_inline_content;
use crate::style::{HeadingStyler, InlineStyler};
use crate::utils::simple_wrap_preserving_spaces;

/// Render a heading with appropriate styling.
pub fn render_heading<S: InlineStyler + HeadingStyler>(
Expand All @@ -30,7 +29,7 @@ pub fn render_heading<S: InlineStyler + HeadingStyler>(
// chars, etc.)
let prefix_display_width = level as usize + 1;
let content_width = width.saturating_sub(prefix_display_width);
let lines = simple_wrap(&rendered_content, content_width);
let lines = simple_wrap_preserving_spaces(&rendered_content, content_width);
let mut result = Vec::new();

for line in lines {
Expand Down Expand Up @@ -223,6 +222,29 @@ mod tests {
");
}

#[test]
fn test_h3_wrapping_preserves_korean_word_spaces() {
let actual = render_with_width(3, "한글 공백 보존 확인", 12);

insta::assert_snapshot!(actual, @r"
<dim><h3>###</h3></dim> <h3>한글</h3>
<dim><h3>###</h3></dim> <h3>공백</h3>
<dim><h3>###</h3></dim> <h3>보존</h3>
<dim><h3>###</h3></dim> <h3>확인</h3>
");
}

#[test]
fn test_h3_wrapping_splits_long_tokens() {
let actual = render_with_width(3, "supercalifragilistic", 12);

insta::assert_snapshot!(actual, @r"
<dim><h3>###</h3></dim> <h3>supercal</h3>
<dim><h3>###</h3></dim> <h3>ifragili</h3>
<dim><h3>###</h3></dim> <h3>stic</h3>
");
}

#[test]
fn test_special_characters() {
insta::assert_snapshot!(render(2, "Hello & Goodbye < World >"), @"
Expand Down
126 changes: 124 additions & 2 deletions crates/forge_markdown_stream/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
//!
//! fn main() -> io::Result<()> {
//! let mut renderer = StreamdownRenderer::new(io::stdout(), 80);
//!
//!
//! // Push tokens as they arrive from LLM
//! renderer.push("Hello ")?;
//! renderer.push("**world**!\n")?;
//!
//!
//! // Finish rendering
//! let _ = renderer.finish()?;
//! Ok(())
Expand Down Expand Up @@ -109,3 +109,125 @@ impl<W: Write> StreamdownRenderer<W> {
Ok(())
}
}

#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;

use super::StreamdownRenderer;

fn fixture_rendered_output(markdown: &str, width: usize) -> String {
let mut output = Vec::new();
let mut fixture = StreamdownRenderer::new(&mut output, width);
fixture.push(markdown).unwrap();
fixture.finish().unwrap();

let actual = strip_ansi_escapes::strip(output);
String::from_utf8(actual)
.unwrap()
.trim_matches('\n')
.to_string()
}

fn fixture_rendered_output_from_chunks(chunks: &[&str], width: usize) -> String {
let mut output = Vec::new();
let mut fixture = StreamdownRenderer::new(&mut output, width);
for chunk in chunks {
fixture.push(chunk).unwrap();
}
fixture.finish().unwrap();

let actual = strip_ansi_escapes::strip(output);
String::from_utf8(actual)
.unwrap()
.trim_matches('\n')
.to_string()
}

#[test]
fn test_streaming_renderer_preserves_korean_spacing_in_structured_markdown() {
let fixture = concat!(
"## 구현 요약\n",
"- 각 서비스에서 metadata key를 개별 수정하지 않고, object storage 공통 레이어에서 일괄 정규화하도록 반영했습니다.\n",
"## 검토 사항\n",
"- 본 수정은 업로드 시 metadata header 이름 문제를 해결합니다.\n",
"- 추가적인 권한 정책, bucket policy, reverse proxy 제한이 있으면 별도 오류가 발생할 수 있습니다.\n",
);
let actual = fixture_rendered_output(fixture, 200);
let expected = concat!(
"## 구현 요약\n",
"• 각 서비스에서 metadata key를 개별 수정하지 않고, object storage 공통 레이어에서 일괄 정규화하도록 반영했습니다.\n",
"\n",
"## 검토 사항\n",
"• 본 수정은 업로드 시 metadata header 이름 문제를 해결합니다.\n",
"• 추가적인 권한 정책, bucket policy, reverse proxy 제한이 있으면 별도 오류가 발생할 수 있습니다.",
);

assert_eq!(actual, expected);
}

#[test]
fn test_streaming_renderer_preserves_korean_spacing_when_structured_tail_arrives_in_chunks() {
let fixture = [
"## 검토 결과\n",
"- 본 사례는 스트리밍 마크다운 렌더링의 공백 재조합 문제와 관련이 있습니다.\n",
"- 핵심 구현은 공백 보존 래퍼에 위치합니다.\n",
"- 회귀 테스트는 스트리밍 렌더러 검증 항목에 추가되어 있습니다.\n\n",
"후속 작업은 다음과 같습니다.\n",
"1. 변경 사항을 검토 가능한 형식으로 정리합니다.\n",
"2. 실제 대화 출력과 유사한 통합 테스트 범위를 ",
"확장합니다.",
];
let actual = fixture_rendered_output_from_chunks(&fixture, 200);
let expected = concat!(
"## 검토 결과\n",
"• 본 사례는 스트리밍 마크다운 렌더링의 공백 재조합 문제와 관련이 있습니다.\n",
"• 핵심 구현은 공백 보존 래퍼에 위치합니다.\n",
"• 회귀 테스트는 스트리밍 렌더러 검증 항목에 추가되어 있습니다.\n",
"\n",
"후속 작업은 다음과 같습니다.\n",
"1. 변경 사항을 검토 가능한 형식으로 정리합니다.\n",
"2. 실제 대화 출력과 유사한 통합 테스트 범위를 확장합니다.",
);

assert_eq!(actual, expected);
}

#[test]
fn test_streaming_renderer_wraps_blockquotes_with_prefix_width_and_long_tokens() {
let fixture = "> supercalifragilistic\n> 한글 공백\n";
let actual = fixture_rendered_output(fixture, 10);
let expected = concat!(
"│ supercal\n",
"│ ifragili\n",
"│ stic\n",
"│ 한글\n",
"│ 공백"
);

assert_eq!(actual, expected);
}

#[test]
fn test_streaming_renderer_wraps_blockquote_links_without_losing_separator() {
let fixture = "> [링크](https://example.com/very/long/path) 설명\n";
let actual = fixture_rendered_output(fixture, 20);
let expected = concat!(
"│ 링크\n",
"│ (https://example.c\n",
"│ om/very/long/path)\n",
"│ 설명"
);

assert_eq!(actual, expected);
}

#[test]
fn test_streaming_renderer_wraps_nested_blockquotes_with_correct_prefix_width() {
let fixture = ">> supercalifragilistic\n";
let actual = fixture_rendered_output(fixture, 12);
let expected = concat!("│ │ supercal\n", "│ │ ifragili\n", "│ │ stic");

assert_eq!(actual, expected);
}
}
Loading
Loading