A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Add word wrapping to the stream renderer

The renderer was writing characters directly to stdout with no column
awareness, so the terminal did hard wrapping at the margin — cutting
words in half mid-syllable. Now inline text is buffered word-by-word
and checked against the terminal width before output. Words that won't
fit get pushed to the next line.

Width is read live from the Rich Console as a property, so resizing the
terminal or changing font size mid-response is picked up on the next
word boundary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+165 -9
+84 -9
src/storied/display.py
··· 73 73 - SOL peek: buffers start-of-line characters to classify line type 74 74 - Inline text: streams characters with bold/italic/code ANSI escapes 75 75 - Block: accumulates lines, renders Rich Panel on closing fence 76 + 77 + Word wrapping: inline text is buffered word-by-word. At each word 78 + boundary (space), we check whether the word fits on the current line 79 + and wrap to the next line if not. ANSI escapes ride along in the 80 + word buffer but don't count toward visible width. 76 81 """ 77 82 78 83 def __init__(self, console: Console) -> None: ··· 93 98 self._at_sol = True 94 99 self._sol_buf = "" 95 100 101 + # Word wrapping state 102 + self._col = 0 103 + self._word_buf = "" 104 + self._word_width = 0 105 + self._pending_space = False 106 + 107 + @property 108 + def _width(self) -> int: 109 + return self._console.width 110 + 96 111 def feed(self, chunk: str) -> None: 97 112 """Process a text chunk, streaming output to terminal.""" 98 113 for char in chunk: ··· 107 122 def flush(self) -> None: 108 123 """Flush all pending state at end of stream.""" 109 124 self._flush_hold() 125 + self._flush_word() 110 126 if self._sol_buf: 111 127 for c in self._sol_buf: 112 128 self._feed_inline(c) 113 129 self._sol_buf = "" 130 + self._flush_hold() 131 + self._flush_word() 114 132 if self._block is not None: 115 133 for line in self._block["lines"]: 116 134 self._out.write(line + "\n") ··· 120 138 self._block_line = "" 121 139 self._reset_styles() 122 140 self._at_sol = True 141 + self._col = 0 142 + self._pending_space = False 123 143 self._out.flush() 124 144 125 145 # ── Block mode ─────────────────────────────────────────────────── ··· 195 215 heading = re.match(r"^(#{1,3})\s+(.*)", line) 196 216 if heading: 197 217 text = heading.group(2) 198 - self._out.write(BOLD_ON) 218 + self._word_buf += BOLD_ON 199 219 self._emit_inline_text(text) 200 220 self._flush_hold() 221 + self._flush_word() 201 222 self._out.write(BOLD_OFF + "\n") 223 + self._col = 0 224 + self._pending_space = False 202 225 self._out.flush() 203 226 return 204 227 205 228 if line.startswith("- ") or line.startswith("* "): 206 - self._out.write(" • ") 229 + self._out.write(" \u2022 ") 230 + self._col = 4 207 231 self._emit_inline_text(line[2:]) 208 232 self._flush_hold() 233 + self._flush_word() 209 234 self._out.write("\n") 235 + self._col = 0 236 + self._pending_space = False 210 237 self._out.flush() 211 238 return 212 239 213 240 if line.startswith("> "): 214 - self._out.write(" " + DIM_ON) 241 + self._out.write(" ") 242 + self._word_buf += DIM_ON 243 + self._col = 2 215 244 self._emit_inline_text(line[2:]) 216 245 self._flush_hold() 246 + self._flush_word() 217 247 self._out.write(DIM_OFF + "\n") 248 + self._col = 0 249 + self._pending_space = False 218 250 self._out.flush() 219 251 return 220 252 221 253 numbered = re.match(r"^(\d+\.\s)(.*)", line) 222 254 if numbered: 223 - self._out.write(" " + numbered.group(1)) 255 + prefix = " " + numbered.group(1) 256 + self._out.write(prefix) 257 + self._col = len(prefix) 224 258 self._emit_inline_text(numbered.group(2)) 225 259 self._flush_hold() 260 + self._flush_word() 226 261 self._out.write("\n") 262 + self._col = 0 263 + self._pending_space = False 227 264 self._out.flush() 228 265 return 229 266 230 267 # Fallback: regular text that happened to be line-buffered 231 268 self._emit_inline_text(line) 232 269 self._flush_hold() 270 + self._flush_word() 233 271 self._out.write("\n") 272 + self._col = 0 273 + self._pending_space = False 234 274 self._out.flush() 235 275 236 276 # ── Inline text mode ───────────────────────────────────────────── ··· 241 281 self._hold = "" 242 282 if prev == "*" and char == "*": 243 283 self._bold = not self._bold 244 - self._out.write(BOLD_ON if self._bold else BOLD_OFF) 284 + self._word_buf += BOLD_ON if self._bold else BOLD_OFF 245 285 return 246 286 # Single * — toggle italic, then process current char 247 287 self._italic = not self._italic 248 - self._out.write(ITALIC_ON if self._italic else ITALIC_OFF) 288 + self._word_buf += ITALIC_ON if self._italic else ITALIC_OFF 249 289 250 290 if char == "*" and not self._code: 251 291 self._hold = "*" 252 292 elif char == "`": 253 293 self._code = not self._code 254 - self._out.write(CODE_ON if self._code else CODE_OFF) 294 + self._word_buf += CODE_ON if self._code else CODE_OFF 255 295 elif char == "\n": 296 + self._flush_word() 256 297 self._out.write("\n") 298 + self._col = 0 299 + self._pending_space = False 257 300 self._at_sol = True 258 301 self._sol_buf = "" 302 + elif char == " ": 303 + self._flush_word() 304 + self._pending_space = True 259 305 else: 260 - self._out.write(char) 306 + self._word_buf += char 307 + self._word_width += 1 308 + 309 + def _flush_word(self) -> None: 310 + """Flush the buffered word, wrapping to the next line if needed.""" 311 + if not self._word_buf: 312 + if self._pending_space and self._col > 0: 313 + self._out.write(" ") 314 + self._col += 1 315 + self._pending_space = False 316 + return 317 + 318 + needed = self._word_width 319 + if self._pending_space: 320 + needed += 1 321 + 322 + if self._col > 0 and self._col + needed > self._width: 323 + self._out.write("\n") 324 + self._col = 0 325 + self._pending_space = False 326 + 327 + if self._pending_space and self._col > 0: 328 + self._out.write(" ") 329 + self._col += 1 330 + self._pending_space = False 331 + 332 + self._out.write(self._word_buf) 333 + self._col += self._word_width 334 + self._word_buf = "" 335 + self._word_width = 0 261 336 262 337 def _emit_inline_text(self, text: str) -> None: 263 338 """Emit a string through inline markdown processing.""" ··· 268 343 """Emit any held * character.""" 269 344 if self._hold: 270 345 self._italic = not self._italic 271 - self._out.write(ITALIC_ON if self._italic else ITALIC_OFF) 346 + self._word_buf += ITALIC_ON if self._italic else ITALIC_OFF 272 347 self._hold = "" 273 348 274 349 def _reset_styles(self) -> None:
+81
tests/test_display.py
··· 287 287 renderer.feed("##") # SOL buffer, waiting for more 288 288 text = rendered(renderer, out) 289 289 assert "##" in text 290 + 291 + 292 + # ── Word wrapping ─────────────────────────────────────────────────────── 293 + 294 + 295 + @pytest.fixture 296 + def narrow_renderer(out: io.StringIO) -> StreamRenderer: 297 + """Renderer with a narrow terminal width for testing word wrap.""" 298 + console = Console(file=out, force_terminal=True, no_color=False, width=20) 299 + return StreamRenderer(console) 300 + 301 + 302 + class TestWordWrap: 303 + 304 + def test_short_line_no_wrap(self, narrow_renderer: StreamRenderer, out: io.StringIO): 305 + narrow_renderer.feed("hello world\n") 306 + text = rendered(narrow_renderer, out) 307 + assert "hello world" in text 308 + assert text.count("\n") == 1 # just the trailing newline 309 + 310 + def test_wraps_at_word_boundary(self, narrow_renderer: StreamRenderer, out: io.StringIO): 311 + # "one two three four" = 18 chars, fits. Add "five" and it wraps. 312 + narrow_renderer.feed("one two three four five\n") 313 + text = rendered(narrow_renderer, out) 314 + assert "four\n" in text or "four \n" not in text 315 + # "five" should be on the next line 316 + lines = text.strip().split("\n") 317 + assert len(lines) == 2 318 + assert "five" in lines[1] 319 + 320 + def test_no_mid_word_break(self, narrow_renderer: StreamRenderer, out: io.StringIO): 321 + narrow_renderer.feed("aaa bbb ccccccccccccc ddd\n") 322 + text = rendered(narrow_renderer, out) 323 + # "ccccccccccccc" is 13 chars, should not be broken 324 + for line in text.split("\n"): 325 + assert "ccccccc" not in line or "ccccccccccccc" in line 326 + 327 + def test_word_longer_than_width_overflows(self, narrow_renderer: StreamRenderer, out: io.StringIO): 328 + # A single word longer than 20 chars just overflows (no crash) 329 + narrow_renderer.feed("superlongwordthatexceedstwentycharacters end\n") 330 + text = rendered(narrow_renderer, out) 331 + assert "superlongwordthatexceedstwentycharacters" in text 332 + assert "end" in text 333 + 334 + def test_wrap_preserves_bold(self, narrow_renderer: StreamRenderer, out: io.StringIO): 335 + narrow_renderer.feed("aaa bbb **ccc ddd eee fff** ggg\n") 336 + text = rendered(narrow_renderer, out) 337 + assert BOLD_ON in text 338 + assert BOLD_OFF in text 339 + # All words should be present 340 + for word in ("aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg"): 341 + assert word in text 342 + 343 + def test_wrap_preserves_italic(self, narrow_renderer: StreamRenderer, out: io.StringIO): 344 + narrow_renderer.feed("aaa bbb *ccc ddd eee fff* ggg\n") 345 + text = rendered(narrow_renderer, out) 346 + assert ITALIC_ON in text 347 + assert ITALIC_OFF in text 348 + 349 + def test_bullet_wrap_accounts_for_prefix(self, narrow_renderer: StreamRenderer, out: io.StringIO): 350 + # " • " = 4 chars, so only 16 chars of content before wrap 351 + narrow_renderer.feed("- aaa bbb ccc ddd eee\n") 352 + text = rendered(narrow_renderer, out) 353 + assert "•" in text 354 + lines = text.strip().split("\n") 355 + assert len(lines) >= 2 # should wrap 356 + 357 + def test_streaming_chunks_wrap_correctly(self, narrow_renderer: StreamRenderer, out: io.StringIO): 358 + # Words arrive across multiple chunks 359 + narrow_renderer.feed("one two thr") 360 + narrow_renderer.feed("ee four five ") 361 + narrow_renderer.feed("six\n") 362 + text = rendered(narrow_renderer, out) 363 + lines = text.strip().split("\n") 364 + assert len(lines) >= 2 365 + # No word should be broken 366 + all_words = set() 367 + for line in lines: 368 + all_words.update(line.split()) 369 + for word in ("one", "two", "three", "four", "five", "six"): 370 + assert word in all_words