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.
@@ -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._spinner: Status = self.console.status(
46
- ShimmerStatusText(const.STATUS_DEFAULT_TEXT),
47
- spinner=r_status.spinner_name(),
48
- spinner_style=ThemeKey.STATUS_SPINNER,
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 = session_status.sub_agent_state.sub_agent_desc if session_status and session_status.sub_agent_state else None
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._spinner.start()
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._spinner.stop()
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._spinner.update(ShimmerStatusText(status_text, right_text))
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._spinner.renderable
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
- return Panel.fit(
82
- Group(
83
- Text(
84
- "use /export to view full output",
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
@@ -10,7 +10,7 @@ from klaude_code.ui.rich.markdown import ThinkingMarkdown
10
10
  from klaude_code.ui.rich.theme import ThemeKey
11
11
 
12
12
  # UI markers
13
- THINKING_MESSAGE_MARK = ""
13
+ THINKING_MESSAGE_MARK = ""
14
14
 
15
15
 
16
16
  def normalize_thinking_content(content: str) -> str:
@@ -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, 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]