klaude-code 1.2.23__py3-none-any.whl → 1.2.25__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/cli/runtime.py +17 -1
- klaude_code/command/prompt-jj-describe.md +32 -0
- klaude_code/command/thinking_cmd.py +37 -28
- klaude_code/{const/__init__.py → const.py} +7 -6
- klaude_code/core/executor.py +46 -3
- klaude_code/core/tool/file/read_tool.py +23 -1
- klaude_code/core/tool/file/write_tool.py +7 -3
- klaude_code/llm/openai_compatible/client.py +29 -102
- klaude_code/llm/openai_compatible/stream.py +272 -0
- klaude_code/llm/openrouter/client.py +29 -109
- klaude_code/llm/openrouter/{reasoning_handler.py → reasoning.py} +24 -2
- klaude_code/protocol/model.py +13 -1
- klaude_code/protocol/op.py +11 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/ui/core/stage_manager.py +0 -3
- klaude_code/ui/modes/repl/display.py +2 -0
- klaude_code/ui/modes/repl/event_handler.py +97 -57
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +25 -4
- klaude_code/ui/modes/repl/renderer.py +119 -25
- klaude_code/ui/renderers/assistant.py +1 -1
- klaude_code/ui/renderers/metadata.py +2 -6
- klaude_code/ui/renderers/sub_agent.py +28 -5
- klaude_code/ui/renderers/thinking.py +16 -10
- klaude_code/ui/renderers/tools.py +26 -2
- klaude_code/ui/rich/code_panel.py +24 -5
- klaude_code/ui/rich/live.py +17 -0
- klaude_code/ui/rich/markdown.py +185 -107
- klaude_code/ui/rich/status.py +19 -17
- klaude_code/ui/rich/theme.py +63 -12
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/METADATA +2 -1
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/RECORD +33 -32
- klaude_code/llm/openai_compatible/stream_processor.py +0 -83
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.25.dist-info}/entry_points.txt +0 -0
|
@@ -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.
|
|
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
|
-
|
|
134
|
-
-
|
|
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.
|
|
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.
|
|
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
|
|
195
|
+
def set_todo_status(self, status: str | None) -> None:
|
|
159
196
|
"""Set base status from TodoChange."""
|
|
160
|
-
self.
|
|
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
|
-
|
|
191
|
-
|
|
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(
|
|
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":
|
|
374
|
+
"style": ThemeKey.THINKING,
|
|
328
375
|
},
|
|
329
376
|
theme=self.renderer.themes.thinking_markdown_theme,
|
|
330
377
|
console=self.renderer.console,
|
|
331
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
#
|
|
553
|
-
|
|
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
|
-
|
|
556
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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.
|
|
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:{
|
|
90
|
-
"completion-menu.meta.completion.current": f"bg:default fg:{
|
|
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
|
|
9
|
-
from rich.
|
|
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.
|
|
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] = {}
|
|
@@ -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
|
-
|
|
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
|
|
87
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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=
|
|
218
|
+
Panel.fit(panel_content, border_style=border_style, box=box.ROUNDED),
|
|
223
219
|
"", # empty line
|
|
224
220
|
)
|