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.
Files changed (40) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/cli/cost_cmd.py +4 -4
  3. klaude_code/cli/list_model.py +1 -2
  4. klaude_code/const.py +4 -3
  5. klaude_code/core/bash_mode.py +276 -0
  6. klaude_code/core/executor.py +40 -7
  7. klaude_code/core/manager/llm_clients.py +1 -0
  8. klaude_code/core/manager/llm_clients_builder.py +2 -2
  9. klaude_code/core/memory.py +140 -0
  10. klaude_code/core/reminders.py +17 -89
  11. klaude_code/core/turn.py +10 -4
  12. klaude_code/protocol/events.py +17 -0
  13. klaude_code/protocol/op.py +12 -0
  14. klaude_code/protocol/op_handler.py +5 -0
  15. klaude_code/tui/command/resume_cmd.py +1 -1
  16. klaude_code/tui/commands.py +15 -0
  17. klaude_code/tui/components/command_output.py +4 -5
  18. klaude_code/tui/components/developer.py +1 -3
  19. klaude_code/tui/components/metadata.py +23 -23
  20. klaude_code/tui/components/rich/code_panel.py +31 -16
  21. klaude_code/tui/components/rich/markdown.py +53 -124
  22. klaude_code/tui/components/rich/theme.py +19 -10
  23. klaude_code/tui/components/tools.py +1 -0
  24. klaude_code/tui/components/user_input.py +48 -59
  25. klaude_code/tui/components/welcome.py +47 -2
  26. klaude_code/tui/display.py +15 -7
  27. klaude_code/tui/input/completers.py +8 -0
  28. klaude_code/tui/input/key_bindings.py +37 -1
  29. klaude_code/tui/input/prompt_toolkit.py +58 -31
  30. klaude_code/tui/machine.py +63 -3
  31. klaude_code/tui/renderer.py +113 -19
  32. klaude_code/tui/runner.py +22 -0
  33. klaude_code/tui/terminal/notifier.py +11 -12
  34. klaude_code/tui/terminal/selector.py +1 -1
  35. klaude_code/ui/terminal/title.py +4 -2
  36. {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/METADATA +1 -1
  37. {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/RECORD +39 -38
  38. klaude_code/tui/components/assistant.py +0 -2
  39. {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/WHEEL +0 -0
  40. {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
- if not _should_submit_instead_of_accepting_completion(buf) and _accept_current_completion(buf):
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 = "bg:#2a2a2a fg:#5a5a5a"
69
- PLACEHOLDER_SYMBOL_STYLE_LIGHT_BG = "bg:#e6e6e6 fg:#7a7a7a"
70
- PLACEHOLDER_SYMBOL_STYLE_UNKNOWN_BG = "bg:#2a2a2a fg:#8a8a8a"
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
- toolbar_text = left_text + padding
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
- (text_style, "files"),
638
- (text_style, " • "),
639
- (symbol_style, " $ "),
640
- (text_style, " "),
641
- (text_style, "skills"),
642
- (text_style, " • "),
643
- (symbol_style, " / "),
644
- (text_style, " "),
645
- (text_style, "commands"),
646
- (text_style, " • "),
647
- (symbol_style, " ctrl-l "),
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:
@@ -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 == STATUS_THINKING_TEXT
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
 
@@ -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
- gap_part: RenderableType = Group()
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=self.set_stream_renderable,
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 first_delta:
651
- self._thinking_stream.render(transform=c_thinking.normalize_thinking_content)
652
- self._flush_thinking()
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 first_delta:
671
- self._assistant_stream.render()
672
- self._flush_assistant()
673
- case EndAssistantStream():
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):