klaude-code 1.2.23__py3-none-any.whl → 1.2.25__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 (34) hide show
  1. klaude_code/cli/runtime.py +17 -1
  2. klaude_code/command/prompt-jj-describe.md +32 -0
  3. klaude_code/command/thinking_cmd.py +37 -28
  4. klaude_code/{const/__init__.py → const.py} +7 -6
  5. klaude_code/core/executor.py +46 -3
  6. klaude_code/core/tool/file/read_tool.py +23 -1
  7. klaude_code/core/tool/file/write_tool.py +7 -3
  8. klaude_code/llm/openai_compatible/client.py +29 -102
  9. klaude_code/llm/openai_compatible/stream.py +272 -0
  10. klaude_code/llm/openrouter/client.py +29 -109
  11. klaude_code/llm/openrouter/{reasoning_handler.py → reasoning.py} +24 -2
  12. klaude_code/protocol/model.py +13 -1
  13. klaude_code/protocol/op.py +11 -0
  14. klaude_code/protocol/op_handler.py +5 -0
  15. klaude_code/ui/core/stage_manager.py +0 -3
  16. klaude_code/ui/modes/repl/display.py +2 -0
  17. klaude_code/ui/modes/repl/event_handler.py +97 -57
  18. klaude_code/ui/modes/repl/input_prompt_toolkit.py +25 -4
  19. klaude_code/ui/modes/repl/renderer.py +119 -25
  20. klaude_code/ui/renderers/assistant.py +1 -1
  21. klaude_code/ui/renderers/metadata.py +2 -6
  22. klaude_code/ui/renderers/sub_agent.py +28 -5
  23. klaude_code/ui/renderers/thinking.py +16 -10
  24. klaude_code/ui/renderers/tools.py +26 -2
  25. klaude_code/ui/rich/code_panel.py +24 -5
  26. klaude_code/ui/rich/live.py +17 -0
  27. klaude_code/ui/rich/markdown.py +185 -107
  28. klaude_code/ui/rich/status.py +19 -17
  29. klaude_code/ui/rich/theme.py +63 -12
  30. {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/METADATA +2 -1
  31. {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/RECORD +33 -32
  32. klaude_code/llm/openai_compatible/stream_processor.py +0 -83
  33. {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/WHEEL +0 -0
  34. {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  from typing import Any, cast
3
3
 
4
+ from rich import box
4
5
  from rich.console import Group, RenderableType
5
6
  from rich.json import JSON
6
7
  from rich.panel import Panel
@@ -58,10 +59,22 @@ def render_sub_agent_call(e: model.SubAgentState, style: Style | None = None) ->
58
59
 
59
60
 
60
61
  def render_sub_agent_result(
61
- result: str, *, code_theme: str, style: Style | None = None, has_structured_output: bool = False
62
+ result: str,
63
+ *,
64
+ code_theme: str,
65
+ style: Style | None = None,
66
+ has_structured_output: bool = False,
67
+ description: str | None = None,
68
+ panel_style: Style | None = None,
62
69
  ) -> RenderableType:
63
70
  stripped_result = result.strip()
64
71
 
72
+ # Add markdown heading if description is provided
73
+ if description:
74
+ stripped_result = f"# {description}\n\n{stripped_result}"
75
+
76
+ result_panel_style = panel_style or ThemeKey.SUB_AGENT_RESULT_PANEL
77
+
65
78
  # Use rich JSON for structured output
66
79
  if has_structured_output:
67
80
  try:
@@ -73,7 +86,9 @@ def render_sub_agent_result(
73
86
  ),
74
87
  JSON(stripped_result),
75
88
  ),
89
+ box=box.SIMPLE,
76
90
  border_style=ThemeKey.LINES,
91
+ style=result_panel_style,
77
92
  )
78
93
  except json.JSONDecodeError:
79
94
  # Fall back to markdown if not valid JSON
@@ -82,20 +97,28 @@ def render_sub_agent_result(
82
97
  lines = stripped_result.splitlines()
83
98
  if len(lines) > const.SUB_AGENT_RESULT_MAX_LINES:
84
99
  hidden_count = len(lines) - const.SUB_AGENT_RESULT_MAX_LINES
85
- truncated_text = "\n".join(lines[-const.SUB_AGENT_RESULT_MAX_LINES :])
100
+ head_count = const.SUB_AGENT_RESULT_MAX_LINES // 2
101
+ tail_count = const.SUB_AGENT_RESULT_MAX_LINES - head_count
102
+ head_text = "\n".join(lines[:head_count])
103
+ tail_text = "\n".join(lines[-tail_count:])
86
104
  return Panel.fit(
87
105
  Group(
106
+ NoInsetMarkdown(head_text, code_theme=code_theme, style=style or ""),
88
107
  Text(
89
- f"… more {hidden_count} lines — use /export to view full output",
90
- style=ThemeKey.TOOL_RESULT,
108
+ f"\n… more {hidden_count} lines — use /export to view full output\n",
109
+ style=ThemeKey.TOOL_RESULT_TRUNCATED,
91
110
  ),
92
- NoInsetMarkdown(truncated_text, code_theme=code_theme, style=style or ""),
111
+ NoInsetMarkdown(tail_text, code_theme=code_theme, style=style or ""),
93
112
  ),
113
+ box=box.SIMPLE,
94
114
  border_style=ThemeKey.LINES,
115
+ style=result_panel_style,
95
116
  )
96
117
  return Panel.fit(
97
118
  NoInsetMarkdown(stripped_result, code_theme=code_theme),
119
+ box=box.SIMPLE,
98
120
  border_style=ThemeKey.LINES,
121
+ style=result_panel_style,
99
122
  )
100
123
 
101
124
 
@@ -4,12 +4,13 @@ from rich.console import RenderableType
4
4
  from rich.padding import Padding
5
5
  from rich.text import Text
6
6
 
7
+ from klaude_code import const
8
+ from klaude_code.ui.renderers.common import create_grid
7
9
  from klaude_code.ui.rich.markdown import ThinkingMarkdown
8
10
  from klaude_code.ui.rich.theme import ThemeKey
9
11
 
10
-
11
- def thinking_prefix() -> Text:
12
- return Text.from_markup("[not italic]⸫[/not italic] Thinking …", style=ThemeKey.THINKING_BOLD)
12
+ # UI markers
13
+ THINKING_MESSAGE_MARK = "⠶"
13
14
 
14
15
 
15
16
  def normalize_thinking_content(content: str) -> str:
@@ -37,7 +38,7 @@ def normalize_thinking_content(content: str) -> str:
37
38
 
38
39
 
39
40
  def render_thinking(content: str, *, code_theme: str, style: str) -> RenderableType | None:
40
- """Render thinking content as indented markdown.
41
+ """Render thinking content as markdown with left mark.
41
42
 
42
43
  Returns None if content is empty.
43
44
  Note: Caller should push thinking_markdown_theme before printing.
@@ -45,11 +46,16 @@ def render_thinking(content: str, *, code_theme: str, style: str) -> RenderableT
45
46
  if len(content.strip()) == 0:
46
47
  return None
47
48
 
48
- return Padding.indent(
49
- ThinkingMarkdown(
50
- normalize_thinking_content(content),
51
- code_theme=code_theme,
52
- style=style,
49
+ grid = create_grid()
50
+ grid.add_row(
51
+ Text(THINKING_MESSAGE_MARK, style=ThemeKey.THINKING),
52
+ Padding(
53
+ ThinkingMarkdown(
54
+ normalize_thinking_content(content),
55
+ code_theme=code_theme,
56
+ style=style,
57
+ ),
58
+ (0, const.MARKDOWN_RIGHT_MARGIN, 0, 0),
53
59
  ),
54
- level=2,
55
60
  )
61
+ return grid
@@ -2,8 +2,10 @@ import json
2
2
  from pathlib import Path
3
3
  from typing import Any, cast
4
4
 
5
+ from rich import box
5
6
  from rich.console import Group, RenderableType
6
7
  from rich.padding import Padding
8
+ from rich.panel import Panel
7
9
  from rich.text import Text
8
10
 
9
11
  from klaude_code import const
@@ -11,6 +13,7 @@ from klaude_code.protocol import events, model, tools
11
13
  from klaude_code.protocol.sub_agent import is_sub_agent_tool as _is_sub_agent_tool
12
14
  from klaude_code.ui.renderers import diffs as r_diffs
13
15
  from klaude_code.ui.renderers.common import create_grid, truncate_display
16
+ from klaude_code.ui.rich.markdown import NoInsetMarkdown
14
17
  from klaude_code.ui.rich.theme import ThemeKey
15
18
 
16
19
  # Tool markers (Unicode symbols for UI display)
@@ -528,7 +531,23 @@ def _extract_diff(ui_extra: model.ToolResultUIExtra | None) -> model.DiffUIExtra
528
531
  return None
529
532
 
530
533
 
531
- def render_tool_result(e: events.ToolResultEvent) -> RenderableType | None:
534
+ def _extract_markdown_doc(ui_extra: model.ToolResultUIExtra | None) -> model.MarkdownDocUIExtra | None:
535
+ if isinstance(ui_extra, model.MarkdownDocUIExtra):
536
+ return ui_extra
537
+ return None
538
+
539
+
540
+ def render_markdown_doc(md_ui: model.MarkdownDocUIExtra, *, code_theme: str) -> RenderableType:
541
+ """Render markdown document content in a panel."""
542
+ return Panel.fit(
543
+ NoInsetMarkdown(md_ui.content, code_theme=code_theme),
544
+ box=box.SIMPLE,
545
+ border_style=ThemeKey.LINES,
546
+ style=ThemeKey.WRITE_MARKDOWN_PANEL,
547
+ )
548
+
549
+
550
+ def render_tool_result(e: events.ToolResultEvent, *, code_theme: str = "monokai") -> RenderableType | None:
532
551
  """Unified entry point for rendering tool results.
533
552
 
534
553
  Returns a Rich Renderable or None if the tool result should not be rendered.
@@ -549,11 +568,16 @@ def render_tool_result(e: events.ToolResultEvent) -> RenderableType | None:
549
568
  return Group(render_truncation_info(truncation_info), render_generic_tool_result(e.result))
550
569
 
551
570
  diff_ui = _extract_diff(e.ui_extra)
571
+ md_ui = _extract_markdown_doc(e.ui_extra)
552
572
 
553
573
  match e.tool_name:
554
574
  case tools.READ:
555
575
  return None
556
- case tools.EDIT | tools.WRITE:
576
+ case tools.EDIT:
577
+ return Padding.indent(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""), level=2)
578
+ case tools.WRITE:
579
+ if md_ui:
580
+ return Padding.indent(render_markdown_doc(md_ui, code_theme=code_theme), level=2)
557
581
  return Padding.indent(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""), level=2)
558
582
  case tools.APPLY_PATCH:
559
583
  if diff_ui:
@@ -4,9 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
+ from rich.cells import cell_len
7
8
  from rich.console import ConsoleRenderable, RichCast
8
9
  from rich.jupyter import JupyterMixin
9
- from rich.measure import Measurement, measure_renderables
10
+ from rich.measure import Measurement
10
11
  from rich.segment import Segment
11
12
  from rich.style import StyleType
12
13
 
@@ -58,17 +59,29 @@ class CodePanel(JupyterMixin):
58
59
  self.expand = expand
59
60
  self.padding = padding
60
61
 
62
+ @staticmethod
63
+ def _measure_max_line_cells(lines: list[list[Segment]]) -> int:
64
+ max_cells = 0
65
+ for line in lines:
66
+ plain = "".join(segment.text for segment in line).rstrip()
67
+ max_cells = max(max_cells, cell_len(plain))
68
+ return max_cells
69
+
61
70
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
62
71
  border_style = console.get_style(self.border_style)
63
72
  max_width = options.max_width
64
73
  pad = self.padding
65
74
 
75
+ max_content_width = max(max_width - pad * 2, 1)
76
+
66
77
  # Measure the content width (account for padding)
67
78
  if self.expand:
68
- content_width = max_width - pad * 2
79
+ content_width = max_content_width
69
80
  else:
70
- content_width = console.measure(self.renderable, options=options.update(width=max_width - pad * 2)).maximum
71
- content_width = min(content_width, max_width - pad * 2)
81
+ probe_options = options.update(width=max_content_width)
82
+ probe_lines = console.render_lines(self.renderable, probe_options, pad=False)
83
+ content_width = self._measure_max_line_cells(probe_lines)
84
+ content_width = max(1, min(content_width, max_content_width))
72
85
 
73
86
  # Render content lines
74
87
  child_options = options.update(width=content_width)
@@ -108,5 +121,11 @@ class CodePanel(JupyterMixin):
108
121
  def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
109
122
  if self.expand:
110
123
  return Measurement(options.max_width, options.max_width)
111
- width = measure_renderables(console, options, [self.renderable]).maximum + self.padding * 2
124
+ max_width = options.max_width
125
+ max_content_width = max(max_width - self.padding * 2, 1)
126
+ probe_options = options.update(width=max_content_width)
127
+ probe_lines = console.render_lines(self.renderable, probe_options, pad=False)
128
+ content_width = self._measure_max_line_cells(probe_lines)
129
+ content_width = max(1, min(content_width, max_content_width))
130
+ width = content_width + self.padding * 2
112
131
  return Measurement(width, width)
@@ -63,3 +63,20 @@ class CropAboveLive(Live):
63
63
 
64
64
  def update(self, renderable: RenderableType, refresh: bool = True) -> None: # type: ignore[override]
65
65
  super().update(CropAbove(renderable, style=self._crop_style), refresh=refresh)
66
+
67
+
68
+ class SingleLine:
69
+ """Render only the first line of a renderable.
70
+
71
+ This is used to ensure dynamic UI elements (spinners / status) never wrap
72
+ to multiple lines, which would appear as a vertical "jump".
73
+ """
74
+
75
+ def __init__(self, renderable: RenderableType) -> None:
76
+ self.renderable = renderable
77
+
78
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
79
+ line_options = options.update(no_wrap=True, overflow="ellipsis", height=1)
80
+ lines = console.render_lines(self.renderable, line_options, pad=False)
81
+ if lines:
82
+ yield from lines[0]
@@ -7,12 +7,12 @@ import time
7
7
  from collections.abc import Callable
8
8
  from typing import Any, ClassVar
9
9
 
10
- from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
11
- from rich.live import Live
10
+ from markdown_it import MarkdownIt
11
+ from markdown_it.token import Token
12
+ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
12
13
  from rich.markdown import CodeBlock, Heading, Markdown, MarkdownElement
13
14
  from rich.rule import Rule
14
- from rich.spinner import Spinner
15
- from rich.style import Style
15
+ from rich.style import Style, StyleType
16
16
  from rich.syntax import Syntax
17
17
  from rich.text import Text
18
18
  from rich.theme import Theme
@@ -64,7 +64,7 @@ class LeftHeading(Heading):
64
64
  yield h1_text
65
65
  elif self.tag == "h2":
66
66
  text.stylize(Style(bold=True, underline=False))
67
- yield Rule(title=text, characters="-", style="markdown.h2.border", align="left")
67
+ yield Rule(title=text, characters="·", style="markdown.h2.border", align="left")
68
68
  else:
69
69
  yield text
70
70
 
@@ -94,20 +94,25 @@ class ThinkingMarkdown(Markdown):
94
94
 
95
95
 
96
96
  class MarkdownStream:
97
- """Streaming markdown renderer that progressively displays content with a live updating window.
97
+ """Block-based streaming Markdown renderer.
98
98
 
99
- Uses rich.console and rich.live to render markdown content with smooth scrolling
100
- and partial updates. Maintains a sliding window of visible content while streaming
101
- in new markdown text.
99
+ This renderer is optimized for terminal UX:
100
+
101
+ - Stable area: only prints *completed* Markdown blocks to scrollback (append-only).
102
+ - Live area: continuously repaints only the final *possibly incomplete* block.
103
+
104
+ Block boundaries are computed with `MarkdownIt("commonmark")` (token maps / top-level tokens).
105
+ Rendering is done with Rich Markdown (customizable via `markdown_class`).
102
106
  """
103
107
 
104
108
  def __init__(
105
109
  self,
110
+ console: Console,
106
111
  mdargs: dict[str, Any] | None = None,
107
112
  theme: Theme | None = None,
108
- console: Console | None = None,
109
- spinner: Spinner | None = None,
113
+ live_sink: Callable[[RenderableType | None], None] | None = None,
110
114
  mark: str | None = None,
115
+ mark_style: StyleType | None = None,
111
116
  left_margin: int = 0,
112
117
  right_margin: int = const.MARKDOWN_RIGHT_MARGIN,
113
118
  markdown_class: Callable[..., Markdown] | None = None,
@@ -119,29 +124,30 @@ class MarkdownStream:
119
124
  theme (Theme, optional): Theme for rendering markdown
120
125
  console (Console, optional): External console to use for rendering
121
126
  mark (str | None, optional): Marker shown before the first non-empty line when left_margin >= 2
127
+ mark_style (StyleType | None, optional): Style to apply to the mark
122
128
  left_margin (int, optional): Number of columns to reserve on the left side
123
129
  right_margin (int, optional): Number of columns to reserve on the right side
124
130
  markdown_class: Markdown class to use for rendering (defaults to NoInsetMarkdown)
125
131
  """
126
- self.printed: list[str] = [] # Stores lines that have already been printed
132
+ self._stable_rendered_lines: list[str] = []
133
+ self._stable_source_line_count: int = 0
127
134
 
128
135
  if mdargs:
129
136
  self.mdargs: dict[str, Any] = mdargs
130
137
  else:
131
138
  self.mdargs = {}
132
139
 
133
- # Defer Live creation until the first update.
134
- self.live: Live | None = None
140
+ self._live_sink = live_sink
135
141
 
136
142
  # Streaming control
137
143
  self.when: float = 0.0 # Timestamp of last update
138
144
  self.min_delay: float = 1.0 / 20 # Minimum time between updates (20fps)
139
- self.live_window: int = const.MARKDOWN_STREAM_LIVE_WINDOW
145
+ self._parser: MarkdownIt = MarkdownIt("commonmark")
140
146
 
141
147
  self.theme = theme
142
148
  self.console = console
143
- self.spinner: Spinner | None = spinner
144
149
  self.mark: str | None = mark
150
+ self.mark_style: StyleType | None = mark_style
145
151
 
146
152
  self.left_margin: int = max(left_margin, 0)
147
153
 
@@ -151,9 +157,117 @@ class MarkdownStream:
151
157
  @property
152
158
  def _live_started(self) -> bool:
153
159
  """Check if Live display has been started (derived from self.live)."""
154
- return self.live is not None
160
+ return self._live_sink is not None
161
+
162
+ def _get_base_width(self) -> int:
163
+ return self.console.options.max_width
164
+
165
+ def compute_candidate_stable_line(self, text: str) -> int:
166
+ """Return the start line of the last top-level block, or 0.
167
+
168
+ This value is not monotonic; callers should clamp it (e.g. with the
169
+ previous stable line) before using it to advance state.
170
+ """
171
+
172
+ try:
173
+ tokens = self._parser.parse(text)
174
+ except Exception:
175
+ return 0
176
+
177
+ top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
178
+ if len(top_level) < 2:
179
+ return 0
180
+
181
+ last = top_level[-1]
182
+ assert last.map is not None
183
+ start_line = last.map[0]
184
+ return max(start_line, 0)
185
+
186
+ def split_blocks(self, text: str, *, min_stable_line: int = 0, final: bool = False) -> tuple[str, str, int]:
187
+ """Split full markdown into stable and live sources.
188
+
189
+ Returns:
190
+ stable_source: Completed blocks (append-only)
191
+ live_source: Last (possibly incomplete) block
192
+ stable_line: Line index where live starts
193
+ """
194
+
195
+ lines = text.splitlines(keepends=True)
196
+ line_count = len(lines)
197
+
198
+ stable_line = line_count if final else self.compute_candidate_stable_line(text)
199
+
200
+ stable_line = min(stable_line, line_count)
201
+ stable_line = max(stable_line, min_stable_line)
202
+
203
+ stable_source = "".join(lines[:stable_line])
204
+ live_source = "".join(lines[stable_line:])
205
+ return stable_source, live_source, stable_line
206
+
207
+ def render_ansi(self, text: str, *, apply_mark: bool) -> str:
208
+ """Render markdown source to an ANSI string.
209
+
210
+ This is primarily intended for internal debugging and tests.
211
+ """
212
+
213
+ return "".join(self._render_markdown_to_lines(text, apply_mark=apply_mark))
214
+
215
+ def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> str:
216
+ """Render stable prefix to ANSI, preserving inter-block spacing."""
217
+
218
+ if not stable_source:
219
+ return ""
220
+
221
+ render_source = stable_source
222
+ if not final and has_live_suffix:
223
+ render_source = self._append_nonfinal_sentinel(stable_source)
224
+
225
+ return self.render_ansi(render_source, apply_mark=True)
226
+
227
+ @staticmethod
228
+ def normalize_live_ansi_for_boundary(*, stable_ansi: str, live_ansi: str) -> str:
229
+ """Normalize whitespace at the stable/live boundary.
230
+
231
+ Some Rich Markdown blocks (e.g. lists) render with a leading blank line.
232
+ If the stable prefix already renders a trailing blank line, rendering the
233
+ live suffix separately may introduce an extra blank line that wouldn't
234
+ appear when rendering the full document.
155
235
 
156
- def _render_markdown_to_lines(self, text: str) -> list[str]:
236
+ This function removes leading blank lines from the live ANSI when the
237
+ stable ANSI already ends with a blank line.
238
+ """
239
+
240
+ stable_lines = stable_ansi.splitlines(keepends=True)
241
+ stable_ends_blank = bool(stable_lines) and not stable_lines[-1].strip()
242
+ if not stable_ends_blank:
243
+ return live_ansi
244
+
245
+ live_lines = live_ansi.splitlines(keepends=True)
246
+ while live_lines and not live_lines[0].strip():
247
+ live_lines.pop(0)
248
+ return "".join(live_lines)
249
+
250
+ def _append_nonfinal_sentinel(self, stable_source: str) -> str:
251
+ """Make Rich render stable content as if it isn't the last block.
252
+
253
+ Rich Markdown may omit trailing spacing for the last block in a document.
254
+ When we render only the stable prefix (without the live suffix), we still
255
+ need the *inter-block* spacing to match the full document.
256
+
257
+ A harmless HTML comment block causes Rich Markdown to emit the expected
258
+ spacing while rendering no visible content.
259
+ """
260
+
261
+ if not stable_source:
262
+ return stable_source
263
+
264
+ if stable_source.endswith("\n\n"):
265
+ return stable_source + "<!-- -->"
266
+ if stable_source.endswith("\n"):
267
+ return stable_source + "\n<!-- -->"
268
+ return stable_source + "\n\n<!-- -->"
269
+
270
+ def _render_markdown_to_lines(self, text: str, *, apply_mark: bool) -> list[str]:
157
271
  """Render markdown text to a list of lines.
158
272
 
159
273
  Args:
@@ -165,13 +279,8 @@ class MarkdownStream:
165
279
  # Render the markdown to a string buffer
166
280
  string_io = io.StringIO()
167
281
 
168
- # Determine console width and adjust for left margin so that
169
- # the rendered content plus margins does not exceed the available width.
170
- if self.console is not None:
171
- base_width = self.console.options.max_width
172
- else:
173
- probe_console = Console(theme=self.theme)
174
- base_width = probe_console.options.max_width
282
+ # Keep width stable across frames to prevent reflow/jitter.
283
+ base_width = self._get_base_width()
175
284
 
176
285
  effective_width = max(base_width - self.left_margin - self.right_margin, 1)
177
286
 
@@ -192,14 +301,26 @@ class MarkdownStream:
192
301
  indent_prefix = " " * self.left_margin if self.left_margin > 0 else ""
193
302
  processed_lines: list[str] = []
194
303
  mark_applied = False
195
- use_mark = bool(self.mark) and self.left_margin >= 2
304
+ use_mark = apply_mark and bool(self.mark) and self.left_margin >= 2
305
+
306
+ # Pre-render styled mark if needed
307
+ styled_mark: str | None = None
308
+ if use_mark and self.mark:
309
+ if self.mark_style:
310
+ mark_text = Text(self.mark, style=self.mark_style)
311
+ mark_buffer = io.StringIO()
312
+ mark_console = Console(file=mark_buffer, force_terminal=True, theme=self.theme)
313
+ mark_console.print(mark_text, end="")
314
+ styled_mark = mark_buffer.getvalue()
315
+ else:
316
+ styled_mark = self.mark
196
317
 
197
318
  for line in lines:
198
319
  stripped = line.rstrip()
199
320
 
200
321
  # Apply mark to the first non-empty line only when left_margin is at least 2.
201
322
  if use_mark and not mark_applied and stripped:
202
- stripped = f"{self.mark} {stripped}"
323
+ stripped = f"{styled_mark} {stripped}"
203
324
  mark_applied = True
204
325
  elif indent_prefix:
205
326
  stripped = indent_prefix + stripped
@@ -212,102 +333,59 @@ class MarkdownStream:
212
333
 
213
334
  def __del__(self) -> None:
214
335
  """Destructor to ensure Live display is properly cleaned up."""
215
- if self.live:
216
- # Ignore any errors during cleanup
217
- with contextlib.suppress(Exception):
218
- self.live.stop()
336
+ if self._live_sink is None:
337
+ return
338
+ with contextlib.suppress(Exception):
339
+ self._live_sink(None)
219
340
 
220
341
  def update(self, text: str, final: bool = False) -> None:
221
- """Update the displayed markdown content.
222
-
223
- Args:
224
- text (str): The markdown text received so far
225
- final (bool): If True, this is the final update and we should clean up
342
+ """Update the display with the latest full markdown buffer."""
226
343
 
227
- Splits the output into "stable" older lines and the "last few" lines
228
- which aren't considered stable. They may shift around as new chunks
229
- are appended to the markdown text.
230
-
231
- The stable lines emit to the console above the Live window.
232
- The unstable lines emit into the Live window so they can be repainted.
233
-
234
- Markdown going to the console works better in terminal scrollback buffers.
235
- The live window doesn't play nice with terminal scrollback.
236
- """
237
- if not self._live_started:
238
- initial_content = self._live_renderable(Text(""), final=False)
239
- # transient=False keeps final frame on screen after stop()
240
- self.live = Live(
241
- initial_content,
242
- refresh_per_second=1.0 / self.min_delay,
243
- console=self.console,
244
- )
245
- self.live.start()
246
-
247
- if self.live is None:
344
+ if self._live_sink is None:
248
345
  return
249
346
 
250
347
  now = time.time()
251
- # Throttle updates to maintain smooth rendering
252
348
  if not final and now - self.when < self.min_delay:
253
349
  return
254
350
  self.when = now
255
351
 
256
- # Measure render time and adjust min_delay to maintain smooth rendering
257
- start = time.time()
258
- lines = self._render_markdown_to_lines(text)
259
- render_time = time.time() - start
260
-
261
- # Set min_delay to render time plus a small buffer
262
- self.min_delay = min(max(render_time * 10, 1.0 / 20), 2)
263
-
264
- num_lines = len(lines)
265
-
266
- # Reserve last live_window lines for Live area to keep height stable
267
- num_lines = max(num_lines - self.live_window, 0)
352
+ previous_stable_line = self._stable_source_line_count
268
353
 
269
- # Print new stable lines above Live window
270
- if num_lines > 0:
271
- num_printed = len(self.printed)
272
- to_append_count = num_lines - num_printed
354
+ stable_source, live_source, stable_line = self.split_blocks(
355
+ text,
356
+ min_stable_line=previous_stable_line,
357
+ final=final,
358
+ )
273
359
 
274
- if to_append_count > 0:
275
- append_chunk = lines[num_printed:num_lines]
276
- append_chunk_text = Text.from_ansi("".join(append_chunk))
277
- live = self.live
278
- assert live is not None
279
- live.console.print(append_chunk_text)
280
- self.printed = lines[:num_lines]
360
+ start = time.time()
281
361
 
282
- rest_lines = lines[num_lines:]
362
+ stable_changed = final or stable_line > self._stable_source_line_count
363
+ if stable_changed and stable_source:
364
+ stable_ansi = self.render_stable_ansi(stable_source, has_live_suffix=bool(live_source), final=final)
365
+ stable_lines = stable_ansi.splitlines(keepends=True)
366
+ new_lines = stable_lines[len(self._stable_rendered_lines) :]
367
+ if new_lines:
368
+ stable_chunk = "".join(new_lines)
369
+ self.console.print(Text.from_ansi(stable_chunk), end="\n")
370
+ self._stable_rendered_lines = stable_lines
371
+ self._stable_source_line_count = stable_line
372
+ elif final and not stable_source:
373
+ self._stable_rendered_lines = []
374
+ self._stable_source_line_count = stable_line
283
375
 
284
- # Final: render remaining lines without spinner, then stop Live
285
376
  if final:
286
- live = self.live
287
- assert live is not None
288
- rest = "".join(rest_lines)
289
- rest_text = Text.from_ansi(rest)
290
- final_renderable = self._live_renderable(rest_text, final=True)
291
- live.update(final_renderable)
292
- live.stop()
293
- self.live = None
377
+ self._live_sink(None)
294
378
  return
295
379
 
296
- rest = "".join(rest_lines)
297
- rest = Text.from_ansi(rest)
298
- live = self.live
299
- assert live is not None
300
- live_renderable = self._live_renderable(rest, final)
301
- live.update(live_renderable)
380
+ apply_mark_live = self._stable_source_line_count == 0
381
+ live_lines = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_live)
302
382
 
303
- def _live_renderable(self, rest: Text, final: bool) -> RenderableType:
304
- if final or not self.spinner:
305
- return rest
306
- else:
307
- return Group(rest, Text(), self.spinner)
383
+ if self._stable_rendered_lines and not self._stable_rendered_lines[-1].strip():
384
+ while live_lines and not live_lines[0].strip():
385
+ live_lines.pop(0)
308
386
 
309
- def find_minimal_suffix(self, text: str, match_lines: int = 50) -> None:
310
- """
311
- Splits text into chunks on blank lines "\n\n".
312
- """
313
- return None
387
+ live_text = Text.from_ansi("".join(live_lines))
388
+ self._live_sink(live_text)
389
+
390
+ elapsed = time.time() - start
391
+ self.min_delay = min(max(elapsed * 6, 1.0 / 30), 0.5)