klaude-code 1.2.24__py3-none-any.whl → 1.2.26__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/thinking_cmd.py +37 -28
- klaude_code/const.py +8 -6
- klaude_code/core/executor.py +45 -2
- klaude_code/core/tool/file/apply_patch_tool.py +26 -3
- klaude_code/protocol/model.py +24 -1
- klaude_code/protocol/op.py +11 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/ui/modes/repl/display.py +2 -0
- klaude_code/ui/modes/repl/event_handler.py +23 -12
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +12 -1
- klaude_code/ui/modes/repl/renderer.py +104 -12
- klaude_code/ui/renderers/sub_agent.py +14 -12
- klaude_code/ui/renderers/thinking.py +1 -1
- 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 +192 -109
- klaude_code/ui/rich/status.py +5 -11
- klaude_code/ui/rich/theme.py +1 -4
- {klaude_code-1.2.24.dist-info → klaude_code-1.2.26.dist-info}/METADATA +2 -1
- {klaude_code-1.2.24.dist-info → klaude_code-1.2.26.dist-info}/RECORD +24 -24
- {klaude_code-1.2.24.dist-info → klaude_code-1.2.26.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.24.dist-info → klaude_code-1.2.26.dist-info}/entry_points.txt +0 -0
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
from collections.abc import Iterator
|
|
4
5
|
from contextlib import contextmanager
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
|
-
from rich.console import Console
|
|
9
|
+
from rich.console import Console, Group, RenderableType
|
|
10
|
+
from rich.padding import Padding
|
|
9
11
|
from rich.spinner import Spinner
|
|
10
|
-
from rich.status import Status
|
|
11
12
|
from rich.style import Style, StyleType
|
|
12
13
|
from rich.text import Text
|
|
13
14
|
|
|
@@ -23,8 +24,9 @@ from klaude_code.ui.renderers import tools as r_tools
|
|
|
23
24
|
from klaude_code.ui.renderers import user_input as r_user_input
|
|
24
25
|
from klaude_code.ui.renderers.common import truncate_display
|
|
25
26
|
from klaude_code.ui.rich import status as r_status
|
|
27
|
+
from klaude_code.ui.rich.live import CropAboveLive, SingleLine
|
|
26
28
|
from klaude_code.ui.rich.quote import Quote
|
|
27
|
-
from klaude_code.ui.rich.status import ShimmerStatusText
|
|
29
|
+
from klaude_code.ui.rich.status import BreathingSpinner, ShimmerStatusText
|
|
28
30
|
from klaude_code.ui.rich.theme import ThemeKey, get_theme
|
|
29
31
|
|
|
30
32
|
|
|
@@ -42,10 +44,18 @@ class REPLRenderer:
|
|
|
42
44
|
self.themes = get_theme(theme)
|
|
43
45
|
self.console: Console = Console(theme=self.themes.app_theme)
|
|
44
46
|
self.console.push_theme(self.themes.markdown_theme)
|
|
45
|
-
self.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
self._bottom_live: CropAboveLive | None = None
|
|
48
|
+
self._stream_renderable: RenderableType | None = None
|
|
49
|
+
self._stream_max_height: int = 0
|
|
50
|
+
self._stream_last_height: int = 0
|
|
51
|
+
self._stream_last_width: int = 0
|
|
52
|
+
self._spinner_visible: bool = False
|
|
53
|
+
|
|
54
|
+
self._status_text: ShimmerStatusText = ShimmerStatusText(const.STATUS_DEFAULT_TEXT)
|
|
55
|
+
self._status_spinner: Spinner = BreathingSpinner(
|
|
56
|
+
r_status.spinner_name(),
|
|
57
|
+
text=SingleLine(self._status_text),
|
|
58
|
+
style=ThemeKey.STATUS_SPINNER,
|
|
49
59
|
)
|
|
50
60
|
|
|
51
61
|
self.session_map: dict[str, SessionStatus] = {}
|
|
@@ -235,7 +245,11 @@ class REPLRenderer:
|
|
|
235
245
|
def display_task_finish(self, event: events.TaskFinishEvent) -> None:
|
|
236
246
|
if self.is_sub_agent_session(event.session_id):
|
|
237
247
|
session_status = self.session_map.get(event.session_id)
|
|
238
|
-
description =
|
|
248
|
+
description = (
|
|
249
|
+
session_status.sub_agent_state.sub_agent_desc
|
|
250
|
+
if session_status and session_status.sub_agent_state
|
|
251
|
+
else None
|
|
252
|
+
)
|
|
239
253
|
panel_style = self.get_session_sub_agent_background(event.session_id)
|
|
240
254
|
with self.session_print_context(event.session_id):
|
|
241
255
|
self.print(
|
|
@@ -265,16 +279,94 @@ class REPLRenderer:
|
|
|
265
279
|
|
|
266
280
|
def spinner_start(self) -> None:
|
|
267
281
|
"""Start the spinner animation."""
|
|
268
|
-
self.
|
|
282
|
+
self._spinner_visible = True
|
|
283
|
+
self._ensure_bottom_live_started()
|
|
284
|
+
self._refresh_bottom_live()
|
|
269
285
|
|
|
270
286
|
def spinner_stop(self) -> None:
|
|
271
287
|
"""Stop the spinner animation."""
|
|
272
|
-
self.
|
|
288
|
+
self._spinner_visible = False
|
|
289
|
+
self._refresh_bottom_live()
|
|
273
290
|
|
|
274
291
|
def spinner_update(self, status_text: str | Text, right_text: Text | None = None) -> None:
|
|
275
292
|
"""Update the spinner status text with optional right-aligned text."""
|
|
276
|
-
self.
|
|
293
|
+
self._status_text = ShimmerStatusText(status_text, right_text)
|
|
294
|
+
self._status_spinner.update(text=SingleLine(self._status_text), style=ThemeKey.STATUS_SPINNER)
|
|
295
|
+
self._refresh_bottom_live()
|
|
277
296
|
|
|
278
297
|
def spinner_renderable(self) -> Spinner:
|
|
279
298
|
"""Return the spinner's renderable for embedding in other components."""
|
|
280
|
-
return self.
|
|
299
|
+
return self._status_spinner
|
|
300
|
+
|
|
301
|
+
def set_stream_renderable(self, renderable: RenderableType | None) -> None:
|
|
302
|
+
"""Set the current streaming renderable displayed above the status line."""
|
|
303
|
+
|
|
304
|
+
if renderable is None:
|
|
305
|
+
self._stream_renderable = None
|
|
306
|
+
self._stream_max_height = 0
|
|
307
|
+
self._stream_last_height = 0
|
|
308
|
+
self._stream_last_width = 0
|
|
309
|
+
self._refresh_bottom_live()
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
self._ensure_bottom_live_started()
|
|
313
|
+
self._stream_renderable = renderable
|
|
314
|
+
|
|
315
|
+
height = len(self.console.render_lines(renderable, self.console.options, pad=False))
|
|
316
|
+
self._stream_last_height = height
|
|
317
|
+
self._stream_last_width = self.console.size.width
|
|
318
|
+
self._stream_max_height = max(self._stream_max_height, height)
|
|
319
|
+
self._refresh_bottom_live()
|
|
320
|
+
|
|
321
|
+
def _ensure_bottom_live_started(self) -> None:
|
|
322
|
+
if self._bottom_live is not None:
|
|
323
|
+
return
|
|
324
|
+
self._bottom_live = CropAboveLive(
|
|
325
|
+
Text(""),
|
|
326
|
+
console=self.console,
|
|
327
|
+
refresh_per_second=30,
|
|
328
|
+
transient=True,
|
|
329
|
+
redirect_stdout=False,
|
|
330
|
+
redirect_stderr=False,
|
|
331
|
+
)
|
|
332
|
+
self._bottom_live.start()
|
|
333
|
+
|
|
334
|
+
def _bottom_renderable(self) -> RenderableType:
|
|
335
|
+
stream_part: RenderableType = Group()
|
|
336
|
+
gap_part: RenderableType = Group()
|
|
337
|
+
|
|
338
|
+
if const.MARKDOWN_STREAM_LIVE_REPAINT_ENABLED:
|
|
339
|
+
stream = self._stream_renderable
|
|
340
|
+
if stream is not None:
|
|
341
|
+
current_width = self.console.size.width
|
|
342
|
+
if self._stream_last_width != current_width:
|
|
343
|
+
height = len(self.console.render_lines(stream, self.console.options, pad=False))
|
|
344
|
+
self._stream_last_height = height
|
|
345
|
+
self._stream_last_width = current_width
|
|
346
|
+
self._stream_max_height = max(self._stream_max_height, height)
|
|
347
|
+
else:
|
|
348
|
+
height = self._stream_last_height
|
|
349
|
+
|
|
350
|
+
pad_lines = max(self._stream_max_height - height, 0)
|
|
351
|
+
if pad_lines:
|
|
352
|
+
stream = Padding(stream, (0, 0, pad_lines, 0))
|
|
353
|
+
stream_part = stream
|
|
354
|
+
|
|
355
|
+
gap_part = Text("") if self._spinner_visible else Group()
|
|
356
|
+
|
|
357
|
+
status_part: RenderableType = SingleLine(self._status_spinner) if self._spinner_visible else Group()
|
|
358
|
+
return Group(stream_part, gap_part, status_part)
|
|
359
|
+
|
|
360
|
+
def _refresh_bottom_live(self) -> None:
|
|
361
|
+
if self._bottom_live is None:
|
|
362
|
+
return
|
|
363
|
+
self._bottom_live.update(self._bottom_renderable(), refresh=True)
|
|
364
|
+
|
|
365
|
+
def stop_bottom_live(self) -> None:
|
|
366
|
+
if self._bottom_live is None:
|
|
367
|
+
return
|
|
368
|
+
with contextlib.suppress(Exception):
|
|
369
|
+
# Avoid cursor restore when stopping right before prompt_toolkit.
|
|
370
|
+
self._bottom_live.transient = False
|
|
371
|
+
self._bottom_live.stop()
|
|
372
|
+
self._bottom_live = None
|
|
@@ -68,24 +68,22 @@ def render_sub_agent_result(
|
|
|
68
68
|
panel_style: Style | None = None,
|
|
69
69
|
) -> RenderableType:
|
|
70
70
|
stripped_result = result.strip()
|
|
71
|
-
|
|
72
|
-
# Add markdown heading if description is provided
|
|
73
|
-
if description:
|
|
74
|
-
stripped_result = f"# {description}\n\n{stripped_result}"
|
|
75
|
-
|
|
76
71
|
result_panel_style = panel_style or ThemeKey.SUB_AGENT_RESULT_PANEL
|
|
77
72
|
|
|
78
73
|
# Use rich JSON for structured output
|
|
79
74
|
if has_structured_output:
|
|
80
75
|
try:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
style=ThemeKey.TOOL_RESULT,
|
|
86
|
-
),
|
|
87
|
-
JSON(stripped_result),
|
|
76
|
+
group_elements: list[RenderableType] = [
|
|
77
|
+
Text(
|
|
78
|
+
"use /export to view full output",
|
|
79
|
+
style=ThemeKey.TOOL_RESULT,
|
|
88
80
|
),
|
|
81
|
+
JSON(stripped_result),
|
|
82
|
+
]
|
|
83
|
+
if description:
|
|
84
|
+
group_elements.insert(0, NoInsetMarkdown(f"# {description}", code_theme=code_theme, style=style or ""))
|
|
85
|
+
return Panel.fit(
|
|
86
|
+
Group(*group_elements),
|
|
89
87
|
box=box.SIMPLE,
|
|
90
88
|
border_style=ThemeKey.LINES,
|
|
91
89
|
style=result_panel_style,
|
|
@@ -94,6 +92,10 @@ def render_sub_agent_result(
|
|
|
94
92
|
# Fall back to markdown if not valid JSON
|
|
95
93
|
pass
|
|
96
94
|
|
|
95
|
+
# Add markdown heading if description is provided for non-structured output
|
|
96
|
+
if description:
|
|
97
|
+
stripped_result = f"# {description}\n\n{stripped_result}"
|
|
98
|
+
|
|
97
99
|
lines = stripped_result.splitlines()
|
|
98
100
|
if len(lines) > const.SUB_AGENT_RESULT_MAX_LINES:
|
|
99
101
|
hidden_count = len(lines) - const.SUB_AGENT_RESULT_MAX_LINES
|
|
@@ -19,7 +19,7 @@ from klaude_code.ui.rich.theme import ThemeKey
|
|
|
19
19
|
# Tool markers (Unicode symbols for UI display)
|
|
20
20
|
MARK_GENERIC = "⚒"
|
|
21
21
|
MARK_BASH = "→"
|
|
22
|
-
MARK_PLAN = "
|
|
22
|
+
MARK_PLAN = "Ξ"
|
|
23
23
|
MARK_READ = "←"
|
|
24
24
|
MARK_EDIT = "±"
|
|
25
25
|
MARK_WRITE = "+"
|
|
@@ -528,19 +528,28 @@ def render_tool_call(e: events.ToolCallEvent) -> RenderableType | None:
|
|
|
528
528
|
def _extract_diff(ui_extra: model.ToolResultUIExtra | None) -> model.DiffUIExtra | None:
|
|
529
529
|
if isinstance(ui_extra, model.DiffUIExtra):
|
|
530
530
|
return ui_extra
|
|
531
|
+
if isinstance(ui_extra, model.MultiUIExtra):
|
|
532
|
+
for item in ui_extra.items:
|
|
533
|
+
if isinstance(item, model.DiffUIExtra):
|
|
534
|
+
return item
|
|
531
535
|
return None
|
|
532
536
|
|
|
533
537
|
|
|
534
538
|
def _extract_markdown_doc(ui_extra: model.ToolResultUIExtra | None) -> model.MarkdownDocUIExtra | None:
|
|
535
539
|
if isinstance(ui_extra, model.MarkdownDocUIExtra):
|
|
536
540
|
return ui_extra
|
|
541
|
+
if isinstance(ui_extra, model.MultiUIExtra):
|
|
542
|
+
for item in ui_extra.items:
|
|
543
|
+
if isinstance(item, model.MarkdownDocUIExtra):
|
|
544
|
+
return item
|
|
537
545
|
return None
|
|
538
546
|
|
|
539
547
|
|
|
540
548
|
def render_markdown_doc(md_ui: model.MarkdownDocUIExtra, *, code_theme: str) -> RenderableType:
|
|
541
549
|
"""Render markdown document content in a panel."""
|
|
550
|
+
header = render_path(md_ui.file_path, ThemeKey.TOOL_PARAM_FILE_PATH)
|
|
542
551
|
return Panel.fit(
|
|
543
|
-
NoInsetMarkdown(md_ui.content, code_theme=code_theme),
|
|
552
|
+
Group(header, Text(""), NoInsetMarkdown(md_ui.content, code_theme=code_theme)),
|
|
544
553
|
box=box.SIMPLE,
|
|
545
554
|
border_style=ThemeKey.LINES,
|
|
546
555
|
style=ThemeKey.WRITE_MARKDOWN_PANEL,
|
|
@@ -562,6 +571,19 @@ def render_tool_result(e: events.ToolResultEvent, *, code_theme: str = "monokai"
|
|
|
562
571
|
error_msg = truncate_display(e.result)
|
|
563
572
|
return r_errors.render_error(error_msg)
|
|
564
573
|
|
|
574
|
+
# Render multiple ui blocks if present
|
|
575
|
+
if isinstance(e.ui_extra, model.MultiUIExtra) and e.ui_extra.items:
|
|
576
|
+
rendered: list[RenderableType] = []
|
|
577
|
+
for item in e.ui_extra.items:
|
|
578
|
+
if isinstance(item, model.MarkdownDocUIExtra):
|
|
579
|
+
rendered.append(Padding.indent(render_markdown_doc(item, code_theme=code_theme), level=2))
|
|
580
|
+
elif isinstance(item, model.DiffUIExtra):
|
|
581
|
+
show_file_name = e.tool_name == tools.APPLY_PATCH
|
|
582
|
+
rendered.append(
|
|
583
|
+
Padding.indent(r_diffs.render_structured_diff(item, show_file_name=show_file_name), level=2)
|
|
584
|
+
)
|
|
585
|
+
return Group(*rendered) if rendered else None
|
|
586
|
+
|
|
565
587
|
# Show truncation info if output was truncated and saved to file
|
|
566
588
|
truncation_info = get_truncation_info(e)
|
|
567
589
|
if truncation_info:
|
|
@@ -580,6 +602,8 @@ def render_tool_result(e: events.ToolResultEvent, *, code_theme: str = "monokai"
|
|
|
580
602
|
return Padding.indent(render_markdown_doc(md_ui, code_theme=code_theme), level=2)
|
|
581
603
|
return Padding.indent(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""), level=2)
|
|
582
604
|
case tools.APPLY_PATCH:
|
|
605
|
+
if md_ui:
|
|
606
|
+
return Padding.indent(render_markdown_doc(md_ui, code_theme=code_theme), level=2)
|
|
583
607
|
if diff_ui:
|
|
584
608
|
return Padding.indent(r_diffs.render_structured_diff(diff_ui, show_file_name=True), level=2)
|
|
585
609
|
if len(e.result.strip()) == 0:
|
|
@@ -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]
|