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.
- klaude_code/cli/runtime.py +17 -1
- klaude_code/command/prompt-jj-describe.md +32 -0
- klaude_code/command/thinking_cmd.py +37 -28
- klaude_code/{const/__init__.py → const.py} +7 -6
- klaude_code/core/executor.py +46 -3
- klaude_code/core/tool/file/read_tool.py +23 -1
- klaude_code/core/tool/file/write_tool.py +7 -3
- klaude_code/llm/openai_compatible/client.py +29 -102
- klaude_code/llm/openai_compatible/stream.py +272 -0
- klaude_code/llm/openrouter/client.py +29 -109
- klaude_code/llm/openrouter/{reasoning_handler.py → reasoning.py} +24 -2
- klaude_code/protocol/model.py +13 -1
- klaude_code/protocol/op.py +11 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/ui/core/stage_manager.py +0 -3
- klaude_code/ui/modes/repl/display.py +2 -0
- klaude_code/ui/modes/repl/event_handler.py +97 -57
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +25 -4
- klaude_code/ui/modes/repl/renderer.py +119 -25
- klaude_code/ui/renderers/assistant.py +1 -1
- klaude_code/ui/renderers/metadata.py +2 -6
- klaude_code/ui/renderers/sub_agent.py +28 -5
- klaude_code/ui/renderers/thinking.py +16 -10
- klaude_code/ui/renderers/tools.py +26 -2
- klaude_code/ui/rich/code_panel.py +24 -5
- klaude_code/ui/rich/live.py +17 -0
- klaude_code/ui/rich/markdown.py +185 -107
- klaude_code/ui/rich/status.py +19 -17
- klaude_code/ui/rich/theme.py +63 -12
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/METADATA +2 -1
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/RECORD +33 -32
- klaude_code/llm/openai_compatible/stream_processor.py +0 -83
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/WHEEL +0 -0
- {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,
|
|
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
|
-
|
|
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.
|
|
108
|
+
f"\n… more {hidden_count} lines — use /export to view full output\n",
|
|
109
|
+
style=ThemeKey.TOOL_RESULT_TRUNCATED,
|
|
91
110
|
),
|
|
92
|
-
NoInsetMarkdown(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
79
|
+
content_width = max_content_width
|
|
69
80
|
else:
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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)
|
klaude_code/ui/rich/live.py
CHANGED
|
@@ -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]
|
klaude_code/ui/rich/markdown.py
CHANGED
|
@@ -7,12 +7,12 @@ import time
|
|
|
7
7
|
from collections.abc import Callable
|
|
8
8
|
from typing import Any, ClassVar
|
|
9
9
|
|
|
10
|
-
from
|
|
11
|
-
from
|
|
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.
|
|
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="
|
|
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
|
-
"""
|
|
97
|
+
"""Block-based streaming Markdown renderer.
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
169
|
-
|
|
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"{
|
|
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.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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)
|