klaude-code 1.2.6__py3-none-any.whl → 1.8.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.
Files changed (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -1,22 +1,25 @@
1
- # copy from https://github.com/Aider-AI/aider/blob/main/aider/mdstream.py
2
1
  from __future__ import annotations
3
2
 
3
+ import contextlib
4
4
  import io
5
5
  import time
6
+ from collections.abc import Callable
6
7
  from typing import Any, ClassVar
7
8
 
8
- from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
9
- from rich.live import Live
10
- from rich.markdown import CodeBlock, Heading, Markdown
11
- from rich.panel import Panel
9
+ from markdown_it import MarkdownIt
10
+ from markdown_it.token import Token
11
+ from rich import box
12
+ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
13
+ from rich.markdown import CodeBlock, Heading, Markdown, MarkdownElement, TableElement
12
14
  from rich.rule import Rule
13
- from rich.spinner import Spinner
14
- from rich.style import Style
15
+ from rich.style import Style, StyleType
15
16
  from rich.syntax import Syntax
17
+ from rich.table import Table
16
18
  from rich.text import Text
17
19
  from rich.theme import Theme
18
20
 
19
21
  from klaude_code import const
22
+ from klaude_code.ui.rich.code_panel import CodePanel
20
23
 
21
24
 
22
25
  class NoInsetCodeBlock(CodeBlock):
@@ -29,9 +32,44 @@ class NoInsetCodeBlock(CodeBlock):
29
32
  self.lexer_name,
30
33
  theme=self.theme,
31
34
  word_wrap=True,
32
- padding=(0, 1),
35
+ padding=(0, 0),
33
36
  )
34
- yield Panel.fit(syntax, padding=0, border_style="markdown.code.panel")
37
+ yield CodePanel(syntax, border_style="markdown.code.border")
38
+
39
+
40
+ class ThinkingCodeBlock(CodeBlock):
41
+ """A code block for thinking content that uses grey styling instead of syntax highlighting."""
42
+
43
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
44
+ code = str(self.text).rstrip()
45
+ text = Text(code, "markdown.code.block")
46
+ yield CodePanel(text, border_style="markdown.code.border")
47
+
48
+
49
+ class Divider(MarkdownElement):
50
+ """A horizontal rule with an extra blank line below."""
51
+
52
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
53
+ style = console.get_style("markdown.hr", default="none")
54
+ yield Rule(style=style, characters="-")
55
+
56
+
57
+ class MarkdownTable(TableElement):
58
+ """A table element with MINIMAL_HEAVY_HEAD box style."""
59
+
60
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
61
+ table = Table(box=box.MARKDOWN, border_style=console.get_style("markdown.table.border"))
62
+
63
+ if self.header is not None and self.header.row is not None:
64
+ for column in self.header.row.cells:
65
+ table.add_column(column.content)
66
+
67
+ if self.body is not None:
68
+ for row in self.body.rows:
69
+ row_content = [element.content for element in row.cells]
70
+ table.add_row(*row_content)
71
+
72
+ yield table
35
73
 
36
74
 
37
75
  class LeftHeading(Heading):
@@ -40,18 +78,12 @@ class LeftHeading(Heading):
40
78
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
41
79
  text = self.text
42
80
  text.justify = "left" # Override justification
43
- # if self.tag == "h1":
44
- # from rich.panel import Panel
45
- # from rich import box
46
- # # Draw a border around h1s, but keep text left-aligned
47
- # yield Panel(
48
- # text,
49
- # box=box.SQUARE,
50
- # style="markdown.h1.border",
51
- # )
52
- if self.tag == "h2":
81
+ if self.tag == "h1":
82
+ h1_text = text.assemble((" ", "markdown.h1"), text, (" ", "markdown.h1"))
83
+ yield h1_text
84
+ elif self.tag == "h2":
53
85
  text.stylize(Style(bold=True, underline=False))
54
- yield Rule(title=text, characters="-", style="markdown.h2.border", align="left")
86
+ yield text
55
87
  else:
56
88
  yield text
57
89
 
@@ -64,25 +96,47 @@ class NoInsetMarkdown(Markdown):
64
96
  "fence": NoInsetCodeBlock,
65
97
  "code_block": NoInsetCodeBlock,
66
98
  "heading_open": LeftHeading,
99
+ "hr": Divider,
100
+ "table_open": MarkdownTable,
101
+ }
102
+
103
+
104
+ class ThinkingMarkdown(Markdown):
105
+ """Markdown for thinking content with grey-styled code blocks and left-justified headings."""
106
+
107
+ elements: ClassVar[dict[str, type[Any]]] = {
108
+ **Markdown.elements,
109
+ "fence": ThinkingCodeBlock,
110
+ "code_block": ThinkingCodeBlock,
111
+ "heading_open": LeftHeading,
112
+ "hr": Divider,
113
+ "table_open": MarkdownTable,
67
114
  }
68
115
 
69
116
 
70
117
  class MarkdownStream:
71
- """Streaming markdown renderer that progressively displays content with a live updating window.
118
+ """Block-based streaming Markdown renderer.
119
+
120
+ This renderer is optimized for terminal UX:
72
121
 
73
- Uses rich.console and rich.live to render markdown content with smooth scrolling
74
- and partial updates. Maintains a sliding window of visible content while streaming
75
- in new markdown text.
122
+ - Stable area: only prints *completed* Markdown blocks to scrollback (append-only).
123
+ - Live area: continuously repaints only the final *possibly incomplete* block.
124
+
125
+ Block boundaries are computed with `MarkdownIt("commonmark")` (token maps / top-level tokens).
126
+ Rendering is done with Rich Markdown (customizable via `markdown_class`).
76
127
  """
77
128
 
78
129
  def __init__(
79
130
  self,
131
+ console: Console,
80
132
  mdargs: dict[str, Any] | None = None,
81
133
  theme: Theme | None = None,
82
- console: Console | None = None,
83
- spinner: Spinner | None = None,
134
+ live_sink: Callable[[RenderableType | None], None] | None = None,
84
135
  mark: str | None = None,
85
- indent: int = 0,
136
+ mark_style: StyleType | None = None,
137
+ left_margin: int = 0,
138
+ right_margin: int = const.MARKDOWN_RIGHT_MARGIN,
139
+ markdown_class: Callable[..., Markdown] | None = None,
86
140
  ) -> None:
87
141
  """Initialize the markdown stream.
88
142
 
@@ -90,40 +144,169 @@ class MarkdownStream:
90
144
  mdargs (dict, optional): Additional arguments to pass to rich Markdown renderer
91
145
  theme (Theme, optional): Theme for rendering markdown
92
146
  console (Console, optional): External console to use for rendering
93
- mark (str | None, optional): Marker shown before the first non-empty line when indent >= 2
94
- indent (int, optional): Number of spaces to indent all rendered lines on the left
147
+ mark (str | None, optional): Marker shown before the first non-empty line when left_margin >= 2
148
+ mark_style (StyleType | None, optional): Style to apply to the mark
149
+ left_margin (int, optional): Number of columns to reserve on the left side
150
+ right_margin (int, optional): Number of columns to reserve on the right side
151
+ markdown_class: Markdown class to use for rendering (defaults to NoInsetMarkdown)
95
152
  """
96
- self.printed: list[str] = [] # Stores lines that have already been printed
153
+ self._stable_rendered_lines: list[str] = []
154
+ self._stable_source_line_count: int = 0
97
155
 
98
156
  if mdargs:
99
157
  self.mdargs: dict[str, Any] = mdargs
100
158
  else:
101
159
  self.mdargs = {}
102
160
 
103
- # Defer Live creation until the first update.
104
- self.live: Live | None = None
105
- self._live_started: bool = False
161
+ self._live_sink = live_sink
106
162
 
107
163
  # Streaming control
108
164
  self.when: float = 0.0 # Timestamp of last update
109
165
  self.min_delay: float = 1.0 / 20 # Minimum time between updates (20fps)
110
- self.live_window: int = const.MARKDOWN_STREAM_LIVE_WINDOW
111
- # Track the maximum height the live window has ever reached
112
- # so we only pad when it shrinks from a previous height,
113
- # instead of always padding to live_window from the start.
114
- self._live_window_seen_height: int = 0
166
+ self._parser: MarkdownIt = MarkdownIt("commonmark")
115
167
 
116
168
  self.theme = theme
117
169
  self.console = console
118
- self.spinner: Spinner | None = spinner
119
170
  self.mark: str | None = mark
120
- self.indent: int = max(indent, 0)
171
+ self.mark_style: StyleType | None = mark_style
172
+
173
+ self.left_margin: int = max(left_margin, 0)
174
+
175
+ self.right_margin: int = max(right_margin, 0)
176
+ self.markdown_class: Callable[..., Markdown] = markdown_class or NoInsetMarkdown
177
+
178
+ @property
179
+ def _live_started(self) -> bool:
180
+ """Check if Live display has been started (derived from self.live)."""
181
+ return self._live_sink is not None
182
+
183
+ def _get_base_width(self) -> int:
184
+ return self.console.options.max_width
185
+
186
+ def compute_candidate_stable_line(self, text: str) -> int:
187
+ """Return the start line of the last top-level block, or 0.
188
+
189
+ This value is not monotonic; callers should clamp it (e.g. with the
190
+ previous stable line) before using it to advance state.
191
+ """
192
+
193
+ try:
194
+ tokens = self._parser.parse(text)
195
+ except Exception: # markdown-it-py may raise various internal errors during parsing
196
+ return 0
197
+
198
+ top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
199
+ if len(top_level) < 2:
200
+ return 0
201
+
202
+ last = top_level[-1]
203
+ assert last.map is not None
204
+ start_line = last.map[0]
205
+ return max(start_line, 0)
206
+
207
+ def split_blocks(self, text: str, *, min_stable_line: int = 0, final: bool = False) -> tuple[str, str, int]:
208
+ """Split full markdown into stable and live sources.
209
+
210
+ Returns:
211
+ stable_source: Completed blocks (append-only)
212
+ live_source: Last (possibly incomplete) block
213
+ stable_line: Line index where live starts
214
+ """
215
+
216
+ lines = text.splitlines(keepends=True)
217
+ line_count = len(lines)
218
+
219
+ stable_line = line_count if final else self.compute_candidate_stable_line(text)
220
+
221
+ stable_line = min(stable_line, line_count)
222
+ stable_line = max(stable_line, min_stable_line)
223
+
224
+ stable_source = "".join(lines[:stable_line])
225
+ live_source = "".join(lines[stable_line:])
226
+ return stable_source, live_source, stable_line
227
+
228
+ def render_ansi(self, text: str, *, apply_mark: bool) -> str:
229
+ """Render markdown source to an ANSI string.
230
+
231
+ This is primarily intended for internal debugging and tests.
232
+ """
233
+
234
+ return "".join(self._render_markdown_to_lines(text, apply_mark=apply_mark))
235
+
236
+ def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> str:
237
+ """Render stable prefix to ANSI, preserving inter-block spacing."""
238
+
239
+ if not stable_source:
240
+ return ""
241
+
242
+ render_source = stable_source
243
+ if not final and has_live_suffix:
244
+ render_source = self._append_nonfinal_sentinel(stable_source)
245
+
246
+ return self.render_ansi(render_source, apply_mark=True)
247
+
248
+ @staticmethod
249
+ def normalize_live_ansi_for_boundary(*, stable_ansi: str, live_ansi: str) -> str:
250
+ """Normalize whitespace at the stable/live boundary.
251
+
252
+ Some Rich Markdown blocks (e.g. lists) render with a leading blank line.
253
+ If the stable prefix already renders a trailing blank line, rendering the
254
+ live suffix separately may introduce an extra blank line that wouldn't
255
+ appear when rendering the full document.
256
+
257
+ This function removes *overlapping* blank lines from the live ANSI when
258
+ the stable ANSI already ends with one or more blank lines.
259
+
260
+ Important: don't remove *all* leading blank lines from the live suffix.
261
+ In some incomplete-block cases, the live render may begin with multiple
262
+ blank lines while the full-document render would keep one of them.
263
+ """
264
+
265
+ stable_lines = stable_ansi.splitlines(keepends=True)
266
+ if not stable_lines:
267
+ return live_ansi
268
+
269
+ stable_trailing_blank = 0
270
+ for line in reversed(stable_lines):
271
+ if line.strip():
272
+ break
273
+ stable_trailing_blank += 1
274
+ if stable_trailing_blank <= 0:
275
+ return live_ansi
276
+
277
+ live_lines = live_ansi.splitlines(keepends=True)
278
+ live_leading_blank = 0
279
+ for line in live_lines:
280
+ if line.strip():
281
+ break
282
+ live_leading_blank += 1
283
+
284
+ drop = min(stable_trailing_blank, live_leading_blank)
285
+ if drop > 0:
286
+ live_lines = live_lines[drop:]
287
+ return "".join(live_lines)
288
+
289
+ def _append_nonfinal_sentinel(self, stable_source: str) -> str:
290
+ """Make Rich render stable content as if it isn't the last block.
291
+
292
+ Rich Markdown may omit trailing spacing for the last block in a document.
293
+ When we render only the stable prefix (without the live suffix), we still
294
+ need the *inter-block* spacing to match the full document.
295
+
296
+ A harmless HTML comment block causes Rich Markdown to emit the expected
297
+ spacing while rendering no visible content.
298
+ """
121
299
 
122
- # Defer Live creation until the first update
123
- self.live: Live | None = None
124
- self._live_started: bool = False
300
+ if not stable_source:
301
+ return stable_source
125
302
 
126
- def _render_markdown_to_lines(self, text: str) -> list[str]:
303
+ if stable_source.endswith("\n\n"):
304
+ return stable_source + "<!-- -->"
305
+ if stable_source.endswith("\n"):
306
+ return stable_source + "\n<!-- -->"
307
+ return stable_source + "\n\n<!-- -->"
308
+
309
+ def _render_markdown_to_lines(self, text: str, *, apply_mark: bool) -> list[str]:
127
310
  """Render markdown text to a list of lines.
128
311
 
129
312
  Args:
@@ -135,15 +318,10 @@ class MarkdownStream:
135
318
  # Render the markdown to a string buffer
136
319
  string_io = io.StringIO()
137
320
 
138
- # Determine console width and adjust for left indent so that
139
- # the rendered content plus indent does not exceed the available width.
140
- if self.console is not None:
141
- base_width = self.console.options.max_width
142
- else:
143
- probe_console = Console(theme=self.theme)
144
- base_width = probe_console.options.max_width
321
+ # Keep width stable across frames to prevent reflow/jitter.
322
+ base_width = self._get_base_width()
145
323
 
146
- effective_width = max(base_width - self.indent, 1)
324
+ effective_width = max(base_width - self.left_margin - self.right_margin, 1)
147
325
 
148
326
  # Use external console for consistent theming, or create temporary one
149
327
  temp_console = Console(
@@ -153,23 +331,35 @@ class MarkdownStream:
153
331
  width=effective_width,
154
332
  )
155
333
 
156
- markdown = NoInsetMarkdown(text, **self.mdargs)
334
+ markdown = self.markdown_class(text, **self.mdargs)
157
335
  temp_console.print(markdown)
158
336
  output = string_io.getvalue()
159
337
 
160
- # Split rendered output into lines, strip trailing spaces, and apply left indent.
338
+ # Split rendered output into lines, strip trailing spaces, and apply left margin.
161
339
  lines = output.splitlines(keepends=True)
162
- indent_prefix = " " * self.indent if self.indent > 0 else ""
340
+ indent_prefix = " " * self.left_margin if self.left_margin > 0 else ""
163
341
  processed_lines: list[str] = []
164
342
  mark_applied = False
165
- use_mark = bool(self.mark) and self.indent >= 2
343
+ use_mark = apply_mark and bool(self.mark) and self.left_margin >= 2
344
+
345
+ # Pre-render styled mark if needed
346
+ styled_mark: str | None = None
347
+ if use_mark and self.mark:
348
+ if self.mark_style:
349
+ mark_text = Text(self.mark, style=self.mark_style)
350
+ mark_buffer = io.StringIO()
351
+ mark_console = Console(file=mark_buffer, force_terminal=True, theme=self.theme)
352
+ mark_console.print(mark_text, end="")
353
+ styled_mark = mark_buffer.getvalue()
354
+ else:
355
+ styled_mark = self.mark
166
356
 
167
357
  for line in lines:
168
358
  stripped = line.rstrip()
169
359
 
170
- # Apply mark to the first non-empty line only when indent is at least 2.
360
+ # Apply mark to the first non-empty line only when left_margin is at least 2.
171
361
  if use_mark and not mark_applied and stripped:
172
- stripped = f"{self.mark} {stripped}"
362
+ stripped = f"{styled_mark} {stripped}"
173
363
  mark_applied = True
174
364
  elif indent_prefix:
175
365
  stripped = indent_prefix + stripped
@@ -182,127 +372,72 @@ class MarkdownStream:
182
372
 
183
373
  def __del__(self) -> None:
184
374
  """Destructor to ensure Live display is properly cleaned up."""
185
- if self.live:
186
- try:
187
- self.live.stop()
188
- except Exception:
189
- pass # Ignore any errors during cleanup
375
+ if self._live_sink is None:
376
+ return
377
+ with contextlib.suppress(Exception):
378
+ self._live_sink(None)
190
379
 
191
380
  def update(self, text: str, final: bool = False) -> None:
192
- """Update the displayed markdown content.
193
-
194
- Args:
195
- text (str): The markdown text received so far
196
- final (bool): If True, this is the final update and we should clean up
197
-
198
- Splits the output into "stable" older lines and the "last few" lines
199
- which aren't considered stable. They may shift around as new chunks
200
- are appended to the markdown text.
201
-
202
- The stable lines emit to the console above the Live window.
203
- The unstable lines emit into the Live window so they can be repainted.
204
-
205
- Markdown going to the console works better in terminal scrollback buffers.
206
- The live window doesn't play nice with terminal scrollback.
207
- """
208
- # On the first call, start the Live renderer
209
- if not self._live_started:
210
- initial_content = self._live_renderable(Text(""), final=False)
211
- self.live = Live(
212
- initial_content,
213
- refresh_per_second=1.0 / self.min_delay,
214
- console=self.console,
215
- )
216
- self.live.start()
217
- self._live_started = True
218
-
219
- # If live rendering isn't available (e.g., after a final update), stop.
220
- if self.live is None:
221
- return
381
+ """Update the display with the latest full markdown buffer."""
222
382
 
223
383
  now = time.time()
224
- # Throttle updates to maintain smooth rendering
225
384
  if not final and now - self.when < self.min_delay:
226
385
  return
227
386
  self.when = now
228
387
 
229
- # Measure render time and adjust min_delay to maintain smooth rendering
388
+ previous_stable_line = self._stable_source_line_count
389
+
390
+ stable_source, live_source, stable_line = self.split_blocks(
391
+ text,
392
+ min_stable_line=previous_stable_line,
393
+ final=final,
394
+ )
395
+
230
396
  start = time.time()
231
- lines = self._render_markdown_to_lines(text)
232
- render_time = time.time() - start
233
-
234
- # Set min_delay to render time plus a small buffer
235
- self.min_delay = min(max(render_time * 10, 1.0 / 20), 2)
236
-
237
- num_lines = len(lines)
238
-
239
- # How many lines have "left" the live window and are now considered stable?
240
- # Or if final, consider all lines to be stable.
241
- if not final:
242
- num_lines = max(num_lines - self.live_window, 0)
243
-
244
- # If there is new stable content, append only the new part
245
- # Update Live window to prevent visual duplication
246
- if final or num_lines > 0:
247
- # Lines to append to stable area
248
- num_printed = len(self.printed)
249
- to_append_count = num_lines - num_printed
250
-
251
- if to_append_count > 0:
252
- # Print new stable lines above Live window
253
- append_chunk = lines[num_printed:num_lines]
254
- append_chunk_text = Text.from_ansi("".join(append_chunk))
255
- live = self.live
256
- assert live is not None
257
- live.console.print(append_chunk_text) # Print above Live area
258
-
259
- # Track printed stable lines
260
- self.printed = lines[:num_lines]
261
-
262
- # Handle final update cleanup
397
+
398
+ stable_changed = final or stable_line > self._stable_source_line_count
399
+ if stable_changed and stable_source:
400
+ stable_ansi = self.render_stable_ansi(stable_source, has_live_suffix=bool(live_source), final=final)
401
+ stable_lines = stable_ansi.splitlines(keepends=True)
402
+ new_lines = stable_lines[len(self._stable_rendered_lines) :]
403
+ if new_lines:
404
+ stable_chunk = "".join(new_lines)
405
+ self.console.print(Text.from_ansi(stable_chunk), end="\n")
406
+ self._stable_rendered_lines = stable_lines
407
+ self._stable_source_line_count = stable_line
408
+ elif final and not stable_source:
409
+ self._stable_rendered_lines = []
410
+ self._stable_source_line_count = stable_line
411
+
263
412
  if final:
264
- live = self.live
265
- assert live is not None
266
- live.update(Text(""))
267
- live.stop()
268
- self.live = None
413
+ if self._live_sink is not None:
414
+ self._live_sink(None)
269
415
  return
270
416
 
271
- # Update Live window to prevent timing issues
272
- # with console.print above. We pad the live region
273
- # so that its height stays stable when it shrinks
274
- # from a previously reached height, avoiding spinner jitter.
275
- rest_lines = lines[num_lines:]
276
-
277
- if not final:
278
- current_height = len(rest_lines)
279
-
280
- # Update the maximum height we've seen so far for this live window.
281
- if current_height > self._live_window_seen_height:
282
- # Never exceed configured live_window, even if logic changes later.
283
- self._live_window_seen_height = min(current_height, self.live_window)
284
-
285
- target_height = min(self._live_window_seen_height, self.live_window)
286
- if target_height > 0 and current_height < target_height:
287
- pad_count = target_height - current_height + 1
288
- # Pad after the existing lines so spinner visually stays at the bottom.
289
- rest_lines = rest_lines + ["\n"] * pad_count
290
-
291
- rest = "".join(rest_lines)
292
- rest = Text.from_ansi(rest)
293
- live = self.live
294
- assert live is not None
295
- live_renderable = self._live_renderable(rest, final)
296
- live.update(live_renderable)
297
-
298
- def _live_renderable(self, rest: Text, final: bool) -> RenderableType:
299
- if final or not self.spinner:
300
- return rest
301
- else:
302
- return Group(rest, Text(), self.spinner)
303
-
304
- def find_minimal_suffix(self, text: str, match_lines: int = 50) -> None:
305
- """
306
- Splits text into chunks on blank lines "\n\n".
307
- """
308
- return None
417
+ if const.MARKDOWN_STREAM_LIVE_REPAINT_ENABLED and self._live_sink is not None:
418
+ apply_mark_live = self._stable_source_line_count == 0
419
+ live_lines = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_live)
420
+
421
+ if self._stable_rendered_lines:
422
+ stable_trailing_blank = 0
423
+ for line in reversed(self._stable_rendered_lines):
424
+ if line.strip():
425
+ break
426
+ stable_trailing_blank += 1
427
+
428
+ if stable_trailing_blank > 0:
429
+ live_leading_blank = 0
430
+ for line in live_lines:
431
+ if line.strip():
432
+ break
433
+ live_leading_blank += 1
434
+
435
+ drop = min(stable_trailing_blank, live_leading_blank)
436
+ if drop > 0:
437
+ live_lines = live_lines[drop:]
438
+
439
+ live_text = Text.from_ansi("".join(live_lines))
440
+ self._live_sink(live_text)
441
+
442
+ elapsed = time.time() - start
443
+ self.min_delay = min(max(elapsed * 6, 1.0 / 30), 0.5)
@@ -1,14 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Iterable, List, Sequence, Tuple
3
+ from collections.abc import Iterable, Sequence
4
4
 
5
5
 
6
6
  class SearchableFormattedText:
7
7
  """
8
8
  Wrapper for prompt_toolkit formatted text that also supports string-like
9
- methods used by questionary's search filter (e.g., ``.lower()``).
10
-
11
- This allows using ``use_search_filter=True`` with a formatted ``Choice.title``.
9
+ methods commonly expected by search filters (e.g., ``.lower()``).
12
10
 
13
11
  - ``fragments``: A sequence of (style, text) tuples accepted by
14
12
  prompt_toolkit's ``to_formatted_text``.
@@ -16,8 +14,8 @@ class SearchableFormattedText:
16
14
  concatenating the text parts of the fragments.
17
15
  """
18
16
 
19
- def __init__(self, fragments: Sequence[Tuple[str, str]], plain: str | None = None):
20
- self._fragments: List[Tuple[str, str]] = list(fragments)
17
+ def __init__(self, fragments: Sequence[tuple[str, str]], plain: str | None = None):
18
+ self._fragments: list[tuple[str, str]] = list(fragments)
21
19
  if plain is None:
22
20
  plain = "".join(text for _, text in self._fragments)
23
21
  self._plain = plain
@@ -25,14 +23,14 @@ class SearchableFormattedText:
25
23
  # Recognized by prompt_toolkit's to_formatted_text(value)
26
24
  def __pt_formatted_text__(
27
25
  self,
28
- ) -> Iterable[Tuple[str, str]]: # pragma: no cover - passthrough
26
+ ) -> Iterable[tuple[str, str]]: # pragma: no cover - passthrough
29
27
  return self._fragments
30
28
 
31
29
  # Provide a human-readable representation.
32
30
  def __str__(self) -> str: # pragma: no cover - utility
33
31
  return self._plain
34
32
 
35
- # Minimal string API to satisfy questionary's search filter logic.
33
+ # Minimal string API for search filtering.
36
34
  def lower(self) -> str:
37
35
  return self._plain.lower()
38
36
 
@@ -45,16 +43,15 @@ class SearchableFormattedText:
45
43
  return self._plain
46
44
 
47
45
 
48
- class SearchableFormattedList(list[Tuple[str, str]]):
46
+ class SearchableFormattedList(list[tuple[str, str]]):
49
47
  """
50
- List variant compatible with questionary's expected ``Choice.title`` type.
48
+ List variant compatible with prompt_toolkit formatted-text usage.
51
49
 
52
- - Behaves like ``List[Tuple[str, str]]`` for rendering (so ``isinstance(..., list)`` works),
53
- preserving existing styling behavior in questionary.
50
+ - Behaves like ``List[Tuple[str, str]]`` for rendering (so ``isinstance(..., list)`` works).
54
51
  - Provides ``.lower()``/``.upper()`` returning the plain text for search filtering.
55
52
  """
56
53
 
57
- def __init__(self, fragments: Sequence[Tuple[str, str]], plain: str | None = None):
54
+ def __init__(self, fragments: Sequence[tuple[str, str]], plain: str | None = None):
58
55
  super().__init__(fragments)
59
56
  if plain is None:
60
57
  plain = "".join(text for _, text in fragments)