klaude-code 2.9.1__py3-none-any.whl → 2.10.1__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/app/runtime.py +5 -1
- klaude_code/cli/cost_cmd.py +4 -4
- klaude_code/cli/list_model.py +1 -2
- klaude_code/cli/main.py +10 -0
- klaude_code/config/assets/builtin_config.yaml +15 -14
- klaude_code/const.py +4 -3
- klaude_code/core/agent_profile.py +23 -0
- klaude_code/core/bash_mode.py +276 -0
- klaude_code/core/executor.py +40 -7
- klaude_code/core/manager/llm_clients.py +1 -0
- klaude_code/core/manager/llm_clients_builder.py +2 -2
- klaude_code/core/memory.py +140 -0
- klaude_code/core/prompts/prompt-sub-agent-web.md +2 -2
- klaude_code/core/reminders.py +17 -89
- klaude_code/core/tool/offload.py +4 -4
- klaude_code/core/tool/web/web_fetch_tool.md +2 -1
- klaude_code/core/tool/web/web_fetch_tool.py +1 -1
- klaude_code/core/turn.py +9 -4
- klaude_code/protocol/events.py +17 -0
- klaude_code/protocol/op.py +12 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/session/templates/mermaid_viewer.html +85 -0
- klaude_code/tui/command/resume_cmd.py +1 -1
- klaude_code/tui/commands.py +15 -0
- klaude_code/tui/components/command_output.py +4 -5
- klaude_code/tui/components/developer.py +1 -3
- klaude_code/tui/components/metadata.py +28 -25
- klaude_code/tui/components/rich/code_panel.py +31 -16
- klaude_code/tui/components/rich/markdown.py +56 -124
- klaude_code/tui/components/rich/theme.py +22 -12
- klaude_code/tui/components/thinking.py +0 -35
- klaude_code/tui/components/tools.py +4 -2
- klaude_code/tui/components/user_input.py +49 -59
- klaude_code/tui/components/welcome.py +47 -2
- klaude_code/tui/display.py +14 -6
- klaude_code/tui/input/completers.py +8 -0
- klaude_code/tui/input/key_bindings.py +37 -1
- klaude_code/tui/input/prompt_toolkit.py +57 -31
- klaude_code/tui/machine.py +108 -28
- klaude_code/tui/renderer.py +117 -19
- klaude_code/tui/runner.py +22 -0
- klaude_code/tui/terminal/notifier.py +11 -12
- klaude_code/tui/terminal/selector.py +1 -1
- klaude_code/ui/terminal/title.py +4 -2
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/METADATA +1 -1
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/RECORD +48 -47
- klaude_code/tui/components/assistant.py +0 -2
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/WHEEL +0 -0
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/entry_points.txt +0 -0
|
@@ -34,8 +34,8 @@ def _render_task_metadata_block(
|
|
|
34
34
|
content = Text()
|
|
35
35
|
if metadata.provider is not None:
|
|
36
36
|
content.append_text(Text(metadata.provider.lower().replace(" ", "-"), style=ThemeKey.METADATA))
|
|
37
|
-
content.append_text(Text("/", style=ThemeKey.
|
|
38
|
-
content.append_text(Text(metadata.model_name, style=ThemeKey.
|
|
37
|
+
content.append_text(Text("/", style=ThemeKey.METADATA))
|
|
38
|
+
content.append_text(Text(metadata.model_name, style=ThemeKey.METADATA))
|
|
39
39
|
if metadata.description:
|
|
40
40
|
content.append_text(Text(" ", style=ThemeKey.METADATA)).append_text(
|
|
41
41
|
Text(metadata.description, style=ThemeKey.METADATA_ITALIC)
|
|
@@ -47,18 +47,21 @@ def _render_task_metadata_block(
|
|
|
47
47
|
if metadata.usage is not None:
|
|
48
48
|
# Tokens: ↑37k ◎5k ↓907 ∿45k ⌗ 100
|
|
49
49
|
token_text = Text()
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
input_tokens = max(metadata.usage.input_tokens - metadata.usage.cached_tokens, 0)
|
|
51
|
+
output_tokens = max(metadata.usage.output_tokens - metadata.usage.reasoning_tokens, 0)
|
|
52
|
+
|
|
53
|
+
token_text.append("↑", style=ThemeKey.METADATA)
|
|
54
|
+
token_text.append(format_number(input_tokens), style=ThemeKey.METADATA)
|
|
52
55
|
if metadata.usage.cached_tokens > 0:
|
|
53
|
-
token_text.append(" ◎", style=ThemeKey.
|
|
56
|
+
token_text.append(" ◎", style=ThemeKey.METADATA)
|
|
54
57
|
token_text.append(format_number(metadata.usage.cached_tokens), style=ThemeKey.METADATA)
|
|
55
|
-
token_text.append(" ↓", style=ThemeKey.
|
|
56
|
-
token_text.append(format_number(
|
|
58
|
+
token_text.append(" ↓", style=ThemeKey.METADATA)
|
|
59
|
+
token_text.append(format_number(output_tokens), style=ThemeKey.METADATA)
|
|
57
60
|
if metadata.usage.reasoning_tokens > 0:
|
|
58
|
-
token_text.append(" ∿", style=ThemeKey.
|
|
61
|
+
token_text.append(" ∿", style=ThemeKey.METADATA)
|
|
59
62
|
token_text.append(format_number(metadata.usage.reasoning_tokens), style=ThemeKey.METADATA)
|
|
60
63
|
if metadata.usage.image_tokens > 0:
|
|
61
|
-
token_text.append(" ⊡", style=ThemeKey.
|
|
64
|
+
token_text.append(" ⊡", style=ThemeKey.METADATA)
|
|
62
65
|
token_text.append(format_number(metadata.usage.image_tokens), style=ThemeKey.METADATA)
|
|
63
66
|
parts.append(token_text)
|
|
64
67
|
|
|
@@ -66,7 +69,7 @@ def _render_task_metadata_block(
|
|
|
66
69
|
if metadata.usage is not None and metadata.usage.total_cost is not None:
|
|
67
70
|
parts.append(
|
|
68
71
|
Text.assemble(
|
|
69
|
-
(currency_symbol, ThemeKey.
|
|
72
|
+
(currency_symbol, ThemeKey.METADATA),
|
|
70
73
|
(f"{metadata.usage.total_cost:.4f}", ThemeKey.METADATA),
|
|
71
74
|
)
|
|
72
75
|
)
|
|
@@ -79,9 +82,9 @@ def _render_task_metadata_block(
|
|
|
79
82
|
parts.append(
|
|
80
83
|
Text.assemble(
|
|
81
84
|
(context_size, ThemeKey.METADATA),
|
|
82
|
-
("/", ThemeKey.
|
|
85
|
+
("/", ThemeKey.METADATA),
|
|
83
86
|
(effective_limit_str, ThemeKey.METADATA),
|
|
84
|
-
(f"({metadata.usage.context_usage_percent:.1f}%)", ThemeKey.
|
|
87
|
+
(f"({metadata.usage.context_usage_percent:.1f}%)", ThemeKey.METADATA),
|
|
85
88
|
)
|
|
86
89
|
)
|
|
87
90
|
|
|
@@ -90,7 +93,7 @@ def _render_task_metadata_block(
|
|
|
90
93
|
parts.append(
|
|
91
94
|
Text.assemble(
|
|
92
95
|
(f"{metadata.usage.throughput_tps:.1f}", ThemeKey.METADATA),
|
|
93
|
-
("tps", ThemeKey.
|
|
96
|
+
("tps", ThemeKey.METADATA),
|
|
94
97
|
)
|
|
95
98
|
)
|
|
96
99
|
|
|
@@ -101,7 +104,7 @@ def _render_task_metadata_block(
|
|
|
101
104
|
parts.append(
|
|
102
105
|
Text.assemble(
|
|
103
106
|
(ftl_str, ThemeKey.METADATA),
|
|
104
|
-
("-ftl", ThemeKey.
|
|
107
|
+
("-ftl", ThemeKey.METADATA),
|
|
105
108
|
)
|
|
106
109
|
)
|
|
107
110
|
|
|
@@ -110,7 +113,7 @@ def _render_task_metadata_block(
|
|
|
110
113
|
parts.append(
|
|
111
114
|
Text.assemble(
|
|
112
115
|
(f"{metadata.task_duration_s:.1f}", ThemeKey.METADATA),
|
|
113
|
-
("s", ThemeKey.
|
|
116
|
+
("s", ThemeKey.METADATA),
|
|
114
117
|
)
|
|
115
118
|
)
|
|
116
119
|
|
|
@@ -120,13 +123,13 @@ def _render_task_metadata_block(
|
|
|
120
123
|
parts.append(
|
|
121
124
|
Text.assemble(
|
|
122
125
|
(str(metadata.turn_count), ThemeKey.METADATA),
|
|
123
|
-
(suffix, ThemeKey.
|
|
126
|
+
(suffix, ThemeKey.METADATA),
|
|
124
127
|
)
|
|
125
128
|
)
|
|
126
129
|
|
|
127
130
|
if parts:
|
|
128
|
-
content.append_text(Text(" ", style=ThemeKey.
|
|
129
|
-
content.append_text(Text(" ", style=ThemeKey.
|
|
131
|
+
content.append_text(Text(" ", style=ThemeKey.METADATA))
|
|
132
|
+
content.append_text(Text(" ", style=ThemeKey.METADATA).join(parts))
|
|
130
133
|
|
|
131
134
|
grid.add_row(mark, content)
|
|
132
135
|
return grid
|
|
@@ -138,14 +141,14 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
|
|
|
138
141
|
|
|
139
142
|
has_sub_agents = len(e.metadata.sub_agent_task_metadata) > 0
|
|
140
143
|
# Use an extra space for the main agent mark to align with two-character marks (├─, └─)
|
|
141
|
-
main_mark_text = "
|
|
144
|
+
main_mark_text = "●"
|
|
142
145
|
main_mark = Text(main_mark_text, style=ThemeKey.METADATA)
|
|
143
146
|
|
|
144
147
|
renderables.append(_render_task_metadata_block(e.metadata.main_agent, mark=main_mark, show_context_and_time=True))
|
|
145
148
|
|
|
146
149
|
# Render each sub-agent metadata block
|
|
147
150
|
for meta in e.metadata.sub_agent_task_metadata:
|
|
148
|
-
sub_mark = Text(" └", style=ThemeKey.
|
|
151
|
+
sub_mark = Text(" └", style=ThemeKey.METADATA)
|
|
149
152
|
renderables.append(_render_task_metadata_block(meta, mark=sub_mark, show_context_and_time=True))
|
|
150
153
|
|
|
151
154
|
# Add total cost line when there are sub-agents
|
|
@@ -162,11 +165,11 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
|
|
|
162
165
|
|
|
163
166
|
currency_symbol = "¥" if currency == "CNY" else "$"
|
|
164
167
|
total_line = Text.assemble(
|
|
165
|
-
(" └", ThemeKey.
|
|
166
|
-
(" Σ ", ThemeKey.
|
|
167
|
-
("total ", ThemeKey.
|
|
168
|
-
(currency_symbol, ThemeKey.
|
|
169
|
-
(f"{total_cost:.4f}", ThemeKey.
|
|
168
|
+
(" └", ThemeKey.METADATA),
|
|
169
|
+
(" Σ ", ThemeKey.METADATA),
|
|
170
|
+
("total ", ThemeKey.METADATA),
|
|
171
|
+
(currency_symbol, ThemeKey.METADATA),
|
|
172
|
+
(f"{total_cost:.4f}", ThemeKey.METADATA),
|
|
170
173
|
)
|
|
171
174
|
|
|
172
175
|
renderables.append(total_line)
|
|
@@ -14,12 +14,12 @@ from rich.style import StyleType
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from rich.console import Console, ConsoleOptions, RenderResult
|
|
16
16
|
|
|
17
|
-
# Box drawing characters
|
|
18
|
-
TOP_LEFT = "
|
|
19
|
-
TOP_RIGHT = "
|
|
20
|
-
BOTTOM_LEFT = "
|
|
21
|
-
BOTTOM_RIGHT = "
|
|
22
|
-
HORIZONTAL = "─"
|
|
17
|
+
# Box drawing characters (rounded corners)
|
|
18
|
+
TOP_LEFT = "╭"
|
|
19
|
+
TOP_RIGHT = "╮"
|
|
20
|
+
BOTTOM_LEFT = "╰"
|
|
21
|
+
BOTTOM_RIGHT = "╯"
|
|
22
|
+
HORIZONTAL = "─"
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class CodePanel(JupyterMixin):
|
|
@@ -32,10 +32,10 @@ class CodePanel(JupyterMixin):
|
|
|
32
32
|
>>> console.print(CodePanel(Syntax(code, "python")))
|
|
33
33
|
|
|
34
34
|
Renders as:
|
|
35
|
-
|
|
35
|
+
╭──────────────────────────╮
|
|
36
36
|
code line 1
|
|
37
37
|
code line 2
|
|
38
|
-
|
|
38
|
+
╰──────────────────────────╯
|
|
39
39
|
"""
|
|
40
40
|
|
|
41
41
|
def __init__(
|
|
@@ -44,7 +44,9 @@ class CodePanel(JupyterMixin):
|
|
|
44
44
|
*,
|
|
45
45
|
border_style: StyleType = "none",
|
|
46
46
|
expand: bool = False,
|
|
47
|
-
padding: int =
|
|
47
|
+
padding: int = 0,
|
|
48
|
+
title: str | None = None,
|
|
49
|
+
title_style: StyleType = "none",
|
|
48
50
|
) -> None:
|
|
49
51
|
"""Initialize the CodePanel.
|
|
50
52
|
|
|
@@ -52,12 +54,16 @@ class CodePanel(JupyterMixin):
|
|
|
52
54
|
renderable: A console renderable object.
|
|
53
55
|
border_style: The style of the border. Defaults to "none".
|
|
54
56
|
expand: If True, expand to fill available width. Defaults to False.
|
|
55
|
-
padding: Left/right padding for content. Defaults to
|
|
57
|
+
padding: Left/right padding for content. Defaults to 0.
|
|
58
|
+
title: Optional title to display in the top border. Defaults to None.
|
|
59
|
+
title_style: The style of the title. Defaults to "none".
|
|
56
60
|
"""
|
|
57
61
|
self.renderable = renderable
|
|
58
62
|
self.border_style = border_style
|
|
59
63
|
self.expand = expand
|
|
60
64
|
self.padding = padding
|
|
65
|
+
self.title = title
|
|
66
|
+
self.title_style = title_style
|
|
61
67
|
|
|
62
68
|
@staticmethod
|
|
63
69
|
def _measure_max_line_cells(lines: list[list[Segment]]) -> int:
|
|
@@ -93,11 +99,20 @@ class CodePanel(JupyterMixin):
|
|
|
93
99
|
new_line = Segment.line()
|
|
94
100
|
pad_segment = Segment(" " * pad) if pad > 0 else None
|
|
95
101
|
|
|
96
|
-
# Top border:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
# Top border: ╭───...───╮ or ╭ title ───...───╮
|
|
103
|
+
if self.title and border_width >= len(self.title) + 4:
|
|
104
|
+
title_part = f" {self.title} "
|
|
105
|
+
title_style = console.get_style(self.title_style)
|
|
106
|
+
remaining = border_width - 2 - len(title_part)
|
|
107
|
+
yield Segment(TOP_LEFT, border_style)
|
|
108
|
+
yield Segment(title_part, title_style)
|
|
109
|
+
yield Segment((HORIZONTAL * remaining) + TOP_RIGHT, border_style)
|
|
110
|
+
elif border_width >= 2:
|
|
111
|
+
top_border = TOP_LEFT + (HORIZONTAL * (border_width - 2)) + TOP_RIGHT
|
|
112
|
+
yield Segment(top_border, border_style)
|
|
113
|
+
else:
|
|
114
|
+
top_border = HORIZONTAL * border_width
|
|
115
|
+
yield Segment(top_border, border_style)
|
|
101
116
|
yield new_line
|
|
102
117
|
|
|
103
118
|
# Content lines with padding
|
|
@@ -109,7 +124,7 @@ class CodePanel(JupyterMixin):
|
|
|
109
124
|
yield pad_segment
|
|
110
125
|
yield new_line
|
|
111
126
|
|
|
112
|
-
# Bottom border:
|
|
127
|
+
# Bottom border: ╰───...───╯
|
|
113
128
|
bottom_border = (
|
|
114
129
|
BOTTOM_LEFT + (HORIZONTAL * (border_width - 2)) + BOTTOM_RIGHT
|
|
115
130
|
if border_width >= 2
|
|
@@ -22,12 +22,10 @@ from rich.text import Text
|
|
|
22
22
|
from rich.theme import Theme
|
|
23
23
|
|
|
24
24
|
from klaude_code.const import (
|
|
25
|
-
MARKDOWN_RIGHT_MARGIN,
|
|
26
25
|
MARKDOWN_STREAM_LIVE_REPAINT_ENABLED,
|
|
27
26
|
MARKDOWN_STREAM_SYNCHRONIZED_OUTPUT_ENABLED,
|
|
28
27
|
UI_REFRESH_RATE_FPS,
|
|
29
28
|
)
|
|
30
|
-
from klaude_code.tui.components.rich.code_panel import CodePanel
|
|
31
29
|
|
|
32
30
|
_THINKING_HTML_BLOCK_RE = re.compile(
|
|
33
31
|
r"\A\s*<thinking>\s*\n?(?P<body>.*?)(?:\n\s*)?</thinking>\s*\Z",
|
|
@@ -91,10 +89,15 @@ class ThinkingHTMLBlock(MarkdownElement):
|
|
|
91
89
|
|
|
92
90
|
|
|
93
91
|
class NoInsetCodeBlock(CodeBlock):
|
|
94
|
-
"""A code block with syntax highlighting
|
|
92
|
+
"""A code block with syntax highlighting using markdown fence style."""
|
|
95
93
|
|
|
96
94
|
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
97
95
|
code = str(self.text).rstrip()
|
|
96
|
+
lang = self.lexer_name if self.lexer_name != "text" else ""
|
|
97
|
+
fence_style = console.get_style("markdown.code.fence", default="none")
|
|
98
|
+
fence_title_style = console.get_style("markdown.code.fence.title", default="none")
|
|
99
|
+
|
|
100
|
+
yield Text.assemble(("```", fence_style), (lang, fence_title_style))
|
|
98
101
|
syntax = Syntax(
|
|
99
102
|
code,
|
|
100
103
|
self.lexer_name,
|
|
@@ -102,16 +105,21 @@ class NoInsetCodeBlock(CodeBlock):
|
|
|
102
105
|
word_wrap=True,
|
|
103
106
|
padding=(0, 0),
|
|
104
107
|
)
|
|
105
|
-
yield
|
|
108
|
+
yield syntax
|
|
109
|
+
yield Text("```", style=fence_style)
|
|
106
110
|
|
|
107
111
|
|
|
108
112
|
class ThinkingCodeBlock(CodeBlock):
|
|
109
|
-
"""A code block for thinking content that uses
|
|
113
|
+
"""A code block for thinking content that uses simple ``` delimiters."""
|
|
110
114
|
|
|
111
115
|
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
112
116
|
code = str(self.text).rstrip()
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
fence_style = "markdown.code.fence"
|
|
118
|
+
code_style = "markdown.code.block"
|
|
119
|
+
lang = self.lexer_name if self.lexer_name != "text" else ""
|
|
120
|
+
yield Text(f"```{lang}", style=fence_style)
|
|
121
|
+
yield Text(code, style=code_style)
|
|
122
|
+
yield Text("```", style=fence_style)
|
|
115
123
|
|
|
116
124
|
|
|
117
125
|
class Divider(MarkdownElement):
|
|
@@ -289,7 +297,7 @@ class MarkdownStream:
|
|
|
289
297
|
mark: str | None = None,
|
|
290
298
|
mark_style: StyleType | None = None,
|
|
291
299
|
left_margin: int = 0,
|
|
292
|
-
right_margin: int =
|
|
300
|
+
right_margin: int = 0,
|
|
293
301
|
markdown_class: Callable[..., Markdown] | None = None,
|
|
294
302
|
image_callback: Callable[[str], None] | None = None,
|
|
295
303
|
) -> None:
|
|
@@ -325,10 +333,10 @@ class MarkdownStream:
|
|
|
325
333
|
|
|
326
334
|
self.theme = theme
|
|
327
335
|
self.console = console
|
|
328
|
-
self.mark: str | None = mark
|
|
329
|
-
self.mark_style: StyleType | None = mark_style
|
|
330
|
-
|
|
331
336
|
self.left_margin: int = max(left_margin, 0)
|
|
337
|
+
# Default mark "•" when left_margin >= 2 and no mark specified
|
|
338
|
+
self.mark: str | None = mark if mark is not None else ("•" if self.left_margin >= 2 else None)
|
|
339
|
+
self.mark_style: StyleType | None = mark_style
|
|
332
340
|
|
|
333
341
|
self.right_margin: int = max(right_margin, 0)
|
|
334
342
|
self.markdown_class: Callable[..., Markdown] = markdown_class or NoInsetMarkdown
|
|
@@ -395,25 +403,12 @@ class MarkdownStream:
|
|
|
395
403
|
return 0
|
|
396
404
|
|
|
397
405
|
top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
|
|
398
|
-
if
|
|
406
|
+
if len(top_level) < 2:
|
|
399
407
|
return 0
|
|
400
408
|
|
|
401
409
|
last = top_level[-1]
|
|
402
410
|
assert last.map is not None
|
|
403
411
|
|
|
404
|
-
# Lists are a special case: markdown-it-py treats the whole list as one
|
|
405
|
-
# top-level block, which would keep the entire list in the live area
|
|
406
|
-
# until the list ends.
|
|
407
|
-
#
|
|
408
|
-
# For streaming UX, we want all but the final list item to be eligible
|
|
409
|
-
# for stabilization, leaving only the *last* item in the live area.
|
|
410
|
-
if last.type in {"bullet_list_open", "ordered_list_open"}:
|
|
411
|
-
stable_line = self._compute_list_item_boundary(tokens, last)
|
|
412
|
-
return max(stable_line, 0)
|
|
413
|
-
|
|
414
|
-
if len(top_level) < 2:
|
|
415
|
-
return 0
|
|
416
|
-
|
|
417
412
|
# When the buffer ends mid-line, markdown-it-py can temporarily classify
|
|
418
413
|
# some lines as a thematic break (hr). For example, a trailing "- --"
|
|
419
414
|
# parses as an hr, but appending a non-hr character ("- --0") turns it
|
|
@@ -430,59 +425,6 @@ class MarkdownStream:
|
|
|
430
425
|
start_line = last.map[0]
|
|
431
426
|
return max(start_line, 0)
|
|
432
427
|
|
|
433
|
-
def _compute_list_item_boundary(self, tokens: list[Token], list_open: Token) -> int:
|
|
434
|
-
"""Return the start line of the last list item in a list.
|
|
435
|
-
|
|
436
|
-
This allows stabilizing all list items except the final one.
|
|
437
|
-
"""
|
|
438
|
-
|
|
439
|
-
if list_open.map is None:
|
|
440
|
-
return 0
|
|
441
|
-
|
|
442
|
-
list_start, list_end = list_open.map
|
|
443
|
-
item_level = list_open.level + 1
|
|
444
|
-
|
|
445
|
-
last_item_start: int | None = None
|
|
446
|
-
for token in tokens:
|
|
447
|
-
if token.type != "list_item_open":
|
|
448
|
-
continue
|
|
449
|
-
if token.level != item_level:
|
|
450
|
-
continue
|
|
451
|
-
if token.map is None:
|
|
452
|
-
continue
|
|
453
|
-
start_line = token.map[0]
|
|
454
|
-
if start_line < list_start or start_line >= list_end:
|
|
455
|
-
continue
|
|
456
|
-
last_item_start = start_line
|
|
457
|
-
|
|
458
|
-
return last_item_start if last_item_start is not None else list_start
|
|
459
|
-
|
|
460
|
-
def _stable_boundary_continues_list(self, text: str, stable_line: int, *, final: bool) -> bool:
|
|
461
|
-
"""Whether the stable/live split point is inside the final top-level list."""
|
|
462
|
-
|
|
463
|
-
if final:
|
|
464
|
-
return False
|
|
465
|
-
if stable_line <= 0:
|
|
466
|
-
return False
|
|
467
|
-
|
|
468
|
-
try:
|
|
469
|
-
tokens = self._parser.parse(text)
|
|
470
|
-
except Exception:
|
|
471
|
-
return False
|
|
472
|
-
|
|
473
|
-
top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
|
|
474
|
-
if not top_level:
|
|
475
|
-
return False
|
|
476
|
-
|
|
477
|
-
last = top_level[-1]
|
|
478
|
-
if last.type not in {"bullet_list_open", "ordered_list_open"}:
|
|
479
|
-
return False
|
|
480
|
-
if last.map is None:
|
|
481
|
-
return False
|
|
482
|
-
|
|
483
|
-
list_start, list_end = last.map
|
|
484
|
-
return list_start < stable_line < list_end
|
|
485
|
-
|
|
486
428
|
def split_blocks(self, text: str, *, min_stable_line: int = 0, final: bool = False) -> tuple[str, str, int]:
|
|
487
429
|
"""Split full markdown into stable and live sources.
|
|
488
430
|
|
|
@@ -512,14 +454,7 @@ class MarkdownStream:
|
|
|
512
454
|
return "", text, 0
|
|
513
455
|
return stable_source, live_source, stable_line
|
|
514
456
|
|
|
515
|
-
def render_stable_ansi(
|
|
516
|
-
self,
|
|
517
|
-
stable_source: str,
|
|
518
|
-
*,
|
|
519
|
-
has_live_suffix: bool,
|
|
520
|
-
final: bool,
|
|
521
|
-
continues_list: bool = False,
|
|
522
|
-
) -> tuple[str, list[str]]:
|
|
457
|
+
def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> tuple[str, list[str]]:
|
|
523
458
|
"""Render stable prefix to ANSI, preserving inter-block spacing.
|
|
524
459
|
|
|
525
460
|
Returns:
|
|
@@ -529,14 +464,10 @@ class MarkdownStream:
|
|
|
529
464
|
return "", []
|
|
530
465
|
|
|
531
466
|
render_source = stable_source
|
|
532
|
-
if not final and has_live_suffix
|
|
467
|
+
if not final and has_live_suffix:
|
|
533
468
|
render_source = self._append_nonfinal_sentinel(stable_source)
|
|
534
469
|
|
|
535
470
|
lines, images = self._render_markdown_to_lines(render_source, apply_mark=True)
|
|
536
|
-
|
|
537
|
-
if continues_list:
|
|
538
|
-
while lines and not lines[-1].strip():
|
|
539
|
-
lines.pop()
|
|
540
471
|
return "".join(lines), images
|
|
541
472
|
|
|
542
473
|
def _append_nonfinal_sentinel(self, stable_source: str) -> str:
|
|
@@ -590,12 +521,17 @@ class MarkdownStream:
|
|
|
590
521
|
|
|
591
522
|
collected_images = getattr(markdown, "collected_images", [])
|
|
592
523
|
|
|
593
|
-
# Split rendered output into lines, strip trailing spaces, and apply left margin.
|
|
594
524
|
lines = output.splitlines(keepends=True)
|
|
595
|
-
|
|
525
|
+
use_mark = apply_mark and bool(self.mark) and self.left_margin >= 2
|
|
526
|
+
|
|
527
|
+
# Fast path: no margin, no mark -> just rstrip each line
|
|
528
|
+
if self.left_margin == 0 and not use_mark:
|
|
529
|
+
processed_lines = [line.rstrip() + "\n" if line.endswith("\n") else line.rstrip() for line in lines]
|
|
530
|
+
return processed_lines, list(collected_images)
|
|
531
|
+
|
|
532
|
+
indent_prefix = " " * self.left_margin
|
|
596
533
|
processed_lines: list[str] = []
|
|
597
534
|
mark_applied = False
|
|
598
|
-
use_mark = apply_mark and bool(self.mark) and self.left_margin >= 2
|
|
599
535
|
|
|
600
536
|
# Pre-render styled mark if needed
|
|
601
537
|
styled_mark: str | None = None
|
|
@@ -616,7 +552,7 @@ class MarkdownStream:
|
|
|
616
552
|
if use_mark and not mark_applied and stripped:
|
|
617
553
|
stripped = f"{styled_mark} {stripped}"
|
|
618
554
|
mark_applied = True
|
|
619
|
-
|
|
555
|
+
else:
|
|
620
556
|
stripped = indent_prefix + stripped
|
|
621
557
|
|
|
622
558
|
if line.endswith("\n"):
|
|
@@ -648,8 +584,6 @@ class MarkdownStream:
|
|
|
648
584
|
final=final,
|
|
649
585
|
)
|
|
650
586
|
|
|
651
|
-
continues_list = self._stable_boundary_continues_list(text, stable_line, final=final) and bool(live_source)
|
|
652
|
-
|
|
653
587
|
start = time.time()
|
|
654
588
|
|
|
655
589
|
stable_chunk_to_print: str | None = None
|
|
@@ -657,10 +591,7 @@ class MarkdownStream:
|
|
|
657
591
|
stable_changed = final or stable_line > self._stable_source_line_count
|
|
658
592
|
if stable_changed and stable_source:
|
|
659
593
|
stable_ansi, collected_images = self.render_stable_ansi(
|
|
660
|
-
stable_source,
|
|
661
|
-
has_live_suffix=bool(live_source),
|
|
662
|
-
final=final,
|
|
663
|
-
continues_list=continues_list,
|
|
594
|
+
stable_source, has_live_suffix=bool(live_source), final=final
|
|
664
595
|
)
|
|
665
596
|
stable_lines = stable_ansi.splitlines(keepends=True)
|
|
666
597
|
new_lines = stable_lines[len(self._stable_rendered_lines) :]
|
|
@@ -678,33 +609,34 @@ class MarkdownStream:
|
|
|
678
609
|
|
|
679
610
|
live_text_to_set: Text | None = None
|
|
680
611
|
if not final and MARKDOWN_STREAM_LIVE_REPAINT_ENABLED and self._live_sink is not None:
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
612
|
+
# Only update live area after we have rendered at least one stable block
|
|
613
|
+
if not self._stable_rendered_lines:
|
|
614
|
+
return
|
|
615
|
+
# When nothing is stable yet, we still want to show incremental output.
|
|
616
|
+
# Apply the mark only for the first (all-live) frame so it stays anchored
|
|
617
|
+
# to the first visible line of the full message.
|
|
618
|
+
apply_mark_to_live = stable_line == 0
|
|
619
|
+
live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_to_live)
|
|
620
|
+
|
|
621
|
+
if self._stable_rendered_lines:
|
|
622
|
+
stable_trailing_blank = 0
|
|
623
|
+
for line in reversed(self._stable_rendered_lines):
|
|
624
|
+
if line.strip():
|
|
625
|
+
break
|
|
626
|
+
stable_trailing_blank += 1
|
|
627
|
+
|
|
628
|
+
if stable_trailing_blank > 0:
|
|
629
|
+
live_leading_blank = 0
|
|
630
|
+
for line in live_lines:
|
|
692
631
|
if line.strip():
|
|
693
632
|
break
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
if stable_trailing_blank > 0:
|
|
697
|
-
live_leading_blank = 0
|
|
698
|
-
for line in live_lines:
|
|
699
|
-
if line.strip():
|
|
700
|
-
break
|
|
701
|
-
live_leading_blank += 1
|
|
633
|
+
live_leading_blank += 1
|
|
702
634
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
635
|
+
drop = min(stable_trailing_blank, live_leading_blank)
|
|
636
|
+
if drop > 0:
|
|
637
|
+
live_lines = live_lines[drop:]
|
|
706
638
|
|
|
707
|
-
|
|
639
|
+
live_text_to_set = Text.from_ansi("".join(live_lines))
|
|
708
640
|
|
|
709
641
|
with self._synchronized_output():
|
|
710
642
|
# Update/clear live area first to avoid blank padding when stable block appears
|
|
@@ -39,6 +39,7 @@ class Palette:
|
|
|
39
39
|
red_background: str
|
|
40
40
|
grey_background: str
|
|
41
41
|
yellow_background: str
|
|
42
|
+
user_message_background: str
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
LIGHT_PALETTE = Palette(
|
|
@@ -73,6 +74,7 @@ LIGHT_PALETTE = Palette(
|
|
|
73
74
|
red_background="#f9ecec",
|
|
74
75
|
grey_background="#f0f0f0",
|
|
75
76
|
yellow_background="#f9f9ec",
|
|
77
|
+
user_message_background="#f0f0f0",
|
|
76
78
|
)
|
|
77
79
|
|
|
78
80
|
DARK_PALETTE = Palette(
|
|
@@ -107,6 +109,7 @@ DARK_PALETTE = Palette(
|
|
|
107
109
|
red_background="#3d1f23",
|
|
108
110
|
grey_background="#2a2d30",
|
|
109
111
|
yellow_background="#3d3a1a",
|
|
112
|
+
user_message_background="#2a2d30",
|
|
110
113
|
)
|
|
111
114
|
|
|
112
115
|
|
|
@@ -116,6 +119,7 @@ class ThemeKey(str, Enum):
|
|
|
116
119
|
|
|
117
120
|
# CODE
|
|
118
121
|
CODE_BACKGROUND = "code_background"
|
|
122
|
+
CODE_PANEL_TITLE = "code_panel.title"
|
|
119
123
|
|
|
120
124
|
# PANEL
|
|
121
125
|
SUB_AGENT_RESULT_PANEL = "panel.sub_agent_result"
|
|
@@ -258,18 +262,18 @@ def get_theme(theme: str | None = None) -> Themes:
|
|
|
258
262
|
ThemeKey.ERROR_DIM.value: "dim " + palette.red,
|
|
259
263
|
ThemeKey.INTERRUPT.value: palette.red,
|
|
260
264
|
# USER_INPUT
|
|
261
|
-
ThemeKey.USER_INPUT.value: palette.magenta,
|
|
262
|
-
ThemeKey.USER_INPUT_PROMPT.value: "bold
|
|
263
|
-
ThemeKey.USER_INPUT_AT_PATTERN.value: palette.purple,
|
|
264
|
-
ThemeKey.USER_INPUT_SLASH_COMMAND.value: "bold
|
|
265
|
-
ThemeKey.USER_INPUT_SKILL.value: "bold
|
|
265
|
+
ThemeKey.USER_INPUT.value: f"{palette.magenta} on {palette.user_message_background}",
|
|
266
|
+
ThemeKey.USER_INPUT_PROMPT.value: f"bold {palette.magenta} on {palette.user_message_background}",
|
|
267
|
+
ThemeKey.USER_INPUT_AT_PATTERN.value: f"{palette.purple} on {palette.user_message_background}",
|
|
268
|
+
ThemeKey.USER_INPUT_SLASH_COMMAND.value: f"bold {palette.blue} on {palette.user_message_background}",
|
|
269
|
+
ThemeKey.USER_INPUT_SKILL.value: f"bold {palette.green} on {palette.user_message_background}",
|
|
266
270
|
# ASSISTANT
|
|
267
271
|
ThemeKey.ASSISTANT_MESSAGE_MARK.value: "bold",
|
|
268
272
|
# METADATA
|
|
269
|
-
ThemeKey.METADATA.value: palette.
|
|
270
|
-
ThemeKey.METADATA_DIM.value: "dim " + palette.
|
|
271
|
-
ThemeKey.METADATA_BOLD.value: "bold " + palette.
|
|
272
|
-
ThemeKey.METADATA_ITALIC.value: "italic " + palette.
|
|
273
|
+
ThemeKey.METADATA.value: palette.grey1,
|
|
274
|
+
ThemeKey.METADATA_DIM.value: "dim " + palette.grey1,
|
|
275
|
+
ThemeKey.METADATA_BOLD.value: "bold " + palette.grey1,
|
|
276
|
+
ThemeKey.METADATA_ITALIC.value: "italic " + palette.grey1,
|
|
273
277
|
# STATUS
|
|
274
278
|
ThemeKey.STATUS_SPINNER.value: palette.blue,
|
|
275
279
|
ThemeKey.STATUS_TEXT.value: palette.blue,
|
|
@@ -347,7 +351,9 @@ def get_theme(theme: str | None = None) -> Themes:
|
|
|
347
351
|
# it is used while rendering assistant output.
|
|
348
352
|
"markdown.thinking": "italic " + palette.grey2,
|
|
349
353
|
"markdown.thinking.tag": palette.grey2,
|
|
350
|
-
"markdown.code.border": palette.
|
|
354
|
+
"markdown.code.border": palette.grey2,
|
|
355
|
+
"markdown.code.fence": palette.grey2,
|
|
356
|
+
"markdown.code.fence.title": palette.grey1,
|
|
351
357
|
# Used by ThinkingMarkdown when rendering `<thinking>` blocks.
|
|
352
358
|
"markdown.code.block": palette.grey1,
|
|
353
359
|
"markdown.h1": "bold reverse " + palette.black,
|
|
@@ -362,6 +368,7 @@ def get_theme(theme: str | None = None) -> Themes:
|
|
|
362
368
|
"markdown.link_url": "underline " + palette.blue,
|
|
363
369
|
"markdown.table.border": palette.grey2,
|
|
364
370
|
"markdown.checkbox.checked": palette.green,
|
|
371
|
+
"markdown.block_quote": palette.cyan,
|
|
365
372
|
}
|
|
366
373
|
),
|
|
367
374
|
thinking_markdown_theme=Theme(
|
|
@@ -371,8 +378,10 @@ def get_theme(theme: str | None = None) -> Themes:
|
|
|
371
378
|
ThemeKey.THINKING_BOLD.value: "italic " + palette.grey1,
|
|
372
379
|
"markdown.strong": "italic " + palette.grey1,
|
|
373
380
|
"markdown.code": palette.grey1 + " italic on " + palette.code_background,
|
|
374
|
-
"markdown.code.block": palette.
|
|
375
|
-
"markdown.code.
|
|
381
|
+
"markdown.code.block": palette.grey2,
|
|
382
|
+
"markdown.code.fence": palette.grey2,
|
|
383
|
+
"markdown.code.fence.title": palette.grey1,
|
|
384
|
+
"markdown.code.border": palette.grey2,
|
|
376
385
|
"markdown.thinking.tag": palette.grey2 + " dim",
|
|
377
386
|
"markdown.h1": "bold reverse",
|
|
378
387
|
"markdown.h1.border": palette.grey3,
|
|
@@ -385,6 +394,7 @@ def get_theme(theme: str | None = None) -> Themes:
|
|
|
385
394
|
"markdown.link_url": "underline " + palette.blue,
|
|
386
395
|
"markdown.table.border": palette.grey2,
|
|
387
396
|
"markdown.checkbox.checked": palette.green,
|
|
397
|
+
"markdown.block_quote": palette.grey1,
|
|
388
398
|
}
|
|
389
399
|
),
|
|
390
400
|
code_theme=palette.code_theme,
|
|
@@ -26,38 +26,3 @@ def normalize_thinking_content(content: str) -> str:
|
|
|
26
26
|
text = text.replace("**\n\n", "** \n")
|
|
27
27
|
|
|
28
28
|
return text
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def extract_last_bold_header(text: str) -> str | None:
|
|
32
|
-
"""Extract the latest complete bold header ("**…**") from text.
|
|
33
|
-
|
|
34
|
-
We treat a bold segment as a "header" only if it appears at the beginning
|
|
35
|
-
of a line (ignoring leading whitespace). This avoids picking up incidental
|
|
36
|
-
emphasis inside paragraphs.
|
|
37
|
-
|
|
38
|
-
Returns None if no complete bold segment is available yet.
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
last: str | None = None
|
|
42
|
-
i = 0
|
|
43
|
-
while True:
|
|
44
|
-
start = text.find("**", i)
|
|
45
|
-
if start < 0:
|
|
46
|
-
break
|
|
47
|
-
|
|
48
|
-
line_start = text.rfind("\n", 0, start) + 1
|
|
49
|
-
if text[line_start:start].strip():
|
|
50
|
-
i = start + 2
|
|
51
|
-
continue
|
|
52
|
-
|
|
53
|
-
end = text.find("**", start + 2)
|
|
54
|
-
if end < 0:
|
|
55
|
-
break
|
|
56
|
-
|
|
57
|
-
inner = " ".join(text[start + 2 : end].split())
|
|
58
|
-
if inner and "\n" not in inner:
|
|
59
|
-
last = inner
|
|
60
|
-
|
|
61
|
-
i = end + 2
|
|
62
|
-
|
|
63
|
-
return last
|