klaude-code 1.2.23__py3-none-any.whl → 1.2.25__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. klaude_code/cli/runtime.py +17 -1
  2. klaude_code/command/prompt-jj-describe.md +32 -0
  3. klaude_code/command/thinking_cmd.py +37 -28
  4. klaude_code/{const/__init__.py → const.py} +7 -6
  5. klaude_code/core/executor.py +46 -3
  6. klaude_code/core/tool/file/read_tool.py +23 -1
  7. klaude_code/core/tool/file/write_tool.py +7 -3
  8. klaude_code/llm/openai_compatible/client.py +29 -102
  9. klaude_code/llm/openai_compatible/stream.py +272 -0
  10. klaude_code/llm/openrouter/client.py +29 -109
  11. klaude_code/llm/openrouter/{reasoning_handler.py → reasoning.py} +24 -2
  12. klaude_code/protocol/model.py +13 -1
  13. klaude_code/protocol/op.py +11 -0
  14. klaude_code/protocol/op_handler.py +5 -0
  15. klaude_code/ui/core/stage_manager.py +0 -3
  16. klaude_code/ui/modes/repl/display.py +2 -0
  17. klaude_code/ui/modes/repl/event_handler.py +97 -57
  18. klaude_code/ui/modes/repl/input_prompt_toolkit.py +25 -4
  19. klaude_code/ui/modes/repl/renderer.py +119 -25
  20. klaude_code/ui/renderers/assistant.py +1 -1
  21. klaude_code/ui/renderers/metadata.py +2 -6
  22. klaude_code/ui/renderers/sub_agent.py +28 -5
  23. klaude_code/ui/renderers/thinking.py +16 -10
  24. klaude_code/ui/renderers/tools.py +26 -2
  25. klaude_code/ui/rich/code_panel.py +24 -5
  26. klaude_code/ui/rich/live.py +17 -0
  27. klaude_code/ui/rich/markdown.py +185 -107
  28. klaude_code/ui/rich/status.py +19 -17
  29. klaude_code/ui/rich/theme.py +63 -12
  30. {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/METADATA +2 -1
  31. {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/RECORD +33 -32
  32. klaude_code/llm/openai_compatible/stream_processor.py +0 -83
  33. {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/WHEEL +0 -0
  34. {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/entry_points.txt +0 -0
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
 
5
+ from rich.cells import cell_len
5
6
  from rich.rule import Rule
6
7
  from rich.text import Text
7
8
 
@@ -10,13 +11,48 @@ from klaude_code.protocol import events
10
11
  from klaude_code.ui.core.stage_manager import Stage, StageManager
11
12
  from klaude_code.ui.modes.repl.renderer import REPLRenderer
12
13
  from klaude_code.ui.renderers.assistant import ASSISTANT_MESSAGE_MARK
13
- from klaude_code.ui.renderers.thinking import normalize_thinking_content
14
+ from klaude_code.ui.renderers.thinking import THINKING_MESSAGE_MARK, normalize_thinking_content
14
15
  from klaude_code.ui.rich.markdown import MarkdownStream, ThinkingMarkdown
15
16
  from klaude_code.ui.rich.theme import ThemeKey
16
17
  from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier
17
18
  from klaude_code.ui.terminal.progress_bar import OSC94States, emit_osc94
18
19
 
19
20
 
21
+ def extract_last_bold_header(text: str) -> str | None:
22
+ """Extract the latest complete bold header ("**...**") from text.
23
+
24
+ We treat a bold segment as a "header" only if it appears at the beginning
25
+ of a line (ignoring leading whitespace). This avoids picking up incidental
26
+ emphasis inside paragraphs.
27
+
28
+ Returns None if no complete bold segment is available yet.
29
+ """
30
+
31
+ last: str | None = None
32
+ i = 0
33
+ while True:
34
+ start = text.find("**", i)
35
+ if start < 0:
36
+ break
37
+
38
+ line_start = text.rfind("\n", 0, start) + 1
39
+ if text[line_start:start].strip():
40
+ i = start + 2
41
+ continue
42
+
43
+ end = text.find("**", start + 2)
44
+ if end < 0:
45
+ break
46
+
47
+ inner = " ".join(text[start + 2 : end].split())
48
+ if inner and "\n" not in inner:
49
+ last = inner
50
+
51
+ i = end + 2
52
+
53
+ return last
54
+
55
+
20
56
  @dataclass
21
57
  class ActiveStream:
22
58
  """Active streaming state containing buffer and markdown renderer.
@@ -117,7 +153,7 @@ class ActivityState:
117
153
  for name, count in self._tool_calls.items():
118
154
  if not first:
119
155
  activity_text.append(", ")
120
- activity_text.append(Text(name, style=ThemeKey.SPINNER_STATUS_TEXT_BOLD))
156
+ activity_text.append(Text(name, style=ThemeKey.STATUS_TEXT_BOLD))
121
157
  if count > 1:
122
158
  activity_text.append(f" x {count}")
123
159
  first = False
@@ -130,8 +166,9 @@ class ActivityState:
130
166
  class SpinnerStatusState:
131
167
  """Multi-layer spinner status state management.
132
168
 
133
- Composed of two independent layers:
134
- - base_status: Set by TodoChange, persistent within a turn
169
+ Layers:
170
+ - todo_status: Set by TodoChange (preferred when present)
171
+ - reasoning_status: Derived from Thinking/ThinkingDelta bold headers
135
172
  - activity: Current activity (composing or tool_calls), mutually exclusive
136
173
  - context_percent: Context usage percentage, updated during task execution
137
174
 
@@ -142,25 +179,31 @@ class SpinnerStatusState:
142
179
  - Context percent is appended at the end if available
143
180
  """
144
181
 
145
- DEFAULT_STATUS = "Thinking …"
146
-
147
182
  def __init__(self) -> None:
148
- self._base_status: str | None = None
183
+ self._todo_status: str | None = None
184
+ self._reasoning_status: str | None = None
149
185
  self._activity = ActivityState()
150
186
  self._context_percent: float | None = None
151
187
 
152
188
  def reset(self) -> None:
153
189
  """Reset all layers."""
154
- self._base_status = None
190
+ self._todo_status = None
191
+ self._reasoning_status = None
155
192
  self._activity.reset()
156
193
  self._context_percent = None
157
194
 
158
- def set_base_status(self, status: str | None) -> None:
195
+ def set_todo_status(self, status: str | None) -> None:
159
196
  """Set base status from TodoChange."""
160
- self._base_status = status
197
+ self._todo_status = status
198
+
199
+ def set_reasoning_status(self, status: str | None) -> None:
200
+ """Set reasoning-derived base status from ThinkingDelta bold headers."""
201
+ self._reasoning_status = status
161
202
 
162
203
  def set_composing(self, composing: bool) -> None:
163
204
  """Set composing state when assistant is streaming."""
205
+ if composing:
206
+ self._reasoning_status = None
164
207
  self._activity.set_composing(composing)
165
208
 
166
209
  def add_tool_call(self, tool_name: str) -> None:
@@ -187,8 +230,10 @@ class SpinnerStatusState:
187
230
  """Get current spinner status as rich Text (without context)."""
188
231
  activity_text = self._activity.get_activity_text()
189
232
 
190
- if self._base_status:
191
- result = Text(self._base_status)
233
+ base_status = self._todo_status or self._reasoning_status
234
+
235
+ if base_status:
236
+ result = Text(base_status, style=ThemeKey.STATUS_TEXT_BOLD)
192
237
  if activity_text:
193
238
  result.append(" | ")
194
239
  result.append_text(activity_text)
@@ -196,7 +241,7 @@ class SpinnerStatusState:
196
241
  activity_text.append(" …")
197
242
  result = activity_text
198
243
  else:
199
- result = Text(self.DEFAULT_STATUS)
244
+ result = Text(const.STATUS_DEFAULT_TEXT, style=ThemeKey.STATUS_TEXT)
200
245
 
201
246
  return result
202
247
 
@@ -220,7 +265,6 @@ class DisplayEventHandler:
220
265
  self.stage_manager = StageManager(
221
266
  finish_assistant=self._finish_assistant_stream,
222
267
  finish_thinking=self._finish_thinking_stream,
223
- on_enter_thinking=self._print_thinking_prefix,
224
268
  )
225
269
 
226
270
  async def consume_event(self, event: events.Event) -> None:
@@ -311,6 +355,10 @@ class DisplayEventHandler:
311
355
  await self._finish_thinking_stream()
312
356
  else:
313
357
  # Non-streaming path (history replay or models without delta support)
358
+ reasoning_status = extract_last_bold_header(normalize_thinking_content(event.content))
359
+ if reasoning_status:
360
+ self.spinner_status.set_reasoning_status(reasoning_status)
361
+ self._update_spinner()
314
362
  await self.stage_manager.enter_thinking_stage()
315
363
  self.renderer.display_thinking(event.content)
316
364
 
@@ -320,23 +368,28 @@ class DisplayEventHandler:
320
368
 
321
369
  first_delta = not self.thinking_stream.is_active
322
370
  if first_delta:
323
- self.renderer.console.push_theme(self.renderer.themes.thinking_markdown_theme)
324
371
  mdstream = MarkdownStream(
325
372
  mdargs={
326
373
  "code_theme": self.renderer.themes.code_theme,
327
- "style": self.renderer.console.get_style(ThemeKey.THINKING),
374
+ "style": ThemeKey.THINKING,
328
375
  },
329
376
  theme=self.renderer.themes.thinking_markdown_theme,
330
377
  console=self.renderer.console,
331
- spinner=self.renderer.spinner_renderable(),
378
+ live_sink=self.renderer.set_stream_renderable,
379
+ mark=THINKING_MESSAGE_MARK,
380
+ mark_style=ThemeKey.THINKING,
332
381
  left_margin=const.MARKDOWN_LEFT_MARGIN,
333
382
  markdown_class=ThinkingMarkdown,
334
383
  )
335
384
  self.thinking_stream.start(mdstream)
336
- self.renderer.spinner_stop()
337
385
 
338
386
  self.thinking_stream.append(event.content)
339
387
 
388
+ reasoning_status = extract_last_bold_header(normalize_thinking_content(self.thinking_stream.buffer))
389
+ if reasoning_status:
390
+ self.spinner_status.set_reasoning_status(reasoning_status)
391
+ self._update_spinner()
392
+
340
393
  if first_delta and self.thinking_stream.mdstream is not None:
341
394
  self.thinking_stream.mdstream.update(normalize_thinking_content(self.thinking_stream.buffer))
342
395
 
@@ -359,17 +412,13 @@ class DisplayEventHandler:
359
412
  mdargs={"code_theme": self.renderer.themes.code_theme},
360
413
  theme=self.renderer.themes.markdown_theme,
361
414
  console=self.renderer.console,
362
- spinner=self.renderer.spinner_renderable(),
415
+ live_sink=self.renderer.set_stream_renderable,
363
416
  mark=ASSISTANT_MESSAGE_MARK,
364
417
  left_margin=const.MARKDOWN_LEFT_MARGIN,
365
418
  )
366
419
  self.assistant_stream.start(mdstream)
367
420
  self.assistant_stream.append(event.content)
368
421
  if first_delta and self.assistant_stream.mdstream is not None:
369
- # Stop spinner and immediately start MarkdownStream's Live
370
- # to avoid flicker. The update() call starts the Live with
371
- # the spinner embedded, providing seamless transition.
372
- self.renderer.spinner_stop()
373
422
  self.assistant_stream.mdstream.update(self.assistant_stream.buffer)
374
423
  await self.stage_manager.transition_to(Stage.ASSISTANT)
375
424
  await self._flush_assistant_buffer(self.assistant_stream)
@@ -415,7 +464,7 @@ class DisplayEventHandler:
415
464
 
416
465
  def _on_todo_change(self, event: events.TodoChangeEvent) -> None:
417
466
  active_form_status_text = self._extract_active_form_text(event)
418
- self.spinner_status.set_base_status(active_form_status_text if active_form_status_text else None)
467
+ self.spinner_status.set_todo_status(active_form_status_text if active_form_status_text else None)
419
468
  # Clear tool calls when todo changes, as the tool execution has advanced
420
469
  self.spinner_status.clear_for_new_turn()
421
470
  self._update_spinner()
@@ -433,7 +482,6 @@ class DisplayEventHandler:
433
482
  self.spinner_status.reset()
434
483
  self.renderer.spinner_stop()
435
484
  self.renderer.console.print(Rule(characters="-", style=ThemeKey.LINES))
436
- self.renderer.print()
437
485
  await self.stage_manager.transition_to(Stage.WAITING)
438
486
  self._maybe_notify_task_finish(event)
439
487
 
@@ -469,14 +517,14 @@ class DisplayEventHandler:
469
517
  mdstream.update(self.assistant_stream.buffer, final=True)
470
518
  self.assistant_stream.finish()
471
519
 
472
- def _print_thinking_prefix(self) -> None:
473
- self.renderer.display_thinking_prefix()
474
-
475
520
  def _update_spinner(self) -> None:
476
521
  """Update spinner text from current status state."""
522
+ status_text = self.spinner_status.get_status()
523
+ context_text = self.spinner_status.get_context_text()
524
+ status_text = self._truncate_spinner_status_text(status_text, right_text=context_text)
477
525
  self.renderer.spinner_update(
478
- self.spinner_status.get_status(),
479
- self.spinner_status.get_context_text(),
526
+ status_text,
527
+ context_text,
480
528
  )
481
529
 
482
530
  async def _flush_assistant_buffer(self, state: StreamState) -> None:
@@ -497,7 +545,6 @@ class DisplayEventHandler:
497
545
  assert mdstream is not None
498
546
  mdstream.update(normalize_thinking_content(self.thinking_stream.buffer), final=True)
499
547
  self.thinking_stream.finish()
500
- self.renderer.console.pop_theme()
501
548
  self.renderer.print()
502
549
  self.renderer.spinner_start()
503
550
 
@@ -534,35 +581,28 @@ class DisplayEventHandler:
534
581
  status_text = todo.active_form
535
582
  if len(todo.content) > 0:
536
583
  status_text = todo.content
537
- status_text = status_text.replace("\n", "")
538
- max_length = self._calculate_base_status_max_length()
539
- return self._truncate_status_text(status_text, max_length=max_length)
540
-
541
- def _calculate_base_status_max_length(self) -> int:
542
- """Calculate max length for base_status based on terminal width.
543
-
544
- Reserve space for:
545
- - Spinner glyph + space + context text: 2 chars + context text length 10 chars
546
- - " | " separator: 3 chars (only if activity text present)
547
- - Activity text: actual length (only if present)
548
- - Status hint text (esc to interrupt)
584
+ return status_text.replace("\n", " ").strip()
585
+
586
+ def _truncate_spinner_status_text(self, status_text: Text, *, right_text: Text | None) -> Text:
587
+ """Truncate spinner status to a single line based on terminal width.
588
+
589
+ Rich wraps based on terminal cell width (CJK chars count as 2). Use
590
+ cell-aware truncation to prevent the status from wrapping into two lines.
549
591
  """
592
+
550
593
  terminal_width = self.renderer.console.size.width
551
594
 
552
- # Base reserved space: spinner + context + status hint
553
- reserved_space = 12 + len(const.STATUS_HINT_TEXT)
595
+ # BreathingSpinner renders as a 2-column Table.grid(padding=1):
596
+ # 1 cell for glyph + 1 cell of padding between columns (collapsed).
597
+ spinner_prefix_cells = 2
554
598
 
555
- # Add space for activity text if present
556
- activity_text = self.spinner_status.get_activity_text()
557
- if activity_text:
558
- # " | " separator + actual activity text length
559
- reserved_space += 3 + len(activity_text.plain)
599
+ hint_cells = cell_len(const.STATUS_HINT_TEXT)
600
+ right_cells = cell_len(right_text.plain) if right_text is not None else 0
560
601
 
561
- max_length = max(10, terminal_width - reserved_space)
562
- return max_length
602
+ max_main_cells = terminal_width - spinner_prefix_cells - hint_cells - right_cells - 1
603
+ # rich.text.Text.truncate behaves unexpectedly for 0; clamp to at least 1.
604
+ max_main_cells = max(1, max_main_cells)
563
605
 
564
- def _truncate_status_text(self, text: str, max_length: int) -> str:
565
- if len(text) <= max_length:
566
- return text
567
- truncated = text[:max_length]
568
- return truncated + "…"
606
+ truncated = status_text.copy()
607
+ truncated.truncate(max_main_cells, overflow="ellipsis", pad=False)
608
+ return truncated
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  import shutil
4
5
  from collections.abc import AsyncIterator, Callable
5
6
  from pathlib import Path
@@ -33,7 +34,9 @@ class REPLStatusSnapshot(NamedTuple):
33
34
  update_message: str | None = None
34
35
 
35
36
 
36
- COMPLETION_SELECTED = "#5869f7"
37
+ COMPLETION_SELECTED_DARK_BG = "#8b9bff"
38
+ COMPLETION_SELECTED_LIGHT_BG = "#5869f7"
39
+ COMPLETION_SELECTED_UNKNOWN_BG = "#7080f0"
37
40
  COMPLETION_MENU = "ansibrightblack"
38
41
  INPUT_PROMPT_STYLE = "ansimagenta bold"
39
42
  PLACEHOLDER_TEXT_STYLE_DARK_BG = "fg:#5a5a5a italic"
@@ -49,8 +52,12 @@ class PromptToolkitInput(InputProviderABC):
49
52
  self,
50
53
  prompt: str = USER_MESSAGE_MARK,
51
54
  status_provider: Callable[[], REPLStatusSnapshot] | None = None,
55
+ pre_prompt: Callable[[], None] | None = None,
56
+ post_prompt: Callable[[], None] | None = None,
52
57
  ): # ▌
53
58
  self._status_provider = status_provider
59
+ self._pre_prompt = pre_prompt
60
+ self._post_prompt = post_prompt
54
61
  self._is_light_terminal_background = is_light_terminal_background(timeout=0.2)
55
62
 
56
63
  project = str(Path.cwd()).strip("/").replace("/", "-")
@@ -66,11 +73,19 @@ class PromptToolkitInput(InputProviderABC):
66
73
  at_token_pattern=AT_TOKEN_PATTERN,
67
74
  )
68
75
 
76
+ # Select completion selected color based on terminal background
77
+ if self._is_light_terminal_background is True:
78
+ completion_selected = COMPLETION_SELECTED_LIGHT_BG
79
+ elif self._is_light_terminal_background is False:
80
+ completion_selected = COMPLETION_SELECTED_DARK_BG
81
+ else:
82
+ completion_selected = COMPLETION_SELECTED_UNKNOWN_BG
83
+
69
84
  self._session: PromptSession[str] = PromptSession(
70
85
  [(INPUT_PROMPT_STYLE, prompt)],
71
86
  history=FileHistory(str(history_path)),
72
87
  multiline=True,
73
- cursor=CursorShape.BEAM,
88
+ cursor=CursorShape.BLINKING_BEAM,
74
89
  prompt_continuation=[(INPUT_PROMPT_STYLE, " ")],
75
90
  key_bindings=kb,
76
91
  completer=ThreadedCompleter(create_repl_completer()),
@@ -86,8 +101,8 @@ class PromptToolkitInput(InputProviderABC):
86
101
  "scrollbar.button": "bg:default",
87
102
  "completion-menu.completion": f"bg:default fg:{COMPLETION_MENU}",
88
103
  "completion-menu.meta.completion": f"bg:default fg:{COMPLETION_MENU}",
89
- "completion-menu.completion.current": f"noreverse bg:default fg:{COMPLETION_SELECTED} bold",
90
- "completion-menu.meta.completion.current": f"bg:default fg:{COMPLETION_SELECTED} bold",
104
+ "completion-menu.completion.current": f"noreverse bg:default fg:{completion_selected} bold",
105
+ "completion-menu.meta.completion.current": f"bg:default fg:{completion_selected} bold",
91
106
  }
92
107
  ),
93
108
  )
@@ -192,8 +207,14 @@ class PromptToolkitInput(InputProviderABC):
192
207
  @override
193
208
  async def iter_inputs(self) -> AsyncIterator[UserInputPayload]:
194
209
  while True:
210
+ if self._pre_prompt is not None:
211
+ with contextlib.suppress(Exception):
212
+ self._pre_prompt()
195
213
  with patch_stdout():
196
214
  line: str = await self._session.prompt_async(placeholder=self._render_input_placeholder())
215
+ if self._post_prompt is not None:
216
+ with contextlib.suppress(Exception):
217
+ self._post_prompt()
197
218
 
198
219
  # Extract images referenced in the input text
199
220
  images = extract_images_from_text(line)
@@ -1,18 +1,18 @@
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 import box
9
- from rich.box import Box
10
- from rich.console import Console
9
+ from rich.console import Console, Group, RenderableType
10
+ from rich.padding import Padding
11
11
  from rich.spinner import Spinner
12
- from rich.status import Status
13
12
  from rich.style import Style, StyleType
14
13
  from rich.text import Text
15
14
 
15
+ from klaude_code import const
16
16
  from klaude_code.protocol import events, model
17
17
  from klaude_code.ui.renderers import assistant as r_assistant
18
18
  from klaude_code.ui.renderers import developer as r_developer
@@ -24,14 +24,16 @@ from klaude_code.ui.renderers import tools as r_tools
24
24
  from klaude_code.ui.renderers import user_input as r_user_input
25
25
  from klaude_code.ui.renderers.common import truncate_display
26
26
  from klaude_code.ui.rich import status as r_status
27
+ from klaude_code.ui.rich.live import CropAboveLive, SingleLine
27
28
  from klaude_code.ui.rich.quote import Quote
28
- from klaude_code.ui.rich.status import ShimmerStatusText
29
+ from klaude_code.ui.rich.status import BreathingSpinner, ShimmerStatusText
29
30
  from klaude_code.ui.rich.theme import ThemeKey, get_theme
30
31
 
31
32
 
32
33
  @dataclass
33
34
  class SessionStatus:
34
35
  color: Style | None = None
36
+ color_index: int | None = None
35
37
  sub_agent_state: model.SubAgentState | None = None
36
38
 
37
39
 
@@ -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("Thinking …", ThemeKey.SPINNER_STATUS_TEXT),
47
- spinner=r_status.spinner_name(),
48
- spinner_style=ThemeKey.SPINNER_STATUS,
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] = {}
@@ -57,7 +67,9 @@ class REPLRenderer:
57
67
  sub_agent_state=sub_agent_state,
58
68
  )
59
69
  if sub_agent_state is not None:
60
- session_status.color = self.pick_sub_agent_color()
70
+ color, color_index = self.pick_sub_agent_color()
71
+ session_status.color = color
72
+ session_status.color_index = color_index
61
73
  self.session_map[session_id] = session_status
62
74
 
63
75
  def is_sub_agent_session(self, session_id: str) -> bool:
@@ -70,12 +82,12 @@ class REPLRenderer:
70
82
  return
71
83
  self.sub_agent_color_index = (self.sub_agent_color_index + 1) % palette_size
72
84
 
73
- def pick_sub_agent_color(self) -> Style:
85
+ def pick_sub_agent_color(self) -> tuple[Style, int]:
74
86
  self._advance_sub_agent_color_index()
75
87
  palette = self.themes.sub_agent_colors
76
88
  if not palette:
77
- return Style()
78
- return palette[self.sub_agent_color_index]
89
+ return Style(), 0
90
+ return palette[self.sub_agent_color_index], self.sub_agent_color_index
79
91
 
80
92
  def get_session_sub_agent_color(self, session_id: str) -> Style:
81
93
  status = self.session_map.get(session_id)
@@ -83,8 +95,12 @@ class REPLRenderer:
83
95
  return status.color
84
96
  return Style()
85
97
 
86
- def box_style(self) -> Box:
87
- return box.ROUNDED
98
+ def get_session_sub_agent_background(self, session_id: str) -> Style:
99
+ status = self.session_map.get(session_id)
100
+ backgrounds = self.themes.sub_agent_backgrounds
101
+ if status and status.color_index is not None and backgrounds:
102
+ return backgrounds[status.color_index]
103
+ return Style()
88
104
 
89
105
  @contextmanager
90
106
  def session_print_context(self, session_id: str) -> Iterator[None]:
@@ -114,7 +130,7 @@ class REPLRenderer:
114
130
  def display_tool_call_result(self, e: events.ToolResultEvent) -> None:
115
131
  if r_tools.is_sub_agent_tool(e.tool_name):
116
132
  return
117
- renderable = r_tools.render_tool_result(e)
133
+ renderable = r_tools.render_tool_result(e, code_theme=self.themes.code_theme)
118
134
  if renderable is not None:
119
135
  self.print(renderable)
120
136
 
@@ -152,7 +168,6 @@ class REPLRenderer:
152
168
  case events.ThinkingEvent() as e:
153
169
  if is_sub_agent:
154
170
  continue
155
- self.display_thinking_prefix()
156
171
  self.display_thinking(e.content)
157
172
  case events.DeveloperMessageEvent() as e:
158
173
  self.display_developer_message(e)
@@ -196,7 +211,7 @@ class REPLRenderer:
196
211
  self.print()
197
212
 
198
213
  def display_welcome(self, event: events.WelcomeEvent) -> None:
199
- self.print(r_metadata.render_welcome(event, box_style=self.box_style()))
214
+ self.print(r_metadata.render_welcome(event))
200
215
 
201
216
  def display_user_message(self, event: events.UserMessageEvent) -> None:
202
217
  self.print(r_user_input.render_user_input(event.content))
@@ -229,12 +244,21 @@ class REPLRenderer:
229
244
 
230
245
  def display_task_finish(self, event: events.TaskFinishEvent) -> None:
231
246
  if self.is_sub_agent_session(event.session_id):
247
+ session_status = self.session_map.get(event.session_id)
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
+ )
253
+ panel_style = self.get_session_sub_agent_background(event.session_id)
232
254
  with self.session_print_context(event.session_id):
233
255
  self.print(
234
256
  r_sub_agent.render_sub_agent_result(
235
257
  event.task_result,
236
258
  code_theme=self.themes.code_theme,
237
259
  has_structured_output=event.has_structured_output,
260
+ description=description,
261
+ panel_style=panel_style,
238
262
  )
239
263
  )
240
264
 
@@ -249,25 +273,95 @@ class REPLRenderer:
249
273
  )
250
274
  )
251
275
 
252
- def display_thinking_prefix(self) -> None:
253
- self.print(r_thinking.thinking_prefix())
254
-
255
276
  # -------------------------------------------------------------------------
256
277
  # Spinner control methods
257
278
  # -------------------------------------------------------------------------
258
279
 
259
280
  def spinner_start(self) -> None:
260
281
  """Start the spinner animation."""
261
- self._spinner.start()
282
+ self._spinner_visible = True
283
+ self._ensure_bottom_live_started()
284
+ self._refresh_bottom_live()
262
285
 
263
286
  def spinner_stop(self) -> None:
264
287
  """Stop the spinner animation."""
265
- self._spinner.stop()
288
+ self._spinner_visible = False
289
+ self._refresh_bottom_live()
266
290
 
267
291
  def spinner_update(self, status_text: str | Text, right_text: Text | None = None) -> None:
268
292
  """Update the spinner status text with optional right-aligned text."""
269
- self._spinner.update(ShimmerStatusText(status_text, ThemeKey.SPINNER_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()
270
296
 
271
297
  def spinner_renderable(self) -> Spinner:
272
298
  """Return the spinner's renderable for embedding in other components."""
273
- 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 = self._stream_renderable
336
+ if stream is not None:
337
+ current_width = self.console.size.width
338
+ if self._stream_last_width != current_width:
339
+ height = len(self.console.render_lines(stream, self.console.options, pad=False))
340
+ self._stream_last_height = height
341
+ self._stream_last_width = current_width
342
+ self._stream_max_height = max(self._stream_max_height, height)
343
+ else:
344
+ height = self._stream_last_height
345
+
346
+ pad_lines = max(self._stream_max_height - height, 0)
347
+ if pad_lines:
348
+ stream = Padding(stream, (0, 0, pad_lines, 0))
349
+
350
+ stream_part: RenderableType = stream if stream is not None else Group()
351
+ gap_part: RenderableType = Text("") if self._spinner_visible else Group()
352
+ status_part: RenderableType = SingleLine(self._status_spinner) if self._spinner_visible else Group()
353
+ return Group(stream_part, gap_part, status_part)
354
+
355
+ def _refresh_bottom_live(self) -> None:
356
+ if self._bottom_live is None:
357
+ return
358
+ self._bottom_live.update(self._bottom_renderable(), refresh=True)
359
+
360
+ def stop_bottom_live(self) -> None:
361
+ if self._bottom_live is None:
362
+ return
363
+ with contextlib.suppress(Exception):
364
+ # Avoid cursor restore when stopping right before prompt_toolkit.
365
+ self._bottom_live.transient = False
366
+ self._bottom_live.stop()
367
+ self._bottom_live = None
@@ -6,7 +6,7 @@ from klaude_code.ui.renderers.common import create_grid
6
6
  from klaude_code.ui.rich.markdown import NoInsetMarkdown
7
7
 
8
8
  # UI markers
9
- ASSISTANT_MESSAGE_MARK = ""
9
+ ASSISTANT_MESSAGE_MARK = ""
10
10
 
11
11
 
12
12
  def render_assistant_message(content: str, *, code_theme: str) -> RenderableType | None:
@@ -1,7 +1,6 @@
1
1
  from importlib.metadata import version
2
2
 
3
3
  from rich import box
4
- from rich.box import Box
5
4
  from rich.console import Group, RenderableType
6
5
  from rich.padding import Padding
7
6
  from rich.panel import Panel
@@ -165,11 +164,8 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
165
164
  return Group(*renderables)
166
165
 
167
166
 
168
- def render_welcome(e: events.WelcomeEvent, *, box_style: Box | None = None) -> RenderableType:
167
+ def render_welcome(e: events.WelcomeEvent) -> RenderableType:
169
168
  """Render the welcome panel with model info and settings."""
170
- if box_style is None:
171
- box_style = box.ROUNDED
172
-
173
169
  debug_mode = is_debug_enabled()
174
170
 
175
171
  # First line: Klaude Code version
@@ -219,6 +215,6 @@ def render_welcome(e: events.WelcomeEvent, *, box_style: Box | None = None) -> R
219
215
 
220
216
  border_style = ThemeKey.WELCOME_DEBUG_BORDER if debug_mode else ThemeKey.LINES
221
217
  return Group(
222
- Panel.fit(panel_content, border_style=border_style, box=box_style),
218
+ Panel.fit(panel_content, border_style=border_style, box=box.ROUNDED),
223
219
  "", # empty line
224
220
  )