polyllmkit 0.1.0__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.
- llmkit/__init__.py +20 -0
- llmkit/bridge/__init__.py +52 -0
- llmkit/bridge/__main__.py +11 -0
- llmkit/bridge/adapters/__init__.py +14 -0
- llmkit/bridge/adapters/anthropic.py +154 -0
- llmkit/bridge/adapters/claude_code.py +166 -0
- llmkit/bridge/adapters/google.py +106 -0
- llmkit/bridge/adapters/openai.py +60 -0
- llmkit/bridge/bridge.py +374 -0
- llmkit/bridge/cli.py +174 -0
- llmkit/bridge/client.py +88 -0
- llmkit/bridge/config.py +277 -0
- llmkit/bridge/sinks.py +56 -0
- llmkit/bridge/stream.py +161 -0
- llmkit/md/__init__.py +13 -0
- llmkit/md/render/__init__.py +1 -0
- llmkit/md/render/__main__.py +8 -0
- llmkit/md/render/cli.py +231 -0
- llmkit/md/render/stream.py +244 -0
- llmkit/md/view/__init__.py +1 -0
- llmkit/md/view/__main__.py +8 -0
- llmkit/md/view/app.py +258 -0
- llmkit/md/view/cli.py +74 -0
- llmkit/py.typed +0 -0
- polyllmkit-0.1.0.dist-info/METADATA +17 -0
- polyllmkit-0.1.0.dist-info/RECORD +27 -0
- polyllmkit-0.1.0.dist-info/WHEEL +4 -0
llmkit/md/render/cli.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""mdrender — render markdown from a file or stdin through rich to stdout.
|
|
2
|
+
|
|
3
|
+
A standalone CLI over the same rich pipeline as the modal viewer
|
|
4
|
+
(:mod:`llmkit.md.view`). Two roles:
|
|
5
|
+
|
|
6
|
+
1. Validation tool: see exactly how the rich pipeline renders a
|
|
7
|
+
markdown stream, headlessly, without any modal UI.
|
|
8
|
+
2. Drop-in formatter for a streaming producer — a single-process
|
|
9
|
+
replacement for mdansi / glow / mdcat with live re-render.
|
|
10
|
+
|
|
11
|
+
Two modes:
|
|
12
|
+
|
|
13
|
+
--no-stream (default): consume all stdin, render once at EOF, emit
|
|
14
|
+
to stdout. Best for piped one-shot use.
|
|
15
|
+
--stream : re-render incrementally as stdin arrives
|
|
16
|
+
(rich.live.Live + Markdown). Best for live
|
|
17
|
+
use with a streaming producer such as the
|
|
18
|
+
bridge. Re-render is debounced so frequent
|
|
19
|
+
small chunks don't thrash the terminal.
|
|
20
|
+
Note: Live repaints in place, so very long
|
|
21
|
+
documents may overflow visible area — use
|
|
22
|
+
bin/mdview (alt-screen + scroll) for
|
|
23
|
+
documents larger than one screenful.
|
|
24
|
+
|
|
25
|
+
Theming: ``--theme-file`` points at an INI theme (see themes/*.ini).
|
|
26
|
+
Rich has no global theme config, so the file is loaded explicitly here
|
|
27
|
+
and applied to this Console only. Without it, Rich's stock markdown
|
|
28
|
+
styling is used.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import configparser
|
|
35
|
+
import os
|
|
36
|
+
import select
|
|
37
|
+
import sys
|
|
38
|
+
from typing import List, Optional, Tuple, cast
|
|
39
|
+
|
|
40
|
+
from rich.console import Console
|
|
41
|
+
from rich.markdown import Markdown
|
|
42
|
+
from rich.style import Style
|
|
43
|
+
from rich.text import Text
|
|
44
|
+
from rich.theme import Theme
|
|
45
|
+
|
|
46
|
+
from .stream import LiveMarkdownStream, Mode
|
|
47
|
+
|
|
48
|
+
# Rich's default Pygments style for fenced code blocks.
|
|
49
|
+
_DEFAULT_CODE_THEME = "monokai"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_theme(path: Optional[str]) -> Tuple[Optional[Theme], str]:
|
|
53
|
+
"""Load a markdown Theme + Pygments code-theme name from an INI file.
|
|
54
|
+
|
|
55
|
+
The INI has a ``[styles]`` section (Rich style names → style specs,
|
|
56
|
+
layered onto Rich's defaults via ``inherit=True``) and an optional
|
|
57
|
+
``[code]`` section with ``theme = <pygments style>`` for fenced code
|
|
58
|
+
blocks. Returns ``(None, "monokai")`` — Rich's stock look — when
|
|
59
|
+
*path* is falsy or the file can't be read.
|
|
60
|
+
"""
|
|
61
|
+
if not path:
|
|
62
|
+
return None, _DEFAULT_CODE_THEME
|
|
63
|
+
cfg = configparser.ConfigParser()
|
|
64
|
+
if not cfg.read(path):
|
|
65
|
+
return None, _DEFAULT_CODE_THEME
|
|
66
|
+
theme: Optional[Theme] = None
|
|
67
|
+
if cfg.has_section("styles"):
|
|
68
|
+
styles = {name: Style.parse(value) for name, value in cfg.items("styles")}
|
|
69
|
+
if styles:
|
|
70
|
+
theme = Theme(styles, inherit=True)
|
|
71
|
+
code_theme = cfg.get("code", "theme", fallback=_DEFAULT_CODE_THEME)
|
|
72
|
+
return theme, code_theme
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
76
|
+
p = argparse.ArgumentParser(
|
|
77
|
+
prog="mdrender",
|
|
78
|
+
description="Render markdown from a file or stdin through rich to stdout.",
|
|
79
|
+
)
|
|
80
|
+
p.add_argument(
|
|
81
|
+
"--render",
|
|
82
|
+
choices=["markdown", "raw"],
|
|
83
|
+
default="markdown",
|
|
84
|
+
help="Render mode (default: markdown). raw = ANSI passthrough.",
|
|
85
|
+
)
|
|
86
|
+
p.add_argument(
|
|
87
|
+
"--stream",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help="Re-render incrementally as stdin arrives (uses rich.live). "
|
|
90
|
+
"Default: consume all stdin, render once at EOF.",
|
|
91
|
+
)
|
|
92
|
+
p.add_argument(
|
|
93
|
+
"--debounce",
|
|
94
|
+
type=float,
|
|
95
|
+
default=0.1,
|
|
96
|
+
help="Min seconds between re-renders in --stream mode (default 0.1).",
|
|
97
|
+
)
|
|
98
|
+
p.add_argument(
|
|
99
|
+
"--color",
|
|
100
|
+
choices=["always", "auto", "never"],
|
|
101
|
+
default="auto",
|
|
102
|
+
help="When to emit ANSI color (default: auto — colorized only "
|
|
103
|
+
"if stdout is a tty). Use 'always' when piping to a pager "
|
|
104
|
+
"or capturing to a file you'll view later.",
|
|
105
|
+
)
|
|
106
|
+
p.add_argument(
|
|
107
|
+
"--theme-file",
|
|
108
|
+
default=None,
|
|
109
|
+
help="Path to an INI theme file (see themes/*.ini). Without it, "
|
|
110
|
+
"Rich's default markdown styling is used.",
|
|
111
|
+
)
|
|
112
|
+
p.add_argument(
|
|
113
|
+
"--pager",
|
|
114
|
+
action="store_true",
|
|
115
|
+
help="Pipe output through $PAGER (or `less -R` if unset). "
|
|
116
|
+
"Implies --color always. Incompatible with --stream.",
|
|
117
|
+
)
|
|
118
|
+
p.add_argument(
|
|
119
|
+
"file",
|
|
120
|
+
nargs="?",
|
|
121
|
+
default=None,
|
|
122
|
+
help="Markdown source file. If omitted (or '-'), read from stdin.",
|
|
123
|
+
)
|
|
124
|
+
args = p.parse_args(argv)
|
|
125
|
+
|
|
126
|
+
if args.pager and args.stream:
|
|
127
|
+
print("mdrender: --pager and --stream are incompatible", file=sys.stderr)
|
|
128
|
+
return 2
|
|
129
|
+
|
|
130
|
+
theme, code_theme = _load_theme(args.theme_file)
|
|
131
|
+
|
|
132
|
+
# Color resolution. rich's Console respects:
|
|
133
|
+
# force_terminal=True — emit colors regardless of stdout tty
|
|
134
|
+
# no_color=True — strip colors regardless of tty
|
|
135
|
+
# neither set — auto-detect via isatty()
|
|
136
|
+
force_terminal: Optional[bool] = None
|
|
137
|
+
no_color: Optional[bool] = None
|
|
138
|
+
if args.color == "always" or args.pager:
|
|
139
|
+
force_terminal = True
|
|
140
|
+
elif args.color == "never":
|
|
141
|
+
no_color = True
|
|
142
|
+
console = Console(force_terminal=force_terminal, no_color=no_color, theme=theme)
|
|
143
|
+
|
|
144
|
+
# "-" is the conventional stdin sentinel; treat it like no file.
|
|
145
|
+
source: Optional[str] = args.file if args.file not in (None, "-") else None
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
mode = cast(Mode, args.render)
|
|
149
|
+
if args.pager:
|
|
150
|
+
# console.pager(styles=True) spawns $PAGER (or `less -RFX`)
|
|
151
|
+
# and pipes our captured output through it on context exit.
|
|
152
|
+
# styles=True ensures ANSI is preserved through the pipe.
|
|
153
|
+
with console.pager(styles=True):
|
|
154
|
+
return _run_oneshot(console, mode, code_theme, source)
|
|
155
|
+
if args.stream:
|
|
156
|
+
return _run_stream(console, mode, args.debounce, theme, code_theme, source)
|
|
157
|
+
return _run_oneshot(console, mode, code_theme, source)
|
|
158
|
+
except KeyboardInterrupt:
|
|
159
|
+
return 130
|
|
160
|
+
except BrokenPipeError:
|
|
161
|
+
return 0
|
|
162
|
+
except OSError as e:
|
|
163
|
+
print(f"mdrender: {e}", file=sys.stderr)
|
|
164
|
+
return 1
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run_oneshot(
|
|
168
|
+
console: Console, mode: str, code_theme: str, source: Optional[str]
|
|
169
|
+
) -> int:
|
|
170
|
+
if source is None:
|
|
171
|
+
text = sys.stdin.read()
|
|
172
|
+
else:
|
|
173
|
+
with open(source, encoding="utf-8", errors="replace") as f:
|
|
174
|
+
text = f.read()
|
|
175
|
+
if mode == "markdown":
|
|
176
|
+
console.print(Markdown(text, code_theme=code_theme))
|
|
177
|
+
else:
|
|
178
|
+
console.print(Text.from_ansi(text), end="")
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _run_stream(
|
|
183
|
+
console: Console,
|
|
184
|
+
mode: Mode,
|
|
185
|
+
debounce: float,
|
|
186
|
+
theme: Optional[Theme],
|
|
187
|
+
code_theme: str,
|
|
188
|
+
source: Optional[str],
|
|
189
|
+
) -> int:
|
|
190
|
+
"""Read the source (stdin or a file) as a byte stream, feeding chunks
|
|
191
|
+
to LiveMarkdownStream.
|
|
192
|
+
|
|
193
|
+
Interface parallels textual's:
|
|
194
|
+
|
|
195
|
+
stream = Markdown.get_stream(widget)
|
|
196
|
+
for chunk in source: await stream.write(chunk)
|
|
197
|
+
await stream.stop()
|
|
198
|
+
|
|
199
|
+
We poll the raw fd with select() + non-blocking os.read() — using
|
|
200
|
+
``for line in sys.stdin`` would buffer ~4 KB at a pipe before
|
|
201
|
+
yielding, killing the live-update feel through slow producers. A
|
|
202
|
+
file (or fifo) path is opened directly; the same loop drains it.
|
|
203
|
+
"""
|
|
204
|
+
if source is None:
|
|
205
|
+
fd = sys.stdin.fileno()
|
|
206
|
+
close_fd = False
|
|
207
|
+
else:
|
|
208
|
+
fd = os.open(source, os.O_RDONLY)
|
|
209
|
+
close_fd = True
|
|
210
|
+
stream = LiveMarkdownStream.get_stream(
|
|
211
|
+
console, mode=mode, debounce=debounce, theme=theme, code_theme=code_theme
|
|
212
|
+
)
|
|
213
|
+
try:
|
|
214
|
+
while True:
|
|
215
|
+
select.select([fd], [], [], debounce)
|
|
216
|
+
try:
|
|
217
|
+
chunk = os.read(fd, 4096)
|
|
218
|
+
except OSError:
|
|
219
|
+
break
|
|
220
|
+
if not chunk:
|
|
221
|
+
break
|
|
222
|
+
stream.write(chunk.decode("utf-8", errors="replace"))
|
|
223
|
+
finally:
|
|
224
|
+
stream.stop()
|
|
225
|
+
if close_fd:
|
|
226
|
+
os.close(fd)
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
sys.exit(main())
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Reusable streaming-Markdown renderer that mirrors textual's API.
|
|
2
|
+
|
|
3
|
+
Interface parallels textual's ``Markdown.get_stream()`` so callers can
|
|
4
|
+
swap the target (rich Console vs textual Markdown widget) without
|
|
5
|
+
restructuring their code:
|
|
6
|
+
|
|
7
|
+
# textual side
|
|
8
|
+
stream = Markdown.get_stream(markdown_widget)
|
|
9
|
+
try:
|
|
10
|
+
async for chunk in source: await stream.write(chunk)
|
|
11
|
+
finally:
|
|
12
|
+
await stream.stop()
|
|
13
|
+
|
|
14
|
+
# rich side (this module)
|
|
15
|
+
stream = LiveMarkdownStream.get_stream(Console())
|
|
16
|
+
try:
|
|
17
|
+
for chunk in source: stream.write(chunk)
|
|
18
|
+
finally:
|
|
19
|
+
stream.stop()
|
|
20
|
+
|
|
21
|
+
The append mechanism mirrors textual too. From reading
|
|
22
|
+
``textual.widgets._markdown.Markdown.append``:
|
|
23
|
+
|
|
24
|
+
1. Track ``_last_parsed_line`` — the source line up to which we've
|
|
25
|
+
committed rendered output that won't change.
|
|
26
|
+
2. On write: parse ONLY the new source (from _last_parsed_line on)
|
|
27
|
+
with markdown-it-py, not the whole document.
|
|
28
|
+
3. Advance _last_parsed_line to the START of the LAST top-level
|
|
29
|
+
token. Everything before that is "complete" and gets frozen.
|
|
30
|
+
The trailing block is potentially incomplete (more chunks may
|
|
31
|
+
extend it) so it stays mutable.
|
|
32
|
+
4. Render the newly-frozen prefix once, append to a cached ANSI
|
|
33
|
+
string. The mutable trailing block is re-rendered each tick
|
|
34
|
+
into ``Text.from_ansi(frozen + fresh_trailing)``.
|
|
35
|
+
|
|
36
|
+
Cost per write: parse only new source (typically a few lines).
|
|
37
|
+
Cost per render: re-render only the trailing block (small).
|
|
38
|
+
Cost per stop: one final commit + render.
|
|
39
|
+
|
|
40
|
+
This is the same shape as textual's mount-new-blocks pattern adapted
|
|
41
|
+
for rich's flat ANSI-string output instead of a widget tree.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import io
|
|
47
|
+
import time
|
|
48
|
+
from types import TracebackType
|
|
49
|
+
from typing import Literal, Optional, Type
|
|
50
|
+
|
|
51
|
+
from markdown_it import MarkdownIt
|
|
52
|
+
from rich.console import Console, RenderableType
|
|
53
|
+
from rich.live import Live
|
|
54
|
+
from rich.markdown import Markdown
|
|
55
|
+
from rich.text import Text
|
|
56
|
+
from rich.theme import Theme
|
|
57
|
+
|
|
58
|
+
Mode = Literal["markdown", "raw"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LiveMarkdownStream:
|
|
62
|
+
"""Streaming Markdown writer backed by ``rich.live.Live``.
|
|
63
|
+
|
|
64
|
+
Don't instantiate directly — use :meth:`get_stream`, which mirrors
|
|
65
|
+
``textual.widgets.Markdown.get_stream(widget)``.
|
|
66
|
+
|
|
67
|
+
Caveats:
|
|
68
|
+
Live repaints in place. Documents that grow past the visible
|
|
69
|
+
terminal area will overflow / scroll oddly — for long content
|
|
70
|
+
use a scrollable widget (textual MarkdownViewer) instead.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# ── factory (mirrors textual's Markdown.get_stream) ─────────────────
|
|
74
|
+
@classmethod
|
|
75
|
+
def get_stream(
|
|
76
|
+
cls,
|
|
77
|
+
console: Console,
|
|
78
|
+
*,
|
|
79
|
+
mode: Mode = "markdown",
|
|
80
|
+
debounce: float = 0.1,
|
|
81
|
+
theme: Optional[Theme] = None,
|
|
82
|
+
code_theme: str = "monokai",
|
|
83
|
+
) -> "LiveMarkdownStream":
|
|
84
|
+
"""Create a started stream against ``console``. Caller is
|
|
85
|
+
responsible for ``stop()`` (or use the returned object as a
|
|
86
|
+
context manager)."""
|
|
87
|
+
stream = cls(
|
|
88
|
+
console=console,
|
|
89
|
+
mode=mode,
|
|
90
|
+
debounce=debounce,
|
|
91
|
+
theme=theme,
|
|
92
|
+
code_theme=code_theme,
|
|
93
|
+
)
|
|
94
|
+
stream.start()
|
|
95
|
+
return stream
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
console: Console,
|
|
100
|
+
mode: Mode = "markdown",
|
|
101
|
+
debounce: float = 0.1,
|
|
102
|
+
theme: Optional[Theme] = None,
|
|
103
|
+
code_theme: str = "monokai",
|
|
104
|
+
) -> None:
|
|
105
|
+
self.console = console
|
|
106
|
+
self.mode: Mode = mode
|
|
107
|
+
self.debounce = debounce
|
|
108
|
+
self._theme = theme
|
|
109
|
+
self._code_theme = code_theme
|
|
110
|
+
self._buf = ""
|
|
111
|
+
self._last_render = 0.0
|
|
112
|
+
self._live: Optional[Live] = None
|
|
113
|
+
self._stopped = False
|
|
114
|
+
# ── incremental-parse state (markdown mode only) ────────────────
|
|
115
|
+
# _last_parsed_line: line index in _buf up to which we've
|
|
116
|
+
# committed rendered output (won't change on further appends).
|
|
117
|
+
# _committed_ansi: rendered ANSI for lines [0 : _last_parsed_line].
|
|
118
|
+
# _parser: shared MarkdownIt instance — re-using across writes
|
|
119
|
+
# is fine since parse() is stateless.
|
|
120
|
+
self._last_parsed_line = 0
|
|
121
|
+
self._committed_ansi = ""
|
|
122
|
+
self._parser = MarkdownIt("gfm-like") if mode == "markdown" else None
|
|
123
|
+
|
|
124
|
+
# ── public API (mirrors textual's MarkdownStream) ───────────────────
|
|
125
|
+
def start(self) -> None:
|
|
126
|
+
"""Open the underlying Live context."""
|
|
127
|
+
if self._live is not None:
|
|
128
|
+
return
|
|
129
|
+
self._live = Live(
|
|
130
|
+
self._build_renderable(),
|
|
131
|
+
console=self.console,
|
|
132
|
+
auto_refresh=False,
|
|
133
|
+
transient=False,
|
|
134
|
+
)
|
|
135
|
+
self._live.__enter__()
|
|
136
|
+
|
|
137
|
+
def write(self, chunk: str) -> None:
|
|
138
|
+
"""Append a markdown fragment. Commits any newly-complete
|
|
139
|
+
blocks to the cached prefix; re-renders the live view if
|
|
140
|
+
the debounce window has elapsed. Empty chunks are no-ops."""
|
|
141
|
+
if self._stopped:
|
|
142
|
+
raise RuntimeError("Can't write to the stream after it has stopped.")
|
|
143
|
+
if not chunk:
|
|
144
|
+
return
|
|
145
|
+
self._buf += chunk
|
|
146
|
+
self._commit_complete_blocks()
|
|
147
|
+
now = time.monotonic()
|
|
148
|
+
if now - self._last_render >= self.debounce:
|
|
149
|
+
self._refresh()
|
|
150
|
+
self._last_render = now
|
|
151
|
+
|
|
152
|
+
def stop(self) -> None:
|
|
153
|
+
"""Final commit + render + close the Live context."""
|
|
154
|
+
if self._stopped:
|
|
155
|
+
return
|
|
156
|
+
self._stopped = True
|
|
157
|
+
if self._live is not None:
|
|
158
|
+
try:
|
|
159
|
+
self._commit_complete_blocks()
|
|
160
|
+
self._refresh()
|
|
161
|
+
finally:
|
|
162
|
+
self._live.__exit__(None, None, None)
|
|
163
|
+
self._live = None
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def buffer(self) -> str:
|
|
167
|
+
"""The accumulated text seen so far."""
|
|
168
|
+
return self._buf
|
|
169
|
+
|
|
170
|
+
# ── context-manager sugar ───────────────────────────────────────────
|
|
171
|
+
def __enter__(self) -> "LiveMarkdownStream":
|
|
172
|
+
self.start()
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def __exit__(
|
|
176
|
+
self,
|
|
177
|
+
exc_type: Optional[Type[BaseException]],
|
|
178
|
+
exc_val: Optional[BaseException],
|
|
179
|
+
exc_tb: Optional[TracebackType],
|
|
180
|
+
) -> None:
|
|
181
|
+
self.stop()
|
|
182
|
+
|
|
183
|
+
# ── incremental-parse internals ─────────────────────────────────────
|
|
184
|
+
def _commit_complete_blocks(self) -> None:
|
|
185
|
+
"""Mirrors textual's Markdown.append's commit logic. Parses only
|
|
186
|
+
the source from ``_last_parsed_line`` onward, finds the start
|
|
187
|
+
of the LAST top-level token, and freezes everything before it
|
|
188
|
+
into ``_committed_ansi``. The trailing block remains mutable —
|
|
189
|
+
future writes may extend it."""
|
|
190
|
+
if self._parser is None: # raw mode: nothing to commit
|
|
191
|
+
return
|
|
192
|
+
lines = self._buf.splitlines(keepends=True)
|
|
193
|
+
if self._last_parsed_line >= len(lines):
|
|
194
|
+
return
|
|
195
|
+
new_source = "".join(lines[self._last_parsed_line :])
|
|
196
|
+
if not new_source:
|
|
197
|
+
return
|
|
198
|
+
tokens = self._parser.parse(new_source)
|
|
199
|
+
# Find the start line of the last top-level token. That's the
|
|
200
|
+
# boundary: everything before is "complete enough" to commit.
|
|
201
|
+
last_block_start: Optional[int] = None
|
|
202
|
+
for token in reversed(tokens):
|
|
203
|
+
if token.map is not None and token.level == 0:
|
|
204
|
+
last_block_start = token.map[0]
|
|
205
|
+
break
|
|
206
|
+
if last_block_start is None or last_block_start == 0:
|
|
207
|
+
# No fully-completed blocks since the last commit.
|
|
208
|
+
return
|
|
209
|
+
committed_source = "".join(
|
|
210
|
+
lines[self._last_parsed_line : self._last_parsed_line + last_block_start]
|
|
211
|
+
)
|
|
212
|
+
if committed_source:
|
|
213
|
+
self._committed_ansi += self._render_to_ansi(committed_source)
|
|
214
|
+
self._last_parsed_line += last_block_start
|
|
215
|
+
|
|
216
|
+
def _refresh(self) -> None:
|
|
217
|
+
if self._live is not None:
|
|
218
|
+
self._live.update(self._build_renderable(), refresh=True)
|
|
219
|
+
|
|
220
|
+
def _build_renderable(self) -> RenderableType:
|
|
221
|
+
"""Compose the current frame: cached committed-ANSI prefix +
|
|
222
|
+
freshly-rendered trailing (incomplete) block."""
|
|
223
|
+
if self.mode == "raw":
|
|
224
|
+
return Text.from_ansi(self._buf)
|
|
225
|
+
# markdown mode: prefix + trailing render
|
|
226
|
+
lines = self._buf.splitlines(keepends=True)
|
|
227
|
+
trailing_source = "".join(lines[self._last_parsed_line :])
|
|
228
|
+
trailing_ansi = self._render_to_ansi(trailing_source) if trailing_source else ""
|
|
229
|
+
return Text.from_ansi(self._committed_ansi + trailing_ansi)
|
|
230
|
+
|
|
231
|
+
def _render_to_ansi(self, markdown_source: str) -> str:
|
|
232
|
+
"""Render a markdown fragment to a captured ANSI string at the
|
|
233
|
+
Console's current width. Used for both the frozen-prefix cache
|
|
234
|
+
and the trailing-block per-tick render."""
|
|
235
|
+
sink = io.StringIO()
|
|
236
|
+
Console(
|
|
237
|
+
file=sink,
|
|
238
|
+
force_terminal=True,
|
|
239
|
+
color_system="truecolor",
|
|
240
|
+
width=self.console.size.width,
|
|
241
|
+
legacy_windows=False,
|
|
242
|
+
theme=self._theme,
|
|
243
|
+
).print(Markdown(markdown_source, code_theme=self._code_theme), end="")
|
|
244
|
+
return sink.getvalue()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scrollable modal markdown viewer (textual)."""
|