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/md/view/app.py ADDED
@@ -0,0 +1,258 @@
1
+ """Textual-based modal viewer for streamed markdown output.
2
+
3
+ Input sources: a regular file, a fifo, or stdin (path "-"). The viewer
4
+ auto-detects the kind and picks sensible defaults for ``follow`` and
5
+ ``exit_on_eof``:
6
+
7
+ regular file : follow=off, exit_on_eof=off → static view, q to quit
8
+ fifo : follow=on, exit_on_eof=on → live stream, exits on
9
+ writer close
10
+ stdin ("-") : follow=on, exit_on_eof=on → pipeline shape;
11
+ keyboard via /dev/tty
12
+
13
+ The defaults can be overridden via CLI flags (``--follow``,
14
+ ``--exit-on-eof`` and their ``--no-...`` counterparts).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import os
20
+ import shutil
21
+ import stat
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import Optional, TypedDict
25
+
26
+ from textual.app import App, ComposeResult
27
+ from textual.binding import Binding
28
+ from textual.containers import VerticalScroll
29
+ from textual.widgets import Footer, Markdown
30
+
31
+
32
+ class ViewArgs(TypedDict):
33
+ """Resolved CLI args for the viewer. Lives here (not in cli.py)
34
+ so ViewerApp can type its constructor against it without circular
35
+ imports. Mirror of the argparse Namespace."""
36
+ path: str
37
+ title: str
38
+ subtitle: str
39
+ follow: Optional[bool] # None = auto-detect
40
+ exit_on_eof: Optional[bool] # None = auto-detect
41
+ inline: bool
42
+ height: int
43
+ width: int
44
+
45
+
46
+ class ViewerApp(App[int]):
47
+ BINDINGS = [
48
+ Binding("q,escape", "quit", "Quit"),
49
+ Binding("ctrl+c", "quit", "Quit", show=False),
50
+ Binding("f", "toggle_follow", "Toggle follow"),
51
+ ]
52
+
53
+ CSS = """
54
+ #scroller {
55
+ /* Rounded chrome around the scrollable area. The title /
56
+ subtitle are placed in the border (top-left / top-right)
57
+ via border_title / border_subtitle set in on_mount. */
58
+ border: round $primary;
59
+ padding: 0 1;
60
+ /* 1fr = "consume the remaining vertical space after the
61
+ fixed-height Footer sibling". Required because the inner
62
+ Markdown widget defaults to height:auto, which is 0 when
63
+ content is empty at mount; without 1fr on the scroller
64
+ the layout collapses regardless of --height. */
65
+ height: 1fr;
66
+ }
67
+ """
68
+
69
+ def __init__(self, config: ViewArgs) -> None:
70
+ super().__init__()
71
+ # Use ANSI default colors instead of the theme's RGB palette
72
+ # so the background is the terminal's own bg, not textual's
73
+ # dark fill. Set on the instance (not as a class attr) to
74
+ # play nice with pyright's view of the Reactive descriptor.
75
+ self.ansi_color = True
76
+ self.config: ViewArgs = config
77
+ # Classify the input source and pick defaults.
78
+ self._source = _classify(config["path"])
79
+ self._following = (
80
+ config["follow"]
81
+ if config["follow"] is not None
82
+ else self._source != "file"
83
+ )
84
+ self._exit_on_eof = (
85
+ config["exit_on_eof"]
86
+ if config["exit_on_eof"] is not None
87
+ else self._source != "file"
88
+ )
89
+ # File-mode state.
90
+ self._file_offset = 0
91
+ # Stdin/fifo state.
92
+ self._read_fd: Optional[int] = None
93
+ if config["title"]:
94
+ self.title = config["title"]
95
+ if config["subtitle"]:
96
+ self.sub_title = config["subtitle"]
97
+
98
+ # ── compose ────────────────────────────────────────────────────────
99
+ def compose(self) -> ComposeResult:
100
+ with VerticalScroll(id="scroller"):
101
+ yield Markdown(id="md")
102
+ yield Footer()
103
+
104
+ # ── lifecycle ──────────────────────────────────────────────────────
105
+ def on_mount(self) -> None:
106
+ scroller = self.query_one("#scroller", VerticalScroll)
107
+ if self.config["title"]:
108
+ scroller.border_title = self.config["title"]
109
+ if self.config["subtitle"]:
110
+ scroller.border_subtitle = self.config["subtitle"]
111
+ if self.config["inline"]:
112
+ # In inline mode, Screen.styles.height drives the rendered
113
+ # area; the `size=(W, H)` arg only caps that value.
114
+ self.screen.styles.height = self.config["height"]
115
+ if self._following:
116
+ scroller.anchor()
117
+ # Open the read fd for stdin / fifo here so we can attach it
118
+ # to the running event loop in the worker.
119
+ if self._source != "file":
120
+ self._read_fd = self._open_read_fd()
121
+ self.run_worker(self._stream_markdown(), exclusive=True)
122
+
123
+ # ── streaming worker ───────────────────────────────────────────────
124
+ async def _stream_markdown(self) -> None:
125
+ md = self.query_one("#md", Markdown)
126
+ scroller = self.query_one("#scroller", VerticalScroll)
127
+ stream = Markdown.get_stream(md)
128
+ # Status indicator: subtitle starts as "waiting…" so a slow
129
+ # TTFT doesn't leave the user staring at empty chrome with no
130
+ # feedback. Flips to "streaming" on first content, "complete"
131
+ # on EOF when we're not auto-exiting.
132
+ base_sub = self.config["subtitle"]
133
+ scroller.border_subtitle = self._subtitle("waiting…", base_sub)
134
+ seen_first_chunk = False
135
+ try:
136
+ while True:
137
+ chunk = await self._next_chunk()
138
+ if chunk is None:
139
+ # EOF.
140
+ if self._exit_on_eof:
141
+ self.exit()
142
+ return
143
+ scroller.border_subtitle = self._subtitle("complete", base_sub)
144
+ return
145
+ if not seen_first_chunk:
146
+ scroller.border_subtitle = self._subtitle("streaming", base_sub)
147
+ seen_first_chunk = True
148
+ await stream.write(chunk)
149
+ finally:
150
+ await stream.stop()
151
+ if self._read_fd is not None and self._read_fd != 0:
152
+ # Don't close fd 0 (stdin) — Python owns it.
153
+ try:
154
+ os.close(self._read_fd)
155
+ except OSError:
156
+ pass
157
+
158
+ @staticmethod
159
+ def _subtitle(status: str, base: str) -> str:
160
+ """Compose the border subtitle: "<base> · <status>" or just <status>."""
161
+ return f"{base} · {status}" if base else status
162
+
163
+ async def _next_chunk(self) -> Optional[str]:
164
+ """Block until new bytes are available, or EOF. Returns the
165
+ chunk, or None to signal the worker should terminate.
166
+
167
+ Cooperative: when there's no data, we `await asyncio.sleep`
168
+ instead of blocking the thread. select.select() would freeze
169
+ textual's event loop and starve input handling — a 50ms select
170
+ per iteration is enough to make keystrokes feel sluggish."""
171
+ while True:
172
+ if self._source == "file":
173
+ chunk = self._read_new_bytes_from_file()
174
+ if chunk:
175
+ return chunk
176
+ if not self._following:
177
+ # Static view: read what's there, then idle until q.
178
+ return None
179
+ await asyncio.sleep(0.05)
180
+ else:
181
+ # stdin or fifo: non-blocking read; EAGAIN/EWOULDBLOCK
182
+ # → yield to the event loop, try again. Never blocks
183
+ # the thread synchronously.
184
+ assert self._read_fd is not None
185
+ try:
186
+ data = os.read(self._read_fd, 4096)
187
+ except BlockingIOError:
188
+ await asyncio.sleep(0.05)
189
+ continue
190
+ except OSError:
191
+ return None
192
+ if not data:
193
+ # EOF — writer closed.
194
+ return None
195
+ return data.decode("utf-8", errors="replace")
196
+
197
+ def _read_new_bytes_from_file(self) -> str:
198
+ try:
199
+ size = os.path.getsize(self.config["path"])
200
+ except OSError:
201
+ return ""
202
+ if size <= self._file_offset:
203
+ return ""
204
+ try:
205
+ with open(self.config["path"], "rb") as f:
206
+ f.seek(self._file_offset)
207
+ chunk = f.read(size - self._file_offset)
208
+ except OSError:
209
+ return ""
210
+ if not chunk:
211
+ return ""
212
+ self._file_offset += len(chunk)
213
+ return chunk.decode("utf-8", errors="replace")
214
+
215
+ def _open_read_fd(self) -> int:
216
+ """Open the input fd for stdin or fifo sources."""
217
+ if self._source == "stdin":
218
+ return sys.stdin.fileno()
219
+ # fifo: open RW so we don't block waiting for a writer; once a
220
+ # writer connects and closes, our reads will see EOF.
221
+ return os.open(self.config["path"], os.O_RDWR | os.O_NONBLOCK)
222
+
223
+ # ── actions ────────────────────────────────────────────────────────
224
+ def action_toggle_follow(self) -> None:
225
+ self._following = not self._following
226
+ if self._following:
227
+ self.query_one("#scroller", VerticalScroll).anchor()
228
+
229
+ # ── entry point ────────────────────────────────────────────────────
230
+ def run_viewer(self) -> int:
231
+ if self._source == "file" and not Path(self.config["path"]).is_file():
232
+ print(
233
+ f"mdview: '{self.config['path']}' not found",
234
+ file=sys.stderr,
235
+ )
236
+ return 2
237
+ try:
238
+ if self.config["inline"]:
239
+ width = self.config["width"] or shutil.get_terminal_size().columns
240
+ self.run(inline=True, size=(width, self.config["height"]))
241
+ else:
242
+ self.run()
243
+ except KeyboardInterrupt:
244
+ return 130
245
+ return 0
246
+
247
+
248
+ def _classify(path: str) -> str:
249
+ """Return 'stdin', 'fifo', or 'file' for the input source."""
250
+ if path == "-":
251
+ return "stdin"
252
+ try:
253
+ st = os.stat(path)
254
+ except OSError:
255
+ return "file" # may not exist yet; caller validates
256
+ if stat.S_ISFIFO(st.st_mode):
257
+ return "fifo"
258
+ return "file"
llmkit/md/view/cli.py ADDED
@@ -0,0 +1,74 @@
1
+ """argparse + dispatch for ``mdview``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from typing import List, Optional, cast
6
+
7
+ from .app import ViewArgs, ViewerApp
8
+
9
+
10
+ def build_parser() -> argparse.ArgumentParser:
11
+ p = argparse.ArgumentParser(
12
+ prog="mdview",
13
+ description="Modal viewer for streamed markdown (file, fifo, or stdin).",
14
+ )
15
+ p.add_argument(
16
+ "path",
17
+ help="File to display. Use '-' for stdin (e.g. `bridge | view -`). "
18
+ "If the path is a fifo or '-', the viewer auto-follows and "
19
+ "exits on EOF unless overridden.",
20
+ )
21
+ p.add_argument(
22
+ "--title", default="thinking",
23
+ help="Panel title (default: 'thinking').",
24
+ )
25
+ p.add_argument(
26
+ "--subtitle", default="",
27
+ help="Panel subtitle (shown in the border on the top-right).",
28
+ )
29
+ follow = p.add_mutually_exclusive_group()
30
+ follow.add_argument(
31
+ "--follow", action="store_true", dest="follow", default=None,
32
+ help="Tail-follow the input as it grows. Default: on for stdin "
33
+ "and fifos, off for regular files.",
34
+ )
35
+ follow.add_argument(
36
+ "--no-follow", action="store_false", dest="follow",
37
+ help="Open the file as-is, no follow.",
38
+ )
39
+ eof = p.add_mutually_exclusive_group()
40
+ eof.add_argument(
41
+ "--exit-on-eof", action="store_true", dest="exit_on_eof", default=None,
42
+ help="Exit when the input source EOFs. Default: on for stdin and "
43
+ "fifos (writer closing = done); off for regular files (no "
44
+ "natural EOF signal — user quits with q).",
45
+ )
46
+ eof.add_argument(
47
+ "--no-exit-on-eof", action="store_false", dest="exit_on_eof",
48
+ help="Stay open after EOF; user quits with q.",
49
+ )
50
+ # ── inline-mode sizing ─────────────────────────────────────────────
51
+ p.add_argument(
52
+ "--inline", action="store_true",
53
+ help="Render inline below the cursor instead of taking over the "
54
+ "screen with alt-screen. Default: alt-screen.",
55
+ )
56
+ p.add_argument(
57
+ "--height", type=int, default=20,
58
+ help="Rows for the inline area (default 20). Ignored unless "
59
+ "--inline is set.",
60
+ )
61
+ p.add_argument(
62
+ "--width", type=int, default=0,
63
+ help="Columns for the inline area (default 0 = full terminal "
64
+ "width). Ignored unless --inline is set.",
65
+ )
66
+ return p
67
+
68
+
69
+ def main(argv: Optional[List[str]] = None) -> int:
70
+ args = build_parser().parse_args(argv)
71
+ # Defaults that depend on the input source kind happen in the App
72
+ # (it stats the path); leaving --follow / --exit-on-eof as None
73
+ # here signals "auto" to the App.
74
+ return ViewerApp(cast(ViewArgs, vars(args))).run_viewer()
llmkit/py.typed ADDED
File without changes
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: polyllmkit
3
+ Version: 0.1.0
4
+ Summary: Streaming, multi-provider LLM bridge + markdown rendering toolkit for terminal apps.
5
+ Requires-Dist: anthropic>=0.40 ; extra == 'anthropic'
6
+ Requires-Dist: openai>=1.0 ; extra == 'bridge'
7
+ Requires-Dist: claude-agent-sdk>=0.2 ; extra == 'claude'
8
+ Requires-Dist: google-genai>=1.0 ; extra == 'google'
9
+ Requires-Dist: rich>=13.0 ; extra == 'md'
10
+ Requires-Dist: textual>=0.50 ; extra == 'md'
11
+ Requires-Dist: markdown-it-py>=3.0 ; extra == 'md'
12
+ Requires-Python: >=3.11
13
+ Provides-Extra: anthropic
14
+ Provides-Extra: bridge
15
+ Provides-Extra: claude
16
+ Provides-Extra: google
17
+ Provides-Extra: md
@@ -0,0 +1,27 @@
1
+ llmkit/__init__.py,sha256=bLAzfdtbVtvjY1RGqjdpLPNCyG55ezc3PL5T7vS4OV0,940
2
+ llmkit/bridge/__init__.py,sha256=M72Zi6rfCenBnAWHIBQKgwe0UNjybGpnFnODa7T9G3Y,1542
3
+ llmkit/bridge/__main__.py,sha256=xPFtVm4Yw1AvRYODXxu6wektJ0dEQyAiQ35Fdu_5dkY,202
4
+ llmkit/bridge/adapters/__init__.py,sha256=QicJlecW2fCFp1-8K9dyf5sistv4vrvfAPSI2wZ6cKE,779
5
+ llmkit/bridge/adapters/anthropic.py,sha256=Z9FUnGO1poPxFSWwxomQkEb4lZ5ZZUhWCTZkqN_jNqE,6883
6
+ llmkit/bridge/adapters/claude_code.py,sha256=6VXo8EKZRZvSvWjsrmXMyvpO2Oyv4OEGqksRDA0jJlQ,7053
7
+ llmkit/bridge/adapters/google.py,sha256=cFjDxxadPQ2MxG2CzmlDuFDKUlWE-jLkumU0VeqTQnw,4413
8
+ llmkit/bridge/adapters/openai.py,sha256=SeQAoVrhP9sGKNRBUYWEh8nk-TqkOuk62JoEtswMxiQ,2444
9
+ llmkit/bridge/bridge.py,sha256=IUxDxjLRAc7v-jtBbtTOtCENoEyfoI5X3p2XPWchd-8,13670
10
+ llmkit/bridge/cli.py,sha256=qqO_YbeGAwGOtAhUTEMjRvp99ELLlwlRiR99hcWZUHA,6135
11
+ llmkit/bridge/client.py,sha256=rmBd9h5eeoJ7tNRZ4RYf1VpUndtqPDglXkk1PNqaPEM,3254
12
+ llmkit/bridge/config.py,sha256=mo-fyfry6oY_O-XtAsT_mksJXxqJECKCWqGuoC50ewU,10929
13
+ llmkit/bridge/sinks.py,sha256=xVavomgi7SHElpziCBhXSxNHgBzdh9kuiQI0FadREqg,1944
14
+ llmkit/bridge/stream.py,sha256=DRXVQskHRi4EgWLVzZlkg-1_7-S4LlAtyENNHZLhQzE,6570
15
+ llmkit/md/__init__.py,sha256=2tSVeD5QLy4doE1hNbrq9sjvzd3lUN8ghvWB1APEIlI,561
16
+ llmkit/md/render/__init__.py,sha256=d02nHe71mZ7fphJKLKZXWHZ89XYl4LmeadMgL_dBjdo,60
17
+ llmkit/md/render/__main__.py,sha256=ayAHlfGR7GlcqXIBSlgaEb7WpwyCqHWP_0pLCxNgTo4,144
18
+ llmkit/md/render/cli.py,sha256=oAOyoh5AdwYOLVsHWaWms-4DbuKBRvLL-QmW0uuMAZ4,8016
19
+ llmkit/md/render/stream.py,sha256=YcDW0UUZzOMtyA9wiAYgKbUzSdYCN_DuVg52ifseSMo,9358
20
+ llmkit/md/view/__init__.py,sha256=EpokGpMl9Qj0CHu-BbvLRBTLJ8IFAdRyV_7Z7lygWV4,50
21
+ llmkit/md/view/__main__.py,sha256=gsO_RhoJLxPMNuYtoPtnUGNvcUzgRpqX8YXGo5Cdnpk,142
22
+ llmkit/md/view/app.py,sha256=NRZOzf1N6YMBEzNJGe3YuGWAFwiXc3BM7p-E-dhrfNk,10564
23
+ llmkit/md/view/cli.py,sha256=Vuz8-EgdV0MqOHAoVB0tl8XL2-_-LCzlpGwsIMM-mWQ,2855
24
+ llmkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
+ polyllmkit-0.1.0.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
26
+ polyllmkit-0.1.0.dist-info/METADATA,sha256=wApTjsq9kHo9lK_t_RQo3eIMb20k_59hvcUU_1wN2hI,635
27
+ polyllmkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.28
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any