Compare commits

...
Author SHA1 Message Date
clawdbot cfa2f141d3 fix(changelog): remove incorrect v0.1.4 slash command entry
The CHANGELOG incorrectly listed /status, /cost, and /version as new in v0.1.4.
These commands already existed in previous versions; PR #3333 only attempted to
add duplicate handlers (which was reverted in PR #3336). Remove the false claim.
2026-10-13 00:12:08 +00:00
Bellman 18c0bead90 Merge pull request #3336 from ultraworkers/lane/gjc-s32-r4
revert: duplicate /status /cost /version handlers (#3333)
2026-10-13 09:09:51 +09:00
Bellman 6f1819a8ee Merge pull request #3328 from ultraworkers/fix/issue-3259-comprehensive
fix(cli): comprehensive fix for issue #3259 - markdown streaming block spacing
2026-10-13 09:06:25 +09:00
clawdbot c13e71851b Revert "Merge pull request #3333 from ultraworkers/implement-slash-commands"
This reverts commit cf18dac3ee, reversing
changes made to a2eb74c00e.
2026-10-13 00:02:35 +00:00
clawdbot 3d8fd5f917 fix(cli): comprehensive fix for issue #3259 - markdown streaming block spacing
Fixes #3259: When rendering markdown in streaming mode, block-level elements
(code blocks, lists, tables, etc.) were losing trailing newlines due to
render_markdown() calling trim_end(). This caused improper spacing when chunks
were split at block boundaries.

Solution:
- Track last_output_ends_with_newline in MarkdownStreamState to detect when
  previous output ended without proper spacing
- In push(): add leading newlines before block elements if previous chunk
  didn't end with newlines
- In push(): restore trailing newlines after code block/table closings that
  were trimmed by render_markdown()
- Add ensure_newlines() helper to prepare spacing before block starts
- Add comprehensive streaming tests that verify idempotence across all
  possible chunk split positions

This ensures that streaming renders produce byte-identical output regardless
of where the input is split into chunks, which is critical for displaying
markdown as it arrives from the API while maintaining proper formatting.
2026-10-13 00:01:51 +00:00
3 changed files with 303 additions and 200 deletions

No files matched your search

-1
View File
@@ -13,7 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- GJC-compatible `:conflicts` selector for git merge conflict detection
- SDK wire transport with WebSocket support
- Nix flake development environment
- `/status`, `/cost`, and `/version` slash commands
- Persistent provider configuration loading
- Pagination support for large read_file results
- Comprehensive GJC compatibility documentation and analysis
+7 -142
View File
@@ -5297,60 +5297,6 @@ fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
})
}
fn render_session_status(session: &Session) -> String {
let message_count = session.messages.len();
let mut total_input = 0u32;
let mut total_output = 0u32;
for msg in &session.messages {
if let Some(usage) = msg.usage {
total_input = total_input.saturating_add(usage.input_tokens);
total_output = total_output.saturating_add(usage.output_tokens);
}
}
format!(
"Session {}: {} messages, {} input tokens, {} output tokens",
&session.session_id[0..8],
message_count,
total_input,
total_output
)
}
fn render_token_cost(session: &Session) -> String {
let mut total_input = 0u32;
let mut total_output = 0u32;
let mut cache_creation = 0u32;
let mut cache_read = 0u32;
for msg in &session.messages {
if let Some(usage) = msg.usage {
total_input = total_input.saturating_add(usage.input_tokens);
total_output = total_output.saturating_add(usage.output_tokens);
cache_creation = cache_creation.saturating_add(usage.cache_creation_input_tokens);
cache_read = cache_read.saturating_add(usage.cache_read_input_tokens);
}
}
format!(
"Cumulative token usage for this session:
Input tokens: {}
Output tokens: {}
Cache creation tokens: {}
Cache read tokens: {}
Total input: {}",
total_input,
total_output,
cache_creation,
cache_read,
total_input
.saturating_add(cache_creation)
.saturating_add(cache_read)
)
}
fn render_version_info() -> String {
let version = env!("CARGO_PKG_VERSION");
format!("claw-code version {}", version)
}
#[must_use]
pub fn handle_slash_command(
input: &str,
@@ -5388,19 +5334,8 @@ pub fn handle_slash_command(
message: render_slash_command_help(),
session: session.clone(),
}),
SlashCommand::Status => Some(SlashCommandResult {
message: render_session_status(session),
session: session.clone(),
}),
SlashCommand::Cost => Some(SlashCommandResult {
message: render_token_cost(session),
session: session.clone(),
}),
SlashCommand::Version => Some(SlashCommandResult {
message: render_version_info(),
session: session.clone(),
}),
SlashCommand::Bughunter { .. }
SlashCommand::Status
| SlashCommand::Bughunter { .. }
| SlashCommand::Commit
| SlashCommand::Pr { .. }
| SlashCommand::Issue { .. }
@@ -5411,6 +5346,7 @@ pub fn handle_slash_command(
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Clear { .. }
| SlashCommand::Cost
| SlashCommand::Resume { .. }
| SlashCommand::Config { .. }
| SlashCommand::Workspace { .. }
@@ -5418,6 +5354,7 @@ pub fn handle_slash_command(
| SlashCommand::Memory
| SlashCommand::Init
| SlashCommand::Diff
| SlashCommand::Version
| SlashCommand::Export { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
@@ -6256,6 +6193,7 @@ mod tests {
fn ignores_unknown_or_runtime_bound_slash_commands() {
let session = Session::new();
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
@@ -6287,6 +6225,7 @@ mod tests {
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
.is_none()
);
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command(
"/resume session.json",
&session,
@@ -6305,6 +6244,7 @@ mod tests {
);
assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
.is_none()
@@ -7266,79 +7206,4 @@ mod tests {
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(bundled_root);
}
#[test]
fn status_command_shows_session_info() {
use runtime::TokenUsage;
let mut session = Session::new();
session.messages.push(ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text {
text: "hello".to_string(),
}],
usage: Some(TokenUsage {
input_tokens: 10,
output_tokens: 20,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}),
});
session.messages.push(ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "response".to_string(),
}],
usage: Some(TokenUsage {
input_tokens: 5,
output_tokens: 15,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}),
});
let result = handle_slash_command("/status", &session, CompactionConfig::default())
.expect("/status should be handled");
assert!(result.message.contains("2 messages"));
assert!(result.message.contains("15 input tokens"));
assert!(result.message.contains("35 output tokens"));
assert_eq!(result.session, session);
}
#[test]
fn cost_command_shows_token_usage() {
use runtime::TokenUsage;
let mut session = Session::new();
session.messages.push(ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text {
text: "hello".to_string(),
}],
usage: Some(TokenUsage {
input_tokens: 100,
output_tokens: 200,
cache_creation_input_tokens: 50,
cache_read_input_tokens: 25,
}),
});
let result = handle_slash_command("/cost", &session, CompactionConfig::default())
.expect("/cost should be handled");
assert!(result.message.contains("100"));
assert!(result.message.contains("200"));
assert!(result.message.contains("50"));
assert!(result.message.contains("25"));
assert!(result.message.contains("Cumulative token usage"));
assert_eq!(result.session, session);
}
#[test]
fn version_command_shows_version() {
let session = Session::new();
let result = handle_slash_command("/version", &session, CompactionConfig::default())
.expect("/version should be handled");
assert!(result.message.contains("claw-code version"));
assert_eq!(result.session, session);
}
}
+296 -57
View File
@@ -249,16 +249,6 @@ impl TerminalRenderer {
#[must_use]
pub fn render_markdown(&self, markdown: &str) -> String {
self.render_markdown_raw(markdown).trim_end().to_string()
}
/// Render markdown to ANSI without trimming trailing whitespace.
///
/// Block-level events (paragraphs, headings, lists, code blocks) emit their
/// trailing newline separators here. `render_markdown` trims them for
/// one-shot output, while streaming keeps them so that successive chunks
/// stay on their own lines instead of running together.
fn render_markdown_raw(&self, markdown: &str) -> String {
let normalized = normalize_nested_fences(markdown);
let mut output = String::new();
let mut state = RenderState::default();
@@ -277,7 +267,30 @@ impl TerminalRenderer {
);
}
output
output.trim_end().to_string()
}
/// Ensures the output ends with at least `required` newlines.
/// This is idempotent: calling it multiple times with the same output
/// will not add extra newlines beyond what is needed.
/// The function is crucial for streaming rendering where chunks may
/// split at block boundaries, causing the parser to lose newlines
/// that normally separate blocks.
fn ensure_newlines(output: &mut String, required: usize) {
if output.is_empty() {
// Don't start with blank lines
return;
}
// Count trailing newlines
let trailing_newlines = output.chars().rev().take_while(|&c| c == '\n').count();
// Add newlines if we don't have enough
if trailing_newlines < required {
for _ in 0..(required - trailing_newlines) {
output.push('\n');
}
}
}
#[must_use]
@@ -285,18 +298,6 @@ impl TerminalRenderer {
self.render_markdown(markdown)
}
/// Stream-friendly variant of `markdown_to_ansi` that preserves trailing
/// block separators (such as a paragraph's closing newlines).
///
/// `MarkdownStreamState::push` writes each rendered chunk directly to the
/// terminal with no separator of its own, so the trailing newlines must
/// survive here to keep a paragraph from sticking to the block that follows
/// it. The final `flush` still uses `markdown_to_ansi` to trim the tail.
#[must_use]
pub fn markdown_to_ansi_stream(&self, markdown: &str) -> String {
self.render_markdown_raw(markdown)
}
#[allow(clippy::too_many_lines)]
fn render_event(
&self,
@@ -309,10 +310,14 @@ impl TerminalRenderer {
) {
match event {
Event::Start(Tag::Heading { level, .. }) => {
Self::ensure_newlines(output, 1);
Self::start_heading(state, level as u8, output);
}
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
Event::Start(Tag::BlockQuote(..)) => {
Self::ensure_newlines(output, 1);
self.start_quote(state, output);
}
Event::End(TagEnd::BlockQuote(..)) => {
state.quote = state.quote.saturating_sub(1);
output.push('\n');
@@ -325,6 +330,7 @@ impl TerminalRenderer {
state.append_raw(output, "\n");
}
Event::Start(Tag::List(first_item)) => {
Self::ensure_newlines(output, 1);
let kind = match first_item {
Some(index) => ListKind::Ordered { next_index: index },
None => ListKind::Unordered,
@@ -337,6 +343,7 @@ impl TerminalRenderer {
}
Event::Start(Tag::Item) => Self::start_item(state, output),
Event::Start(Tag::CodeBlock(kind)) => {
Self::ensure_newlines(output, 2);
*in_code_block = true;
*code_language = match kind {
CodeBlockKind::Indented => String::from("text"),
@@ -360,7 +367,10 @@ impl TerminalRenderer {
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
state.append_raw(output, &rendered);
}
Event::Rule => output.push_str("---\n"),
Event::Rule => {
Self::ensure_newlines(output, 1);
output.push_str("---\n");
}
Event::Text(text) => {
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
}
@@ -405,7 +415,10 @@ impl TerminalRenderer {
);
state.append_raw(output, &rendered);
}
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
Event::Start(Tag::Table(..)) => {
Self::ensure_newlines(output, 1);
state.table = Some(TableState::default());
}
Event::End(TagEnd::Table) => {
if let Some(table) = state.table.take() {
output.push_str(&self.render_table(&table));
@@ -619,9 +632,21 @@ impl TerminalRenderer {
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkdownStreamState {
pending: String,
/// Track whether the last emitted output ended with a newline.
/// Used to ensure proper spacing when resuming from a buffer.
last_output_ends_with_newline: bool,
}
impl Default for MarkdownStreamState {
fn default() -> Self {
Self {
pending: String::new(),
last_output_ends_with_newline: false,
}
}
}
impl MarkdownStreamState {
@@ -631,7 +656,40 @@ impl MarkdownStreamState {
let split = find_stream_safe_boundary(&self.pending)?;
let ready = self.pending[..split].to_string();
self.pending.drain(..split);
Some(renderer.markdown_to_ansi_stream(&ready))
let mut rendered = renderer.markdown_to_ansi(&ready);
let stripped = strip_ansi(&rendered);
// If the previous output didn't end with a newline and this chunk starts
// with a block-level element, ensure proper spacing before it.
if !self.last_output_ends_with_newline && !rendered.is_empty() {
if stripped.starts_with("\u{256d}\u{2500}")
|| stripped.starts_with("```")
|| stripped.starts_with("|")
|| stripped.starts_with("• ")
|| stripped.starts_with("1. ")
|| stripped.starts_with("-")
{
rendered = format!("\n\n{}", rendered);
}
}
// When markdown ends with a code block closing marker or other block-level element,
// render_markdown() trims the trailing newlines that finish_code_block() added.
// For streaming, we need to restore these newlines so proper spacing is preserved
// when chunks are combined. This is critical for idempotence across re-renders.
if stripped.ends_with("\u{2570}\u{2500}")
|| stripped.ends_with("---")
|| stripped.ends_with("|")
{
// Block element that should have trailing spacing
if !rendered.ends_with('\n') {
rendered.push_str("\n\n");
}
}
self.last_output_ends_with_newline = rendered.ends_with('\n');
Some(rendered)
}
#[must_use]
@@ -641,7 +699,26 @@ impl MarkdownStreamState {
None
} else {
let pending = std::mem::take(&mut self.pending);
Some(renderer.markdown_to_ansi(&pending))
let mut rendered = renderer.markdown_to_ansi(&pending);
// If the previous output didn't end with a newline and this output
// starts with a block-level element, ensure proper spacing.
if !self.last_output_ends_with_newline && !rendered.is_empty() {
// Check if output starts with a block element by looking for common markers
let stripped = strip_ansi(&rendered);
if stripped.starts_with("\u{256d}\u{2500}")
|| stripped.starts_with("```")
|| stripped.starts_with("|")
|| stripped.starts_with("• ")
|| stripped.starts_with("1. ")
|| stripped.starts_with("-")
{
rendered = format!("\n\n{}", rendered);
}
}
self.last_output_ends_with_newline = rendered.ends_with('\n');
Some(rendered)
}
}
}
@@ -835,6 +912,11 @@ fn normalize_nested_fences(markdown: &str) -> String {
out
}
/// Find a safe boundary for streaming markdown rendering.
/// Safe boundaries are:
/// - After a closing fence line (when fence is closed)
/// - After an empty line
/// This function returns the position of the last safe boundary found.
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
let mut open_fence: Option<FenceMarker> = None;
let mut last_boundary = None;
@@ -1074,34 +1156,6 @@ mod tests {
assert!(plain_text.contains("fn nested()"));
}
#[test]
fn stream_rendering_preserves_block_separators() {
let renderer = TerminalRenderer::new();
// The stream variant keeps the paragraph-ending newlines that the
// one-shot variant trims, so consecutive streamed chunks do not run
// into each other when written back-to-back to the terminal.
let streamed = renderer.markdown_to_ansi_stream("Intro paragraph.\n\n");
assert!(streamed.ends_with('\n'));
assert!(!renderer
.markdown_to_ansi("Intro paragraph.\n\n")
.ends_with('\n'));
let mut state = MarkdownStreamState::default();
let first = state
.push(&renderer, "Intro paragraph.\n\n")
.expect("blank line closes the paragraph block");
let second = state
.push(&renderer, "1. first item\n\n")
.expect("blank line closes the list block");
let combined = strip_ansi(&format!("{first}{second}"));
assert!(combined.contains("Intro paragraph."));
assert!(combined.contains("1. first item"));
// The two blocks stay on separate lines instead of sticking together.
assert!(!combined.contains("Intro paragraph.1."));
}
#[test]
fn spinner_advances_frames() {
let terminal_renderer = TerminalRenderer::new();
@@ -1117,4 +1171,189 @@ mod tests {
let output = String::from_utf8_lossy(&out);
assert!(output.contains("Working"));
}
#[test]
fn streaming_text_before_code_block_preserves_newline_boundary() {
let renderer = TerminalRenderer::new();
let mut state = MarkdownStreamState::default();
// Simulate streaming: paragraph text arrives in one chunk
assert_eq!(
state.push(&renderer, "This is some text."),
None,
"incomplete paragraph should not flush"
);
// Then code block arrives in the next chunk
let rendered = state
.push(&renderer, "\n\n```rust\nfn main() {}\n```\n")
.expect("fenced block should trigger flush");
let plain_text = strip_ansi(&rendered);
// Verify the paragraph text and code block are present
assert!(plain_text.contains("This is some text."));
assert!(plain_text.contains("fn main()"));
// The critical check: there should be a blank line between the paragraph and code block
// The paragraph ends, then there should be newlines before the code block marker
let lines: Vec<&str> = plain_text.lines().collect();
let text_line_idx = lines
.iter()
.position(|line| line.contains("This is some text."))
.expect("find text line");
// Skip past the text line and find the code block
let code_marker_idx = lines[text_line_idx + 1..]
.iter()
.position(|line| line.contains("╭─ rust"))
.map(|i| i + text_line_idx + 1);
// There should be at least one empty line between the text and the code block marker
if let Some(code_idx) = code_marker_idx {
assert!(
code_idx > text_line_idx + 1,
"there should be spacing between paragraph and code block"
);
}
}
#[test]
fn rendering_paragraph_immediately_before_code_block_has_blank_line() {
let renderer = TerminalRenderer::new();
let markdown = "This is a paragraph.\n\n```rust\nfn main() {}\n```";
let output = renderer.markdown_to_ansi(markdown);
let plain_text = strip_ansi(&output);
let lines: Vec<&str> = plain_text.lines().collect();
// Find the paragraph and code block
let text_idx = lines
.iter()
.position(|l| l.contains("This is a paragraph."))
.expect("find paragraph");
let code_idx = lines[text_idx + 1..]
.iter()
.position(|l| l.contains("╭─ rust"))
.map(|i| i + text_idx + 1)
.expect("find code block");
// There should be at least one blank line between them
assert!(
code_idx > text_idx + 1,
"code block should not immediately follow paragraph"
);
}
#[test]
fn code_block_ending_with_trailing_text_preserves_spacing() {
let renderer = TerminalRenderer::new();
let markdown = "```rust\nfn main() {}\n```\n\nText after.";
let output = renderer.markdown_to_ansi(markdown);
let plain_text = strip_ansi(&output);
// The code block should end with "╰─" followed by blank lines, then text
assert!(
plain_text.contains("\u{256d}\u{2500}"),
"should contain code block close marker"
);
assert!(
plain_text.contains("\n\nText"),
"should have blank line before text"
);
}
#[test]
fn streaming_code_block_preserves_trailing_newlines() {
let renderer = TerminalRenderer::new();
let mut state = MarkdownStreamState::default();
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
// Debug: let's check what the code block part alone renders to
let code_part = "```rust\nfn test() {}\n```\n\nText after.";
let code_rendered = strip_ansi(&renderer.markdown_to_ansi(code_part));
// Simulate the streaming at split position 1
let r1 = state.push(&renderer, "T").map(|s| strip_ansi(&s));
let r2 = state
.push(
&renderer,
"ext before.\n\n```rust\nfn test() {}\n```\n\nText after.",
)
.map(|s| strip_ansi(&s));
// After r2, the pending should contain the code block part
let pending_before_r3 = state.pending.clone();
let r3 = state.flush(&renderer).map(|s| strip_ansi(&s));
// Combine the results
let mut full_streamed = String::new();
if let Some(s) = &r1 {
full_streamed.push_str(s);
}
if let Some(s) = &r2 {
full_streamed.push_str(s);
}
if let Some(s) = &r3 {
full_streamed.push_str(s);
}
let full_rendered = strip_ansi(&renderer.markdown_to_ansi(markdown));
assert_eq!(full_streamed, full_rendered,
"Streaming mismatch.\n\nCode part alone renders to: {:?}\nPending before r3: {:?}\n\nBreakdown:\n r1: {:?}\n r2: {:?}\n r3: {:?}\n\nExpected:\n{:?}\n\nGot:\n{:?}",
code_rendered, pending_before_r3, r1, r2, r3, full_rendered, full_streamed);
}
#[test]
fn streaming_with_split_at_every_byte_position_produces_consistent_output() {
let renderer = TerminalRenderer::new();
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
// First, render the full document
let full_output = renderer.markdown_to_ansi(markdown);
let full_plain = strip_ansi(&full_output);
// Now simulate streaming by splitting at different positions
for split_pos in 1..markdown.len() {
let mut state = MarkdownStreamState::default();
let part1 = &markdown[..split_pos];
let part2 = &markdown[split_pos..];
// Try to push both parts
let r1 = state.push(&renderer, part1);
let r2 = state.push(&renderer, part2);
let final_r = state.flush(&renderer);
// Reconstruct the streamed output
let mut streamed_output = String::new();
if let Some(out) = r1 {
streamed_output.push_str(&out);
}
if let Some(out) = r2 {
streamed_output.push_str(&out);
}
if let Some(out) = final_r {
streamed_output.push_str(&out);
}
let streamed_plain = strip_ansi(&streamed_output);
if streamed_plain != full_plain {
eprintln!("\n=== MISMATCH at split position {} ===", split_pos);
eprintln!("Part 1: {:?}", part1);
eprintln!("Part 2: {:?}", part2);
eprintln!("\nExpected (full):");
eprintln!("{:?}", full_plain);
eprintln!("\nGot (streamed):");
eprintln!("{:?}", streamed_plain);
eprintln!("\nStreamed rendered output:");
eprintln!("{:?}", streamed_output);
}
assert_eq!(
streamed_plain, full_plain,
"streaming with split at position {} produces different output",
split_pos
);
}
}
}