From 78f0a5ce0f1eb3f4f11e9d2ee89e5f7d5fa8780e Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Sat, 28 Mar 2026 17:39:01 -0400 Subject: [PATCH] Replace Rich Live/Markdown with streaming ANSI renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old display pipeline used Rich's Live context to redraw the entire response on every chunk (~10x/sec), which polluted scrollback with hundreds of partial copies and forced a complex column layout that fought the streaming model. The new StreamRenderer writes directly to stdout character by character with its own markdown handling — bold, italic, code spans (orange), headings, bullets, blockquotes, numbered lists, horizontal rules, and fenced display blocks. SOL peek buffering classifies line types with two regexes, inline formatting tracks */`` delimiters to never break mid-markup. Also adds 6 new themed block types (scroll, letter, sign, lore, verse, dream) each with distinct border/color styling, consolidates the block prompt docs into a clean table, and feeds terminal width to the DM for sizing. Co-Authored-By: Claude Opus 4.6 (1M context) --- prompts/dm-system.md | 105 ++------ src/storied/cli.py | 31 ++- src/storied/display.py | 395 ++++++++++++++++++----------- src/storied/engine.py | 24 +- tests/test_display.py | 550 ++++++++++++++++++++--------------------- tests/test_engine.py | 7 - 6 files changed, 563 insertions(+), 549 deletions(-) diff --git a/prompts/dm-system.md b/prompts/dm-system.md index 6ffe310..e91b948 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -69,96 +69,37 @@ Don't over-describe. One vivid detail beats three adequate ones. Trust the playe ## Display Blocks -You have special fenced block types that render as distinct panels in the terminal. **Use them generously** — they make the world feel tangible. Draw a map when the player walks into a new place. Show the sign on the tavern door. Sketch the dagger they just looted. These visual moments are what players remember. - -### Maps — ` ```map Title` - -Use for spatial layouts: rooms, buildings, cities, regions. The terminal renders these in a bordered panel. - -```map The Rusty Anchor — Ground Floor -┌──────────────┬───────────┐ -│ COMMON │ KITCHEN │ -│ ROOM [B]│ │ -│ [T] [T] ───┤ [F] │ -│ │ │ -│ [☆] [T] ├───────────┤ -│ ═════ │ STORAGE │ -└─────═════────┴───────────┘ - ☆ You T Table B Bar F Fireplace -``` +You have special fenced block types that render as centered panels in the terminal. Each block type has its own visual style — borders, colors, and padding are handled automatically. Account for ~4-6 characters of overhead (border + padding) when sizing content. Check the terminal width in your context. -**Drawing guidelines:** -- Check the Display Layout section in your context for exact column widths -- Use Unicode freely: box-drawing (┌┐└┘─│├┤┬┴┼═║╔╗╚╝), blocks (█▓▒░), arrows (→←↑↓), symbols (●○◆★☆⚔⛪🏠) -- Legend below the map -- Mark the player's position with ☆ -- Scale to the situation: - - **Room**: furniture, doors, objects, cover - - **Building**: rooms, corridors, stairs - - **City**: districts, landmarks, gates, major roads - - **Region**: towns, roads, terrain, rivers - -**When to draw maps:** -- When the player enters a significant new location -- When spatial layout matters (combat, exploration, chase) -- When the player asks to see the area -- When you `establish` a location, include a map in the description +**Use them generously** — they make the world feel tangible. Draw a map when the player walks into a new place. Show the sign on the tavern door. Sketch the dagger they just looted. These visual moments are what players remember. -For important locations, also `establish` the map as a `maps` entity so it persists across sessions. +**Vary the rhythm.** Mix wide maps with narrow asides. A sprawling city map followed by a tight tavern menu followed by compact item art keeps the visual pace interesting. Don't make every block the same width — a hand-scrawled note should feel smaller than a district map. Emoji are typically 2 cells wide in terminals, so account for that in alignment. -### Asides — ` ```aside Title` +| Block | Use for | +|-------|---------| +| ` ```map` | Spatial layouts: rooms, buildings, cities, regions | +| ` ```aside` | Documents in the world: menus, notices, ledgers | +| ` ```item` | Equipment, artifacts, loot (draw with Unicode art) | +| ` ```scroll` | Ancient texts, prophecies, magical inscriptions | +| ` ```letter` | Personal correspondence, notes, journal entries | +| ` ```sign` | Posted notices, shop signs, gravestones, decrees | +| ` ```lore` | Legends, histories, scholarly excerpts | +| ` ```verse` | Songs, poems, riddles, chants | +| ` ```dream` | Visions, flashbacks, supernatural moments | -Use for documents the character reads: letters, signs, inscriptions, wanted posters, journal entries, prophecies. +Each type has its own visual style — colored borders, padding, and sometimes background tints are added automatically. **Don't draw your own borders** inside blocks; just write the content. The renderer wraps it in a styled panel. Text after the block type on the fence line becomes the panel title (e.g., ` ```aside Notice on the Door` → titled "Notice on the Door"). Pick the type that matches what the content **is** — a prophecy carved in stone is a `scroll`, a wanted poster nailed to a door is a `sign`, a bardic ballad is a `verse`. -```aside Notice on the Tavern Door -WANTED — Mira Ashvale -For questioning in connection with -the disappearance of Merchant Aldric. -50 gp reward. See Constable Harrik. -``` +### Map drawing guidelines -Asides render in a softer panel in the right column. Use them whenever the character encounters written text in the world — it makes the moment feel distinct from narration. Keep titles concise (e.g., "House Rules" not "Notice Posted on the Staircase Post") — long titles force the panel wider. - -**Good opportunities for asides:** -- Tavern menus, chalkboards, house rules -- Wanted posters, bounty boards, job listings -- Letters, notes, journal entries the character finds -- Signs, inscriptions, carved warnings -- Prophecies, riddles, magical runes -- Shop inventories, price lists -- Grave markers, plaques, dedications - -### Items — ` ```item Title` - -Use for significant items: magical weapons, artifacts, potions, treasures. Draw the item in Unicode art with labeled parts — blade, hilt, gem, etc. The terminal renders these in a magenta-bordered panel. - -```item The Tide's Tooth — Curved Dagger - . - / - / ≈ ≈ - | ≈ ≈ ≈ blade shifts - | ≈ ≈ ≈ between steel - | ≈ ≈ and seawater - | ≈ ≈ - ┌─────┐ - │░░░░░│ crossguard: barnacle-crusted bronze - └──┬──┘ - ◆◆◆◆◆◆ grip: sharkskin wound with - ◆◆◆◆◆◆ salt-stained cord - ┌──┴──┐ - │ ●● │ pommel: a smooth black sea stone - │●●●●│ (always cold, always wet) - └─────┘ -``` +- Use Unicode freely: box-drawing (┌┐└┘─│├┤┬┴┼═║╔╗╚╝), blocks (█▓▒░), arrows (→←↑↓), symbols (●○◆★☆⚔⛪🏠) +- Legend below the map, mark the player's position with ☆ +- Scale to the situation: room → furniture/doors, building → rooms/corridors, city → districts/landmarks, region → towns/roads +- When you `establish` a location, include a map in the description +- For important locations, also `establish` the map as a `maps` entity so it persists -Use item blocks when: -- The player finds or examines a notable item -- A magical item is identified or its properties are revealed -- A quest-relevant object is discovered -- A shopkeeper displays their wares -- The player inspects loot after combat +### Item drawing guidelines -Draw the item with labeled parts — a sword has a blade, crossguard, grip, pommel. A potion has a bottle shape, liquid color, stopper. A ring has a band, setting, gem. Let the art tell the story of the item's history and nature. +Draw items with Unicode art and labeled parts — a sword has a blade, crossguard, grip, pommel. A potion has a bottle shape, liquid color, stopper. A ring has a band, setting, gem. Let the art tell the story of the item's history and nature. ## Narrative Rhythm diff --git a/src/storied/cli.py b/src/storied/cli.py index e25a2cd..f230888 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -197,7 +197,6 @@ def cmd_play(args: argparse.Namespace) -> int: import tempfile from rich.console import Console - from rich.live import Live from rich.markdown import Markdown from rich.panel import Panel from rich.rule import Rule @@ -329,7 +328,7 @@ def cmd_play(args: argparse.Namespace) -> int: console.print("[dim]The DM will guide you through character creation...[/dim]") console.print() - from storied.display import StreamClassifier, build_display + from storied.display import StreamRenderer # Kick off the conversation with appropriate first message if sandbox: @@ -490,18 +489,24 @@ def cmd_play(args: argparse.Namespace) -> int: try: console.print(Rule(style="dim blue")) console.print() # Blank line before DM response - classifier = StreamClassifier() - - with Live(console=console, refresh_per_second=10, vertical_overflow="visible") as live: - for chunk in engine.stream_action(action): - if chunk.startswith("\n[") or chunk.startswith("Rolled "): - classifier.feed_tool(chunk) - else: - classifier.feed(chunk) - live.update(build_display(classifier.parts, console.width)) - classifier.flush() - live.update(build_display(classifier.parts, console.width)) + renderer = StreamRenderer(console) + prev_type: str | None = None + + for chunk in engine.stream_action(action): + if chunk.startswith("\n[") or chunk.startswith("Rolled "): + if prev_type != "tool": + renderer.flush() + console.file.write("\n") + console.print() + console.print(f"[dim]{chunk.strip()}[/dim]") + prev_type = "tool" + else: + if prev_type == "tool": + console.print() + renderer.feed(chunk) + prev_type = "text" + renderer.flush() console.print() # Show debug token info if enabled diff --git a/src/storied/display.py b/src/storied/display.py index e3f3f81..6a8b2ad 100644 --- a/src/storied/display.py +++ b/src/storied/display.py @@ -1,179 +1,280 @@ -"""Display rendering for DM output — stream classification and column layout.""" +"""Streaming markdown renderer for DM output. + +Streams text to the terminal character-by-character with ANSI formatting. +Display blocks (```map, ```aside, ```item) accumulate and render as +Rich Panels. No Rich Live context, no redraws, perfect scrollback. +""" import re +from io import TextIOBase from rich import box as rich_box from rich.align import Align -from rich.console import Group -from rich.markdown import Markdown +from rich.console import Console from rich.panel import Panel -from rich.table import Table -from rich.text import Text +from rich.rule import Rule + +BLOCK_TYPES = ("map", "aside", "item", "scroll", "letter", "sign", "lore", "verse", "dream") -BLOCK_STYLES: dict[str, tuple[str, rich_box.Box]] = { - "map": ("green", rich_box.HEAVY), - "aside": ("yellow dim", rich_box.ROUNDED), - "item": ("magenta", rich_box.DOUBLE), +# (border_style, box, content_style) +BLOCK_STYLES: dict[str, tuple[str, rich_box.Box, str]] = { + "map": ("green", rich_box.HEAVY, ""), + "aside": ("yellow", rich_box.ROUNDED, ""), + "item": ("magenta", rich_box.DOUBLE, ""), + "scroll": ("dark_goldenrod", rich_box.DOUBLE, "navajo_white1 on grey7"), + "letter": ("grey50", rich_box.ROUNDED, ""), + "sign": ("bright_white", rich_box.HEAVY, "bold"), + "lore": ("cornflower_blue", rich_box.ROUNDED, ""), + "verse": ("pale_turquoise1", rich_box.ROUNDED, "italic"), + "dream": ("medium_purple1", rich_box.ROUNDED, "dim italic"), } -_FENCE_RE = re.compile(r"^```(map|aside|item)\s*(.*)") +_FENCE_RE = re.compile(r"^```(" + "|".join(BLOCK_TYPES) + r")\s*(.*)") + +# SOL classification: still ambiguous, need more characters +_SOL_NEEDS_MORE = re.compile(r"^(`{1,2}|#{1,3}|-{1,2}|\*|>|\d+\.?)$") + +# SOL classification: matched a structural line, buffer until \n +_SOL_LINE_BUFFERED = re.compile(r"^(```|#{1,3} |---|- |\* |> |\d+\. )") +# ANSI escape sequences +BOLD_ON = "\033[1m" +BOLD_OFF = "\033[22m" +ITALIC_ON = "\033[3m" +ITALIC_OFF = "\033[23m" +CODE_ON = "\033[1;33m" +CODE_OFF = "\033[22;39m" +DIM_ON = "\033[2m" +DIM_OFF = "\033[22m" +RESET = "\033[0m" -class StreamClassifier: - """Line-oriented state machine for classifying DM output chunks. - Splits streaming text into typed parts: plain text, tool notifications, - and fenced display blocks (```map, ```aside, ```item). +def make_panel(kind: str, title: str, content: str) -> Panel: + """Create a styled Panel for a display block.""" + border_style, border_box, content_style = BLOCK_STYLES.get( + kind, ("white", rich_box.ROUNDED, ""), + ) + kwargs: dict = dict( + title=title or None, + border_style=border_style, + box=border_box, + padding=2, + expand=False, + ) + if content_style: + kwargs["style"] = content_style + return Panel(content, **kwargs) + + +class StreamRenderer: + """Streams markdown-formatted text to the terminal with ANSI styling. + + Three modes: + - SOL peek: buffers start-of-line characters to classify line type + - Inline text: streams characters with bold/italic/code ANSI escapes + - Block: accumulates lines, renders Rich Panel on closing fence """ - def __init__(self) -> None: - self.parts: list[tuple] = [] - self._partial: str = "" + def __init__(self, console: Console) -> None: + self._console = console + self._out: TextIOBase = console.file + + # Inline markdown state + self._bold = False + self._italic = False + self._code = False + self._hold = "" + + # Block accumulation self._block: dict | None = None + self._block_line = "" - def feed_tool(self, chunk: str) -> None: - """Add a tool notification chunk directly.""" - self.parts.append(("tool", chunk.strip())) + # Line classification + self._at_sol = True + self._sol_buf = "" def feed(self, chunk: str) -> None: - """Process a narrative text chunk through the state machine.""" - text = self._partial + chunk - *lines, self._partial = text.split("\n") - - for line in lines: - self._process_line(line) + """Process a text chunk, streaming output to terminal.""" + for char in chunk: + if self._block is not None: + self._feed_block(char) + elif self._at_sol: + self._feed_sol(char) + else: + self._feed_inline(char) + self._out.flush() def flush(self) -> None: - """Flush any remaining partial line at end of stream.""" - if self._partial: - self._process_line(self._partial) - self._partial = "" - - def _process_line(self, line: str) -> None: + """Flush all pending state at end of stream.""" + self._flush_hold() + if self._sol_buf: + for c in self._sol_buf: + self._feed_inline(c) + self._sol_buf = "" if self._block is not None: - if line.strip() == "```": - # Remove live preview if present - if self.parts and self.parts[-1][0] == "block_preview": - self.parts.pop() - # Closing fence — finalize block - self.parts.append(( - "block", self._block["kind"], - self._block["title"], - "\n".join(self._block["lines"]), - )) - self._block = None + for line in self._block["lines"]: + self._out.write(line + "\n") + if self._block_line: + self._out.write(self._block_line) + self._block = None + self._block_line = "" + self._reset_styles() + self._at_sol = True + self._out.flush() + + # ── Block mode ─────────────────────────────────────────────────── + + def _feed_block(self, char: str) -> None: + if char == "\n": + if self._block_line.strip() == "```": + self._close_block() else: - self._block["lines"].append(line) - self._update_live_block() + self._block["lines"].append(self._block_line) + self._block_line = "" else: - m = _FENCE_RE.match(line) - if m: - self._block = { - "kind": m.group(1), - "title": m.group(2).strip(), - "lines": [], - } - else: - self._append_text(line) + self._block_line += char - def _append_text(self, line: str) -> None: - if self.parts and self.parts[-1][0] == "text": - self.parts[-1] = ("text", self.parts[-1][1] + "\n" + line) - else: - self.parts.append(("text", line)) - - def _update_live_block(self) -> None: - """Update or append an in-progress block part for live preview.""" - preview = ( - "block", self._block["kind"], - self._block["title"], - "\n".join(self._block["lines"]), - ) - if self.parts and self.parts[-1][0] == "block_preview": - self.parts[-1] = ("block_preview", *preview[1:]) - else: - self.parts.append(("block_preview", *preview[1:])) + def _close_block(self) -> None: + kind = self._block["kind"] + title = self._block["title"] + content = "\n".join(self._block["lines"]) + self._block = None + self._block_line = "" + panel = make_panel(kind, title, content) + self._console.print(Align.center(panel)) -def make_panel(kind: str, title: str, content: str) -> Panel: - """Create a styled Panel for a display block.""" - border_style, border_box = BLOCK_STYLES.get(kind, ("white", rich_box.ROUNDED)) - return Panel( - content, title=title or None, - border_style=border_style, box=border_box, - padding=(0, 1), expand=False, - ) + self._at_sol = True + self._sol_buf = "" + # ── SOL peek mode ──────────────────────────────────────────────── -def build_display(parts: list[tuple], console_width: int) -> Group | Text: - """Build display as a continuous 2/3 + 1/3 column layout. + def _feed_sol(self, char: str) -> None: + self._sol_buf += char - Text and tool notifications go in the left column. Narrow blocks - (fitting in 1/3 of terminal) go in the right column. Wide blocks - break the grid and render centered full-width. Text always renders - in the left 2/3 for consistent, comfortable reading width. - """ - sections: list = [] - grid: Table | None = None - left_parts: list = [] - right_panel: Panel | None = None - prev_type: str | None = None - narrow_max = console_width // 3 - - def new_grid() -> Table: - g = Table.grid(padding=(0, 2)) - g.add_column(ratio=2) - g.add_column(ratio=1, min_width=narrow_max) - return g - - def close_row() -> None: - nonlocal right_panel, grid - if not left_parts and right_panel is None: + if char == "\n": + self._resolve_line(self._sol_buf.rstrip("\n")) + self._sol_buf = "" return - if grid is None: - grid = new_grid() - left = Group(*left_parts) if left_parts else Text("") - grid.add_row(left, right_panel or Text("")) - left_parts.clear() - right_panel = None - - def close_grid() -> None: - nonlocal grid - close_row() - if grid is not None and grid.row_count > 0: - sections.append(grid) - grid = None - - for part in parts: - part_type = part[0] - - if part_type == "tool": - if prev_type == "text": - left_parts.append(Text("")) - left_parts.append(Text(part[1], style="dim")) - - elif part_type == "text" and part[1].strip(): - if prev_type in ("tool", "block", "block_preview"): - left_parts.append(Text("")) - left_parts.append(Markdown(part[1])) - - elif part_type in ("block", "block_preview"): - _, kind, title, content = part - content_width = max( - (len(line) for line in content.splitlines()), default=0, - ) - panel_width = content_width + 4 - panel = make_panel(kind, title, content) - - if panel_width <= narrow_max: - if right_panel is not None: - close_row() - right_panel = panel - else: - close_grid() - sections.append(Align.center(panel)) - prev_type = part_type + buf = self._sol_buf + if _SOL_NEEDS_MORE.match(buf): + return + if _SOL_LINE_BUFFERED.match(buf): + return + + # Not a structural line — flush to inline text mode + self._at_sol = False + text = self._sol_buf + self._sol_buf = "" + for c in text: + self._feed_inline(c) + self._out.flush() + + def _resolve_line(self, line: str) -> None: + """Render a complete structural line.""" + self._at_sol = True + + if not line: + self._out.write("\n") + return + + if line.strip() == "---": + self._console.print(Rule(style="dim")) + return + + m = _FENCE_RE.match(line) + if m: + self._block = { + "kind": m.group(1), + "title": m.group(2).strip(), + "lines": [], + } + return + + heading = re.match(r"^(#{1,3})\s+(.*)", line) + if heading: + text = heading.group(2) + self._out.write(BOLD_ON) + self._emit_inline_text(text) + self._flush_hold() + self._out.write(BOLD_OFF + "\n") + self._out.flush() + return + + if line.startswith("- ") or line.startswith("* "): + self._out.write(" • ") + self._emit_inline_text(line[2:]) + self._flush_hold() + self._out.write("\n") + self._out.flush() + return + + if line.startswith("> "): + self._out.write(" " + DIM_ON) + self._emit_inline_text(line[2:]) + self._flush_hold() + self._out.write(DIM_OFF + "\n") + self._out.flush() + return + + numbered = re.match(r"^(\d+\.\s)(.*)", line) + if numbered: + self._out.write(" " + numbered.group(1)) + self._emit_inline_text(numbered.group(2)) + self._flush_hold() + self._out.write("\n") + self._out.flush() + return + + # Fallback: regular text that happened to be line-buffered + self._emit_inline_text(line) + self._flush_hold() + self._out.write("\n") + self._out.flush() + + # ── Inline text mode ───────────────────────────────────────────── + + def _feed_inline(self, char: str) -> None: + if self._hold: + prev = self._hold + self._hold = "" + if prev == "*" and char == "*": + self._bold = not self._bold + self._out.write(BOLD_ON if self._bold else BOLD_OFF) + return + # Single * — toggle italic, then process current char + self._italic = not self._italic + self._out.write(ITALIC_ON if self._italic else ITALIC_OFF) + + if char == "*" and not self._code: + self._hold = "*" + elif char == "`": + self._code = not self._code + self._out.write(CODE_ON if self._code else CODE_OFF) + elif char == "\n": + self._out.write("\n") + self._at_sol = True + self._sol_buf = "" + else: + self._out.write(char) + + def _emit_inline_text(self, text: str) -> None: + """Emit a string through inline markdown processing.""" + for c in text: + self._feed_inline(c) + + def _flush_hold(self) -> None: + """Emit any held * character.""" + if self._hold: + self._italic = not self._italic + self._out.write(ITALIC_ON if self._italic else ITALIC_OFF) + self._hold = "" - close_grid() - return Group(*sections) if sections else Text("") + def _reset_styles(self) -> None: + """Reset all ANSI styles if any are active.""" + if self._bold or self._italic or self._code: + self._out.write(RESET) + self._bold = False + self._italic = False + self._code = False diff --git a/src/storied/engine.py b/src/storied/engine.py index a32b7a0..637ee30 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -1,7 +1,6 @@ """DM Engine - drives claude -p for running 5e sessions.""" import json -import os import re from collections.abc import Iterator from datetime import UTC, datetime @@ -242,22 +241,13 @@ class DMEngine: parts.append(entity_context) loaded_names.add(name) - # Display layout info so the DM knows its column sizes - term_width = os.get_terminal_size().columns - right_col = term_width // 3 - left_col = term_width - right_col - right_content = right_col - 4 # panel border + padding - layout = ( - "## Display Layout\n\n" - f"Terminal: {term_width} chars wide. " - f"Text column: ~{left_col} chars. " - f"Side column: ~{right_col} chars.\n" - f"Display blocks under ~{right_content} chars wide " - "inset in the right column. Wider blocks render centered full-width.\n" - "Keep aside/item titles concise — long titles force the panel wider." - ) - self._context_parts["Layout"] = layout - parts.append(layout) + # Terminal width so the DM can size display blocks + import os + try: + term_width = os.get_terminal_size().columns + except OSError: + term_width = 120 + parts.append(f"Terminal width: {term_width} chars.") return "\n\n---\n\n".join(parts) diff --git a/tests/test_display.py b/tests/test_display.py index c3affa5..407a1e7 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -1,305 +1,289 @@ -"""Tests for the display module — stream classification and column layout.""" +"""Tests for the streaming markdown renderer.""" + +import io import pytest -from rich.align import Align -from rich.console import Group -from rich.panel import Panel -from rich.table import Table -from rich.text import Text - -from storied.display import StreamClassifier, build_display, make_panel - - -# ── StreamClassifier ───────────────────────────────────────────────────── - - -class TestStreamClassifierText: - """Plain text accumulation.""" - - def test_single_line(self): - c = StreamClassifier() - c.feed("Hello world\n") - assert c.parts == [("text", "Hello world")] - - def test_multiple_lines_merge(self): - c = StreamClassifier() - c.feed("Line one\nLine two\n") - assert c.parts == [("text", "Line one\nLine two")] - - def test_chunked_delivery(self): - c = StreamClassifier() - c.feed("Hello ") - c.feed("world\n") - assert c.parts == [("text", "Hello world")] - - def test_flush_partial_line(self): - c = StreamClassifier() - c.feed("No trailing newline") - assert len(c.parts) == 0 - c.flush() - assert c.parts == [("text", "No trailing newline")] - - def test_empty_lines_preserved(self): - c = StreamClassifier() - c.feed("Before\n\nAfter\n") - assert c.parts == [("text", "Before\n\nAfter")] - - -class TestStreamClassifierBlocks: - """Fenced display block detection.""" - - def test_map_block(self): - c = StreamClassifier() - c.feed("Before\n```map Tavern\n+-+\n|X|\n+-+\n```\nAfter\n") - assert c.parts[0] == ("text", "Before") - assert c.parts[1] == ("block", "map", "Tavern", "+-+\n|X|\n+-+") - assert c.parts[2] == ("text", "After") - - def test_aside_block(self): - c = StreamClassifier() - c.feed("```aside A Letter\nDear friend,\nCome quickly.\n```\n") - assert c.parts[0] == ("block", "aside", "A Letter", "Dear friend,\nCome quickly.") - - def test_item_block(self): - c = StreamClassifier() - c.feed("```item Magic Sword\n/|\\\n | \n```\n") - assert c.parts[0] == ("block", "item", "Magic Sword", "/|\\\n | ") - - def test_block_without_title(self): - c = StreamClassifier() - c.feed("```map\n+-+\n```\n") - assert c.parts[0] == ("block", "map", "", "+-+") - - def test_regular_code_block_passthrough(self): - c = StreamClassifier() - c.feed("```python\nprint('hi')\n```\n") - assert c.parts[0][0] == "text" - assert "python" in c.parts[0][1] - - def test_chunked_block_delivery(self): - c = StreamClassifier() - c.feed("```map Room") - c.feed("\n+-+\n|") - c.feed("X|\n+-+\n```\nDone\n") - assert c.parts[0] == ("block", "map", "Room", "+-+\n|X|\n+-+") - assert c.parts[1] == ("text", "Done") +from rich.console import Console - def test_block_preview_during_streaming(self): - c = StreamClassifier() - c.feed("```map Room\nline1\n") - previews = [p for p in c.parts if p[0] == "block_preview"] - assert len(previews) == 1 - assert previews[0][1] == "map" +from storied.display import StreamRenderer - def test_preview_replaced_by_final_block(self): - c = StreamClassifier() - c.feed("```map Room\nline1\nline2\n```\n") - assert not any(p[0] == "block_preview" for p in c.parts) - assert c.parts[0] == ("block", "map", "Room", "line1\nline2") +BOLD_ON = "\033[1m" +BOLD_OFF = "\033[22m" +ITALIC_ON = "\033[3m" +ITALIC_OFF = "\033[23m" +CODE_ON = "\033[1;33m" +CODE_OFF = "\033[22;39m" +DIM_ON = "\033[2m" - def test_backticks_inside_block_not_closing(self): - c = StreamClassifier() - c.feed("```map Room\n`code`\n```\n") - assert c.parts[0] == ("block", "map", "Room", "`code`") +@pytest.fixture +def out() -> io.StringIO: + return io.StringIO() -class TestStreamClassifierTools: - """Tool notification handling.""" - def test_tool_via_feed_tool(self): - c = StreamClassifier() - c.feed_tool("\n[Rolling...]\n") - assert c.parts == [("tool", "[Rolling...]")] - - def test_tool_interleaved_with_text(self): - c = StreamClassifier() - c.feed("Some text\n") - c.feed_tool("\n[Rolling...]\n") - c.feed("More text\n") - assert c.parts[0] == ("text", "Some text") - assert c.parts[1] == ("tool", "[Rolling...]") - assert c.parts[2] == ("text", "More text") - - -class TestStreamClassifierMixed: - """Complex mixed content scenarios.""" - - def test_text_block_text(self): - c = StreamClassifier() - c.feed("Intro\n```aside Note\nHello\n```\nOutro\n") - assert len(c.parts) == 3 - assert c.parts[0][0] == "text" - assert c.parts[1][0] == "block" - assert c.parts[2][0] == "text" - - def test_multiple_blocks(self): - c = StreamClassifier() - c.feed("```map A\n+-+\n```\n```aside B\nHi\n```\n") - assert c.parts[0] == ("block", "map", "A", "+-+") - assert c.parts[1] == ("block", "aside", "B", "Hi") +@pytest.fixture +def renderer(out: io.StringIO) -> StreamRenderer: + console = Console(file=out, force_terminal=True, no_color=False) + return StreamRenderer(console) - def test_tool_between_blocks(self): - c = StreamClassifier() - c.feed("Text\n") - c.feed_tool("\n[Setting scene...]\n") - c.feed("```map Room\n+-+\n```\n") - assert c.parts[0] == ("text", "Text") - assert c.parts[1] == ("tool", "[Setting scene...]") - assert c.parts[2] == ("block", "map", "Room", "+-+") - - -# ── make_panel ─────────────────────────────────────────────────────────── - - -class TestMakePanel: - """Panel creation with styled borders.""" - def test_map_panel(self): - panel = make_panel("map", "Room", "+-+") - assert isinstance(panel, Panel) - assert str(panel.title) == "Room" +def rendered(renderer: StreamRenderer, out: io.StringIO) -> str: + """Flush and return all output.""" + renderer.flush() + return out.getvalue() - def test_aside_panel(self): - panel = make_panel("aside", "Note", "Hello") - assert isinstance(panel, Panel) - def test_item_panel(self): - panel = make_panel("item", "Sword", "X") - assert isinstance(panel, Panel) +# ── Inline formatting ──────────────────────────────────────────────────── - def test_no_title(self): - panel = make_panel("map", "", "+-+") - assert panel.title is None - def test_expand_false(self): - panel = make_panel("map", "Room", "+-+") - assert panel.expand is False +class TestBold: + def test_bold_wraps_text(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("**hello**\n") + text = rendered(renderer, out) + assert BOLD_ON in text + assert "hello" in text + assert BOLD_OFF in text -# ── build_display ──────────────────────────────────────────────────────── + def test_bold_across_chunks(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("**hel") + renderer.feed("lo**\n") + text = rendered(renderer, out) + assert BOLD_ON in text + assert BOLD_OFF in text + def test_star_at_chunk_boundary(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("*") + renderer.feed("*bold**\n") + text = rendered(renderer, out) + assert BOLD_ON in text + assert BOLD_OFF in text -WIDTH = 120 -NARROW_MAX = WIDTH // 3 # 40 +class TestItalic: -@pytest.fixture -def narrow_block() -> tuple: - """A block narrow enough to fit in the right column (< 40 chars).""" - return ("block", "aside", "Note", "Short content") + def test_italic_wraps_text(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("*hello*\n") + text = rendered(renderer, out) + assert ITALIC_ON in text + assert "hello" in text + assert ITALIC_OFF in text + def test_italic_across_chunks(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("*hel") + renderer.feed("lo*\n") + text = rendered(renderer, out) + assert ITALIC_ON in text + assert ITALIC_OFF in text -@pytest.fixture -def wide_block() -> tuple: - """A block too wide for the right column (> 40 chars).""" - return ("block", "map", "City", "x" * 50) - - -class TestBuildDisplayTextOnly: - """Text-only responses should use the 2/3 column grid.""" - - def test_returns_group(self): - parts = [("text", "Hello world")] - result = build_display(parts, WIDTH) - assert isinstance(result, Group) - - def test_single_text_creates_grid(self): - parts = [("text", "Hello world")] - result = build_display(parts, WIDTH) - grids = [r for r in result.renderables if isinstance(r, Table)] - assert len(grids) == 1 - - def test_empty_parts_returns_text(self): - result = build_display([], WIDTH) - assert isinstance(result, Text) - - -class TestBuildDisplayNarrowBlocks: - """Narrow blocks should pair with text in the right column.""" - - def test_text_plus_narrow_block_single_grid(self, narrow_block: tuple): - parts = [("text", "Narrative"), narrow_block] - result = build_display(parts, WIDTH) - grids = [r for r in result.renderables if isinstance(r, Table)] - assert len(grids) == 1 - assert grids[0].row_count == 1 - - def test_text_flows_alongside_block(self, narrow_block: tuple): - parts = [ - ("text", "First paragraph"), - narrow_block, - ("text", "Second paragraph"), - ] - result = build_display(parts, WIDTH) - grids = [r for r in result.renderables if isinstance(r, Table)] - # Both text parts should be in the same row as the block - assert len(grids) == 1 - assert grids[0].row_count == 1 - - def test_second_block_closes_row(self): - parts = [ - ("text", "Intro"), - ("block", "aside", "A", "First"), - ("block", "aside", "B", "Second"), - ("text", "Outro"), - ] - result = build_display(parts, WIDTH) - grids = [r for r in result.renderables if isinstance(r, Table)] - assert len(grids) == 1 - assert grids[0].row_count == 2 - - -class TestBuildDisplayWideBlocks: - """Wide blocks should render centered, breaking the grid.""" - - def test_wide_block_centered(self, wide_block: tuple): - parts = [("text", "Before"), wide_block, ("text", "After")] - result = build_display(parts, WIDTH) - aligns = [r for r in result.renderables if isinstance(r, Align)] - assert len(aligns) == 1 - - def test_wide_block_splits_grids(self, wide_block: tuple): - parts = [("text", "Before"), wide_block, ("text", "After")] - result = build_display(parts, WIDTH) - grids = [r for r in result.renderables if isinstance(r, Table)] - assert len(grids) == 2 # one before, one after the wide block - - -class TestBuildDisplayTools: - """Tool notifications go in the left column.""" - - def test_tool_in_grid(self): - parts = [ - ("text", "Narrative"), - ("tool", "[Rolling...]"), - ("text", "Result"), - ] - result = build_display(parts, WIDTH) - grids = [r for r in result.renderables if isinstance(r, Table)] - assert len(grids) == 1 - assert grids[0].row_count == 1 - - def test_tool_with_block(self, narrow_block: tuple): - parts = [ - ("text", "Narrative"), - ("tool", "[Rolling...]"), - narrow_block, - ("text", "After"), - ] - result = build_display(parts, WIDTH) - grids = [r for r in result.renderables if isinstance(r, Table)] - assert len(grids) == 1 - - -class TestBuildDisplayBlockPreview: - """In-progress blocks should render like finalized blocks.""" - - def test_preview_treated_as_block(self): - parts = [ - ("text", "Narrative"), - ("block_preview", "aside", "Note", "In progress..."), - ] - result = build_display(parts, WIDTH) - grids = [r for r in result.renderables if isinstance(r, Table)] - assert len(grids) == 1 - assert grids[0].row_count == 1 + +class TestBoldItalic: + + def test_bold_italic(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("***both***\n") + text = rendered(renderer, out) + assert BOLD_ON in text + assert ITALIC_ON in text + assert "both" in text + + def test_nested_bold_in_italic(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("*italic **bold** text*\n") + text = rendered(renderer, out) + assert ITALIC_ON in text + assert BOLD_ON in text + assert BOLD_OFF in text + assert ITALIC_OFF in text + + +class TestCode: + + def test_inline_code(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("`code`\n") + text = rendered(renderer, out) + assert CODE_ON in text + assert "code" in text + assert CODE_OFF in text + + +class TestPlainText: + + def test_plain_text_passes_through(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("hello world\n") + text = rendered(renderer, out) + assert "hello world" in text + + def test_multiple_chunks(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("hello ") + renderer.feed("world\n") + text = rendered(renderer, out) + assert "hello world" in text + + def test_empty_line(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("before\n\nafter\n") + text = rendered(renderer, out) + assert "before\n\nafter" in text + + +# ── Line-level constructs ──────────────────────────────────────────────── + + +class TestHorizontalRule: + + def test_rule(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("---\n") + text = rendered(renderer, out) + # Rule is rendered by Rich, check it's not literal --- + assert "---" not in text or "─" in text or "━" in text or text.strip() != "---" + + +class TestHeadings: + + def test_h1(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("# Title\n") + text = rendered(renderer, out) + assert BOLD_ON in text + assert "Title" in text + + def test_h2(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("## Section\n") + text = rendered(renderer, out) + assert BOLD_ON in text + assert "Section" in text + + def test_h3(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("### Subsection\n") + text = rendered(renderer, out) + assert BOLD_ON in text + assert "Subsection" in text + + def test_heading_with_inline_bold(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("### **Vera's** Secret\n") + text = rendered(renderer, out) + assert "Vera's" in text + assert "Secret" in text + + +class TestBulletList: + + def test_dash_bullet(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("- first item\n") + text = rendered(renderer, out) + assert "•" in text + assert "first item" in text + + def test_star_bullet(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("* second item\n") + text = rendered(renderer, out) + assert "•" in text + assert "second item" in text + + +class TestBlockquote: + + def test_blockquote(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("> quoted text\n") + text = rendered(renderer, out) + assert "quoted text" in text + assert DIM_ON in text + + def test_narrow_block_centered(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("```aside Note\nShort\n```\n") + text = rendered(renderer, out) + assert "Note" in text + + +class TestNumberedList: + + def test_numbered(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("1. first\n") + text = rendered(renderer, out) + assert "1." in text + assert "first" in text + + +# ── Display blocks ─────────────────────────────────────────────────────── + + +class TestBlocks: + + def test_map_block_renders_panel(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("```map Tavern\n+-+\n|X|\n+-+\n```\n") + text = rendered(renderer, out) + assert "Tavern" in text + assert "+-+" in text + + def test_aside_block(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("```aside A Letter\nDear friend,\n```\n") + text = rendered(renderer, out) + assert "A Letter" in text + assert "Dear friend," in text + + def test_block_between_text(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("Before\n```map Room\n+-+\n```\nAfter\n") + text = rendered(renderer, out) + assert "Before" in text + assert "+-+" in text + assert "After" in text + + def test_regular_code_block_passes_through(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("```python\nprint('hi')\n```\n") + text = rendered(renderer, out) + assert "python" in text + assert "print" in text + + def test_block_after_flush(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("Some text\n") + renderer.flush() + renderer.feed("```item Dagger\n/|\\\n```\n") + text = rendered(renderer, out) + assert "Dagger" in text + assert "/|\\" in text + + +# ── SOL edge cases ─────────────────────────────────────────────────────── + + +class TestSOLClassification: + + def test_text_starting_with_hash_no_space(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("#hashtag\n") + text = rendered(renderer, out) + assert "#hashtag" in text + assert BOLD_ON not in text + + def test_text_starting_with_dash_no_space(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("-not a bullet\n") + text = rendered(renderer, out) + assert "-not a bullet" in text + + def test_dashes_not_a_rule(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("--not a rule\n") + text = rendered(renderer, out) + assert "--not a rule" in text + + def test_line_starting_with_letter(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("The quick brown fox\n") + text = rendered(renderer, out) + assert "The quick brown fox" in text + + +# ── Flush ──────────────────────────────────────────────────────────────── + + +class TestFlush: + + def test_flush_emits_buffered_star_as_italic(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("trailing*") + text = rendered(renderer, out) + assert "trailing" in text + assert ITALIC_ON in text + + def test_flush_resets_bold(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("**unclosed bold") + text = rendered(renderer, out) + assert "\033[0m" in text + + def test_flush_emits_sol_buffer(self, renderer: StreamRenderer, out: io.StringIO): + renderer.feed("##") # SOL buffer, waiting for more + text = rendered(renderer, out) + assert "##" in text diff --git a/tests/test_engine.py b/tests/test_engine.py index 150fa66..0208eb7 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -63,13 +63,6 @@ class TestToolNotification: class TestDMEngineContext: """Tests for DMEngine context building (mocks MCP server startup).""" - @pytest.fixture(autouse=True) - def _mock_terminal(self): - import os - size = os.terminal_size((120, 40)) - with patch("storied.engine.os.get_terminal_size", return_value=size): - yield - @pytest.fixture def engine(self, tmp_path: Path): from storied.engine import DMEngine -- 2.51.2