From 00dd304a1bdeb4b6dd6782c9d60b7c11897da221 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Fri, 31 Jul 2026 10:58:50 -0400 Subject: [PATCH] Trim narration before insert, pack tool lines into one block The model often ends a round's narration with a trailing blank line before calling a tool; flush and finish inserted the live buffer untrimmed, so those newlines stacked blank transcript rows on top of the blank row every insert already appends. Both now trim leading and trailing whitespace first, keeping interior newlines. Every insert also appended its own trailing blank row, so four consecutive dice rolls showed a blank row between each. Screen now tracks whether the last transcript insert was a tool line; a tool line no longer adds a trailing blank, and the next non-tool insert (prose, the player's line, an aside, a failure) puts one blank row back in first. A block of tool lines now packs tight, with one blank row opening and closing it, matching the approved rendering. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CUXjWo1zGFhdJUig1hcQGf --- src/play/screen.rs | 57 ++++++++++++------ src/play/screen_turn_tests.rs | 110 ++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 19 deletions(-) diff --git a/src/play/screen.rs b/src/play/screen.rs index ea728e2..f698c88 100644 --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -67,6 +67,11 @@ struct Screen { /// Counts submits, so the thinking indicator's phrase changes turn to /// turn. turns: usize, + /// True when the most recent transcript insert was a tool line. + /// Consecutive tool lines pack together with no blank row between + /// them; the next non-tool insert reads this to put one blank row + /// back before whatever comes next. + last_was_tool: bool, } /// Runs the game until the player quits. @@ -91,8 +96,9 @@ pub fn play( busy: false, tick: 0, turns: 0, + last_was_tool: false, }; - insert(terminal, banner, aside())?; + insert(terminal, &mut screen, banner, aside())?; loop { screen.tick = screen.tick.wrapping_add(1); drain(&mut screen, terminal, turns)?; @@ -156,7 +162,8 @@ fn submit( return Ok(()); } let input = screen.input.take(); - insert(terminal, &format!("{PLAYER_MARKER}{input}"), player())?; + let line = format!("{PLAYER_MARKER}{input}"); + insert(terminal, screen, &line, player())?; screen.busy = true; let _ = inputs.send(input.clone()); history.record(&input); @@ -182,7 +189,7 @@ fn drain( Err(TryRecvError::Empty) => return Ok(()), Err(TryRecvError::Disconnected) => { if screen.busy { - insert(terminal, WORKER_GONE, failure())?; + insert(terminal, screen, WORKER_GONE, failure())?; settle(screen); } return Ok(()); @@ -192,18 +199,19 @@ fn drain( TurnEvent::Delta(text) => screen.live.push_str(&text), TurnEvent::Tool(text) => { flush(screen, terminal)?; - insert_text(terminal, text)?; + insert_text(terminal, screen, text)?; } TurnEvent::Done(_) => finish(screen, terminal)?, TurnEvent::Cancelled(_) => { - if !screen.live.is_empty() { - insert(terminal, &screen.live, reply())?; + let narration = screen.live.trim().to_string(); + if !narration.is_empty() { + insert(terminal, screen, &narration, reply())?; } - insert(terminal, INTERRUPTED, aside())?; + insert(terminal, screen, INTERRUPTED, aside())?; settle(screen); } TurnEvent::Failed(text) => { - insert(terminal, &text, failure())?; + insert(terminal, screen, &text, failure())?; settle(screen); } } @@ -220,8 +228,9 @@ fn drain( /// narrating first would otherwise leave a blank row before the tool /// line. fn flush(screen: &mut Screen, terminal: &mut Terminal) -> Result<(), B::Error> { - if !screen.live.trim().is_empty() { - insert(terminal, &screen.live, reply())?; + let narration = screen.live.trim().to_string(); + if !narration.is_empty() { + insert(terminal, screen, &narration, reply())?; } screen.live.clear(); Ok(()) @@ -234,10 +243,11 @@ fn flush(screen: &mut Screen, terminal: &mut Terminal) -> Result< /// nothing flushes, and the live region is the only place that has seen /// every delta of the turn. fn finish(screen: &mut Screen, terminal: &mut Terminal) -> Result<(), B::Error> { - if screen.live.trim().is_empty() { - insert(terminal, NOTHING_SAID, aside())?; + let narration = screen.live.trim().to_string(); + if narration.is_empty() { + insert(terminal, screen, NOTHING_SAID, aside())?; } else { - insert(terminal, &screen.live, reply())?; + insert(terminal, screen, &narration, reply())?; } settle(screen); Ok(()) @@ -253,32 +263,41 @@ fn settle(screen: &mut Screen) { } /// Wraps `text` to the terminal width and inserts it above the viewport, -/// with one blank row after it. +/// with one blank row after it. If the previous insert was a tool line, +/// this puts one blank row in first, so a tool block stays visually apart +/// from the prose, prompt, or aside that follows it. fn insert( terminal: &mut Terminal, + screen: &mut Screen, text: &str, style: Style, ) -> Result<(), B::Error> { + let spacer = u16::from(screen.last_was_tool); + screen.last_was_tool = false; let width = terminal.size()?.width; let rows = wrap(text, width as usize); - let height = rows.len() as u16 + 1; + let height = spacer + rows.len() as u16 + 1; terminal.insert_before(height, |buffer| { for (row, line) in rows.iter().enumerate() { - buffer.set_string(0, row as u16, line, style); + buffer.set_string(0, row as u16 + spacer, line, style); } }) } /// Wraps `text` to the terminal width, preserving each span's style, and -/// inserts it above the viewport with one blank row after it. A tool -/// composes its own styling; this adds none of its own. +/// inserts it above the viewport. A tool composes its own styling; this +/// adds none of its own. Consecutive tool lines pack together with no +/// blank row between them; `insert` puts the blank row back once +/// something else follows. fn insert_text( terminal: &mut Terminal, + screen: &mut Screen, text: Text<'static>, ) -> Result<(), B::Error> { + screen.last_was_tool = true; let width = terminal.size()?.width; let rows = wrap_spans(text, width as usize); - let height = rows.len() as u16 + 1; + let height = rows.len() as u16; terminal.insert_before(height, |buffer| { for (row, line) in rows.iter().enumerate() { buffer.set_line(0, row as u16, line, width); diff --git a/src/play/screen_turn_tests.rs b/src/play/screen_turn_tests.rs index 87f8d31..fd33f2d 100644 --- a/src/play/screen_turn_tests.rs +++ b/src/play/screen_turn_tests.rs @@ -163,6 +163,116 @@ fn a_tool_event_with_no_narration_inserts_only_the_tool_line() { assert_eq!(tool - banner, 2); } +#[test] +fn a_round_of_tool_calls_packs_into_one_block() { + let steps = vec![ + Step::Turn(TurnEvent::Delta( + "Let's try the trickier notations!".to_string(), + )), + Step::Turn(TurnEvent::Tool(Text::raw("roll a"))), + Step::Turn(TurnEvent::Tool(Text::raw("roll b"))), + Step::Turn(TurnEvent::Tool(Text::raw("roll c"))), + Step::Turn(TurnEvent::Tool(Text::raw("roll d"))), + Step::Turn(TurnEvent::Delta("All exotic rolls work great!".to_string())), + Step::Turn(TurnEvent::Done("All exotic rolls work great!".to_string())), + ]; + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let opening = lines + .iter() + .position(|line| *line == "Let's try the trickier notations!") + .unwrap(); + let a = lines.iter().position(|line| *line == "roll a").unwrap(); + let b = lines.iter().position(|line| *line == "roll b").unwrap(); + let c = lines.iter().position(|line| *line == "roll c").unwrap(); + let d = lines.iter().position(|line| *line == "roll d").unwrap(); + let closing = lines + .iter() + .position(|line| *line == "All exotic rolls work great!") + .unwrap(); + + // One blank row opens the block, none separate the tool lines inside + // it, and one blank row closes it before the next narration. + assert_eq!(a - opening, 2); + assert_eq!(b - a, 1); + assert_eq!(c - b, 1); + assert_eq!(d - c, 1); + assert_eq!(closing - d, 2); +} + +#[test] +fn narration_ending_in_blank_lines_flushes_without_extra_blank_rows() { + let steps = vec![ + Step::Turn(TurnEvent::Delta("You wake.\n\n".to_string())), + Step::Turn(TurnEvent::Tool(Text::raw("a tool line"))), + ]; + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let narration = lines.iter().position(|line| *line == "You wake.").unwrap(); + let tool = lines + .iter() + .position(|line| *line == "a tool line") + .unwrap(); + assert_eq!(tool - narration, 2); +} + +#[test] +fn a_finished_reply_ending_in_blank_lines_has_no_extra_blank_rows() { + let steps = vec![ + Step::Turn(TurnEvent::Delta("You wake.\n\n".to_string())), + Step::Turn(TurnEvent::Done("You wake.\n\n".to_string())), + ]; + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let banner = lines.iter().position(|line| *line == "a banner").unwrap(); + let narration = lines.iter().position(|line| *line == "You wake.").unwrap(); + assert_eq!(narration - banner, 2); +} + +#[test] +fn a_whitespace_only_reply_says_the_dm_says_nothing() { + let steps = vec![Step::Turn(TurnEvent::Done(" \n".to_string()))]; + + let played = play_script(steps); + + assert!(played.transcript().contains("(the DM says nothing)")); +} + +#[test] +fn the_prompt_after_a_tool_ended_turn_gets_one_blank_row() { + let mut steps = vec![Step::Turn(TurnEvent::Tool(Text::raw("a tool line")))]; + steps.push(Step::Turn(TurnEvent::Delta("ok".to_string()))); + steps.push(done()); + steps.extend(typing("again")); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let tool = lines + .iter() + .position(|line| *line == "a tool line") + .unwrap(); + let narration = lines.iter().position(|line| *line == "ok").unwrap(); + let prompt = lines.iter().position(|line| *line == "> again").unwrap(); + + // One blank row separates the tool line from the narration that ends + // the turn, and the usual single blank row separates it from the + // player's next prompt. + assert_eq!(narration - tool, 2); + assert_eq!(prompt - narration, 2); +} + #[test] fn a_secret_roll_between_narrated_rounds_keeps_every_word_in_the_transcript() { let steps = vec![ -- 2.51.2