klaude-code 2.9.1__py3-none-any.whl → 2.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/app/runtime.py +1 -1
- klaude_code/cli/cost_cmd.py +4 -4
- klaude_code/cli/list_model.py +1 -2
- klaude_code/const.py +4 -3
- klaude_code/core/bash_mode.py +276 -0
- klaude_code/core/executor.py +40 -7
- klaude_code/core/manager/llm_clients.py +1 -0
- klaude_code/core/manager/llm_clients_builder.py +2 -2
- klaude_code/core/memory.py +140 -0
- klaude_code/core/reminders.py +17 -89
- klaude_code/core/turn.py +10 -4
- klaude_code/protocol/events.py +17 -0
- klaude_code/protocol/op.py +12 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/tui/command/resume_cmd.py +1 -1
- klaude_code/tui/commands.py +15 -0
- klaude_code/tui/components/command_output.py +4 -5
- klaude_code/tui/components/developer.py +1 -3
- klaude_code/tui/components/metadata.py +23 -23
- klaude_code/tui/components/rich/code_panel.py +31 -16
- klaude_code/tui/components/rich/markdown.py +53 -124
- klaude_code/tui/components/rich/theme.py +19 -10
- klaude_code/tui/components/tools.py +1 -0
- klaude_code/tui/components/user_input.py +48 -59
- klaude_code/tui/components/welcome.py +47 -2
- klaude_code/tui/display.py +15 -7
- klaude_code/tui/input/completers.py +8 -0
- klaude_code/tui/input/key_bindings.py +37 -1
- klaude_code/tui/input/prompt_toolkit.py +58 -31
- klaude_code/tui/machine.py +63 -3
- klaude_code/tui/renderer.py +113 -19
- klaude_code/tui/runner.py +22 -0
- klaude_code/tui/terminal/notifier.py +11 -12
- klaude_code/tui/terminal/selector.py +1 -1
- klaude_code/ui/terminal/title.py +4 -2
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/METADATA +1 -1
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/RECORD +39 -38
- klaude_code/tui/components/assistant.py +0 -2
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/entry_points.txt +0 -0
|
@@ -219,6 +219,14 @@ class _ComboCompleter(Completer):
|
|
|
219
219
|
document: Document,
|
|
220
220
|
complete_event, # type: ignore[override]
|
|
221
221
|
) -> Iterable[Completion]:
|
|
222
|
+
# Bash mode: disable all completions.
|
|
223
|
+
# A command is considered bash mode only when the first character is `!` (or full-width `!`).
|
|
224
|
+
try:
|
|
225
|
+
if document.text.startswith(("!", "!")):
|
|
226
|
+
return
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
222
230
|
# Try slash command completion first (only on first line)
|
|
223
231
|
if document.cursor_position_row == 0 and self._slash_completer.is_slash_command_context(document):
|
|
224
232
|
yield from self._slash_completer.get_completions(document, complete_event)
|
|
@@ -76,6 +76,9 @@ def create_key_bindings(
|
|
|
76
76
|
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
|
77
77
|
swallow_next_control_j = False
|
|
78
78
|
|
|
79
|
+
def _is_bash_mode_text(text: str) -> bool:
|
|
80
|
+
return text.startswith(("!", "!"))
|
|
81
|
+
|
|
79
82
|
def _data_requests_newline(data: str) -> bool:
|
|
80
83
|
"""Return True when incoming key data should insert a newline.
|
|
81
84
|
|
|
@@ -374,6 +377,33 @@ def create_key_bindings(
|
|
|
374
377
|
buf = event.current_buffer
|
|
375
378
|
doc = buf.document # type: ignore
|
|
376
379
|
|
|
380
|
+
# Normalize a leading full-width exclamation mark to ASCII so that:
|
|
381
|
+
# - UI echo shows `!cmd` consistently
|
|
382
|
+
# - history stores `!cmd` (not `!cmd`)
|
|
383
|
+
# - bash-mode detection is stable
|
|
384
|
+
try:
|
|
385
|
+
current_text = buf.text # type: ignore[reportUnknownMemberType]
|
|
386
|
+
cursor_pos = int(buf.cursor_position) # type: ignore[reportUnknownMemberType]
|
|
387
|
+
except Exception:
|
|
388
|
+
current_text = ""
|
|
389
|
+
cursor_pos = 0
|
|
390
|
+
|
|
391
|
+
if current_text.startswith("!"):
|
|
392
|
+
normalized = "!" + current_text[1:]
|
|
393
|
+
if normalized != current_text:
|
|
394
|
+
with contextlib.suppress(Exception):
|
|
395
|
+
buf.text = normalized # type: ignore[reportUnknownMemberType]
|
|
396
|
+
buf.cursor_position = min(cursor_pos, len(normalized)) # type: ignore[reportUnknownMemberType]
|
|
397
|
+
current_text = normalized
|
|
398
|
+
|
|
399
|
+
# Bash mode: if there is no command after `!` (ignoring only space/tab),
|
|
400
|
+
# ignore Enter but keep the input text as-is.
|
|
401
|
+
if _is_bash_mode_text(current_text):
|
|
402
|
+
after_bang = current_text[1:]
|
|
403
|
+
command = after_bang.lstrip(" \t")
|
|
404
|
+
if command == "":
|
|
405
|
+
return
|
|
406
|
+
|
|
377
407
|
data = getattr(event, "data", "")
|
|
378
408
|
if isinstance(data, str) and _data_requests_newline(data):
|
|
379
409
|
_insert_newline(event)
|
|
@@ -393,7 +423,13 @@ def create_key_bindings(
|
|
|
393
423
|
# When completions are visible, Enter accepts the current selection.
|
|
394
424
|
# This aligns with common TUI completion UX: navigation doesn't modify
|
|
395
425
|
# the buffer, and Enter/Tab inserts the selected option.
|
|
396
|
-
|
|
426
|
+
#
|
|
427
|
+
# Bash mode disables completions entirely, so always prefer submitting.
|
|
428
|
+
if (
|
|
429
|
+
not _is_bash_mode_text(current_text)
|
|
430
|
+
and not _should_submit_instead_of_accepting_completion(buf)
|
|
431
|
+
and _accept_current_completion(buf)
|
|
432
|
+
):
|
|
397
433
|
return
|
|
398
434
|
|
|
399
435
|
# Before submitting, expand any folded paste markers so that:
|
|
@@ -62,12 +62,13 @@ COMPLETION_SELECTED_LIGHT_BG = "ansigreen"
|
|
|
62
62
|
COMPLETION_SELECTED_UNKNOWN_BG = "ansigreen"
|
|
63
63
|
COMPLETION_MENU = "ansibrightblack"
|
|
64
64
|
INPUT_PROMPT_STYLE = "ansimagenta bold"
|
|
65
|
+
INPUT_PROMPT_BASH_STYLE = "ansigreen bold"
|
|
65
66
|
PLACEHOLDER_TEXT_STYLE_DARK_BG = "fg:#5a5a5a"
|
|
66
67
|
PLACEHOLDER_TEXT_STYLE_LIGHT_BG = "fg:#7a7a7a"
|
|
67
68
|
PLACEHOLDER_TEXT_STYLE_UNKNOWN_BG = "fg:#8a8a8a"
|
|
68
|
-
PLACEHOLDER_SYMBOL_STYLE_DARK_BG = "
|
|
69
|
-
PLACEHOLDER_SYMBOL_STYLE_LIGHT_BG = "
|
|
70
|
-
PLACEHOLDER_SYMBOL_STYLE_UNKNOWN_BG = "
|
|
69
|
+
PLACEHOLDER_SYMBOL_STYLE_DARK_BG = "fg:ansiblue"
|
|
70
|
+
PLACEHOLDER_SYMBOL_STYLE_LIGHT_BG = "fg:ansiblue"
|
|
71
|
+
PLACEHOLDER_SYMBOL_STYLE_UNKNOWN_BG = "fg:ansiblue"
|
|
71
72
|
|
|
72
73
|
|
|
73
74
|
# ---------------------------------------------------------------------------
|
|
@@ -244,6 +245,7 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
244
245
|
get_current_llm_config: Callable[[], llm_param.LLMConfigParameter | None] | None = None,
|
|
245
246
|
command_info_provider: Callable[[], list[CommandInfo]] | None = None,
|
|
246
247
|
):
|
|
248
|
+
self._prompt_text = prompt
|
|
247
249
|
self._status_provider = status_provider
|
|
248
250
|
self._pre_prompt = pre_prompt
|
|
249
251
|
self._post_prompt = post_prompt
|
|
@@ -296,11 +298,11 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
296
298
|
completion_selected = COMPLETION_SELECTED_UNKNOWN_BG
|
|
297
299
|
|
|
298
300
|
return PromptSession(
|
|
301
|
+
# Use a stable prompt string; we override the style dynamically in prompt_async.
|
|
299
302
|
[(INPUT_PROMPT_STYLE, prompt)],
|
|
300
303
|
history=FileHistory(str(history_path)),
|
|
301
304
|
multiline=True,
|
|
302
305
|
cursor=CursorShape.BLINKING_BEAM,
|
|
303
|
-
prompt_continuation=[(INPUT_PROMPT_STYLE, " ")],
|
|
304
306
|
key_bindings=kb,
|
|
305
307
|
completer=ThreadedCompleter(create_repl_completer(command_info_provider=self._command_info_provider)),
|
|
306
308
|
complete_while_typing=True,
|
|
@@ -340,6 +342,25 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
340
342
|
),
|
|
341
343
|
)
|
|
342
344
|
|
|
345
|
+
def _is_bash_mode_active(self) -> bool:
|
|
346
|
+
try:
|
|
347
|
+
text = self._session.default_buffer.text
|
|
348
|
+
return text.startswith(("!", "!"))
|
|
349
|
+
except Exception:
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
def _get_prompt_message(self) -> FormattedText:
|
|
353
|
+
style = INPUT_PROMPT_BASH_STYLE if self._is_bash_mode_active() else INPUT_PROMPT_STYLE
|
|
354
|
+
return FormattedText([(style, self._prompt_text)])
|
|
355
|
+
|
|
356
|
+
def _bash_mode_toolbar_fragments(self) -> StyleAndTextTuples:
|
|
357
|
+
if not self._is_bash_mode_active():
|
|
358
|
+
return []
|
|
359
|
+
return [
|
|
360
|
+
("fg:ansigreen", " bash mode"),
|
|
361
|
+
("fg:ansibrightblack", " (type ! at start; backspace first char to exit)"),
|
|
362
|
+
]
|
|
363
|
+
|
|
343
364
|
def _setup_model_picker(self) -> None:
|
|
344
365
|
"""Initialize the model picker overlay and attach it to the layout."""
|
|
345
366
|
model_picker = SelectOverlay[str](
|
|
@@ -600,18 +621,32 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
600
621
|
display_text = f"Debug log: {debug_log_path}"
|
|
601
622
|
text_style = "fg:ansibrightblack"
|
|
602
623
|
|
|
624
|
+
bash_frags = self._bash_mode_toolbar_fragments()
|
|
625
|
+
bash_plain = "".join(frag[1] for frag in bash_frags)
|
|
626
|
+
|
|
603
627
|
if display_text:
|
|
604
628
|
left_text = " " + display_text
|
|
605
629
|
try:
|
|
606
630
|
terminal_width = shutil.get_terminal_size().columns
|
|
607
|
-
padding = " " * max(0, terminal_width - len(left_text))
|
|
608
631
|
except (OSError, ValueError):
|
|
632
|
+
terminal_width = 0
|
|
633
|
+
|
|
634
|
+
if terminal_width > 0 and bash_plain:
|
|
635
|
+
# Keep the right-side bash mode hint visible by truncating the left side if needed.
|
|
636
|
+
reserved = len(bash_plain)
|
|
637
|
+
max_left = max(0, terminal_width - reserved)
|
|
638
|
+
if len(left_text) > max_left:
|
|
639
|
+
left_text = left_text[: max_left - 1] + "…" if max_left >= 2 else ""
|
|
640
|
+
padding = " " * max(0, terminal_width - len(left_text) - reserved)
|
|
641
|
+
else:
|
|
609
642
|
padding = ""
|
|
610
643
|
|
|
611
|
-
|
|
612
|
-
return FormattedText([(text_style, toolbar_text)])
|
|
644
|
+
return FormattedText([(text_style, left_text + padding), *bash_frags])
|
|
613
645
|
|
|
614
|
-
# Show shortcut hints when nothing else to display
|
|
646
|
+
# Show shortcut hints when nothing else to display.
|
|
647
|
+
# In bash mode, prefer showing only the bash hint (no placeholder shortcuts).
|
|
648
|
+
if bash_frags:
|
|
649
|
+
return FormattedText([("fg:default", " "), *bash_frags])
|
|
615
650
|
return self._render_shortcut_hints()
|
|
616
651
|
|
|
617
652
|
# -------------------------------------------------------------------------
|
|
@@ -632,29 +667,20 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
632
667
|
return FormattedText(
|
|
633
668
|
[
|
|
634
669
|
(text_style, " "),
|
|
635
|
-
(symbol_style, "
|
|
636
|
-
(text_style, " "),
|
|
637
|
-
(
|
|
638
|
-
(text_style, " • "),
|
|
639
|
-
(symbol_style, "
|
|
640
|
-
(text_style, " "),
|
|
641
|
-
(
|
|
642
|
-
(text_style, " • "),
|
|
643
|
-
(symbol_style, "
|
|
644
|
-
(text_style, " "),
|
|
645
|
-
(
|
|
646
|
-
(text_style, " • "),
|
|
647
|
-
(symbol_style, "
|
|
648
|
-
(text_style, " "),
|
|
649
|
-
(text_style, "models"),
|
|
650
|
-
(text_style, " • "),
|
|
651
|
-
(symbol_style, " ctrl-t "),
|
|
652
|
-
(text_style, " "),
|
|
653
|
-
(text_style, "think"),
|
|
654
|
-
(text_style, " • "),
|
|
655
|
-
(symbol_style, " ctrl-v "),
|
|
656
|
-
(text_style, " "),
|
|
657
|
-
(text_style, "paste image"),
|
|
670
|
+
(symbol_style, "@"),
|
|
671
|
+
(text_style, " files • "),
|
|
672
|
+
(symbol_style, "$"),
|
|
673
|
+
(text_style, " skills • "),
|
|
674
|
+
(symbol_style, "/"),
|
|
675
|
+
(text_style, " commands • "),
|
|
676
|
+
(symbol_style, "!"),
|
|
677
|
+
(text_style, " shell • "),
|
|
678
|
+
(symbol_style, "ctrl-l"),
|
|
679
|
+
(text_style, " models • "),
|
|
680
|
+
(symbol_style, "ctrl-t"),
|
|
681
|
+
(text_style, " think • "),
|
|
682
|
+
(symbol_style, "ctrl-v"),
|
|
683
|
+
(text_style, " paste image"),
|
|
658
684
|
]
|
|
659
685
|
)
|
|
660
686
|
|
|
@@ -680,6 +706,7 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
680
706
|
# proper styling instead of showing raw escape codes.
|
|
681
707
|
with patch_stdout(raw=True):
|
|
682
708
|
line: str = await self._session.prompt_async(
|
|
709
|
+
message=self._get_prompt_message,
|
|
683
710
|
bottom_toolbar=self._get_bottom_toolbar,
|
|
684
711
|
)
|
|
685
712
|
if self._post_prompt is not None:
|
klaude_code/tui/machine.py
CHANGED
|
@@ -9,20 +9,23 @@ from klaude_code.const import (
|
|
|
9
9
|
STATUS_COMPACTING_TEXT,
|
|
10
10
|
STATUS_COMPOSING_TEXT,
|
|
11
11
|
STATUS_DEFAULT_TEXT,
|
|
12
|
+
STATUS_RUNNING_TEXT,
|
|
12
13
|
STATUS_SHOW_BUFFER_LENGTH,
|
|
13
14
|
STATUS_THINKING_TEXT,
|
|
14
15
|
)
|
|
15
16
|
from klaude_code.protocol import events, model, tools
|
|
16
17
|
from klaude_code.tui.commands import (
|
|
17
18
|
AppendAssistant,
|
|
19
|
+
AppendBashCommandOutput,
|
|
18
20
|
AppendThinking,
|
|
19
21
|
EmitOsc94Error,
|
|
20
22
|
EmitTmuxSignal,
|
|
21
23
|
EndAssistantStream,
|
|
22
24
|
EndThinkingStream,
|
|
23
25
|
PrintBlankLine,
|
|
24
|
-
PrintRuleLine,
|
|
25
26
|
RenderAssistantImage,
|
|
27
|
+
RenderBashCommandEnd,
|
|
28
|
+
RenderBashCommandStart,
|
|
26
29
|
RenderCommand,
|
|
27
30
|
RenderCommandOutput,
|
|
28
31
|
RenderCompactionSummary,
|
|
@@ -245,7 +248,7 @@ class SpinnerStatusState:
|
|
|
245
248
|
|
|
246
249
|
if base_status:
|
|
247
250
|
# Default "Thinking ..." uses normal style; custom headers use bold italic
|
|
248
|
-
is_default_reasoning = base_status
|
|
251
|
+
is_default_reasoning = base_status in {STATUS_THINKING_TEXT, STATUS_RUNNING_TEXT}
|
|
249
252
|
status_style = ThemeKey.STATUS_TEXT if is_default_reasoning else ThemeKey.STATUS_TEXT_BOLD_ITALIC
|
|
250
253
|
if activity_text:
|
|
251
254
|
result = Text()
|
|
@@ -392,6 +395,37 @@ class DisplayStateMachine:
|
|
|
392
395
|
cmds.append(RenderUserMessage(e))
|
|
393
396
|
return cmds
|
|
394
397
|
|
|
398
|
+
case events.BashCommandStartEvent() as e:
|
|
399
|
+
if s.is_sub_agent:
|
|
400
|
+
return []
|
|
401
|
+
if not is_replay:
|
|
402
|
+
self._spinner.set_reasoning_status(STATUS_RUNNING_TEXT)
|
|
403
|
+
cmds.append(TaskClockStart())
|
|
404
|
+
cmds.append(SpinnerStart())
|
|
405
|
+
cmds.extend(self._spinner_update_commands())
|
|
406
|
+
|
|
407
|
+
cmds.append(RenderBashCommandStart(e))
|
|
408
|
+
return cmds
|
|
409
|
+
|
|
410
|
+
case events.BashCommandOutputDeltaEvent() as e:
|
|
411
|
+
if s.is_sub_agent:
|
|
412
|
+
return []
|
|
413
|
+
cmds.append(AppendBashCommandOutput(e))
|
|
414
|
+
return cmds
|
|
415
|
+
|
|
416
|
+
case events.BashCommandEndEvent() as e:
|
|
417
|
+
if s.is_sub_agent:
|
|
418
|
+
return []
|
|
419
|
+
cmds.append(RenderBashCommandEnd(e))
|
|
420
|
+
|
|
421
|
+
if not is_replay:
|
|
422
|
+
self._spinner.set_reasoning_status(None)
|
|
423
|
+
cmds.append(TaskClockClear())
|
|
424
|
+
cmds.append(SpinnerStop())
|
|
425
|
+
cmds.extend(self._spinner_update_commands())
|
|
426
|
+
|
|
427
|
+
return cmds
|
|
428
|
+
|
|
395
429
|
case events.TaskStartEvent() as e:
|
|
396
430
|
s.sub_agent_state = e.sub_agent_state
|
|
397
431
|
s.model_id = e.model_id
|
|
@@ -563,6 +597,31 @@ class DisplayStateMachine:
|
|
|
563
597
|
return []
|
|
564
598
|
if not self._is_primary(e.session_id):
|
|
565
599
|
return []
|
|
600
|
+
|
|
601
|
+
# Some providers/models may not emit fine-grained AssistantText* deltas.
|
|
602
|
+
# In that case, ResponseCompleteEvent.content is the only assistant text we get.
|
|
603
|
+
# Render it as a single assistant stream to avoid dropping the entire message.
|
|
604
|
+
content = e.content
|
|
605
|
+
if content.strip():
|
|
606
|
+
# If we saw no streamed assistant text for this response, render from the final snapshot.
|
|
607
|
+
if s.assistant_char_count == 0:
|
|
608
|
+
if not s.assistant_stream_active:
|
|
609
|
+
s.assistant_stream_active = True
|
|
610
|
+
cmds.append(StartAssistantStream(session_id=e.session_id))
|
|
611
|
+
cmds.append(AppendAssistant(session_id=e.session_id, content=content))
|
|
612
|
+
s.assistant_char_count += len(content)
|
|
613
|
+
|
|
614
|
+
# Ensure any active assistant stream is finalized.
|
|
615
|
+
if s.assistant_stream_active:
|
|
616
|
+
s.assistant_stream_active = False
|
|
617
|
+
cmds.append(EndAssistantStream(session_id=e.session_id))
|
|
618
|
+
else:
|
|
619
|
+
# If there is an active stream but the final snapshot has no text,
|
|
620
|
+
# still finalize to flush any pending markdown rendering.
|
|
621
|
+
if s.assistant_stream_active:
|
|
622
|
+
s.assistant_stream_active = False
|
|
623
|
+
cmds.append(EndAssistantStream(session_id=e.session_id))
|
|
624
|
+
|
|
566
625
|
if not is_replay:
|
|
567
626
|
self._spinner.set_composing(False)
|
|
568
627
|
cmds.append(SpinnerStart())
|
|
@@ -632,6 +691,8 @@ class DisplayStateMachine:
|
|
|
632
691
|
cmds.append(EndThinkingStream(e.session_id))
|
|
633
692
|
cmds.append(EndAssistantStream(e.session_id))
|
|
634
693
|
cmds.append(RenderTaskMetadata(e))
|
|
694
|
+
if is_replay:
|
|
695
|
+
cmds.append(PrintBlankLine())
|
|
635
696
|
return cmds
|
|
636
697
|
|
|
637
698
|
case events.TodoChangeEvent() as e:
|
|
@@ -664,7 +725,6 @@ class DisplayStateMachine:
|
|
|
664
725
|
cmds.append(TaskClockClear())
|
|
665
726
|
self._spinner.reset()
|
|
666
727
|
cmds.append(SpinnerStop())
|
|
667
|
-
cmds.append(PrintRuleLine())
|
|
668
728
|
cmds.append(EmitTmuxSignal())
|
|
669
729
|
return cmds
|
|
670
730
|
|
klaude_code/tui/renderer.py
CHANGED
|
@@ -18,6 +18,7 @@ from rich.text import Text
|
|
|
18
18
|
|
|
19
19
|
from klaude_code.const import (
|
|
20
20
|
MARKDOWN_LEFT_MARGIN,
|
|
21
|
+
MARKDOWN_RIGHT_MARGIN,
|
|
21
22
|
MARKDOWN_STREAM_LIVE_REPAINT_ENABLED,
|
|
22
23
|
STATUS_DEFAULT_TEXT,
|
|
23
24
|
STREAM_MAX_HEIGHT_SHRINK_RESET_LINES,
|
|
@@ -25,6 +26,7 @@ from klaude_code.const import (
|
|
|
25
26
|
from klaude_code.protocol import events, model, tools
|
|
26
27
|
from klaude_code.tui.commands import (
|
|
27
28
|
AppendAssistant,
|
|
29
|
+
AppendBashCommandOutput,
|
|
28
30
|
AppendThinking,
|
|
29
31
|
EmitOsc94Error,
|
|
30
32
|
EmitTmuxSignal,
|
|
@@ -33,6 +35,8 @@ from klaude_code.tui.commands import (
|
|
|
33
35
|
PrintBlankLine,
|
|
34
36
|
PrintRuleLine,
|
|
35
37
|
RenderAssistantImage,
|
|
38
|
+
RenderBashCommandEnd,
|
|
39
|
+
RenderBashCommandStart,
|
|
36
40
|
RenderCommand,
|
|
37
41
|
RenderCommandOutput,
|
|
38
42
|
RenderCompactionSummary,
|
|
@@ -56,7 +60,6 @@ from klaude_code.tui.commands import (
|
|
|
56
60
|
TaskClockClear,
|
|
57
61
|
TaskClockStart,
|
|
58
62
|
)
|
|
59
|
-
from klaude_code.tui.components import assistant as c_assistant
|
|
60
63
|
from klaude_code.tui.components import command_output as c_command_output
|
|
61
64
|
from klaude_code.tui.components import developer as c_developer
|
|
62
65
|
from klaude_code.tui.components import errors as c_errors
|
|
@@ -165,11 +168,29 @@ class TUICommandRenderer:
|
|
|
165
168
|
self._assistant_stream = _StreamState()
|
|
166
169
|
self._thinking_stream = _StreamState()
|
|
167
170
|
|
|
171
|
+
# Replay mode reuses the same event/state machine but does not need streaming UI.
|
|
172
|
+
# When enabled, we avoid bottom Live rendering and defer markdown rendering until
|
|
173
|
+
# the corresponding stream End event.
|
|
174
|
+
self._replay_mode: bool = False
|
|
175
|
+
|
|
176
|
+
self._bash_stream_active: bool = False
|
|
177
|
+
self._bash_last_char_was_newline: bool = True
|
|
178
|
+
|
|
168
179
|
self._sessions: dict[str, _SessionStatus] = {}
|
|
169
180
|
self._current_sub_agent_color: Style | None = None
|
|
170
181
|
self._sub_agent_color_index = 0
|
|
171
182
|
self._sub_agent_thinking_buffers: dict[str, str] = {}
|
|
172
183
|
|
|
184
|
+
def set_replay_mode(self, enabled: bool) -> None:
|
|
185
|
+
"""Enable or disable replay rendering mode.
|
|
186
|
+
|
|
187
|
+
Replay mode is optimized for speed and stability:
|
|
188
|
+
- Avoid Rich Live / bottom status rendering.
|
|
189
|
+
- Defer markdown stream rendering until End events.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
self._replay_mode = enabled
|
|
193
|
+
|
|
173
194
|
# ---------------------------------------------------------------------
|
|
174
195
|
# Session helpers
|
|
175
196
|
# ---------------------------------------------------------------------
|
|
@@ -304,7 +325,9 @@ class TUICommandRenderer:
|
|
|
304
325
|
|
|
305
326
|
def _bottom_renderable(self) -> RenderableType:
|
|
306
327
|
stream_part: RenderableType = Group()
|
|
307
|
-
|
|
328
|
+
# Keep a visible separation between the bottom status line (spinner)
|
|
329
|
+
# and the main terminal output.
|
|
330
|
+
gap_part: RenderableType = Text(" ") if (self._spinner_visible and self._bash_stream_active) else Group()
|
|
308
331
|
|
|
309
332
|
if MARKDOWN_STREAM_LIVE_REPAINT_ENABLED:
|
|
310
333
|
stream = self._stream_renderable
|
|
@@ -326,7 +349,7 @@ class TUICommandRenderer:
|
|
|
326
349
|
if pad_lines:
|
|
327
350
|
stream = Padding(stream, (0, 0, pad_lines, 0))
|
|
328
351
|
stream_part = stream
|
|
329
|
-
gap_part = Text("")
|
|
352
|
+
gap_part = Text(" ") if (self._spinner_visible and self._bash_stream_active) else Group()
|
|
330
353
|
|
|
331
354
|
status_part: RenderableType = SingleLine(self._status_spinner) if self._spinner_visible else Group()
|
|
332
355
|
return Group(stream_part, gap_part, status_part)
|
|
@@ -361,17 +384,19 @@ class TUICommandRenderer:
|
|
|
361
384
|
mark=c_thinking.THINKING_MESSAGE_MARK,
|
|
362
385
|
mark_style=ThemeKey.THINKING,
|
|
363
386
|
left_margin=MARKDOWN_LEFT_MARGIN,
|
|
387
|
+
right_margin=MARKDOWN_RIGHT_MARGIN,
|
|
364
388
|
markdown_class=ThinkingMarkdown,
|
|
365
389
|
)
|
|
366
390
|
|
|
367
391
|
def _new_assistant_mdstream(self) -> MarkdownStream:
|
|
392
|
+
live_sink = None if self._replay_mode else self.set_stream_renderable
|
|
368
393
|
return MarkdownStream(
|
|
369
394
|
mdargs={"code_theme": self.themes.code_theme},
|
|
370
395
|
theme=self.themes.markdown_theme,
|
|
371
396
|
console=self.console,
|
|
372
|
-
live_sink=
|
|
373
|
-
mark=c_assistant.ASSISTANT_MESSAGE_MARK,
|
|
397
|
+
live_sink=live_sink,
|
|
374
398
|
left_margin=MARKDOWN_LEFT_MARGIN,
|
|
399
|
+
right_margin=MARKDOWN_RIGHT_MARGIN,
|
|
375
400
|
image_callback=self.display_image,
|
|
376
401
|
)
|
|
377
402
|
|
|
@@ -460,6 +485,66 @@ class TUICommandRenderer:
|
|
|
460
485
|
self.print(c_command_output.render_command_output(e))
|
|
461
486
|
self.print()
|
|
462
487
|
|
|
488
|
+
def display_bash_command_start(self, e: events.BashCommandStartEvent) -> None:
|
|
489
|
+
# The user input line already shows `!cmd`; bash output is streamed as it arrives.
|
|
490
|
+
# We keep minimal rendering here to avoid adding noise.
|
|
491
|
+
self._bash_stream_active = True
|
|
492
|
+
self._bash_last_char_was_newline = True
|
|
493
|
+
if self._spinner_visible:
|
|
494
|
+
self._refresh_bottom_live()
|
|
495
|
+
|
|
496
|
+
def display_bash_command_delta(self, e: events.BashCommandOutputDeltaEvent) -> None:
|
|
497
|
+
if not self._bash_stream_active:
|
|
498
|
+
self._bash_stream_active = True
|
|
499
|
+
if self._spinner_visible:
|
|
500
|
+
self._refresh_bottom_live()
|
|
501
|
+
|
|
502
|
+
content = e.content
|
|
503
|
+
if content == "":
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
# Rich Live refreshes periodically (even when the renderable doesn't change).
|
|
507
|
+
# If we print bash output without a trailing newline while Live is active,
|
|
508
|
+
# the next refresh can overwrite the partial line.
|
|
509
|
+
#
|
|
510
|
+
# To keep streamed bash output stable, temporarily stop the bottom Live
|
|
511
|
+
# during the print, and only resume it once the output is back at a
|
|
512
|
+
# line boundary (i.e. chunk ends with "\n").
|
|
513
|
+
if self._bottom_live is not None:
|
|
514
|
+
with contextlib.suppress(Exception):
|
|
515
|
+
self._bottom_live.stop()
|
|
516
|
+
self._bottom_live = None
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
# Do not use Renderer.print() here because it forces overflow="ellipsis",
|
|
520
|
+
# which would truncate long command output lines.
|
|
521
|
+
self.console.print(Text(content, style=ThemeKey.TOOL_RESULT), end="", overflow="ignore")
|
|
522
|
+
self._bash_last_char_was_newline = content.endswith("\n")
|
|
523
|
+
finally:
|
|
524
|
+
# Resume the bottom Live only when we're not in the middle of a line,
|
|
525
|
+
# otherwise periodic refresh can clobber the partial line.
|
|
526
|
+
if self._bash_last_char_was_newline and self._spinner_visible:
|
|
527
|
+
self._ensure_bottom_live_started()
|
|
528
|
+
self._refresh_bottom_live()
|
|
529
|
+
|
|
530
|
+
def display_bash_command_end(self, e: events.BashCommandEndEvent) -> None:
|
|
531
|
+
# Stop the bottom Live before finalizing bash output to prevent a refresh
|
|
532
|
+
# from interfering with the final line(s) written to stdout.
|
|
533
|
+
if self._bottom_live is not None:
|
|
534
|
+
with contextlib.suppress(Exception):
|
|
535
|
+
self._bottom_live.stop()
|
|
536
|
+
self._bottom_live = None
|
|
537
|
+
|
|
538
|
+
# Leave a blank line before the next prompt:
|
|
539
|
+
# - If the command output already ended with a newline, print one more "\n".
|
|
540
|
+
# - Otherwise, print "\n\n" to end the line and add one empty line.
|
|
541
|
+
if self._bash_stream_active:
|
|
542
|
+
sep = "\n" if self._bash_last_char_was_newline else "\n\n"
|
|
543
|
+
self.console.print(Text(sep), end="", overflow="ignore")
|
|
544
|
+
|
|
545
|
+
self._bash_stream_active = False
|
|
546
|
+
self._bash_last_char_was_newline = True
|
|
547
|
+
|
|
463
548
|
def display_welcome(self, event: events.WelcomeEvent) -> None:
|
|
464
549
|
self.print(c_welcome.render_welcome(event))
|
|
465
550
|
|
|
@@ -507,7 +592,6 @@ class TUICommandRenderer:
|
|
|
507
592
|
if self.is_sub_agent_session(event.session_id):
|
|
508
593
|
return
|
|
509
594
|
self.print(c_metadata.render_task_metadata(event))
|
|
510
|
-
self.print()
|
|
511
595
|
|
|
512
596
|
def display_task_finish(self, event: events.TaskFinishEvent) -> None:
|
|
513
597
|
if self.is_sub_agent_session(event.session_id):
|
|
@@ -633,6 +717,12 @@ class TUICommandRenderer:
|
|
|
633
717
|
self.display_developer_message(event)
|
|
634
718
|
case RenderCommandOutput(event=event):
|
|
635
719
|
self.display_command_output(event)
|
|
720
|
+
case RenderBashCommandStart(event=event):
|
|
721
|
+
self.display_bash_command_start(event)
|
|
722
|
+
case AppendBashCommandOutput(event=event):
|
|
723
|
+
self.display_bash_command_delta(event)
|
|
724
|
+
case RenderBashCommandEnd(event=event):
|
|
725
|
+
self.display_bash_command_end(event)
|
|
636
726
|
case RenderTurnStart(event=event):
|
|
637
727
|
self.display_turn_start(event)
|
|
638
728
|
case StartThinkingStream(session_id=session_id):
|
|
@@ -645,11 +735,12 @@ class TUICommandRenderer:
|
|
|
645
735
|
if session_id in self._sub_agent_thinking_buffers:
|
|
646
736
|
self._sub_agent_thinking_buffers[session_id] += content
|
|
647
737
|
elif self._thinking_stream.is_active:
|
|
648
|
-
first_delta = self._thinking_stream.buffer == ""
|
|
649
738
|
self._thinking_stream.append(content)
|
|
650
|
-
if
|
|
651
|
-
self._thinking_stream.
|
|
652
|
-
|
|
739
|
+
if not self._replay_mode:
|
|
740
|
+
first_delta = self._thinking_stream.buffer == ""
|
|
741
|
+
if first_delta:
|
|
742
|
+
self._thinking_stream.render(transform=c_thinking.normalize_thinking_content)
|
|
743
|
+
self._flush_thinking()
|
|
653
744
|
case EndThinkingStream(session_id=session_id):
|
|
654
745
|
if self.is_sub_agent_session(session_id):
|
|
655
746
|
buf = self._sub_agent_thinking_buffers.pop(session_id, "")
|
|
@@ -657,22 +748,25 @@ class TUICommandRenderer:
|
|
|
657
748
|
with self.session_print_context(session_id):
|
|
658
749
|
self._render_sub_agent_thinking(buf)
|
|
659
750
|
else:
|
|
751
|
+
had_content = bool(self._thinking_stream.buffer.strip())
|
|
660
752
|
finalized = self._thinking_stream.finalize(transform=c_thinking.normalize_thinking_content)
|
|
661
|
-
if finalized:
|
|
753
|
+
if finalized and had_content:
|
|
662
754
|
self.print()
|
|
663
|
-
case StartAssistantStream():
|
|
755
|
+
case StartAssistantStream(session_id=_):
|
|
664
756
|
if not self._assistant_stream.is_active:
|
|
665
757
|
self._assistant_stream.start(self._new_assistant_mdstream())
|
|
666
|
-
case AppendAssistant(content=content):
|
|
758
|
+
case AppendAssistant(session_id=_, content=content):
|
|
667
759
|
if self._assistant_stream.is_active:
|
|
668
|
-
first_delta = self._assistant_stream.buffer == ""
|
|
669
760
|
self._assistant_stream.append(content)
|
|
670
|
-
if
|
|
671
|
-
self._assistant_stream.
|
|
672
|
-
|
|
673
|
-
|
|
761
|
+
if not self._replay_mode:
|
|
762
|
+
first_delta = self._assistant_stream.buffer == ""
|
|
763
|
+
if first_delta:
|
|
764
|
+
self._assistant_stream.render()
|
|
765
|
+
self._flush_assistant()
|
|
766
|
+
case EndAssistantStream(session_id=_):
|
|
767
|
+
had_content = bool(self._assistant_stream.buffer.strip())
|
|
674
768
|
finalized = self._assistant_stream.finalize()
|
|
675
|
-
if finalized:
|
|
769
|
+
if finalized and had_content:
|
|
676
770
|
self.print()
|
|
677
771
|
case RenderThinkingHeader(session_id=session_id, header=header):
|
|
678
772
|
with self.session_print_context(session_id):
|
klaude_code/tui/runner.py
CHANGED
|
@@ -65,11 +65,27 @@ async def submit_user_input_payload(
|
|
|
65
65
|
|
|
66
66
|
submission_id = uuid4().hex
|
|
67
67
|
|
|
68
|
+
# Normalize a leading full-width exclamation mark for consistent UI/history.
|
|
69
|
+
# (Bash mode is triggered only when the first character is `!`.)
|
|
70
|
+
text = user_input.text
|
|
71
|
+
if text.startswith("!"):
|
|
72
|
+
text = "!" + text[1:]
|
|
73
|
+
user_input = UserInputPayload(text=text, images=user_input.images)
|
|
74
|
+
|
|
68
75
|
# Render the raw user input in the TUI even when it resolves to an event-only command.
|
|
69
76
|
await executor.context.emit_event(
|
|
70
77
|
events.UserMessageEvent(content=user_input.text, session_id=sid, images=user_input.images)
|
|
71
78
|
)
|
|
72
79
|
|
|
80
|
+
# Bash mode: run a user-entered command without invoking the agent.
|
|
81
|
+
if user_input.text.startswith("!"):
|
|
82
|
+
command = user_input.text[1:].lstrip(" \t")
|
|
83
|
+
if command == "":
|
|
84
|
+
# Enter should be ignored in the input layer for this case; keep a guard here.
|
|
85
|
+
return None
|
|
86
|
+
bash_op = op.RunBashOperation(id=submission_id, session_id=sid, command=command)
|
|
87
|
+
return await executor.submit(bash_op)
|
|
88
|
+
|
|
73
89
|
cmd_result = await dispatch_command(user_input, agent, submission_id=submission_id)
|
|
74
90
|
operations: list[op.Operation] = list(cmd_result.operations or [])
|
|
75
91
|
|
|
@@ -304,6 +320,9 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
|
|
|
304
320
|
if is_interactive:
|
|
305
321
|
with _double_ctrl_c_to_exit_while_running():
|
|
306
322
|
await components.executor.wait_for(wait_id)
|
|
323
|
+
# Ensure all trailing events (e.g. final deltas / spinner stop) are rendered
|
|
324
|
+
# before handing control back to prompt_toolkit.
|
|
325
|
+
await components.event_queue.join()
|
|
307
326
|
continue
|
|
308
327
|
|
|
309
328
|
async def _on_esc_interrupt() -> None:
|
|
@@ -313,6 +332,9 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
|
|
|
313
332
|
try:
|
|
314
333
|
with _double_ctrl_c_to_exit_while_running():
|
|
315
334
|
await components.executor.wait_for(wait_id)
|
|
335
|
+
# Ensure all trailing events (e.g. final deltas / spinner stop) are rendered
|
|
336
|
+
# before handing control back to prompt_toolkit.
|
|
337
|
+
await components.event_queue.join()
|
|
316
338
|
finally:
|
|
317
339
|
stop_event.set()
|
|
318
340
|
with contextlib.suppress(Exception):
|