minimalcode 1.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,710 @@
1
+ """Incremental Markdown renderer for streamed assistant text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TextIO
7
+
8
+ from . import color
9
+ from .code import CodeHighlightMixin
10
+ from .palette import _PALETTES, _styled
11
+ from .sanitize import TerminalSanitizer
12
+
13
+ _RULE_RE = re.compile(r"([*_-])(?:[ \t]*\1){2,}")
14
+ _TABLE_SEPARATOR_RE = re.compile(r"\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?")
15
+ _TABLE_SEPARATOR_PREFIX_RE = re.compile(r"[ \t|:-]+")
16
+
17
+
18
+ def _detect_color(stream: TextIO) -> bool:
19
+ """Delegate to the color module so a single patch target stays effective."""
20
+ return color._supports_color(stream)
21
+
22
+
23
+ class MarkdownHighlighter(CodeHighlightMixin):
24
+ """Render streamed Markdown with linear cursor-based parsing."""
25
+
26
+ def __init__(
27
+ self,
28
+ stream: TextIO,
29
+ *,
30
+ theme: str = "dark",
31
+ color: bool | None = None,
32
+ ) -> None:
33
+ self.stream = stream
34
+ self.palette = _PALETTES.get(theme, _PALETTES["dark"])
35
+ self.enabled = _detect_color(stream) if color is None else color
36
+ self._pending = ""
37
+ self._cursor = 0
38
+ self._previous = ""
39
+ self._at_line_start = True
40
+ self._line_kind = "normal"
41
+ self._heading_level = 0
42
+ self._inline_mode: str | None = None
43
+ self._line_indent = 0
44
+ self._in_fence = False
45
+ self._fence_char = ""
46
+ self._fence_length = 0
47
+ self._code_comment = False
48
+ self._code_quote: str | None = None
49
+ self._code_escape = False
50
+ self._code_word: list[str] = []
51
+ self._code_last = ""
52
+ self._sanitizer = TerminalSanitizer()
53
+
54
+ def feed(self, delta: str) -> None:
55
+ """Consume one streamed delta and write the now-stable prefix."""
56
+ delta = self._sanitizer.feed(delta)
57
+ if not delta:
58
+ return
59
+ if not self.enabled:
60
+ # Redirected and JSON output stays raw Markdown.
61
+ self._write(delta)
62
+ return
63
+
64
+ self._pending += delta
65
+ segments: list[tuple[str | None, list[str]]] = []
66
+ self._consume(segments, final=False)
67
+ # Keep only the unconsumed tail: a held marker (a half-received `**` or
68
+ # fence) must survive into the next delta, while the consumed prefix is
69
+ # dropped so the buffer does not grow with the whole reply.
70
+ self._pending = self._pending[self._cursor :]
71
+ self._cursor = 0
72
+ self._write(self._render_segments(segments))
73
+
74
+ def finish(self) -> None:
75
+ """Flush held markers and reset state for the next stream."""
76
+ self._sanitizer.finish()
77
+ if self.enabled:
78
+ segments: list[tuple[str | None, list[str]]] = []
79
+ self._consume(segments, final=True)
80
+ # `_consume_code` only emits a token when a boundary character arrives,
81
+ # so the identifier ending the final line is still buffered here.
82
+ self._flush_code_word(segments)
83
+ if self._cursor >= len(self._pending) and self._line_indent:
84
+ # Indentation is held until the line kind is known; at the end of
85
+ # the stream it is ordinary trailing text and must not vanish.
86
+ self._flush_indent(segments)
87
+ self._write(self._render_segments(segments))
88
+ self._reset_stream_state()
89
+
90
+ def _consume(
91
+ self,
92
+ segments: list[tuple[str | None, list[str]]],
93
+ *,
94
+ final: bool,
95
+ ) -> None:
96
+ """Advance the cursor until input is exhausted or more is needed."""
97
+ while self._cursor < len(self._pending):
98
+ if self._in_fence and self._at_line_start:
99
+ start = self._cursor
100
+ if not self._consume_code_line_start(final=final):
101
+ break
102
+ if self._cursor == start and self._at_line_start:
103
+ # No progress with no state change would spin forever.
104
+ self._flush_unparsed(segments)
105
+ break
106
+ elif self._at_line_start:
107
+ start = self._cursor
108
+ if not self._consume_line_start(segments, final=final):
109
+ break
110
+ if self._cursor == start and self._at_line_start:
111
+ # A `| --- |` row at the very end of a stream used to land
112
+ # here: the separator was dropped without consuming it.
113
+ self._flush_unparsed(segments)
114
+ break
115
+ elif self._line_kind == "code":
116
+ self._consume_code(segments)
117
+ else:
118
+ remaining = self._cursor
119
+ self._consume_markdown(segments, final=final)
120
+ if self._cursor == remaining:
121
+ # A held marker made no progress; wait for the next delta.
122
+ break
123
+
124
+ def _consume_line_start(
125
+ self,
126
+ segments: list[tuple[str | None, list[str]]],
127
+ *,
128
+ final: bool,
129
+ ) -> bool:
130
+ """Classify indentation and the block marker at the start of a line."""
131
+ text = self._pending
132
+ start = self._cursor
133
+ body_start = start
134
+ while body_start < len(text) and text[body_start] == " ":
135
+ body_start += 1
136
+ if body_start > start:
137
+ # Hold the indentation until the line kind is known. A filtered
138
+ # `| --- |` row has to take its own indentation with it, and the
139
+ # cursor still moves forward so later deltas never rescan it.
140
+ self._line_indent += body_start - start
141
+ self._advance(body_start - start)
142
+ if body_start >= len(text):
143
+ if not final:
144
+ return False
145
+ self._flush_indent(segments)
146
+ self._at_line_start = False
147
+ return True
148
+
149
+ if text.startswith(("\r\n", "\n"), body_start):
150
+ ending = 2 if text.startswith("\r\n", body_start) else 1
151
+ self._flush_indent(segments)
152
+ self._emit(segments, body_start + ending, None)
153
+ self._end_line()
154
+ return True
155
+
156
+ first = text[body_start]
157
+ if first == "#":
158
+ level_end = body_start
159
+ while level_end < len(text) and text[level_end] == "#":
160
+ level_end += 1
161
+ level = level_end - body_start
162
+ if level_end == len(text) and not final:
163
+ # The run of '#' may still be growing. Committing now would fix
164
+ # `##` and `###` headings to the h1 style whenever the marker is
165
+ # split across deltas, so wait for the next non-'#' character.
166
+ return False
167
+ if level <= 6 and (level_end == len(text) or text[level_end].isspace()):
168
+ self._flush_indent(segments)
169
+ if level_end == len(text):
170
+ # The stream ended inside the marker; print it rather than
171
+ # swallowing the only characters the model produced.
172
+ self._emit(segments, level_end, self.palette[f"h{level}"])
173
+ self._line_kind = "heading"
174
+ self._heading_level = level
175
+ self._at_line_start = False
176
+ return True
177
+ # Consume the marker (and its separating space) instead of
178
+ # printing it, so `## Title` renders as styled heading text the
179
+ # same way `**bold**` drops its delimiters.
180
+ # Exactly one space is consumed: any further spaces are heading
181
+ # content and would otherwise vanish from the rendered line.
182
+ body_end = level_end + 1 if text[level_end] == " " else level_end
183
+ self._advance(body_end - self._cursor)
184
+ self._line_kind = "heading"
185
+ self._heading_level = level
186
+ self._at_line_start = False
187
+ return True
188
+
189
+ if first == ">":
190
+ self._flush_indent(segments)
191
+ self._emit(segments, body_start + 1, self.palette["quote"])
192
+ self._line_kind = "quote"
193
+ self._at_line_start = False
194
+ return True
195
+
196
+ if first in "-*_":
197
+ line_end = text.find("\n", body_start)
198
+ line = text[body_start:] if line_end < 0 else text[body_start:line_end]
199
+ if _RULE_RE.fullmatch(line.strip()):
200
+ if line_end < 0 and not final:
201
+ return False
202
+ consumed = len(text) if line_end < 0 else line_end + 1
203
+ self._flush_indent(segments)
204
+ self._emit(segments, consumed, self.palette["rule"])
205
+ self._end_line()
206
+ return True
207
+ if line_end < 0 and not final and not line.strip(" \t*_-"):
208
+ # The line may still become a `---` / `***` rule; wait for the
209
+ # rest of it instead of styling a partial marker as emphasis.
210
+ return False
211
+
212
+ if first in "-+*":
213
+ if body_start + 1 >= len(text):
214
+ if not final:
215
+ return False
216
+ elif text[body_start + 1].isspace():
217
+ self._flush_indent(segments)
218
+ self._emit(segments, body_start + 1, self.palette["bullet"])
219
+ self._line_kind = "list"
220
+ self._at_line_start = False
221
+ if text[body_start + 1] == "\n":
222
+ # The marker is the whole line. Leave the terminator to
223
+ # `_consume_markdown` so the next line starts as a line.
224
+ return True
225
+ self._emit(segments, body_start + 2, None)
226
+ return True
227
+
228
+ number_end = body_start
229
+ while number_end < len(text) and text[number_end].isdigit():
230
+ number_end += 1
231
+ number_width = number_end - body_start
232
+ if (
233
+ 1 <= number_width <= 9
234
+ and number_end < len(text)
235
+ and text[number_end] in ".)"
236
+ ):
237
+ marker_end = number_end + 1
238
+ else:
239
+ marker_end = 0
240
+ if marker_end and marker_end < len(text) and text[marker_end].isspace():
241
+ self._flush_indent(segments)
242
+ self._emit(segments, marker_end, self.palette["bullet"])
243
+ self._line_kind = "list"
244
+ self._at_line_start = False
245
+ return True
246
+ if (
247
+ first.isdigit()
248
+ and number_width <= 9
249
+ and (number_end == len(text) or text[number_end] in ".)")
250
+ and not final
251
+ ):
252
+ return False
253
+
254
+ if first in "|-:":
255
+ line_end = text.find("\n", body_start)
256
+ line = text[body_start:] if line_end < 0 else text[body_start:line_end]
257
+ if line_end < 0 and not final:
258
+ if "|" in line and _TABLE_SEPARATOR_PREFIX_RE.fullmatch(line):
259
+ # Still only separator characters, so this row may yet turn
260
+ # into `| --- |`; wait for the rest of it before deciding.
261
+ return False
262
+ elif "|" in line and _TABLE_SEPARATOR_RE.fullmatch(line.strip()):
263
+ # Drop the `| --- |` row wholesale, indentation included, instead
264
+ # of printing a wall of dashes and pipes between header and body.
265
+ self._line_indent = 0
266
+ consumed = len(text) if line_end < 0 else line_end + 1
267
+ self._advance(consumed - self._cursor)
268
+ self._end_line()
269
+ return True
270
+
271
+ if first == "|":
272
+ self._flush_indent(segments)
273
+ self._emit(segments, body_start + 1, self.palette["rule"])
274
+ self._line_kind = "table"
275
+ self._at_line_start = False
276
+ return True
277
+
278
+ if first in "`~":
279
+ marker_end = body_start
280
+ while marker_end < len(text) and text[marker_end] == first:
281
+ marker_end += 1
282
+ marker_length = marker_end - body_start
283
+ if marker_length >= 3:
284
+ line_end = text.find("\n", marker_end)
285
+ if line_end < 0 and not final:
286
+ return False
287
+ consumed = len(text) if line_end < 0 else line_end + 1
288
+ # Hide the opening fence (```python) so only the code body is
289
+ # rendered, the same way `**bold**` drops its delimiters.
290
+ self._line_indent = 0
291
+ self._advance(consumed - self._cursor)
292
+ self._in_fence = True
293
+ self._fence_char = first
294
+ self._fence_length = marker_length
295
+ self._end_line()
296
+ return True
297
+ if marker_length == len(text) - body_start and not final:
298
+ return False
299
+
300
+ self._flush_indent(segments)
301
+ self._line_kind = "normal"
302
+ self._at_line_start = False
303
+ return True
304
+
305
+ def _consume_markdown(
306
+ self,
307
+ segments: list[tuple[str | None, list[str]]],
308
+ *,
309
+ final: bool,
310
+ ) -> None:
311
+ """Consume inline Markdown inside an ordinary block."""
312
+ text = self._pending
313
+ while self._cursor < len(text):
314
+ if not final and self._should_hold():
315
+ return
316
+ start = self._cursor
317
+ char = text[start]
318
+ style = self._inline_style()
319
+
320
+ if text.startswith("\r\n", start):
321
+ self._emit(segments, start + 2, None)
322
+ self._end_line()
323
+ return
324
+ if char == "\n":
325
+ self._emit(segments, start + 1, None)
326
+ self._end_line()
327
+ return
328
+
329
+ if self._inline_mode is None and self._line_kind == "list" and char == "[":
330
+ # Task-list boxes are only rewritten inside list items, so a
331
+ # literal `[x]` in prose is printed as written.
332
+ if text.startswith(("[x]", "[X]"), start):
333
+ self._append(segments, "☑", style)
334
+ self._advance(3)
335
+ continue
336
+ if text.startswith("[ ]", start):
337
+ self._append(segments, "☐", style)
338
+ self._advance(3)
339
+ continue
340
+
341
+ if self._inline_mode == "url":
342
+ if char == ")":
343
+ self._append(segments, ")", self.palette["url"])
344
+ self._inline_mode = None
345
+ else:
346
+ self._append(segments, char, self.palette["url"])
347
+ self._advance(1)
348
+ continue
349
+
350
+ if self._inline_mode is None and self._line_kind == "table" and char == "|":
351
+ self._append(segments, char, self.palette["rule"])
352
+ self._advance(1)
353
+ continue
354
+
355
+ if char == "\\":
356
+ if start + 1 >= len(text):
357
+ if not final:
358
+ return
359
+ # A lone trailing backslash is still text; dropping it would
360
+ # silently lose the final character of the answer.
361
+ self._append(segments, char, style)
362
+ self._advance(1)
363
+ continue
364
+ if text[start + 1] in "\r\n":
365
+ # A backslash before a line ending is literal text. Emitting
366
+ # the pair skipped `_end_line`, so every later line was
367
+ # parsed in the wrong block state.
368
+ self._append(segments, char, style)
369
+ self._advance(1)
370
+ continue
371
+ self._append(segments, text[start : start + 2], style)
372
+ self._advance(2)
373
+ continue
374
+
375
+ if self._inline_mode is None and char == "[":
376
+ link_result = self._consume_link_candidate(segments, final=final)
377
+ if link_result:
378
+ if self._cursor == start:
379
+ return
380
+ continue
381
+
382
+ if self._inline_mode is None:
383
+ if self._consume_opening_marker(segments, final=final):
384
+ if self._cursor == start:
385
+ return
386
+ continue
387
+ elif self._consume_closing_marker(segments, final=final):
388
+ if self._cursor == start:
389
+ return
390
+ continue
391
+
392
+ self._append(segments, char, style)
393
+ self._advance(1)
394
+
395
+ def _consume_link_candidate(
396
+ self,
397
+ segments: list[tuple[str | None, list[str]]],
398
+ *,
399
+ final: bool,
400
+ ) -> bool:
401
+ """Try to parse ``[label](url)`` without waiting on a partial label."""
402
+ text = self._pending
403
+ start = self._cursor
404
+ # The label search is capped at 512 characters past the `[`. An unclosed
405
+ # bracket would otherwise scan (and hold) the rest of the reply, and a
406
+ # label that long is not a link anyway.
407
+ search_end = min(len(text), start + 513)
408
+ newline = text.find("\n", start + 1, search_end)
409
+ if newline >= 0:
410
+ # Labels never span lines, so the newline is a hard stop.
411
+ search_end = newline
412
+ close = text.find("]", start + 1, search_end)
413
+ if close < 0:
414
+ if not final and newline < 0 and search_end == len(text):
415
+ # The `]` may still arrive; consuming the `[` now would print it
416
+ # unstyled and drop the link if the label completes next delta.
417
+ return True
418
+ self._append(segments, "[", None)
419
+ self._advance(1)
420
+ return True
421
+
422
+ if close + 1 >= len(text) and not final:
423
+ return True
424
+ if close + 1 < len(text) and text[close + 1] == "(":
425
+ self._append(segments, text[start + 1 : close], self.palette["link"])
426
+ self._advance(close + 2 - start)
427
+ self._append(segments, " (", self.palette["url"])
428
+ self._inline_mode = "url"
429
+ return True
430
+
431
+ self._append(segments, text[start : close + 1], None)
432
+ self._advance(close + 1 - start)
433
+ return True
434
+
435
+ def _consume_opening_marker(
436
+ self,
437
+ segments: list[tuple[str | None, list[str]]],
438
+ *,
439
+ final: bool,
440
+ ) -> bool:
441
+ """Enter bold/italic/strike/code mode when a valid opener is complete."""
442
+ text = self._pending
443
+ start = self._cursor
444
+ char = text[start]
445
+ if text.startswith("**", start):
446
+ if start + 2 >= len(text):
447
+ if not final:
448
+ return True
449
+ self._append(segments, "**", None)
450
+ self._advance(2)
451
+ elif text[start + 2].isspace():
452
+ self._append(segments, "**", None)
453
+ self._advance(2)
454
+ else:
455
+ self._inline_mode = "bold"
456
+ self._advance(2)
457
+ return True
458
+ if text.startswith("__", start):
459
+ if (
460
+ start + 2 >= len(text)
461
+ or text[start + 2].isspace()
462
+ or self._previous.isalnum()
463
+ ):
464
+ if start + 2 < len(text) or final:
465
+ self._append(segments, "__", None)
466
+ self._advance(2)
467
+ return True
468
+ self._inline_mode = "bold"
469
+ self._advance(2)
470
+ return True
471
+ if text.startswith("~~", start):
472
+ if start + 2 >= len(text):
473
+ if not final:
474
+ return True
475
+ self._append(segments, "~~", None)
476
+ self._advance(2)
477
+ elif text[start + 2].isspace():
478
+ self._append(segments, "~~", None)
479
+ self._advance(2)
480
+ else:
481
+ self._inline_mode = "strike"
482
+ self._advance(2)
483
+ return True
484
+ if char in "*_":
485
+ if (
486
+ start + 1 >= len(text)
487
+ or text[start + 1].isspace()
488
+ or (char == "_" and self._previous.isalnum())
489
+ ):
490
+ if start + 1 >= len(text) and not final:
491
+ return True
492
+ self._append(segments, char, None)
493
+ self._advance(1)
494
+ else:
495
+ self._inline_mode = "italic"
496
+ self._advance(1)
497
+ return True
498
+ if char == "`":
499
+ if start + 1 >= len(text):
500
+ if not final:
501
+ return True
502
+ self._append(segments, "`", None)
503
+ self._advance(1)
504
+ else:
505
+ self._inline_mode = "code"
506
+ self._advance(1)
507
+ return True
508
+ return False
509
+
510
+ def _consume_closing_marker(
511
+ self,
512
+ segments: list[tuple[str | None, list[str]]],
513
+ *,
514
+ final: bool,
515
+ ) -> bool:
516
+ """Leave the active inline mode when its closing marker is valid."""
517
+ text = self._pending
518
+ start = self._cursor
519
+ char = text[start]
520
+ mode = self._inline_mode
521
+ if mode == "bold":
522
+ marker = "**" if text.startswith("**", start) else "__"
523
+ if text.startswith(marker, start):
524
+ if start + len(marker) < len(text) or final:
525
+ # A closer preceded by whitespace is not a closer: in
526
+ # `**bold **` the delimiter belongs to the text, so it is
527
+ # printed in bold instead of ending the mode.
528
+ if self._previous.isspace():
529
+ self._append(segments, marker, self.palette["bold"])
530
+ else:
531
+ self._inline_mode = None
532
+ self._advance(len(marker))
533
+ return True
534
+ return True
535
+ elif mode == "strike":
536
+ if text.startswith("~~", start):
537
+ if self._previous.isspace():
538
+ self._append(segments, "~~", self.palette["strike"])
539
+ else:
540
+ self._inline_mode = None
541
+ self._advance(2)
542
+ return True
543
+ elif mode == "italic" and char in "*_":
544
+ if not self._previous.isspace():
545
+ self._inline_mode = None
546
+ else:
547
+ self._append(segments, char, self.palette["italic"])
548
+ self._advance(1)
549
+ return True
550
+ elif mode == "code" and char == "`":
551
+ self._inline_mode = None
552
+ self._advance(1)
553
+ return True
554
+ return False
555
+
556
+ def _should_hold(self) -> bool:
557
+ """Hold a single trailing marker character that may start a pair."""
558
+ text = self._pending
559
+ # A delimiter can be split across deltas (`*` then `*`). Only a lone
560
+ # trailing marker is ambiguous, because any character after it already
561
+ # decides whether it opens a run.
562
+ if len(text) - self._cursor == 1:
563
+ return text[self._cursor] in "*_~`"
564
+ return False
565
+
566
+ def _inline_style(self) -> str | None:
567
+ """Return the style for ordinary text in the current block/mode."""
568
+ if self._inline_mode == "bold":
569
+ return self.palette["bold"]
570
+ if self._inline_mode == "italic":
571
+ return self.palette["italic"]
572
+ if self._inline_mode == "strike":
573
+ return self.palette["strike"]
574
+ if self._inline_mode == "code":
575
+ return self.palette["inline_code"]
576
+ if self._line_kind == "heading":
577
+ return self.palette[f"h{self._heading_level}"]
578
+ if self._line_kind == "quote":
579
+ return self.palette["italic"]
580
+ return None
581
+
582
+ def _advance(self, count: int) -> None:
583
+ """Move the cursor and remember the last consumed character."""
584
+ if count <= 0:
585
+ return
586
+ self._cursor += count
587
+ self._previous = self._pending[self._cursor - 1]
588
+
589
+ def _flush_indent(
590
+ self,
591
+ segments: list[tuple[str | None, list[str]]],
592
+ ) -> None:
593
+ """Emit indentation that was held until the line kind was known."""
594
+ indent = self._line_indent
595
+ self._line_indent = 0
596
+ if indent:
597
+ self._append(segments, " " * indent, None)
598
+
599
+ def _emit(
600
+ self,
601
+ segments: list[tuple[str | None, list[str]]],
602
+ end: int,
603
+ style: str | None,
604
+ ) -> None:
605
+ """Emit pending text up to ``end`` and advance past it."""
606
+ start = self._cursor
607
+ if end > start:
608
+ self._append(segments, self._pending[start:end], style)
609
+ self._advance(end - start)
610
+
611
+ def _end_line(self) -> None:
612
+ """Reset all per-line inline and code state."""
613
+ # Inline state deliberately does not survive the newline: an unclosed `**`
614
+ # or an unterminated string must not bleed styling into the next line of
615
+ # a stream that will never send the closing delimiter.
616
+ self._at_line_start = True
617
+ self._line_kind = "normal"
618
+ self._heading_level = 0
619
+ self._inline_mode = None
620
+ self._line_indent = 0
621
+ self._reset_code_line()
622
+
623
+ def note_external_newline(self) -> None:
624
+ """Record a newline written to the same stream by another renderer.
625
+
626
+ Reasoning is drawn by ``ThinkingRenderer`` on this stream; without this
627
+ call the highlighter still believes it is mid-line and prints the next
628
+ heading or fence literally. Any held tail is flushed unchanged, because
629
+ the bytes before the newline are already on screen.
630
+ """
631
+ tail = self._pending[self._cursor :]
632
+ if tail:
633
+ self._write(tail)
634
+ self._pending = ""
635
+ self._cursor = 0
636
+ self._previous = "\n"
637
+ self._at_line_start = True
638
+ self._line_kind = "normal"
639
+ self._heading_level = 0
640
+ self._inline_mode = None
641
+ self._line_indent = 0
642
+ # Fence state is deliberately kept: the external newline belongs to a
643
+ # reasoning block printed between two answer deltas, not to the answer
644
+ # stream, so the answer is still inside the code block it opened.
645
+ self._reset_code_line()
646
+
647
+ def _flush_unparsed(
648
+ self,
649
+ segments: list[tuple[str | None, list[str]]],
650
+ ) -> None:
651
+ """Emit the remaining buffer as plain text when parsing cannot advance.
652
+
653
+ This is a safety valve: a construct that no rule consumes must never
654
+ turn `feed()`/`finish()` into an infinite loop.
655
+ """
656
+ if self._cursor < len(self._pending):
657
+ self._emit(segments, len(self._pending), None)
658
+ self._end_line()
659
+
660
+ def _reset_stream_state(self) -> None:
661
+ """Reset parsing state so the renderer can be reused."""
662
+ self._pending = ""
663
+ self._cursor = 0
664
+ self._previous = ""
665
+ self._at_line_start = True
666
+ self._line_kind = "normal"
667
+ self._heading_level = 0
668
+ self._inline_mode = None
669
+ self._line_indent = 0
670
+ self._in_fence = False
671
+ self._fence_char = ""
672
+ self._fence_length = 0
673
+ self._reset_code_line()
674
+ self._sanitizer = TerminalSanitizer()
675
+
676
+ @staticmethod
677
+ def _append(
678
+ segments: list[tuple[str | None, list[str]]],
679
+ text: str,
680
+ style: str | None,
681
+ ) -> None:
682
+ """Append text, merging runs that share a style."""
683
+ if not text:
684
+ return
685
+ if segments and segments[-1][0] == style:
686
+ segments[-1][1].append(text)
687
+ else:
688
+ segments.append((style, [text]))
689
+
690
+ @staticmethod
691
+ def _render_segments(
692
+ segments: list[tuple[str | None, list[str]]],
693
+ ) -> str:
694
+ """Render styled runs into ANSI text."""
695
+ return "".join(
696
+ _styled("".join(chunks), style) if style is not None else "".join(chunks)
697
+ for style, chunks in segments
698
+ )
699
+
700
+ def _write(self, text: str) -> None:
701
+ """Write and flush a rendered fragment."""
702
+ if not text:
703
+ return
704
+ self.stream.write(text)
705
+ # Flushed per fragment so streamed text appears immediately; `flush` is
706
+ # looked up defensively because capture buffers and test doubles may not
707
+ # provide it.
708
+ flush = getattr(self.stream, "flush", None)
709
+ if flush is not None:
710
+ flush()