kimi-cli 0.44__py3-none-any.whl → 0.78__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.

Potentially problematic release.


This version of kimi-cli might be problematic. Click here for more details.

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