A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Replace Rich Live/Markdown with streaming ANSI renderer

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) <noreply@anthropic.com>

+505 -491
+23 -82
prompts/dm-system.md
··· 69 69 70 70 ## Display Blocks 71 71 72 - 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. 72 + 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. 73 + 74 + **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. 75 + 76 + **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. 73 77 74 - ### Maps — ` ```map Title` 78 + | Block | Use for | 79 + |-------|---------| 80 + | ` ```map` | Spatial layouts: rooms, buildings, cities, regions | 81 + | ` ```aside` | Documents in the world: menus, notices, ledgers | 82 + | ` ```item` | Equipment, artifacts, loot (draw with Unicode art) | 83 + | ` ```scroll` | Ancient texts, prophecies, magical inscriptions | 84 + | ` ```letter` | Personal correspondence, notes, journal entries | 85 + | ` ```sign` | Posted notices, shop signs, gravestones, decrees | 86 + | ` ```lore` | Legends, histories, scholarly excerpts | 87 + | ` ```verse` | Songs, poems, riddles, chants | 88 + | ` ```dream` | Visions, flashbacks, supernatural moments | 75 89 76 - Use for spatial layouts: rooms, buildings, cities, regions. The terminal renders these in a bordered panel. 90 + 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`. 77 91 78 - ```map The Rusty Anchor — Ground Floor 79 - ┌──────────────┬───────────┐ 80 - │ COMMON │ KITCHEN │ 81 - │ ROOM [B]│ │ 82 - │ [T] [T] ───┤ [F] │ 83 - │ │ │ 84 - │ [☆] [T] ├───────────┤ 85 - │ ═════ │ STORAGE │ 86 - └─────═════────┴───────────┘ 87 - ☆ You T Table B Bar F Fireplace 88 - ``` 92 + ### Map drawing guidelines 89 93 90 - **Drawing guidelines:** 91 - - Check the Display Layout section in your context for exact column widths 92 94 - Use Unicode freely: box-drawing (┌┐└┘─│├┤┬┴┼═║╔╗╚╝), blocks (█▓▒░), arrows (→←↑↓), symbols (●○◆★☆⚔⛪🏠) 93 - - Legend below the map 94 - - Mark the player's position with ☆ 95 - - Scale to the situation: 96 - - **Room**: furniture, doors, objects, cover 97 - - **Building**: rooms, corridors, stairs 98 - - **City**: districts, landmarks, gates, major roads 99 - - **Region**: towns, roads, terrain, rivers 100 - 101 - **When to draw maps:** 102 - - When the player enters a significant new location 103 - - When spatial layout matters (combat, exploration, chase) 104 - - When the player asks to see the area 95 + - Legend below the map, mark the player's position with ☆ 96 + - Scale to the situation: room → furniture/doors, building → rooms/corridors, city → districts/landmarks, region → towns/roads 105 97 - When you `establish` a location, include a map in the description 98 + - For important locations, also `establish` the map as a `maps` entity so it persists 106 99 107 - For important locations, also `establish` the map as a `maps` entity so it persists across sessions. 100 + ### Item drawing guidelines 108 101 109 - ### Asides — ` ```aside Title` 110 - 111 - Use for documents the character reads: letters, signs, inscriptions, wanted posters, journal entries, prophecies. 112 - 113 - ```aside Notice on the Tavern Door 114 - WANTED — Mira Ashvale 115 - For questioning in connection with 116 - the disappearance of Merchant Aldric. 117 - 50 gp reward. See Constable Harrik. 118 - ``` 119 - 120 - 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. 121 - 122 - **Good opportunities for asides:** 123 - - Tavern menus, chalkboards, house rules 124 - - Wanted posters, bounty boards, job listings 125 - - Letters, notes, journal entries the character finds 126 - - Signs, inscriptions, carved warnings 127 - - Prophecies, riddles, magical runes 128 - - Shop inventories, price lists 129 - - Grave markers, plaques, dedications 130 - 131 - ### Items — ` ```item Title` 132 - 133 - 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. 134 - 135 - ```item The Tide's Tooth — Curved Dagger 136 - . 137 - / 138 - / ≈ ≈ 139 - | ≈ ≈ ≈ blade shifts 140 - | ≈ ≈ ≈ between steel 141 - | ≈ ≈ and seawater 142 - | ≈ ≈ 143 - ┌─────┐ 144 - │░░░░░│ crossguard: barnacle-crusted bronze 145 - └──┬──┘ 146 - ◆◆◆◆◆◆ grip: sharkskin wound with 147 - ◆◆◆◆◆◆ salt-stained cord 148 - ┌──┴──┐ 149 - │ ●● │ pommel: a smooth black sea stone 150 - │●●●●│ (always cold, always wet) 151 - └─────┘ 152 - ``` 153 - 154 - Use item blocks when: 155 - - The player finds or examines a notable item 156 - - A magical item is identified or its properties are revealed 157 - - A quest-relevant object is discovered 158 - - A shopkeeper displays their wares 159 - - The player inspects loot after combat 160 - 161 - 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. 102 + 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. 162 103 163 104 ## Narrative Rhythm 164 105
+17 -12
src/storied/cli.py
··· 197 197 import tempfile 198 198 199 199 from rich.console import Console 200 - from rich.live import Live 201 200 from rich.markdown import Markdown 202 201 from rich.panel import Panel 203 202 from rich.rule import Rule ··· 329 328 console.print("[dim]The DM will guide you through character creation...[/dim]") 330 329 console.print() 331 330 332 - from storied.display import StreamClassifier, build_display 331 + from storied.display import StreamRenderer 333 332 334 333 # Kick off the conversation with appropriate first message 335 334 if sandbox: ··· 490 489 try: 491 490 console.print(Rule(style="dim blue")) 492 491 console.print() # Blank line before DM response 493 - classifier = StreamClassifier() 492 + renderer = StreamRenderer(console) 493 + prev_type: str | None = None 494 494 495 - with Live(console=console, refresh_per_second=10, vertical_overflow="visible") as live: 496 - for chunk in engine.stream_action(action): 497 - if chunk.startswith("\n[") or chunk.startswith("Rolled "): 498 - classifier.feed_tool(chunk) 499 - else: 500 - classifier.feed(chunk) 501 - live.update(build_display(classifier.parts, console.width)) 502 - classifier.flush() 503 - live.update(build_display(classifier.parts, console.width)) 495 + for chunk in engine.stream_action(action): 496 + if chunk.startswith("\n[") or chunk.startswith("Rolled "): 497 + if prev_type != "tool": 498 + renderer.flush() 499 + console.file.write("\n") 500 + console.print() 501 + console.print(f"[dim]{chunk.strip()}[/dim]") 502 + prev_type = "tool" 503 + else: 504 + if prev_type == "tool": 505 + console.print() 506 + renderer.feed(chunk) 507 + prev_type = "text" 504 508 509 + renderer.flush() 505 510 console.print() 506 511 507 512 # Show debug token info if enabled
+239 -138
src/storied/display.py
··· 1 - """Display rendering for DM output — stream classification and column layout.""" 1 + """Streaming markdown renderer for DM output. 2 + 3 + Streams text to the terminal character-by-character with ANSI formatting. 4 + Display blocks (```map, ```aside, ```item) accumulate and render as 5 + Rich Panels. No Rich Live context, no redraws, perfect scrollback. 6 + """ 2 7 3 8 import re 9 + from io import TextIOBase 4 10 5 11 from rich import box as rich_box 6 12 from rich.align import Align 7 - from rich.console import Group 8 - from rich.markdown import Markdown 13 + from rich.console import Console 9 14 from rich.panel import Panel 10 - from rich.table import Table 11 - from rich.text import Text 15 + from rich.rule import Rule 12 16 13 - BLOCK_STYLES: dict[str, tuple[str, rich_box.Box]] = { 14 - "map": ("green", rich_box.HEAVY), 15 - "aside": ("yellow dim", rich_box.ROUNDED), 16 - "item": ("magenta", rich_box.DOUBLE), 17 + BLOCK_TYPES = ("map", "aside", "item", "scroll", "letter", "sign", "lore", "verse", "dream") 18 + 19 + # (border_style, box, content_style) 20 + BLOCK_STYLES: dict[str, tuple[str, rich_box.Box, str]] = { 21 + "map": ("green", rich_box.HEAVY, ""), 22 + "aside": ("yellow", rich_box.ROUNDED, ""), 23 + "item": ("magenta", rich_box.DOUBLE, ""), 24 + "scroll": ("dark_goldenrod", rich_box.DOUBLE, "navajo_white1 on grey7"), 25 + "letter": ("grey50", rich_box.ROUNDED, ""), 26 + "sign": ("bright_white", rich_box.HEAVY, "bold"), 27 + "lore": ("cornflower_blue", rich_box.ROUNDED, ""), 28 + "verse": ("pale_turquoise1", rich_box.ROUNDED, "italic"), 29 + "dream": ("medium_purple1", rich_box.ROUNDED, "dim italic"), 17 30 } 18 31 19 - _FENCE_RE = re.compile(r"^```(map|aside|item)\s*(.*)") 32 + _FENCE_RE = re.compile(r"^```(" + "|".join(BLOCK_TYPES) + r")\s*(.*)") 33 + 34 + # SOL classification: still ambiguous, need more characters 35 + _SOL_NEEDS_MORE = re.compile(r"^(`{1,2}|#{1,3}|-{1,2}|\*|>|\d+\.?)$") 36 + 37 + # SOL classification: matched a structural line, buffer until \n 38 + _SOL_LINE_BUFFERED = re.compile(r"^(```|#{1,3} |---|- |\* |> |\d+\. )") 39 + 40 + # ANSI escape sequences 41 + BOLD_ON = "\033[1m" 42 + BOLD_OFF = "\033[22m" 43 + ITALIC_ON = "\033[3m" 44 + ITALIC_OFF = "\033[23m" 45 + CODE_ON = "\033[1;33m" 46 + CODE_OFF = "\033[22;39m" 47 + DIM_ON = "\033[2m" 48 + DIM_OFF = "\033[22m" 49 + RESET = "\033[0m" 20 50 21 51 22 - class StreamClassifier: 23 - """Line-oriented state machine for classifying DM output chunks. 52 + def make_panel(kind: str, title: str, content: str) -> Panel: 53 + """Create a styled Panel for a display block.""" 54 + border_style, border_box, content_style = BLOCK_STYLES.get( 55 + kind, ("white", rich_box.ROUNDED, ""), 56 + ) 57 + kwargs: dict = dict( 58 + title=title or None, 59 + border_style=border_style, 60 + box=border_box, 61 + padding=2, 62 + expand=False, 63 + ) 64 + if content_style: 65 + kwargs["style"] = content_style 66 + return Panel(content, **kwargs) 24 67 25 - Splits streaming text into typed parts: plain text, tool notifications, 26 - and fenced display blocks (```map, ```aside, ```item). 68 + 69 + class StreamRenderer: 70 + """Streams markdown-formatted text to the terminal with ANSI styling. 71 + 72 + Three modes: 73 + - SOL peek: buffers start-of-line characters to classify line type 74 + - Inline text: streams characters with bold/italic/code ANSI escapes 75 + - Block: accumulates lines, renders Rich Panel on closing fence 27 76 """ 28 77 29 - def __init__(self) -> None: 30 - self.parts: list[tuple] = [] 31 - self._partial: str = "" 78 + def __init__(self, console: Console) -> None: 79 + self._console = console 80 + self._out: TextIOBase = console.file 81 + 82 + # Inline markdown state 83 + self._bold = False 84 + self._italic = False 85 + self._code = False 86 + self._hold = "" 87 + 88 + # Block accumulation 32 89 self._block: dict | None = None 90 + self._block_line = "" 33 91 34 - def feed_tool(self, chunk: str) -> None: 35 - """Add a tool notification chunk directly.""" 36 - self.parts.append(("tool", chunk.strip())) 92 + # Line classification 93 + self._at_sol = True 94 + self._sol_buf = "" 37 95 38 96 def feed(self, chunk: str) -> None: 39 - """Process a narrative text chunk through the state machine.""" 40 - text = self._partial + chunk 41 - *lines, self._partial = text.split("\n") 42 - 43 - for line in lines: 44 - self._process_line(line) 97 + """Process a text chunk, streaming output to terminal.""" 98 + for char in chunk: 99 + if self._block is not None: 100 + self._feed_block(char) 101 + elif self._at_sol: 102 + self._feed_sol(char) 103 + else: 104 + self._feed_inline(char) 105 + self._out.flush() 45 106 46 107 def flush(self) -> None: 47 - """Flush any remaining partial line at end of stream.""" 48 - if self._partial: 49 - self._process_line(self._partial) 50 - self._partial = "" 51 - 52 - def _process_line(self, line: str) -> None: 108 + """Flush all pending state at end of stream.""" 109 + self._flush_hold() 110 + if self._sol_buf: 111 + for c in self._sol_buf: 112 + self._feed_inline(c) 113 + self._sol_buf = "" 53 114 if self._block is not None: 54 - if line.strip() == "```": 55 - # Remove live preview if present 56 - if self.parts and self.parts[-1][0] == "block_preview": 57 - self.parts.pop() 58 - # Closing fence — finalize block 59 - self.parts.append(( 60 - "block", self._block["kind"], 61 - self._block["title"], 62 - "\n".join(self._block["lines"]), 63 - )) 64 - self._block = None 115 + for line in self._block["lines"]: 116 + self._out.write(line + "\n") 117 + if self._block_line: 118 + self._out.write(self._block_line) 119 + self._block = None 120 + self._block_line = "" 121 + self._reset_styles() 122 + self._at_sol = True 123 + self._out.flush() 124 + 125 + # ── Block mode ─────────────────────────────────────────────────── 126 + 127 + def _feed_block(self, char: str) -> None: 128 + if char == "\n": 129 + if self._block_line.strip() == "```": 130 + self._close_block() 65 131 else: 66 - self._block["lines"].append(line) 67 - self._update_live_block() 132 + self._block["lines"].append(self._block_line) 133 + self._block_line = "" 68 134 else: 69 - m = _FENCE_RE.match(line) 70 - if m: 71 - self._block = { 72 - "kind": m.group(1), 73 - "title": m.group(2).strip(), 74 - "lines": [], 75 - } 76 - else: 77 - self._append_text(line) 135 + self._block_line += char 136 + 137 + def _close_block(self) -> None: 138 + kind = self._block["kind"] 139 + title = self._block["title"] 140 + content = "\n".join(self._block["lines"]) 141 + self._block = None 142 + self._block_line = "" 143 + 144 + panel = make_panel(kind, title, content) 145 + self._console.print(Align.center(panel)) 146 + 147 + self._at_sol = True 148 + self._sol_buf = "" 149 + 150 + # ── SOL peek mode ──────────────────────────────────────────────── 151 + 152 + def _feed_sol(self, char: str) -> None: 153 + self._sol_buf += char 154 + 155 + if char == "\n": 156 + self._resolve_line(self._sol_buf.rstrip("\n")) 157 + self._sol_buf = "" 158 + return 78 159 79 - def _append_text(self, line: str) -> None: 80 - if self.parts and self.parts[-1][0] == "text": 81 - self.parts[-1] = ("text", self.parts[-1][1] + "\n" + line) 82 - else: 83 - self.parts.append(("text", line)) 160 + buf = self._sol_buf 161 + if _SOL_NEEDS_MORE.match(buf): 162 + return 163 + if _SOL_LINE_BUFFERED.match(buf): 164 + return 84 165 85 - def _update_live_block(self) -> None: 86 - """Update or append an in-progress block part for live preview.""" 87 - preview = ( 88 - "block", self._block["kind"], 89 - self._block["title"], 90 - "\n".join(self._block["lines"]), 91 - ) 92 - if self.parts and self.parts[-1][0] == "block_preview": 93 - self.parts[-1] = ("block_preview", *preview[1:]) 94 - else: 95 - self.parts.append(("block_preview", *preview[1:])) 166 + # Not a structural line — flush to inline text mode 167 + self._at_sol = False 168 + text = self._sol_buf 169 + self._sol_buf = "" 170 + for c in text: 171 + self._feed_inline(c) 172 + self._out.flush() 96 173 174 + def _resolve_line(self, line: str) -> None: 175 + """Render a complete structural line.""" 176 + self._at_sol = True 97 177 98 - def make_panel(kind: str, title: str, content: str) -> Panel: 99 - """Create a styled Panel for a display block.""" 100 - border_style, border_box = BLOCK_STYLES.get(kind, ("white", rich_box.ROUNDED)) 101 - return Panel( 102 - content, title=title or None, 103 - border_style=border_style, box=border_box, 104 - padding=(0, 1), expand=False, 105 - ) 178 + if not line: 179 + self._out.write("\n") 180 + return 106 181 182 + if line.strip() == "---": 183 + self._console.print(Rule(style="dim")) 184 + return 107 185 108 - def build_display(parts: list[tuple], console_width: int) -> Group | Text: 109 - """Build display as a continuous 2/3 + 1/3 column layout. 186 + m = _FENCE_RE.match(line) 187 + if m: 188 + self._block = { 189 + "kind": m.group(1), 190 + "title": m.group(2).strip(), 191 + "lines": [], 192 + } 193 + return 110 194 111 - Text and tool notifications go in the left column. Narrow blocks 112 - (fitting in 1/3 of terminal) go in the right column. Wide blocks 113 - break the grid and render centered full-width. Text always renders 114 - in the left 2/3 for consistent, comfortable reading width. 115 - """ 116 - sections: list = [] 117 - grid: Table | None = None 118 - left_parts: list = [] 119 - right_panel: Panel | None = None 120 - prev_type: str | None = None 121 - narrow_max = console_width // 3 195 + heading = re.match(r"^(#{1,3})\s+(.*)", line) 196 + if heading: 197 + text = heading.group(2) 198 + self._out.write(BOLD_ON) 199 + self._emit_inline_text(text) 200 + self._flush_hold() 201 + self._out.write(BOLD_OFF + "\n") 202 + self._out.flush() 203 + return 122 204 123 - def new_grid() -> Table: 124 - g = Table.grid(padding=(0, 2)) 125 - g.add_column(ratio=2) 126 - g.add_column(ratio=1, min_width=narrow_max) 127 - return g 205 + if line.startswith("- ") or line.startswith("* "): 206 + self._out.write(" • ") 207 + self._emit_inline_text(line[2:]) 208 + self._flush_hold() 209 + self._out.write("\n") 210 + self._out.flush() 211 + return 128 212 129 - def close_row() -> None: 130 - nonlocal right_panel, grid 131 - if not left_parts and right_panel is None: 213 + if line.startswith("> "): 214 + self._out.write(" " + DIM_ON) 215 + self._emit_inline_text(line[2:]) 216 + self._flush_hold() 217 + self._out.write(DIM_OFF + "\n") 218 + self._out.flush() 132 219 return 133 - if grid is None: 134 - grid = new_grid() 135 - left = Group(*left_parts) if left_parts else Text("") 136 - grid.add_row(left, right_panel or Text("")) 137 - left_parts.clear() 138 - right_panel = None 139 220 140 - def close_grid() -> None: 141 - nonlocal grid 142 - close_row() 143 - if grid is not None and grid.row_count > 0: 144 - sections.append(grid) 145 - grid = None 221 + numbered = re.match(r"^(\d+\.\s)(.*)", line) 222 + if numbered: 223 + self._out.write(" " + numbered.group(1)) 224 + self._emit_inline_text(numbered.group(2)) 225 + self._flush_hold() 226 + self._out.write("\n") 227 + self._out.flush() 228 + return 146 229 147 - for part in parts: 148 - part_type = part[0] 230 + # Fallback: regular text that happened to be line-buffered 231 + self._emit_inline_text(line) 232 + self._flush_hold() 233 + self._out.write("\n") 234 + self._out.flush() 149 235 150 - if part_type == "tool": 151 - if prev_type == "text": 152 - left_parts.append(Text("")) 153 - left_parts.append(Text(part[1], style="dim")) 236 + # ── Inline text mode ───────────────────────────────────────────── 154 237 155 - elif part_type == "text" and part[1].strip(): 156 - if prev_type in ("tool", "block", "block_preview"): 157 - left_parts.append(Text("")) 158 - left_parts.append(Markdown(part[1])) 238 + def _feed_inline(self, char: str) -> None: 239 + if self._hold: 240 + prev = self._hold 241 + self._hold = "" 242 + if prev == "*" and char == "*": 243 + self._bold = not self._bold 244 + self._out.write(BOLD_ON if self._bold else BOLD_OFF) 245 + return 246 + # Single * — toggle italic, then process current char 247 + self._italic = not self._italic 248 + self._out.write(ITALIC_ON if self._italic else ITALIC_OFF) 159 249 160 - elif part_type in ("block", "block_preview"): 161 - _, kind, title, content = part 162 - content_width = max( 163 - (len(line) for line in content.splitlines()), default=0, 164 - ) 165 - panel_width = content_width + 4 166 - panel = make_panel(kind, title, content) 250 + if char == "*" and not self._code: 251 + self._hold = "*" 252 + elif char == "`": 253 + self._code = not self._code 254 + self._out.write(CODE_ON if self._code else CODE_OFF) 255 + elif char == "\n": 256 + self._out.write("\n") 257 + self._at_sol = True 258 + self._sol_buf = "" 259 + else: 260 + self._out.write(char) 167 261 168 - if panel_width <= narrow_max: 169 - if right_panel is not None: 170 - close_row() 171 - right_panel = panel 172 - else: 173 - close_grid() 174 - sections.append(Align.center(panel)) 262 + def _emit_inline_text(self, text: str) -> None: 263 + """Emit a string through inline markdown processing.""" 264 + for c in text: 265 + self._feed_inline(c) 175 266 176 - prev_type = part_type 267 + def _flush_hold(self) -> None: 268 + """Emit any held * character.""" 269 + if self._hold: 270 + self._italic = not self._italic 271 + self._out.write(ITALIC_ON if self._italic else ITALIC_OFF) 272 + self._hold = "" 177 273 178 - close_grid() 179 - return Group(*sections) if sections else Text("") 274 + def _reset_styles(self) -> None: 275 + """Reset all ANSI styles if any are active.""" 276 + if self._bold or self._italic or self._code: 277 + self._out.write(RESET) 278 + self._bold = False 279 + self._italic = False 280 + self._code = False
+7 -17
src/storied/engine.py
··· 1 1 """DM Engine - drives claude -p for running 5e sessions.""" 2 2 3 3 import json 4 - import os 5 4 import re 6 5 from collections.abc import Iterator 7 6 from datetime import UTC, datetime ··· 242 241 parts.append(entity_context) 243 242 loaded_names.add(name) 244 243 245 - # Display layout info so the DM knows its column sizes 246 - term_width = os.get_terminal_size().columns 247 - right_col = term_width // 3 248 - left_col = term_width - right_col 249 - right_content = right_col - 4 # panel border + padding 250 - layout = ( 251 - "## Display Layout\n\n" 252 - f"Terminal: {term_width} chars wide. " 253 - f"Text column: ~{left_col} chars. " 254 - f"Side column: ~{right_col} chars.\n" 255 - f"Display blocks under ~{right_content} chars wide " 256 - "inset in the right column. Wider blocks render centered full-width.\n" 257 - "Keep aside/item titles concise — long titles force the panel wider." 258 - ) 259 - self._context_parts["Layout"] = layout 260 - parts.append(layout) 244 + # Terminal width so the DM can size display blocks 245 + import os 246 + try: 247 + term_width = os.get_terminal_size().columns 248 + except OSError: 249 + term_width = 120 250 + parts.append(f"Terminal width: {term_width} chars.") 261 251 262 252 return "\n\n---\n\n".join(parts) 263 253
+219 -235
tests/test_display.py
··· 1 - """Tests for the display module — stream classification and column layout.""" 1 + """Tests for the streaming markdown renderer.""" 2 + 3 + import io 2 4 3 5 import pytest 4 - from rich.align import Align 5 - from rich.console import Group 6 - from rich.panel import Panel 7 - from rich.table import Table 8 - from rich.text import Text 6 + from rich.console import Console 9 7 10 - from storied.display import StreamClassifier, build_display, make_panel 8 + from storied.display import StreamRenderer 11 9 10 + BOLD_ON = "\033[1m" 11 + BOLD_OFF = "\033[22m" 12 + ITALIC_ON = "\033[3m" 13 + ITALIC_OFF = "\033[23m" 14 + CODE_ON = "\033[1;33m" 15 + CODE_OFF = "\033[22;39m" 16 + DIM_ON = "\033[2m" 12 17 13 - # ── StreamClassifier ───────────────────────────────────────────────────── 14 18 19 + @pytest.fixture 20 + def out() -> io.StringIO: 21 + return io.StringIO() 15 22 16 - class TestStreamClassifierText: 17 - """Plain text accumulation.""" 18 23 19 - def test_single_line(self): 20 - c = StreamClassifier() 21 - c.feed("Hello world\n") 22 - assert c.parts == [("text", "Hello world")] 24 + @pytest.fixture 25 + def renderer(out: io.StringIO) -> StreamRenderer: 26 + console = Console(file=out, force_terminal=True, no_color=False) 27 + return StreamRenderer(console) 23 28 24 - def test_multiple_lines_merge(self): 25 - c = StreamClassifier() 26 - c.feed("Line one\nLine two\n") 27 - assert c.parts == [("text", "Line one\nLine two")] 28 29 29 - def test_chunked_delivery(self): 30 - c = StreamClassifier() 31 - c.feed("Hello ") 32 - c.feed("world\n") 33 - assert c.parts == [("text", "Hello world")] 30 + def rendered(renderer: StreamRenderer, out: io.StringIO) -> str: 31 + """Flush and return all output.""" 32 + renderer.flush() 33 + return out.getvalue() 34 34 35 - def test_flush_partial_line(self): 36 - c = StreamClassifier() 37 - c.feed("No trailing newline") 38 - assert len(c.parts) == 0 39 - c.flush() 40 - assert c.parts == [("text", "No trailing newline")] 41 35 42 - def test_empty_lines_preserved(self): 43 - c = StreamClassifier() 44 - c.feed("Before\n\nAfter\n") 45 - assert c.parts == [("text", "Before\n\nAfter")] 36 + # ── Inline formatting ──────────────────────────────────────────────────── 46 37 47 38 48 - class TestStreamClassifierBlocks: 49 - """Fenced display block detection.""" 39 + class TestBold: 50 40 51 - def test_map_block(self): 52 - c = StreamClassifier() 53 - c.feed("Before\n```map Tavern\n+-+\n|X|\n+-+\n```\nAfter\n") 54 - assert c.parts[0] == ("text", "Before") 55 - assert c.parts[1] == ("block", "map", "Tavern", "+-+\n|X|\n+-+") 56 - assert c.parts[2] == ("text", "After") 41 + def test_bold_wraps_text(self, renderer: StreamRenderer, out: io.StringIO): 42 + renderer.feed("**hello**\n") 43 + text = rendered(renderer, out) 44 + assert BOLD_ON in text 45 + assert "hello" in text 46 + assert BOLD_OFF in text 57 47 58 - def test_aside_block(self): 59 - c = StreamClassifier() 60 - c.feed("```aside A Letter\nDear friend,\nCome quickly.\n```\n") 61 - assert c.parts[0] == ("block", "aside", "A Letter", "Dear friend,\nCome quickly.") 48 + def test_bold_across_chunks(self, renderer: StreamRenderer, out: io.StringIO): 49 + renderer.feed("**hel") 50 + renderer.feed("lo**\n") 51 + text = rendered(renderer, out) 52 + assert BOLD_ON in text 53 + assert BOLD_OFF in text 62 54 63 - def test_item_block(self): 64 - c = StreamClassifier() 65 - c.feed("```item Magic Sword\n/|\\\n | \n```\n") 66 - assert c.parts[0] == ("block", "item", "Magic Sword", "/|\\\n | ") 55 + def test_star_at_chunk_boundary(self, renderer: StreamRenderer, out: io.StringIO): 56 + renderer.feed("*") 57 + renderer.feed("*bold**\n") 58 + text = rendered(renderer, out) 59 + assert BOLD_ON in text 60 + assert BOLD_OFF in text 67 61 68 - def test_block_without_title(self): 69 - c = StreamClassifier() 70 - c.feed("```map\n+-+\n```\n") 71 - assert c.parts[0] == ("block", "map", "", "+-+") 72 62 73 - def test_regular_code_block_passthrough(self): 74 - c = StreamClassifier() 75 - c.feed("```python\nprint('hi')\n```\n") 76 - assert c.parts[0][0] == "text" 77 - assert "python" in c.parts[0][1] 63 + class TestItalic: 78 64 79 - def test_chunked_block_delivery(self): 80 - c = StreamClassifier() 81 - c.feed("```map Room") 82 - c.feed("\n+-+\n|") 83 - c.feed("X|\n+-+\n```\nDone\n") 84 - assert c.parts[0] == ("block", "map", "Room", "+-+\n|X|\n+-+") 85 - assert c.parts[1] == ("text", "Done") 65 + def test_italic_wraps_text(self, renderer: StreamRenderer, out: io.StringIO): 66 + renderer.feed("*hello*\n") 67 + text = rendered(renderer, out) 68 + assert ITALIC_ON in text 69 + assert "hello" in text 70 + assert ITALIC_OFF in text 86 71 87 - def test_block_preview_during_streaming(self): 88 - c = StreamClassifier() 89 - c.feed("```map Room\nline1\n") 90 - previews = [p for p in c.parts if p[0] == "block_preview"] 91 - assert len(previews) == 1 92 - assert previews[0][1] == "map" 72 + def test_italic_across_chunks(self, renderer: StreamRenderer, out: io.StringIO): 73 + renderer.feed("*hel") 74 + renderer.feed("lo*\n") 75 + text = rendered(renderer, out) 76 + assert ITALIC_ON in text 77 + assert ITALIC_OFF in text 93 78 94 - def test_preview_replaced_by_final_block(self): 95 - c = StreamClassifier() 96 - c.feed("```map Room\nline1\nline2\n```\n") 97 - assert not any(p[0] == "block_preview" for p in c.parts) 98 - assert c.parts[0] == ("block", "map", "Room", "line1\nline2") 99 79 100 - def test_backticks_inside_block_not_closing(self): 101 - c = StreamClassifier() 102 - c.feed("```map Room\n`code`\n```\n") 103 - assert c.parts[0] == ("block", "map", "Room", "`code`") 80 + class TestBoldItalic: 104 81 82 + def test_bold_italic(self, renderer: StreamRenderer, out: io.StringIO): 83 + renderer.feed("***both***\n") 84 + text = rendered(renderer, out) 85 + assert BOLD_ON in text 86 + assert ITALIC_ON in text 87 + assert "both" in text 105 88 106 - class TestStreamClassifierTools: 107 - """Tool notification handling.""" 89 + def test_nested_bold_in_italic(self, renderer: StreamRenderer, out: io.StringIO): 90 + renderer.feed("*italic **bold** text*\n") 91 + text = rendered(renderer, out) 92 + assert ITALIC_ON in text 93 + assert BOLD_ON in text 94 + assert BOLD_OFF in text 95 + assert ITALIC_OFF in text 108 96 109 - def test_tool_via_feed_tool(self): 110 - c = StreamClassifier() 111 - c.feed_tool("\n[Rolling...]\n") 112 - assert c.parts == [("tool", "[Rolling...]")] 113 97 114 - def test_tool_interleaved_with_text(self): 115 - c = StreamClassifier() 116 - c.feed("Some text\n") 117 - c.feed_tool("\n[Rolling...]\n") 118 - c.feed("More text\n") 119 - assert c.parts[0] == ("text", "Some text") 120 - assert c.parts[1] == ("tool", "[Rolling...]") 121 - assert c.parts[2] == ("text", "More text") 98 + class TestCode: 122 99 100 + def test_inline_code(self, renderer: StreamRenderer, out: io.StringIO): 101 + renderer.feed("`code`\n") 102 + text = rendered(renderer, out) 103 + assert CODE_ON in text 104 + assert "code" in text 105 + assert CODE_OFF in text 123 106 124 - class TestStreamClassifierMixed: 125 - """Complex mixed content scenarios.""" 126 107 127 - def test_text_block_text(self): 128 - c = StreamClassifier() 129 - c.feed("Intro\n```aside Note\nHello\n```\nOutro\n") 130 - assert len(c.parts) == 3 131 - assert c.parts[0][0] == "text" 132 - assert c.parts[1][0] == "block" 133 - assert c.parts[2][0] == "text" 108 + class TestPlainText: 134 109 135 - def test_multiple_blocks(self): 136 - c = StreamClassifier() 137 - c.feed("```map A\n+-+\n```\n```aside B\nHi\n```\n") 138 - assert c.parts[0] == ("block", "map", "A", "+-+") 139 - assert c.parts[1] == ("block", "aside", "B", "Hi") 110 + def test_plain_text_passes_through(self, renderer: StreamRenderer, out: io.StringIO): 111 + renderer.feed("hello world\n") 112 + text = rendered(renderer, out) 113 + assert "hello world" in text 140 114 141 - def test_tool_between_blocks(self): 142 - c = StreamClassifier() 143 - c.feed("Text\n") 144 - c.feed_tool("\n[Setting scene...]\n") 145 - c.feed("```map Room\n+-+\n```\n") 146 - assert c.parts[0] == ("text", "Text") 147 - assert c.parts[1] == ("tool", "[Setting scene...]") 148 - assert c.parts[2] == ("block", "map", "Room", "+-+") 115 + def test_multiple_chunks(self, renderer: StreamRenderer, out: io.StringIO): 116 + renderer.feed("hello ") 117 + renderer.feed("world\n") 118 + text = rendered(renderer, out) 119 + assert "hello world" in text 149 120 121 + def test_empty_line(self, renderer: StreamRenderer, out: io.StringIO): 122 + renderer.feed("before\n\nafter\n") 123 + text = rendered(renderer, out) 124 + assert "before\n\nafter" in text 150 125 151 - # ── make_panel ─────────────────────────────────────────────────────────── 152 126 127 + # ── Line-level constructs ──────────────────────────────────────────────── 153 128 154 - class TestMakePanel: 155 - """Panel creation with styled borders.""" 156 129 157 - def test_map_panel(self): 158 - panel = make_panel("map", "Room", "+-+") 159 - assert isinstance(panel, Panel) 160 - assert str(panel.title) == "Room" 130 + class TestHorizontalRule: 161 131 162 - def test_aside_panel(self): 163 - panel = make_panel("aside", "Note", "Hello") 164 - assert isinstance(panel, Panel) 132 + def test_rule(self, renderer: StreamRenderer, out: io.StringIO): 133 + renderer.feed("---\n") 134 + text = rendered(renderer, out) 135 + # Rule is rendered by Rich, check it's not literal --- 136 + assert "---" not in text or "─" in text or "━" in text or text.strip() != "---" 165 137 166 - def test_item_panel(self): 167 - panel = make_panel("item", "Sword", "X") 168 - assert isinstance(panel, Panel) 169 138 170 - def test_no_title(self): 171 - panel = make_panel("map", "", "+-+") 172 - assert panel.title is None 139 + class TestHeadings: 173 140 174 - def test_expand_false(self): 175 - panel = make_panel("map", "Room", "+-+") 176 - assert panel.expand is False 141 + def test_h1(self, renderer: StreamRenderer, out: io.StringIO): 142 + renderer.feed("# Title\n") 143 + text = rendered(renderer, out) 144 + assert BOLD_ON in text 145 + assert "Title" in text 177 146 147 + def test_h2(self, renderer: StreamRenderer, out: io.StringIO): 148 + renderer.feed("## Section\n") 149 + text = rendered(renderer, out) 150 + assert BOLD_ON in text 151 + assert "Section" in text 178 152 179 - # ── build_display ──────────────────────────────────────────────────────── 153 + def test_h3(self, renderer: StreamRenderer, out: io.StringIO): 154 + renderer.feed("### Subsection\n") 155 + text = rendered(renderer, out) 156 + assert BOLD_ON in text 157 + assert "Subsection" in text 180 158 159 + def test_heading_with_inline_bold(self, renderer: StreamRenderer, out: io.StringIO): 160 + renderer.feed("### **Vera's** Secret\n") 161 + text = rendered(renderer, out) 162 + assert "Vera's" in text 163 + assert "Secret" in text 181 164 182 - WIDTH = 120 183 - NARROW_MAX = WIDTH // 3 # 40 184 165 166 + class TestBulletList: 185 167 186 - @pytest.fixture 187 - def narrow_block() -> tuple: 188 - """A block narrow enough to fit in the right column (< 40 chars).""" 189 - return ("block", "aside", "Note", "Short content") 168 + def test_dash_bullet(self, renderer: StreamRenderer, out: io.StringIO): 169 + renderer.feed("- first item\n") 170 + text = rendered(renderer, out) 171 + assert "•" in text 172 + assert "first item" in text 190 173 174 + def test_star_bullet(self, renderer: StreamRenderer, out: io.StringIO): 175 + renderer.feed("* second item\n") 176 + text = rendered(renderer, out) 177 + assert "•" in text 178 + assert "second item" in text 191 179 192 - @pytest.fixture 193 - def wide_block() -> tuple: 194 - """A block too wide for the right column (> 40 chars).""" 195 - return ("block", "map", "City", "x" * 50) 196 180 181 + class TestBlockquote: 197 182 198 - class TestBuildDisplayTextOnly: 199 - """Text-only responses should use the 2/3 column grid.""" 183 + def test_blockquote(self, renderer: StreamRenderer, out: io.StringIO): 184 + renderer.feed("> quoted text\n") 185 + text = rendered(renderer, out) 186 + assert "quoted text" in text 187 + assert DIM_ON in text 200 188 201 - def test_returns_group(self): 202 - parts = [("text", "Hello world")] 203 - result = build_display(parts, WIDTH) 204 - assert isinstance(result, Group) 189 + def test_narrow_block_centered(self, renderer: StreamRenderer, out: io.StringIO): 190 + renderer.feed("```aside Note\nShort\n```\n") 191 + text = rendered(renderer, out) 192 + assert "Note" in text 193 + 194 + 195 + class TestNumberedList: 196 + 197 + def test_numbered(self, renderer: StreamRenderer, out: io.StringIO): 198 + renderer.feed("1. first\n") 199 + text = rendered(renderer, out) 200 + assert "1." in text 201 + assert "first" in text 202 + 203 + 204 + # ── Display blocks ─────────────────────────────────────────────────────── 205 + 206 + 207 + class TestBlocks: 208 + 209 + def test_map_block_renders_panel(self, renderer: StreamRenderer, out: io.StringIO): 210 + renderer.feed("```map Tavern\n+-+\n|X|\n+-+\n```\n") 211 + text = rendered(renderer, out) 212 + assert "Tavern" in text 213 + assert "+-+" in text 205 214 206 - def test_single_text_creates_grid(self): 207 - parts = [("text", "Hello world")] 208 - result = build_display(parts, WIDTH) 209 - grids = [r for r in result.renderables if isinstance(r, Table)] 210 - assert len(grids) == 1 215 + def test_aside_block(self, renderer: StreamRenderer, out: io.StringIO): 216 + renderer.feed("```aside A Letter\nDear friend,\n```\n") 217 + text = rendered(renderer, out) 218 + assert "A Letter" in text 219 + assert "Dear friend," in text 211 220 212 - def test_empty_parts_returns_text(self): 213 - result = build_display([], WIDTH) 214 - assert isinstance(result, Text) 221 + def test_block_between_text(self, renderer: StreamRenderer, out: io.StringIO): 222 + renderer.feed("Before\n```map Room\n+-+\n```\nAfter\n") 223 + text = rendered(renderer, out) 224 + assert "Before" in text 225 + assert "+-+" in text 226 + assert "After" in text 215 227 228 + def test_regular_code_block_passes_through(self, renderer: StreamRenderer, out: io.StringIO): 229 + renderer.feed("```python\nprint('hi')\n```\n") 230 + text = rendered(renderer, out) 231 + assert "python" in text 232 + assert "print" in text 216 233 217 - class TestBuildDisplayNarrowBlocks: 218 - """Narrow blocks should pair with text in the right column.""" 234 + def test_block_after_flush(self, renderer: StreamRenderer, out: io.StringIO): 235 + renderer.feed("Some text\n") 236 + renderer.flush() 237 + renderer.feed("```item Dagger\n/|\\\n```\n") 238 + text = rendered(renderer, out) 239 + assert "Dagger" in text 240 + assert "/|\\" in text 219 241 220 - def test_text_plus_narrow_block_single_grid(self, narrow_block: tuple): 221 - parts = [("text", "Narrative"), narrow_block] 222 - result = build_display(parts, WIDTH) 223 - grids = [r for r in result.renderables if isinstance(r, Table)] 224 - assert len(grids) == 1 225 - assert grids[0].row_count == 1 242 + 243 + # ── SOL edge cases ─────────────────────────────────────────────────────── 226 244 227 - def test_text_flows_alongside_block(self, narrow_block: tuple): 228 - parts = [ 229 - ("text", "First paragraph"), 230 - narrow_block, 231 - ("text", "Second paragraph"), 232 - ] 233 - result = build_display(parts, WIDTH) 234 - grids = [r for r in result.renderables if isinstance(r, Table)] 235 - # Both text parts should be in the same row as the block 236 - assert len(grids) == 1 237 - assert grids[0].row_count == 1 238 245 239 - def test_second_block_closes_row(self): 240 - parts = [ 241 - ("text", "Intro"), 242 - ("block", "aside", "A", "First"), 243 - ("block", "aside", "B", "Second"), 244 - ("text", "Outro"), 245 - ] 246 - result = build_display(parts, WIDTH) 247 - grids = [r for r in result.renderables if isinstance(r, Table)] 248 - assert len(grids) == 1 249 - assert grids[0].row_count == 2 246 + class TestSOLClassification: 250 247 248 + def test_text_starting_with_hash_no_space(self, renderer: StreamRenderer, out: io.StringIO): 249 + renderer.feed("#hashtag\n") 250 + text = rendered(renderer, out) 251 + assert "#hashtag" in text 252 + assert BOLD_ON not in text 251 253 252 - class TestBuildDisplayWideBlocks: 253 - """Wide blocks should render centered, breaking the grid.""" 254 + def test_text_starting_with_dash_no_space(self, renderer: StreamRenderer, out: io.StringIO): 255 + renderer.feed("-not a bullet\n") 256 + text = rendered(renderer, out) 257 + assert "-not a bullet" in text 254 258 255 - def test_wide_block_centered(self, wide_block: tuple): 256 - parts = [("text", "Before"), wide_block, ("text", "After")] 257 - result = build_display(parts, WIDTH) 258 - aligns = [r for r in result.renderables if isinstance(r, Align)] 259 - assert len(aligns) == 1 259 + def test_dashes_not_a_rule(self, renderer: StreamRenderer, out: io.StringIO): 260 + renderer.feed("--not a rule\n") 261 + text = rendered(renderer, out) 262 + assert "--not a rule" in text 260 263 261 - def test_wide_block_splits_grids(self, wide_block: tuple): 262 - parts = [("text", "Before"), wide_block, ("text", "After")] 263 - result = build_display(parts, WIDTH) 264 - grids = [r for r in result.renderables if isinstance(r, Table)] 265 - assert len(grids) == 2 # one before, one after the wide block 264 + def test_line_starting_with_letter(self, renderer: StreamRenderer, out: io.StringIO): 265 + renderer.feed("The quick brown fox\n") 266 + text = rendered(renderer, out) 267 + assert "The quick brown fox" in text 266 268 267 269 268 - class TestBuildDisplayTools: 269 - """Tool notifications go in the left column.""" 270 + # ── Flush ──────────────────────────────────────────────────────────────── 270 271 271 - def test_tool_in_grid(self): 272 - parts = [ 273 - ("text", "Narrative"), 274 - ("tool", "[Rolling...]"), 275 - ("text", "Result"), 276 - ] 277 - result = build_display(parts, WIDTH) 278 - grids = [r for r in result.renderables if isinstance(r, Table)] 279 - assert len(grids) == 1 280 - assert grids[0].row_count == 1 281 272 282 - def test_tool_with_block(self, narrow_block: tuple): 283 - parts = [ 284 - ("text", "Narrative"), 285 - ("tool", "[Rolling...]"), 286 - narrow_block, 287 - ("text", "After"), 288 - ] 289 - result = build_display(parts, WIDTH) 290 - grids = [r for r in result.renderables if isinstance(r, Table)] 291 - assert len(grids) == 1 273 + class TestFlush: 292 274 275 + def test_flush_emits_buffered_star_as_italic(self, renderer: StreamRenderer, out: io.StringIO): 276 + renderer.feed("trailing*") 277 + text = rendered(renderer, out) 278 + assert "trailing" in text 279 + assert ITALIC_ON in text 293 280 294 - class TestBuildDisplayBlockPreview: 295 - """In-progress blocks should render like finalized blocks.""" 281 + def test_flush_resets_bold(self, renderer: StreamRenderer, out: io.StringIO): 282 + renderer.feed("**unclosed bold") 283 + text = rendered(renderer, out) 284 + assert "\033[0m" in text 296 285 297 - def test_preview_treated_as_block(self): 298 - parts = [ 299 - ("text", "Narrative"), 300 - ("block_preview", "aside", "Note", "In progress..."), 301 - ] 302 - result = build_display(parts, WIDTH) 303 - grids = [r for r in result.renderables if isinstance(r, Table)] 304 - assert len(grids) == 1 305 - assert grids[0].row_count == 1 286 + def test_flush_emits_sol_buffer(self, renderer: StreamRenderer, out: io.StringIO): 287 + renderer.feed("##") # SOL buffer, waiting for more 288 + text = rendered(renderer, out) 289 + assert "##" in text
-7
tests/test_engine.py
··· 63 63 class TestDMEngineContext: 64 64 """Tests for DMEngine context building (mocks MCP server startup).""" 65 65 66 - @pytest.fixture(autouse=True) 67 - def _mock_terminal(self): 68 - import os 69 - size = os.terminal_size((120, 40)) 70 - with patch("storied.engine.os.get_terminal_size", return_value=size): 71 - yield 72 - 73 66 @pytest.fixture 74 67 def engine(self, tmp_path: Path): 75 68 from storied.engine import DMEngine