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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- 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()
|