kimi-cli 0.35__py3-none-any.whl → 0.52__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 (88) hide show
  1. kimi_cli/CHANGELOG.md +165 -0
  2. kimi_cli/__init__.py +0 -374
  3. kimi_cli/agents/{koder → default}/agent.yaml +1 -1
  4. kimi_cli/agents/{koder → default}/system.md +1 -1
  5. kimi_cli/agentspec.py +115 -0
  6. kimi_cli/app.py +208 -0
  7. kimi_cli/cli.py +321 -0
  8. kimi_cli/config.py +33 -16
  9. kimi_cli/constant.py +4 -0
  10. kimi_cli/exception.py +16 -0
  11. kimi_cli/llm.py +144 -3
  12. kimi_cli/metadata.py +6 -69
  13. kimi_cli/prompts/__init__.py +4 -0
  14. kimi_cli/session.py +103 -0
  15. kimi_cli/soul/__init__.py +130 -9
  16. kimi_cli/soul/agent.py +159 -0
  17. kimi_cli/soul/approval.py +5 -6
  18. kimi_cli/soul/compaction.py +106 -0
  19. kimi_cli/soul/context.py +1 -1
  20. kimi_cli/soul/kimisoul.py +180 -80
  21. kimi_cli/soul/message.py +6 -6
  22. kimi_cli/soul/runtime.py +96 -0
  23. kimi_cli/soul/toolset.py +3 -2
  24. kimi_cli/tools/__init__.py +35 -31
  25. kimi_cli/tools/bash/__init__.py +25 -9
  26. kimi_cli/tools/bash/cmd.md +31 -0
  27. kimi_cli/tools/dmail/__init__.py +5 -4
  28. kimi_cli/tools/file/__init__.py +8 -0
  29. kimi_cli/tools/file/glob.md +1 -1
  30. kimi_cli/tools/file/glob.py +4 -4
  31. kimi_cli/tools/file/grep.py +36 -19
  32. kimi_cli/tools/file/patch.py +52 -10
  33. kimi_cli/tools/file/read.py +6 -5
  34. kimi_cli/tools/file/replace.py +16 -4
  35. kimi_cli/tools/file/write.py +16 -4
  36. kimi_cli/tools/mcp.py +7 -4
  37. kimi_cli/tools/task/__init__.py +60 -41
  38. kimi_cli/tools/task/task.md +1 -1
  39. kimi_cli/tools/todo/__init__.py +4 -2
  40. kimi_cli/tools/utils.py +1 -1
  41. kimi_cli/tools/web/fetch.py +2 -1
  42. kimi_cli/tools/web/search.py +13 -12
  43. kimi_cli/ui/__init__.py +0 -68
  44. kimi_cli/ui/acp/__init__.py +67 -38
  45. kimi_cli/ui/print/__init__.py +46 -69
  46. kimi_cli/ui/shell/__init__.py +145 -154
  47. kimi_cli/ui/shell/console.py +27 -1
  48. kimi_cli/ui/shell/debug.py +187 -0
  49. kimi_cli/ui/shell/keyboard.py +183 -0
  50. kimi_cli/ui/shell/metacmd.py +34 -81
  51. kimi_cli/ui/shell/prompt.py +245 -28
  52. kimi_cli/ui/shell/replay.py +104 -0
  53. kimi_cli/ui/shell/setup.py +19 -19
  54. kimi_cli/ui/shell/update.py +11 -5
  55. kimi_cli/ui/shell/visualize.py +576 -0
  56. kimi_cli/ui/wire/README.md +109 -0
  57. kimi_cli/ui/wire/__init__.py +340 -0
  58. kimi_cli/ui/wire/jsonrpc.py +48 -0
  59. kimi_cli/utils/__init__.py +0 -0
  60. kimi_cli/utils/aiohttp.py +10 -0
  61. kimi_cli/utils/changelog.py +6 -2
  62. kimi_cli/utils/clipboard.py +10 -0
  63. kimi_cli/utils/message.py +15 -1
  64. kimi_cli/utils/rich/__init__.py +33 -0
  65. kimi_cli/utils/rich/markdown.py +959 -0
  66. kimi_cli/utils/rich/markdown_sample.md +108 -0
  67. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  68. kimi_cli/utils/signals.py +41 -0
  69. kimi_cli/utils/string.py +8 -0
  70. kimi_cli/utils/term.py +114 -0
  71. kimi_cli/wire/__init__.py +73 -0
  72. kimi_cli/wire/message.py +191 -0
  73. kimi_cli-0.52.dist-info/METADATA +186 -0
  74. kimi_cli-0.52.dist-info/RECORD +99 -0
  75. kimi_cli-0.52.dist-info/entry_points.txt +3 -0
  76. kimi_cli/agent.py +0 -261
  77. kimi_cli/agents/koder/README.md +0 -3
  78. kimi_cli/prompts/metacmds/__init__.py +0 -4
  79. kimi_cli/soul/wire.py +0 -101
  80. kimi_cli/ui/shell/liveview.py +0 -158
  81. kimi_cli/utils/provider.py +0 -64
  82. kimi_cli-0.35.dist-info/METADATA +0 -24
  83. kimi_cli-0.35.dist-info/RECORD +0 -76
  84. kimi_cli-0.35.dist-info/entry_points.txt +0 -3
  85. /kimi_cli/agents/{koder → default}/sub.yaml +0 -0
  86. /kimi_cli/prompts/{metacmds/compact.md → compact.md} +0 -0
  87. /kimi_cli/prompts/{metacmds/init.md → init.md} +0 -0
  88. {kimi_cli-0.35.dist-info → kimi_cli-0.52.dist-info}/WHEEL +0 -0
@@ -0,0 +1,959 @@
1
+ # This file is modified from https://github.com/Textualize/rich/blob/4d6d631a3d2deddf8405522d4b8c976a6d35726c/rich/markdown.py
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Iterable, Mapping
7
+ from typing import ClassVar, get_args
8
+
9
+ from markdown_it import MarkdownIt
10
+ from markdown_it.token import Token
11
+ from pygments.token import (
12
+ Comment,
13
+ Generic,
14
+ Keyword,
15
+ Name,
16
+ Number,
17
+ Operator,
18
+ Punctuation,
19
+ String,
20
+ )
21
+ from pygments.token import (
22
+ Literal as PygmentsLiteral,
23
+ )
24
+ from pygments.token import (
25
+ Text as PygmentsText,
26
+ )
27
+ from pygments.token import (
28
+ Token as PygmentsToken,
29
+ )
30
+ from rich import box
31
+ from rich._loop import loop_first
32
+ from rich._stack import Stack
33
+ from rich.console import Console, ConsoleOptions, JustifyMethod, RenderResult
34
+ from rich.containers import Renderables
35
+ from rich.jupyter import JupyterMixin
36
+ from rich.rule import Rule
37
+ from rich.segment import Segment
38
+ from rich.style import Style, StyleStack
39
+ from rich.syntax import ANSISyntaxTheme, Syntax, SyntaxTheme
40
+ from rich.table import Table
41
+ from rich.text import Text, TextType
42
+
43
+ LIST_INDENT_WIDTH = 2
44
+
45
+ _FALLBACK_STYLES: Mapping[str, Style] = {
46
+ "markdown.paragraph": Style(),
47
+ "markdown.h1": Style(color="bright_white", bold=True),
48
+ "markdown.h1.underline": Style(color="bright_white", bold=True),
49
+ "markdown.h2": Style(color="white", bold=True, underline=True),
50
+ "markdown.h3": Style(bold=True),
51
+ "markdown.h4": Style(bold=True),
52
+ "markdown.h5": Style(bold=True),
53
+ "markdown.h6": Style(dim=True, italic=True),
54
+ "markdown.code": Style(color="bright_cyan", bold=True),
55
+ "markdown.code_block": Style(color="bright_cyan"),
56
+ "markdown.item": Style(),
57
+ "markdown.item.bullet": Style(),
58
+ "markdown.item.number": Style(),
59
+ "markdown.em": Style(italic=True),
60
+ "markdown.strong": Style(bold=True),
61
+ "markdown.s": Style(strike=True),
62
+ "markdown.link": Style(color="bright_blue", underline=True),
63
+ "markdown.link_url": Style(color="cyan", underline=True),
64
+ "markdown.block_quote": Style(),
65
+ "markdown.hr": Style(color="grey58"),
66
+ }
67
+
68
+ _KIMI_ANSI_THEME_NAME = "kimi-ansi"
69
+ _KIMI_ANSI_THEME = ANSISyntaxTheme(
70
+ {
71
+ PygmentsToken: Style(color="default"),
72
+ PygmentsText: Style(color="default"),
73
+ Comment: Style(color="bright_black", italic=True),
74
+ Keyword: Style(color="bright_magenta", bold=True),
75
+ Keyword.Constant: Style(color="bright_magenta", bold=True),
76
+ Keyword.Declaration: Style(color="bright_magenta", bold=True),
77
+ Keyword.Namespace: Style(color="bright_magenta", bold=True),
78
+ Keyword.Pseudo: Style(color="bright_magenta"),
79
+ Keyword.Reserved: Style(color="bright_magenta", bold=True),
80
+ Keyword.Type: Style(color="bright_magenta", bold=True),
81
+ Name: Style(color="default"),
82
+ Name.Attribute: Style(color="cyan"),
83
+ Name.Builtin: Style(color="bright_cyan"),
84
+ Name.Builtin.Pseudo: Style(color="bright_magenta"),
85
+ Name.Builtin.Type: Style(color="bright_cyan", bold=True),
86
+ Name.Class: Style(color="bright_cyan", bold=True),
87
+ Name.Constant: Style(color="bright_magenta"),
88
+ Name.Decorator: Style(color="bright_magenta"),
89
+ Name.Entity: Style(color="bright_cyan"),
90
+ Name.Exception: Style(color="bright_magenta", bold=True),
91
+ Name.Function: Style(color="bright_blue"),
92
+ Name.Label: Style(color="bright_cyan"),
93
+ Name.Namespace: Style(color="bright_cyan"),
94
+ Name.Other: Style(color="bright_blue"),
95
+ Name.Property: Style(color="bright_blue"),
96
+ Name.Tag: Style(color="bright_blue"),
97
+ Name.Variable: Style(color="bright_blue"),
98
+ PygmentsLiteral: Style(color="bright_green"),
99
+ PygmentsLiteral.Date: Style(color="green"),
100
+ String: Style(color="yellow"),
101
+ String.Doc: Style(color="yellow", italic=True),
102
+ String.Interpol: Style(color="yellow"),
103
+ String.Affix: Style(color="yellow"),
104
+ Number: Style(color="bright_green"),
105
+ Operator: Style(color="default"),
106
+ Punctuation: Style(color="default"),
107
+ Generic.Deleted: Style(color="red"),
108
+ Generic.Emph: Style(italic=True),
109
+ Generic.Error: Style(color="bright_red", bold=True),
110
+ Generic.Heading: Style(color="bright_cyan", bold=True),
111
+ Generic.Inserted: Style(color="green"),
112
+ Generic.Output: Style(color="bright_black"),
113
+ Generic.Prompt: Style(color="bright_magenta"),
114
+ Generic.Strong: Style(bold=True),
115
+ Generic.Subheading: Style(color="bright_cyan"),
116
+ Generic.Traceback: Style(color="bright_red", bold=True),
117
+ }
118
+ )
119
+
120
+
121
+ def _resolve_code_theme(theme: str) -> str | SyntaxTheme:
122
+ if theme.lower() == _KIMI_ANSI_THEME_NAME:
123
+ return _KIMI_ANSI_THEME
124
+ return theme
125
+
126
+
127
+ def _strip_background(text: Text) -> Text:
128
+ """Return a copy of ``text`` with all background colors removed."""
129
+
130
+ clean = Text(
131
+ text.plain,
132
+ justify=text.justify,
133
+ overflow=text.overflow,
134
+ no_wrap=text.no_wrap,
135
+ end=text.end,
136
+ tab_size=text.tab_size,
137
+ )
138
+
139
+ if text.style:
140
+ base_style = text.style
141
+ if not isinstance(base_style, Style):
142
+ base_style = Style.parse(str(base_style))
143
+ base_style = base_style.copy()
144
+ if base_style._bgcolor is not None:
145
+ base_style._bgcolor = None
146
+ clean.stylize(base_style, 0, len(clean))
147
+
148
+ for span in text.spans:
149
+ style = span.style
150
+ if style is None:
151
+ continue
152
+ new_style = Style.parse(str(style)) if not isinstance(style, Style) else style.copy()
153
+ if new_style._bgcolor is not None:
154
+ new_style._bgcolor = None
155
+ clean.stylize(new_style, span.start, span.end)
156
+
157
+ return clean
158
+
159
+
160
+ class MarkdownElement:
161
+ new_line: ClassVar[bool] = True
162
+
163
+ @classmethod
164
+ def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
165
+ """Factory to create markdown element,
166
+
167
+ Args:
168
+ markdown (Markdown): The parent Markdown object.
169
+ token (Token): A node from markdown-it.
170
+
171
+ Returns:
172
+ MarkdownElement: A new markdown element
173
+ """
174
+ return cls()
175
+
176
+ def on_enter(self, context: MarkdownContext) -> None:
177
+ """Called when the node is entered.
178
+
179
+ Args:
180
+ context (MarkdownContext): The markdown context.
181
+ """
182
+
183
+ def on_text(self, context: MarkdownContext, text: TextType) -> None:
184
+ """Called when text is parsed.
185
+
186
+ Args:
187
+ context (MarkdownContext): The markdown context.
188
+ """
189
+
190
+ def on_leave(self, context: MarkdownContext) -> None:
191
+ """Called when the parser leaves the element.
192
+
193
+ Args:
194
+ context (MarkdownContext): [description]
195
+ """
196
+
197
+ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
198
+ """Called when a child element is closed.
199
+
200
+ This method allows a parent element to take over rendering of its children.
201
+
202
+ Args:
203
+ context (MarkdownContext): The markdown context.
204
+ child (MarkdownElement): The child markdown element.
205
+
206
+ Returns:
207
+ bool: Return True to render the element, or False to not render the element.
208
+ """
209
+ return True
210
+
211
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
212
+ return ()
213
+
214
+
215
+ class UnknownElement(MarkdownElement):
216
+ """An unknown element.
217
+
218
+ Hopefully there will be no unknown elements, and we will have a MarkdownElement for
219
+ everything in the document.
220
+
221
+ """
222
+
223
+
224
+ class TextElement(MarkdownElement):
225
+ """Base class for elements that render text."""
226
+
227
+ style_name = "none"
228
+
229
+ def on_enter(self, context: MarkdownContext) -> None:
230
+ self.style = context.enter_style(self.style_name)
231
+ self.text = Text(justify="left")
232
+
233
+ def on_text(self, context: MarkdownContext, text: TextType) -> None:
234
+ self.text.append(text, context.current_style if isinstance(text, str) else None)
235
+
236
+ def on_leave(self, context: MarkdownContext) -> None:
237
+ context.leave_style()
238
+
239
+
240
+ class Paragraph(TextElement):
241
+ """A Paragraph."""
242
+
243
+ style_name = "markdown.paragraph"
244
+ justify: JustifyMethod
245
+
246
+ @classmethod
247
+ def create(cls, markdown: Markdown, token: Token) -> Paragraph:
248
+ return cls(justify=markdown.justify or "left")
249
+
250
+ def __init__(self, justify: JustifyMethod) -> None:
251
+ self.justify = justify
252
+
253
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
254
+ self.text.justify = self.justify
255
+ yield self.text
256
+
257
+
258
+ class Heading(TextElement):
259
+ """A heading."""
260
+
261
+ @classmethod
262
+ def create(cls, markdown: Markdown, token: Token) -> Heading:
263
+ return cls(token.tag)
264
+
265
+ def on_enter(self, context: MarkdownContext) -> None:
266
+ self.text = Text()
267
+ context.enter_style(self.style_name)
268
+
269
+ def __init__(self, tag: str) -> None:
270
+ self.tag = tag
271
+ self.style_name = f"markdown.{tag}"
272
+ super().__init__()
273
+
274
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
275
+ text = self.text
276
+ text.justify = "left"
277
+ width = max(1, text.cell_len)
278
+
279
+ if self.tag == "h1":
280
+ underline = Text("═" * width)
281
+ underline.stylize("markdown.h1.underline")
282
+ yield text
283
+ yield underline
284
+ else:
285
+ yield text
286
+
287
+
288
+ class CodeBlock(TextElement):
289
+ """A code block with syntax highlighting."""
290
+
291
+ style_name = "markdown.code_block"
292
+
293
+ @classmethod
294
+ def create(cls, markdown: Markdown, token: Token) -> CodeBlock:
295
+ node_info = token.info or ""
296
+ lexer_name = node_info.partition(" ")[0]
297
+ return cls(lexer_name or "text", markdown.code_theme)
298
+
299
+ def __init__(self, lexer_name: str, theme: str | SyntaxTheme) -> None:
300
+ self.lexer_name = lexer_name
301
+ self.theme = theme
302
+
303
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
304
+ code = str(self.text).rstrip()
305
+ syntax = Syntax(
306
+ code,
307
+ self.lexer_name,
308
+ theme=self.theme,
309
+ word_wrap=True,
310
+ background_color=None,
311
+ padding=0,
312
+ )
313
+ highlighted = syntax.highlight(code)
314
+ highlighted.rstrip()
315
+ stripped = _strip_background(highlighted)
316
+ stripped.rstrip()
317
+ yield stripped
318
+
319
+
320
+ class BlockQuote(TextElement):
321
+ """A block quote."""
322
+
323
+ style_name = "markdown.block_quote"
324
+
325
+ def __init__(self) -> None:
326
+ self.elements: Renderables = Renderables()
327
+
328
+ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
329
+ self.elements.append(child)
330
+ return False
331
+
332
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
333
+ render_options = options.update(width=options.max_width - 4)
334
+ style = self.style.without_color
335
+ lines = console.render_lines(self.elements, render_options, style=style)
336
+ new_line = Segment("\n")
337
+ padding = Segment("▌ ", style)
338
+ for line in lines:
339
+ yield padding
340
+ yield from line
341
+ yield new_line
342
+
343
+
344
+ class HorizontalRule(MarkdownElement):
345
+ """A horizontal rule to divide sections."""
346
+
347
+ new_line = False
348
+
349
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
350
+ style = _FALLBACK_STYLES["markdown.hr"].copy()
351
+ yield Rule(style=style)
352
+
353
+
354
+ class TableElement(MarkdownElement):
355
+ """MarkdownElement corresponding to `table_open`."""
356
+
357
+ def __init__(self) -> None:
358
+ self.header: TableHeaderElement | None = None
359
+ self.body: TableBodyElement | None = None
360
+
361
+ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
362
+ if isinstance(child, TableHeaderElement):
363
+ self.header = child
364
+ elif isinstance(child, TableBodyElement):
365
+ self.body = child
366
+ else:
367
+ raise RuntimeError("Couldn't process markdown table.")
368
+ return False
369
+
370
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
371
+ table = Table(box=box.SIMPLE_HEAVY, show_edge=False)
372
+
373
+ if self.header is not None and self.header.row is not None:
374
+ for column in self.header.row.cells:
375
+ table.add_column(column.content)
376
+
377
+ if self.body is not None:
378
+ for row in self.body.rows:
379
+ row_content = [element.content for element in row.cells]
380
+ table.add_row(*row_content)
381
+
382
+ yield table
383
+
384
+
385
+ class TableHeaderElement(MarkdownElement):
386
+ """MarkdownElement corresponding to `thead_open` and `thead_close`."""
387
+
388
+ def __init__(self) -> None:
389
+ self.row: TableRowElement | None = None
390
+
391
+ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
392
+ assert isinstance(child, TableRowElement)
393
+ self.row = child
394
+ return False
395
+
396
+
397
+ class TableBodyElement(MarkdownElement):
398
+ """MarkdownElement corresponding to `tbody_open` and `tbody_close`."""
399
+
400
+ def __init__(self) -> None:
401
+ self.rows: list[TableRowElement] = []
402
+
403
+ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
404
+ assert isinstance(child, TableRowElement)
405
+ self.rows.append(child)
406
+ return False
407
+
408
+
409
+ class TableRowElement(MarkdownElement):
410
+ """MarkdownElement corresponding to `tr_open` and `tr_close`."""
411
+
412
+ def __init__(self) -> None:
413
+ self.cells: list[TableDataElement] = []
414
+
415
+ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
416
+ assert isinstance(child, TableDataElement)
417
+ self.cells.append(child)
418
+ return False
419
+
420
+
421
+ class TableDataElement(MarkdownElement):
422
+ """MarkdownElement corresponding to `td_open` and `td_close`
423
+ and `th_open` and `th_close`."""
424
+
425
+ @classmethod
426
+ def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
427
+ style = str(token.attrs.get("style")) or ""
428
+
429
+ justify: JustifyMethod
430
+ if "text-align:right" in style:
431
+ justify = "right"
432
+ elif "text-align:center" in style:
433
+ justify = "center"
434
+ elif "text-align:left" in style:
435
+ justify = "left"
436
+ else:
437
+ justify = "default"
438
+
439
+ assert justify in get_args(JustifyMethod)
440
+ return cls(justify=justify)
441
+
442
+ def __init__(self, justify: JustifyMethod) -> None:
443
+ self.content: Text = Text("", justify=justify)
444
+ self.justify = justify
445
+
446
+ def on_text(self, context: MarkdownContext, text: TextType) -> None:
447
+ text = Text(text) if isinstance(text, str) else text
448
+ text.stylize(context.current_style)
449
+ self.content.append_text(text)
450
+
451
+
452
+ class ListElement(MarkdownElement):
453
+ """A list element."""
454
+
455
+ @classmethod
456
+ def create(cls, markdown: Markdown, token: Token) -> ListElement:
457
+ return cls(token.type, int(token.attrs.get("start", 1)))
458
+
459
+ def __init__(self, list_type: str, list_start: int | None) -> None:
460
+ self.items: list[ListItem] = []
461
+ self.list_type = list_type
462
+ self.list_start = list_start
463
+
464
+ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
465
+ assert isinstance(child, ListItem)
466
+ self.items.append(child)
467
+ return False
468
+
469
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
470
+ if self.list_type == "bullet_list_open":
471
+ for item in self.items:
472
+ yield from item.render_bullet(console, options)
473
+ else:
474
+ number = 1 if self.list_start is None else self.list_start
475
+ last_number = number + len(self.items)
476
+ for index, item in enumerate(self.items):
477
+ yield from item.render_number(console, options, number + index, last_number)
478
+
479
+
480
+ class ListItem(TextElement):
481
+ """An item in a list."""
482
+
483
+ style_name = "markdown.item"
484
+
485
+ @staticmethod
486
+ def _line_starts_with_list_marker(text: str) -> bool:
487
+ stripped = text.lstrip()
488
+ if not stripped:
489
+ return False
490
+ if stripped.startswith(("• ", "- ", "* ")):
491
+ return True
492
+ index = 0
493
+ while index < len(stripped) and stripped[index].isdigit():
494
+ index += 1
495
+ if index == 0 or index >= len(stripped):
496
+ return False
497
+ marker = stripped[index]
498
+ has_space = index + 1 < len(stripped) and stripped[index + 1] == " "
499
+ return marker in {".", ")"} and has_space
500
+
501
+ @classmethod
502
+ def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
503
+ # `list_item_open` levels grow by 2 for each nested list depth.
504
+ depth = max(0, (token.level - 1) // 2)
505
+ return cls(indent=depth)
506
+
507
+ def __init__(self, indent: int = 0) -> None:
508
+ self.indent = indent
509
+ self.elements: Renderables = Renderables()
510
+
511
+ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
512
+ self.elements.append(child)
513
+ return False
514
+
515
+ def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
516
+ lines = console.render_lines(self.elements, options, style=self.style)
517
+ indent_padding_len = LIST_INDENT_WIDTH * self.indent
518
+ indent_text = " " * indent_padding_len
519
+ bullet = Segment("• ")
520
+ new_line = Segment("\n")
521
+ bullet_width = len(bullet.text)
522
+ for first, line in loop_first(lines):
523
+ if first:
524
+ if indent_text:
525
+ yield Segment(indent_text)
526
+ yield bullet
527
+ else:
528
+ plain = "".join(segment.text for segment in line)
529
+ if self._line_starts_with_list_marker(plain):
530
+ prefix = ""
531
+ else:
532
+ existing = len(plain) - len(plain.lstrip(" "))
533
+ target = indent_padding_len + bullet_width
534
+ missing = max(0, target - existing)
535
+ prefix = " " * missing
536
+ if prefix:
537
+ yield Segment(prefix)
538
+ yield from line
539
+ yield new_line
540
+
541
+ def render_number(
542
+ self, console: Console, options: ConsoleOptions, number: int, last_number: int
543
+ ) -> RenderResult:
544
+ lines = console.render_lines(self.elements, options, style=self.style)
545
+ new_line = Segment("\n")
546
+ indent_padding_len = LIST_INDENT_WIDTH * self.indent
547
+ indent_text = " " * indent_padding_len
548
+ numeral_text = f"{number}. "
549
+ numeral = Segment(numeral_text)
550
+ numeral_width = len(numeral_text)
551
+ for first, line in loop_first(lines):
552
+ if first:
553
+ if indent_text:
554
+ yield Segment(indent_text)
555
+ yield numeral
556
+ else:
557
+ plain = "".join(segment.text for segment in line)
558
+ if self._line_starts_with_list_marker(plain):
559
+ prefix = ""
560
+ else:
561
+ existing = len(plain) - len(plain.lstrip(" "))
562
+ target = indent_padding_len + numeral_width
563
+ missing = max(0, target - existing)
564
+ prefix = " " * missing
565
+ if prefix:
566
+ yield Segment(prefix)
567
+ yield from line
568
+ yield new_line
569
+
570
+
571
+ class Link(TextElement):
572
+ @classmethod
573
+ def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
574
+ url = token.attrs.get("href", "#")
575
+ return cls(token.content, str(url))
576
+
577
+ def __init__(self, text: str, href: str):
578
+ self.text = Text(text)
579
+ self.href = href
580
+
581
+
582
+ class ImageItem(TextElement):
583
+ """Renders a placeholder for an image."""
584
+
585
+ new_line = False
586
+
587
+ @classmethod
588
+ def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
589
+ """Factory to create markdown element,
590
+
591
+ Args:
592
+ markdown (Markdown): The parent Markdown object.
593
+ token (Any): A token from markdown-it.
594
+
595
+ Returns:
596
+ MarkdownElement: A new markdown element
597
+ """
598
+ return cls(str(token.attrs.get("src", "")), markdown.hyperlinks)
599
+
600
+ def __init__(self, destination: str, hyperlinks: bool) -> None:
601
+ self.destination = destination
602
+ self.hyperlinks = hyperlinks
603
+ self.link: str | None = None
604
+ super().__init__()
605
+
606
+ def on_enter(self, context: MarkdownContext) -> None:
607
+ self.link = context.current_style.link
608
+ self.text = Text(justify="left")
609
+ super().on_enter(context)
610
+
611
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
612
+ link_style = Style(link=self.link or self.destination or None)
613
+ title = self.text or Text(self.destination.strip("/").rsplit("/", 1)[-1])
614
+ if self.hyperlinks:
615
+ title.stylize(link_style)
616
+ text = Text.assemble("🌆 ", title, " ", end="")
617
+ yield text
618
+
619
+
620
+ class MarkdownContext:
621
+ """Manages the console render state."""
622
+
623
+ def __init__(
624
+ self,
625
+ console: Console,
626
+ options: ConsoleOptions,
627
+ style: Style,
628
+ fallback_styles: Mapping[str, Style],
629
+ inline_code_lexer: str | None = None,
630
+ inline_code_theme: str | SyntaxTheme = _KIMI_ANSI_THEME_NAME,
631
+ ) -> None:
632
+ self.console = console
633
+ self.options = options
634
+ self.style_stack: StyleStack = StyleStack(style)
635
+ self.stack: Stack[MarkdownElement] = Stack()
636
+ self._fallback_styles = fallback_styles
637
+
638
+ self._syntax: Syntax | None = None
639
+ if inline_code_lexer is not None:
640
+ self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme)
641
+
642
+ @property
643
+ def current_style(self) -> Style:
644
+ """Current style which is the product of all styles on the stack."""
645
+ return self.style_stack.current
646
+
647
+ def on_text(self, text: str, node_type: str) -> None:
648
+ """Called when the parser visits text."""
649
+ if node_type in {"fence", "code_inline"} and self._syntax is not None:
650
+ highlighted = self._syntax.highlight(text)
651
+ highlighted.rstrip()
652
+ stripped = _strip_background(highlighted)
653
+ combined = Text.assemble(stripped, style=self.style_stack.current)
654
+ self.stack.top.on_text(self, combined)
655
+ else:
656
+ self.stack.top.on_text(self, text)
657
+
658
+ def enter_style(self, style_name: str | Style) -> Style:
659
+ """Enter a style context."""
660
+ if isinstance(style_name, Style):
661
+ style = style_name
662
+ else:
663
+ fallback = self._fallback_styles.get(style_name, Style())
664
+ style = self.console.get_style(style_name, default=fallback)
665
+ style = fallback + style
666
+ style = style.copy()
667
+ if isinstance(style_name, str) and style_name == "markdown.block_quote":
668
+ style = style.without_color
669
+ if (
670
+ isinstance(style_name, str)
671
+ and style_name in {"markdown.code", "markdown.code_block"}
672
+ and style._bgcolor is not None
673
+ ):
674
+ style._bgcolor = None
675
+ self.style_stack.push(style)
676
+ return self.current_style
677
+
678
+ def leave_style(self) -> Style:
679
+ """Leave a style context."""
680
+ style = self.style_stack.pop()
681
+ return style
682
+
683
+
684
+ class Markdown(JupyterMixin):
685
+ """A Markdown renderable.
686
+
687
+ Args:
688
+ markup (str): A string containing markdown.
689
+ code_theme (str, optional): Pygments theme for code blocks. Defaults to "kimi-ansi".
690
+ See https://pygments.org/styles/ for code themes.
691
+ justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None.
692
+ style (Union[str, Style], optional): Optional style to apply to markdown.
693
+ hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``.
694
+ inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is
695
+ enabled. Defaults to None.
696
+ inline_code_theme: (Optional[str], optional): Pygments theme for inline code
697
+ highlighting, or None for no highlighting. Defaults to None.
698
+ """
699
+
700
+ elements: ClassVar[dict[str, type[MarkdownElement]]] = {
701
+ "paragraph_open": Paragraph,
702
+ "heading_open": Heading,
703
+ "fence": CodeBlock,
704
+ "code_block": CodeBlock,
705
+ "blockquote_open": BlockQuote,
706
+ "hr": HorizontalRule,
707
+ "bullet_list_open": ListElement,
708
+ "ordered_list_open": ListElement,
709
+ "list_item_open": ListItem,
710
+ "image": ImageItem,
711
+ "table_open": TableElement,
712
+ "tbody_open": TableBodyElement,
713
+ "thead_open": TableHeaderElement,
714
+ "tr_open": TableRowElement,
715
+ "td_open": TableDataElement,
716
+ "th_open": TableDataElement,
717
+ }
718
+
719
+ inlines = {"em", "strong", "code", "s"}
720
+
721
+ def __init__(
722
+ self,
723
+ markup: str,
724
+ code_theme: str = _KIMI_ANSI_THEME_NAME,
725
+ justify: JustifyMethod | None = None,
726
+ style: str | Style = "none",
727
+ hyperlinks: bool = True,
728
+ inline_code_lexer: str | None = None,
729
+ inline_code_theme: str | None = None,
730
+ ) -> None:
731
+ parser = MarkdownIt().enable("strikethrough").enable("table")
732
+ self.markup = markup
733
+ self.parsed = parser.parse(markup)
734
+ self.code_theme = _resolve_code_theme(code_theme)
735
+ self.justify: JustifyMethod | None = justify
736
+ self.style = style
737
+ self.hyperlinks = hyperlinks
738
+ self.inline_code_lexer = inline_code_lexer
739
+ self.inline_code_theme = _resolve_code_theme(inline_code_theme or code_theme)
740
+
741
+ def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:
742
+ """Flattens the token stream."""
743
+ for token in tokens:
744
+ is_fence = token.type == "fence"
745
+ is_image = token.tag == "img"
746
+ if token.children and not (is_image or is_fence):
747
+ yield from self._flatten_tokens(token.children)
748
+ else:
749
+ yield token
750
+
751
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
752
+ """Render markdown to the console."""
753
+ style = console.get_style(self.style, default="none")
754
+ options = options.update(height=None)
755
+ context = MarkdownContext(
756
+ console,
757
+ options,
758
+ style,
759
+ _FALLBACK_STYLES,
760
+ inline_code_lexer=self.inline_code_lexer,
761
+ inline_code_theme=self.inline_code_theme,
762
+ )
763
+ tokens = self.parsed
764
+ inline_style_tags = self.inlines
765
+ new_line = False
766
+ _new_line_segment = Segment.line()
767
+ render_started = False
768
+
769
+ for token in self._flatten_tokens(tokens):
770
+ node_type = token.type
771
+ tag = token.tag
772
+
773
+ entering = token.nesting == 1
774
+ exiting = token.nesting == -1
775
+ self_closing = token.nesting == 0
776
+
777
+ if node_type == "text":
778
+ context.on_text(token.content, node_type)
779
+ elif node_type == "hardbreak":
780
+ context.on_text("\n", node_type)
781
+ elif node_type == "softbreak":
782
+ context.on_text(" ", node_type)
783
+ elif node_type == "link_open":
784
+ href = str(token.attrs.get("href", ""))
785
+ if self.hyperlinks:
786
+ link_style = console.get_style("markdown.link_url", default="none")
787
+ link_style += Style(link=href)
788
+ context.enter_style(link_style)
789
+ else:
790
+ context.stack.push(Link.create(self, token))
791
+ elif node_type == "link_close":
792
+ if self.hyperlinks:
793
+ context.leave_style()
794
+ else:
795
+ element = context.stack.pop()
796
+ assert isinstance(element, Link)
797
+ link_style = console.get_style("markdown.link", default="none")
798
+ context.enter_style(link_style)
799
+ context.on_text(element.text.plain, node_type)
800
+ context.leave_style()
801
+ context.on_text(" (", node_type)
802
+ link_url_style = console.get_style("markdown.link_url", default="none")
803
+ context.enter_style(link_url_style)
804
+ context.on_text(element.href, node_type)
805
+ context.leave_style()
806
+ context.on_text(")", node_type)
807
+ elif tag in inline_style_tags and node_type != "fence" and node_type != "code_block":
808
+ if entering:
809
+ # If it's an opening inline token e.g. strong, em, etc.
810
+ # Then we move into a style context i.e. push to stack.
811
+ context.enter_style(f"markdown.{tag}")
812
+ elif exiting:
813
+ # If it's a closing inline style, then we pop the style
814
+ # off of the stack, to move out of the context of it...
815
+ context.leave_style()
816
+ else:
817
+ # If it's a self-closing inline style e.g. `code_inline`
818
+ context.enter_style(f"markdown.{tag}")
819
+ if token.content:
820
+ context.on_text(token.content, node_type)
821
+ context.leave_style()
822
+ else:
823
+ # Map the markdown tag -> MarkdownElement renderable
824
+ element_class = self.elements.get(token.type) or UnknownElement
825
+ element = element_class.create(self, token)
826
+
827
+ if entering or self_closing:
828
+ context.stack.push(element)
829
+ element.on_enter(context)
830
+
831
+ if exiting: # CLOSING tag
832
+ element = context.stack.pop()
833
+
834
+ should_render = not context.stack or (
835
+ context.stack and context.stack.top.on_child_close(context, element)
836
+ )
837
+
838
+ if should_render:
839
+ if new_line and render_started:
840
+ yield _new_line_segment
841
+
842
+ rendered = console.render(element, context.options)
843
+ for segment in rendered:
844
+ render_started = True
845
+ yield segment
846
+ elif self_closing: # SELF-CLOSING tags (e.g. text, code, image)
847
+ context.stack.pop()
848
+ text = token.content
849
+ if text is not None:
850
+ element.on_text(context, text)
851
+
852
+ should_render = (
853
+ not context.stack
854
+ or context.stack
855
+ and context.stack.top.on_child_close(context, element)
856
+ )
857
+ if should_render:
858
+ if new_line and node_type != "inline" and render_started:
859
+ yield _new_line_segment
860
+ rendered = console.render(element, context.options)
861
+ for segment in rendered:
862
+ render_started = True
863
+ yield segment
864
+
865
+ if exiting or self_closing:
866
+ element.on_leave(context)
867
+ new_line = element.new_line
868
+
869
+
870
+ if __name__ == "__main__":
871
+ import argparse
872
+ import sys
873
+
874
+ parser = argparse.ArgumentParser(description="Render Markdown to the console with Rich")
875
+ parser.add_argument(
876
+ "path",
877
+ metavar="PATH",
878
+ help="path to markdown file, or - for stdin",
879
+ )
880
+ parser.add_argument(
881
+ "-c",
882
+ "--force-color",
883
+ dest="force_color",
884
+ action="store_true",
885
+ default=None,
886
+ help="force color for non-terminals",
887
+ )
888
+ parser.add_argument(
889
+ "-t",
890
+ "--code-theme",
891
+ dest="code_theme",
892
+ default=_KIMI_ANSI_THEME_NAME,
893
+ help='code theme (pygments name or "kimi-ansi")',
894
+ )
895
+ parser.add_argument(
896
+ "-i",
897
+ "--inline-code-lexer",
898
+ dest="inline_code_lexer",
899
+ default=None,
900
+ help="inline_code_lexer",
901
+ )
902
+ parser.add_argument(
903
+ "-y",
904
+ "--hyperlinks",
905
+ dest="hyperlinks",
906
+ action="store_true",
907
+ help="enable hyperlinks",
908
+ )
909
+ parser.add_argument(
910
+ "-w",
911
+ "--width",
912
+ type=int,
913
+ dest="width",
914
+ default=None,
915
+ help="width of output (default will auto-detect)",
916
+ )
917
+ parser.add_argument(
918
+ "-j",
919
+ "--justify",
920
+ dest="justify",
921
+ action="store_true",
922
+ help="enable full text justify",
923
+ )
924
+ parser.add_argument(
925
+ "-p",
926
+ "--page",
927
+ dest="page",
928
+ action="store_true",
929
+ help="use pager to scroll output",
930
+ )
931
+ args = parser.parse_args()
932
+
933
+ from rich.console import Console
934
+
935
+ if args.path == "-":
936
+ markdown_body = sys.stdin.read()
937
+ else:
938
+ with open(args.path, encoding="utf-8") as markdown_file:
939
+ markdown_body = markdown_file.read()
940
+
941
+ markdown = Markdown(
942
+ markdown_body,
943
+ justify="full" if args.justify else "left",
944
+ code_theme=args.code_theme,
945
+ hyperlinks=args.hyperlinks,
946
+ inline_code_lexer=args.inline_code_lexer,
947
+ )
948
+ if args.page:
949
+ import io
950
+ import pydoc
951
+
952
+ fileio = io.StringIO()
953
+ console = Console(file=fileio, force_terminal=args.force_color, width=args.width)
954
+ console.print(markdown)
955
+ pydoc.pager(fileio.getvalue())
956
+
957
+ else:
958
+ console = Console(force_terminal=args.force_color, width=args.width, record=True)
959
+ console.print(markdown)