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
@@ -22,12 +22,10 @@ from rich.text import Text
22
22
  from rich.theme import Theme
23
23
 
24
24
  from klaude_code.const import (
25
- MARKDOWN_RIGHT_MARGIN,
26
25
  MARKDOWN_STREAM_LIVE_REPAINT_ENABLED,
27
26
  MARKDOWN_STREAM_SYNCHRONIZED_OUTPUT_ENABLED,
28
27
  UI_REFRESH_RATE_FPS,
29
28
  )
30
- from klaude_code.tui.components.rich.code_panel import CodePanel
31
29
 
32
30
  _THINKING_HTML_BLOCK_RE = re.compile(
33
31
  r"\A\s*<thinking>\s*\n?(?P<body>.*?)(?:\n\s*)?</thinking>\s*\Z",
@@ -91,10 +89,15 @@ class ThinkingHTMLBlock(MarkdownElement):
91
89
 
92
90
 
93
91
  class NoInsetCodeBlock(CodeBlock):
94
- """A code block with syntax highlighting and no padding."""
92
+ """A code block with syntax highlighting using markdown fence style."""
95
93
 
96
94
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
97
95
  code = str(self.text).rstrip()
96
+ lang = self.lexer_name if self.lexer_name != "text" else ""
97
+ fence_style = console.get_style("markdown.code.fence", default="none")
98
+ fence_title_style = console.get_style("markdown.code.fence.title", default="none")
99
+
100
+ yield Text.assemble(("```", fence_style), (lang, fence_title_style))
98
101
  syntax = Syntax(
99
102
  code,
100
103
  self.lexer_name,
@@ -102,16 +105,21 @@ class NoInsetCodeBlock(CodeBlock):
102
105
  word_wrap=True,
103
106
  padding=(0, 0),
104
107
  )
105
- yield CodePanel(syntax, border_style="markdown.code.border")
108
+ yield syntax
109
+ yield Text("```", style=fence_style)
106
110
 
107
111
 
108
112
  class ThinkingCodeBlock(CodeBlock):
109
- """A code block for thinking content that uses grey styling instead of syntax highlighting."""
113
+ """A code block for thinking content that uses simple ``` delimiters."""
110
114
 
111
115
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
112
116
  code = str(self.text).rstrip()
113
- text = Text(code, "markdown.code.block")
114
- yield CodePanel(text, border_style="markdown.code.border")
117
+ fence_style = "markdown.code.fence"
118
+ code_style = "markdown.code.block"
119
+ lang = self.lexer_name if self.lexer_name != "text" else ""
120
+ yield Text(f"```{lang}", style=fence_style)
121
+ yield Text(code, style=code_style)
122
+ yield Text("```", style=fence_style)
115
123
 
116
124
 
117
125
  class Divider(MarkdownElement):
@@ -289,7 +297,7 @@ class MarkdownStream:
289
297
  mark: str | None = None,
290
298
  mark_style: StyleType | None = None,
291
299
  left_margin: int = 0,
292
- right_margin: int = MARKDOWN_RIGHT_MARGIN,
300
+ right_margin: int = 0,
293
301
  markdown_class: Callable[..., Markdown] | None = None,
294
302
  image_callback: Callable[[str], None] | None = None,
295
303
  ) -> None:
@@ -325,10 +333,10 @@ class MarkdownStream:
325
333
 
326
334
  self.theme = theme
327
335
  self.console = console
328
- self.mark: str | None = mark
329
- self.mark_style: StyleType | None = mark_style
330
-
331
336
  self.left_margin: int = max(left_margin, 0)
337
+ # Default mark "•" when left_margin >= 2 and no mark specified
338
+ self.mark: str | None = mark if mark is not None else ("•" if self.left_margin >= 2 else None)
339
+ self.mark_style: StyleType | None = mark_style
332
340
 
333
341
  self.right_margin: int = max(right_margin, 0)
334
342
  self.markdown_class: Callable[..., Markdown] = markdown_class or NoInsetMarkdown
@@ -395,25 +403,12 @@ class MarkdownStream:
395
403
  return 0
396
404
 
397
405
  top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
398
- if not top_level:
406
+ if len(top_level) < 2:
399
407
  return 0
400
408
 
401
409
  last = top_level[-1]
402
410
  assert last.map is not None
403
411
 
404
- # Lists are a special case: markdown-it-py treats the whole list as one
405
- # top-level block, which would keep the entire list in the live area
406
- # until the list ends.
407
- #
408
- # For streaming UX, we want all but the final list item to be eligible
409
- # for stabilization, leaving only the *last* item in the live area.
410
- if last.type in {"bullet_list_open", "ordered_list_open"}:
411
- stable_line = self._compute_list_item_boundary(tokens, last)
412
- return max(stable_line, 0)
413
-
414
- if len(top_level) < 2:
415
- return 0
416
-
417
412
  # When the buffer ends mid-line, markdown-it-py can temporarily classify
418
413
  # some lines as a thematic break (hr). For example, a trailing "- --"
419
414
  # parses as an hr, but appending a non-hr character ("- --0") turns it
@@ -430,59 +425,6 @@ class MarkdownStream:
430
425
  start_line = last.map[0]
431
426
  return max(start_line, 0)
432
427
 
433
- def _compute_list_item_boundary(self, tokens: list[Token], list_open: Token) -> int:
434
- """Return the start line of the last list item in a list.
435
-
436
- This allows stabilizing all list items except the final one.
437
- """
438
-
439
- if list_open.map is None:
440
- return 0
441
-
442
- list_start, list_end = list_open.map
443
- item_level = list_open.level + 1
444
-
445
- last_item_start: int | None = None
446
- for token in tokens:
447
- if token.type != "list_item_open":
448
- continue
449
- if token.level != item_level:
450
- continue
451
- if token.map is None:
452
- continue
453
- start_line = token.map[0]
454
- if start_line < list_start or start_line >= list_end:
455
- continue
456
- last_item_start = start_line
457
-
458
- return last_item_start if last_item_start is not None else list_start
459
-
460
- def _stable_boundary_continues_list(self, text: str, stable_line: int, *, final: bool) -> bool:
461
- """Whether the stable/live split point is inside the final top-level list."""
462
-
463
- if final:
464
- return False
465
- if stable_line <= 0:
466
- return False
467
-
468
- try:
469
- tokens = self._parser.parse(text)
470
- except Exception:
471
- return False
472
-
473
- top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
474
- if not top_level:
475
- return False
476
-
477
- last = top_level[-1]
478
- if last.type not in {"bullet_list_open", "ordered_list_open"}:
479
- return False
480
- if last.map is None:
481
- return False
482
-
483
- list_start, list_end = last.map
484
- return list_start < stable_line < list_end
485
-
486
428
  def split_blocks(self, text: str, *, min_stable_line: int = 0, final: bool = False) -> tuple[str, str, int]:
487
429
  """Split full markdown into stable and live sources.
488
430
 
@@ -512,14 +454,7 @@ class MarkdownStream:
512
454
  return "", text, 0
513
455
  return stable_source, live_source, stable_line
514
456
 
515
- def render_stable_ansi(
516
- self,
517
- stable_source: str,
518
- *,
519
- has_live_suffix: bool,
520
- final: bool,
521
- continues_list: bool = False,
522
- ) -> tuple[str, list[str]]:
457
+ def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> tuple[str, list[str]]:
523
458
  """Render stable prefix to ANSI, preserving inter-block spacing.
524
459
 
525
460
  Returns:
@@ -529,14 +464,10 @@ class MarkdownStream:
529
464
  return "", []
530
465
 
531
466
  render_source = stable_source
532
- if not final and has_live_suffix and not continues_list:
467
+ if not final and has_live_suffix:
533
468
  render_source = self._append_nonfinal_sentinel(stable_source)
534
469
 
535
470
  lines, images = self._render_markdown_to_lines(render_source, apply_mark=True)
536
-
537
- if continues_list:
538
- while lines and not lines[-1].strip():
539
- lines.pop()
540
471
  return "".join(lines), images
541
472
 
542
473
  def _append_nonfinal_sentinel(self, stable_source: str) -> str:
@@ -590,12 +521,17 @@ class MarkdownStream:
590
521
 
591
522
  collected_images = getattr(markdown, "collected_images", [])
592
523
 
593
- # Split rendered output into lines, strip trailing spaces, and apply left margin.
594
524
  lines = output.splitlines(keepends=True)
595
- indent_prefix = " " * self.left_margin if self.left_margin > 0 else ""
525
+ use_mark = apply_mark and bool(self.mark) and self.left_margin >= 2
526
+
527
+ # Fast path: no margin, no mark -> just rstrip each line
528
+ if self.left_margin == 0 and not use_mark:
529
+ processed_lines = [line.rstrip() + "\n" if line.endswith("\n") else line.rstrip() for line in lines]
530
+ return processed_lines, list(collected_images)
531
+
532
+ indent_prefix = " " * self.left_margin
596
533
  processed_lines: list[str] = []
597
534
  mark_applied = False
598
- use_mark = apply_mark and bool(self.mark) and self.left_margin >= 2
599
535
 
600
536
  # Pre-render styled mark if needed
601
537
  styled_mark: str | None = None
@@ -616,7 +552,7 @@ class MarkdownStream:
616
552
  if use_mark and not mark_applied and stripped:
617
553
  stripped = f"{styled_mark} {stripped}"
618
554
  mark_applied = True
619
- elif indent_prefix:
555
+ else:
620
556
  stripped = indent_prefix + stripped
621
557
 
622
558
  if line.endswith("\n"):
@@ -648,8 +584,6 @@ class MarkdownStream:
648
584
  final=final,
649
585
  )
650
586
 
651
- continues_list = self._stable_boundary_continues_list(text, stable_line, final=final) and bool(live_source)
652
-
653
587
  start = time.time()
654
588
 
655
589
  stable_chunk_to_print: str | None = None
@@ -657,10 +591,7 @@ class MarkdownStream:
657
591
  stable_changed = final or stable_line > self._stable_source_line_count
658
592
  if stable_changed and stable_source:
659
593
  stable_ansi, collected_images = self.render_stable_ansi(
660
- stable_source,
661
- has_live_suffix=bool(live_source),
662
- final=final,
663
- continues_list=continues_list,
594
+ stable_source, has_live_suffix=bool(live_source), final=final
664
595
  )
665
596
  stable_lines = stable_ansi.splitlines(keepends=True)
666
597
  new_lines = stable_lines[len(self._stable_rendered_lines) :]
@@ -678,33 +609,31 @@ class MarkdownStream:
678
609
 
679
610
  live_text_to_set: Text | None = None
680
611
  if not final and MARKDOWN_STREAM_LIVE_REPAINT_ENABLED and self._live_sink is not None:
681
- if continues_list and self._stable_rendered_lines:
682
- full_lines, _ = self._render_markdown_to_lines(text, apply_mark=True)
683
- skip = min(len(self._stable_rendered_lines), len(full_lines))
684
- live_text_to_set = Text.from_ansi("".join(full_lines[skip:]))
685
- else:
686
- apply_mark = not self._stable_rendered_lines
687
- live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark)
688
-
689
- if self._stable_rendered_lines:
690
- stable_trailing_blank = 0
691
- for line in reversed(self._stable_rendered_lines):
612
+ # When nothing is stable yet, we still want to show incremental output.
613
+ # Apply the mark only for the first (all-live) frame so it stays anchored
614
+ # to the first visible line of the full message.
615
+ apply_mark_to_live = stable_line == 0
616
+ live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_to_live)
617
+
618
+ if self._stable_rendered_lines:
619
+ stable_trailing_blank = 0
620
+ for line in reversed(self._stable_rendered_lines):
621
+ if line.strip():
622
+ break
623
+ stable_trailing_blank += 1
624
+
625
+ if stable_trailing_blank > 0:
626
+ live_leading_blank = 0
627
+ for line in live_lines:
692
628
  if line.strip():
693
629
  break
694
- stable_trailing_blank += 1
695
-
696
- if stable_trailing_blank > 0:
697
- live_leading_blank = 0
698
- for line in live_lines:
699
- if line.strip():
700
- break
701
- live_leading_blank += 1
630
+ live_leading_blank += 1
702
631
 
703
- drop = min(stable_trailing_blank, live_leading_blank)
704
- if drop > 0:
705
- live_lines = live_lines[drop:]
632
+ drop = min(stable_trailing_blank, live_leading_blank)
633
+ if drop > 0:
634
+ live_lines = live_lines[drop:]
706
635
 
707
- live_text_to_set = Text.from_ansi("".join(live_lines))
636
+ live_text_to_set = Text.from_ansi("".join(live_lines))
708
637
 
709
638
  with self._synchronized_output():
710
639
  # Update/clear live area first to avoid blank padding when stable block appears
@@ -39,6 +39,7 @@ class Palette:
39
39
  red_background: str
40
40
  grey_background: str
41
41
  yellow_background: str
42
+ user_message_background: str
42
43
 
43
44
 
44
45
  LIGHT_PALETTE = Palette(
@@ -73,6 +74,7 @@ LIGHT_PALETTE = Palette(
73
74
  red_background="#f9ecec",
74
75
  grey_background="#f0f0f0",
75
76
  yellow_background="#f9f9ec",
77
+ user_message_background="#f0f0f0",
76
78
  )
77
79
 
78
80
  DARK_PALETTE = Palette(
@@ -107,6 +109,7 @@ DARK_PALETTE = Palette(
107
109
  red_background="#3d1f23",
108
110
  grey_background="#2a2d30",
109
111
  yellow_background="#3d3a1a",
112
+ user_message_background="#2a2d30",
110
113
  )
111
114
 
112
115
 
@@ -116,6 +119,7 @@ class ThemeKey(str, Enum):
116
119
 
117
120
  # CODE
118
121
  CODE_BACKGROUND = "code_background"
122
+ CODE_PANEL_TITLE = "code_panel.title"
119
123
 
120
124
  # PANEL
121
125
  SUB_AGENT_RESULT_PANEL = "panel.sub_agent_result"
@@ -258,18 +262,18 @@ def get_theme(theme: str | None = None) -> Themes:
258
262
  ThemeKey.ERROR_DIM.value: "dim " + palette.red,
259
263
  ThemeKey.INTERRUPT.value: palette.red,
260
264
  # USER_INPUT
261
- ThemeKey.USER_INPUT.value: palette.magenta,
262
- ThemeKey.USER_INPUT_PROMPT.value: "bold " + palette.magenta,
263
- ThemeKey.USER_INPUT_AT_PATTERN.value: palette.purple,
264
- ThemeKey.USER_INPUT_SLASH_COMMAND.value: "bold " + palette.blue,
265
- ThemeKey.USER_INPUT_SKILL.value: "bold " + palette.green,
265
+ ThemeKey.USER_INPUT.value: f"{palette.magenta} on {palette.user_message_background}",
266
+ ThemeKey.USER_INPUT_PROMPT.value: f"bold {palette.magenta} on {palette.user_message_background}",
267
+ ThemeKey.USER_INPUT_AT_PATTERN.value: f"{palette.purple} on {palette.user_message_background}",
268
+ ThemeKey.USER_INPUT_SLASH_COMMAND.value: f"bold {palette.blue} on {palette.user_message_background}",
269
+ ThemeKey.USER_INPUT_SKILL.value: f"bold {palette.green} on {palette.user_message_background}",
266
270
  # ASSISTANT
267
271
  ThemeKey.ASSISTANT_MESSAGE_MARK.value: "bold",
268
272
  # METADATA
269
- ThemeKey.METADATA.value: palette.lavender,
270
- ThemeKey.METADATA_DIM.value: "dim " + palette.lavender,
271
- ThemeKey.METADATA_BOLD.value: "bold " + palette.lavender,
272
- ThemeKey.METADATA_ITALIC.value: "italic " + palette.lavender,
273
+ ThemeKey.METADATA.value: palette.blue,
274
+ ThemeKey.METADATA_DIM.value: "dim " + palette.blue,
275
+ ThemeKey.METADATA_BOLD.value: "bold " + palette.blue,
276
+ ThemeKey.METADATA_ITALIC.value: "italic " + palette.blue,
273
277
  # STATUS
274
278
  ThemeKey.STATUS_SPINNER.value: palette.blue,
275
279
  ThemeKey.STATUS_TEXT.value: palette.blue,
@@ -348,6 +352,8 @@ def get_theme(theme: str | None = None) -> Themes:
348
352
  "markdown.thinking": "italic " + palette.grey2,
349
353
  "markdown.thinking.tag": palette.grey2,
350
354
  "markdown.code.border": palette.grey3,
355
+ "markdown.code.fence": palette.grey3,
356
+ "markdown.code.fence.title": palette.grey1,
351
357
  # Used by ThinkingMarkdown when rendering `<thinking>` blocks.
352
358
  "markdown.code.block": palette.grey1,
353
359
  "markdown.h1": "bold reverse " + palette.black,
@@ -362,6 +368,7 @@ def get_theme(theme: str | None = None) -> Themes:
362
368
  "markdown.link_url": "underline " + palette.blue,
363
369
  "markdown.table.border": palette.grey2,
364
370
  "markdown.checkbox.checked": palette.green,
371
+ "markdown.block_quote": palette.cyan,
365
372
  }
366
373
  ),
367
374
  thinking_markdown_theme=Theme(
@@ -371,7 +378,8 @@ def get_theme(theme: str | None = None) -> Themes:
371
378
  ThemeKey.THINKING_BOLD.value: "italic " + palette.grey1,
372
379
  "markdown.strong": "italic " + palette.grey1,
373
380
  "markdown.code": palette.grey1 + " italic on " + palette.code_background,
374
- "markdown.code.block": palette.grey1,
381
+ "markdown.code.block": palette.grey2,
382
+ "markdown.code.fence": palette.grey3,
375
383
  "markdown.code.border": palette.grey3,
376
384
  "markdown.thinking.tag": palette.grey2 + " dim",
377
385
  "markdown.h1": "bold reverse",
@@ -385,6 +393,7 @@ def get_theme(theme: str | None = None) -> Themes:
385
393
  "markdown.link_url": "underline " + palette.blue,
386
394
  "markdown.table.border": palette.grey2,
387
395
  "markdown.checkbox.checked": palette.green,
396
+ "markdown.block_quote": palette.grey1,
388
397
  }
389
398
  ),
390
399
  code_theme=palette.code_theme,
@@ -565,6 +565,7 @@ _TOOL_ACTIVE_FORM: dict[str, str] = {
565
565
  tools.WEB_SEARCH: "Searching Web",
566
566
  tools.REPORT_BACK: "Reporting",
567
567
  tools.IMAGE_GEN: "Generating Image",
568
+ tools.TASK: "Spawning Task",
568
569
  }
569
570
 
570
571
 
@@ -1,21 +1,19 @@
1
1
  import re
2
2
 
3
3
  from rich.console import Group, RenderableType
4
+ from rich.padding import Padding
4
5
  from rich.text import Text
5
6
 
6
7
  from klaude_code.const import TAB_EXPAND_WIDTH
7
- from klaude_code.skill import get_available_skills
8
- from klaude_code.tui.components.common import create_grid
8
+ from klaude_code.skill import list_skill_names
9
+ from klaude_code.tui.components.bash_syntax import highlight_bash_command
9
10
  from klaude_code.tui.components.rich.theme import ThemeKey
10
11
 
11
- # Match @-file patterns only when they appear at the beginning of the line
12
+ # Match inline patterns only when they appear at the beginning of the line
12
13
  # or immediately after whitespace, to avoid treating mid-word email-like
13
14
  # patterns such as foo@bar.com as file references.
14
- AT_FILE_RENDER_PATTERN = re.compile(r'(?<!\S)@("([^"]+)"|\S+)')
15
-
16
- # Match $skill or ¥skill pattern inline (at start of line or after whitespace)
17
- SKILL_RENDER_PATTERN = re.compile(r"(?<!\S)[$¥](\S+)")
18
-
15
+ # Group 1 is present only for $/¥ skills and captures the skill token (without the $/¥).
16
+ INLINE_RENDER_PATTERN = re.compile(r'(?<!\S)(?:@(?:"[^"]+"|\S+)|[$¥](\S+))')
19
17
  USER_MESSAGE_MARK = "❯ "
20
18
 
21
19
 
@@ -24,86 +22,77 @@ def render_at_and_skill_patterns(
24
22
  at_style: str = ThemeKey.USER_INPUT_AT_PATTERN,
25
23
  skill_style: str = ThemeKey.USER_INPUT_SKILL,
26
24
  other_style: str = ThemeKey.USER_INPUT,
25
+ available_skill_names: set[str] | None = None,
27
26
  ) -> Text:
28
27
  """Render text with highlighted @file and $skill patterns."""
29
- has_at = "@" in text
30
- has_skill = "$" in text or "\u00a5" in text # $ or ¥
31
-
32
- if not has_at and not has_skill:
33
- return Text(text, style=other_style)
34
-
35
- # Collect all matches with their styles
36
- matches: list[tuple[int, int, str]] = [] # (start, end, style)
37
-
38
- if has_at:
39
- for match in AT_FILE_RENDER_PATTERN.finditer(text):
40
- matches.append((match.start(), match.end(), at_style))
41
-
42
- if has_skill:
43
- for match in SKILL_RENDER_PATTERN.finditer(text):
44
- skill_name = match.group(1)
45
- if _is_valid_skill_name(skill_name):
46
- matches.append((match.start(), match.end(), skill_style))
47
-
48
- if not matches:
49
- return Text(text, style=other_style)
50
-
51
- # Sort by start position
52
- matches.sort(key=lambda x: x[0])
28
+ result = Text(text, style=other_style)
29
+ for match in INLINE_RENDER_PATTERN.finditer(text):
30
+ skill_name = match.group(1)
31
+ if skill_name is None:
32
+ result.stylize(at_style, match.start(), match.end())
33
+ continue
53
34
 
54
- result = Text("")
55
- last_end = 0
56
- for start, end, style in matches:
57
- if start < last_end:
58
- continue # Skip overlapping matches
59
- if start > last_end:
60
- result.append_text(Text(text[last_end:start], other_style))
61
- result.append_text(Text(text[start:end], style))
62
- last_end = end
35
+ if available_skill_names is None:
36
+ available_skill_names = set(list_skill_names())
63
37
 
64
- if last_end < len(text):
65
- result.append_text(Text(text[last_end:], other_style))
38
+ short = skill_name.split(":")[-1] if ":" in skill_name else skill_name
39
+ if skill_name in available_skill_names or short in available_skill_names:
40
+ result.stylize(skill_style, match.start(), match.end())
66
41
 
67
42
  return result
68
43
 
69
44
 
70
- def _is_valid_skill_name(name: str) -> bool:
71
- """Check if a skill name is valid (exists in loaded skills)."""
72
- short = name.split(":")[-1] if ":" in name else name
73
- available_skills = get_available_skills()
74
- return any(skill_name in (name, short) for skill_name, _, _ in available_skills)
75
-
76
-
77
45
  def render_user_input(content: str) -> RenderableType:
78
46
  """Render a user message as a group of quoted lines with styles.
79
47
 
80
48
  - Highlights slash command token on the first line
81
49
  - Highlights @file and $skill patterns in all lines
50
+ - Wrapped in a Panel for block-style background
82
51
  """
83
52
  lines = content.strip().split("\n")
53
+ is_bash_mode = bool(lines) and lines[0].startswith("!")
54
+
55
+ available_skill_names: set[str] | None = None
56
+
84
57
  renderables: list[RenderableType] = []
85
58
  for i, line in enumerate(lines):
86
- line = line.expandtabs(TAB_EXPAND_WIDTH)
59
+ if not line.strip():
60
+ continue
61
+ if "\t" in line:
62
+ line = line.expandtabs(TAB_EXPAND_WIDTH)
63
+
64
+ if is_bash_mode and i == 0:
65
+ renderables.append(highlight_bash_command(line[1:]))
66
+ continue
67
+ if is_bash_mode and i > 0:
68
+ renderables.append(highlight_bash_command(line))
69
+ continue
70
+
71
+ if available_skill_names is None and ("$" in line or "\u00a5" in line):
72
+ available_skill_names = set(list_skill_names())
87
73
  # Handle slash command on first line
88
74
  if i == 0 and line.startswith("/"):
89
75
  splits = line.split(" ", maxsplit=1)
90
76
  line_text = Text.assemble(
91
77
  (splits[0], ThemeKey.USER_INPUT_SLASH_COMMAND),
92
78
  " ",
93
- render_at_and_skill_patterns(splits[1]) if len(splits) > 1 else Text(""),
79
+ render_at_and_skill_patterns(splits[1], available_skill_names=available_skill_names)
80
+ if len(splits) > 1
81
+ else Text(""),
94
82
  )
95
83
  renderables.append(line_text)
96
84
  continue
97
85
 
98
86
  # Render @file and $skill patterns
99
- renderables.append(render_at_and_skill_patterns(line))
87
+ renderables.append(render_at_and_skill_patterns(line, available_skill_names=available_skill_names))
100
88
 
101
- grid = create_grid()
102
- grid.padding = (0, 0)
103
- mark = Text(USER_MESSAGE_MARK, style=ThemeKey.USER_INPUT_PROMPT)
104
- grid.add_row(mark, Group(*renderables))
105
- return grid
89
+ return Padding(
90
+ Group(*renderables),
91
+ pad=(0, 1),
92
+ style=ThemeKey.USER_INPUT,
93
+ expand=False,
94
+ )
106
95
 
107
96
 
108
97
  def render_interrupt() -> RenderableType:
109
- return Text(" Interrupted by user\n", style=ThemeKey.INTERRUPT)
98
+ return Text("Interrupted by user", style=ThemeKey.INTERRUPT)
@@ -1,4 +1,5 @@
1
1
  from importlib.metadata import PackageNotFoundError, version
2
+ from pathlib import Path
2
3
 
3
4
  from rich.console import Group, RenderableType
4
5
  from rich.text import Text
@@ -10,6 +11,19 @@ from klaude_code.tui.components.rich.theme import ThemeKey
10
11
  from klaude_code.ui.common import format_model_params
11
12
 
12
13
 
14
+ def _format_memory_path(path: str, *, work_dir: Path) -> str:
15
+ """Format memory path for display - show relative path or ~ for home."""
16
+ p = Path(path)
17
+ try:
18
+ return str(p.relative_to(work_dir))
19
+ except ValueError:
20
+ pass
21
+ try:
22
+ return f"~/{p.relative_to(Path.home())}"
23
+ except ValueError:
24
+ return path
25
+
26
+
13
27
  def _get_version() -> str:
14
28
  """Get the current version of klaude-code."""
15
29
  try:
@@ -50,7 +64,7 @@ def render_welcome(e: events.WelcomeEvent) -> RenderableType:
50
64
  # Render config items with tree-style prefixes
51
65
  for i, param_str in enumerate(param_strings):
52
66
  is_last = i == len(param_strings) - 1
53
- prefix = "└─ " if is_last else "├─ "
67
+ prefix = "╰─ " if is_last else "├─ "
54
68
  panel_content.append_text(
55
69
  Text.assemble(
56
70
  ("\n", ThemeKey.WELCOME_INFO),
@@ -59,6 +73,37 @@ def render_welcome(e: events.WelcomeEvent) -> RenderableType:
59
73
  )
60
74
  )
61
75
 
76
+ # Loaded memories summary
77
+ work_dir = Path(e.work_dir)
78
+ loaded_memories = e.loaded_memories or {}
79
+ user_memories = loaded_memories.get("user") or []
80
+ project_memories = loaded_memories.get("project") or []
81
+
82
+ memory_groups: list[tuple[str, list[str]]] = []
83
+ if user_memories:
84
+ memory_groups.append(("user", user_memories))
85
+ if project_memories:
86
+ memory_groups.append(("project", project_memories))
87
+
88
+ if memory_groups:
89
+ panel_content.append_text(Text("\n\n", style=ThemeKey.WELCOME_INFO))
90
+ panel_content.append_text(Text("context", style=ThemeKey.WELCOME_HIGHLIGHT))
91
+
92
+ label_width = len("[project]")
93
+
94
+ for i, (group_name, paths) in enumerate(memory_groups):
95
+ is_last = i == len(memory_groups) - 1
96
+ prefix = "╰─ " if is_last else "├─ "
97
+ label = f"[{group_name}]"
98
+ formatted_paths = ", ".join(_format_memory_path(p, work_dir=work_dir) for p in paths)
99
+ panel_content.append_text(
100
+ Text.assemble(
101
+ ("\n", ThemeKey.WELCOME_INFO),
102
+ (prefix, ThemeKey.LINES),
103
+ (f"{label.ljust(label_width)} {formatted_paths}", ThemeKey.WELCOME_INFO),
104
+ )
105
+ )
106
+
62
107
  # Loaded skills summary is provided by core via WelcomeEvent to keep TUI decoupled.
63
108
  loaded_skills = e.loaded_skills or {}
64
109
  user_skills = loaded_skills.get("user") or []
@@ -81,7 +126,7 @@ def render_welcome(e: events.WelcomeEvent) -> RenderableType:
81
126
 
82
127
  for i, (group_name, skills) in enumerate(skill_groups):
83
128
  is_last = i == len(skill_groups) - 1
84
- prefix = "└─ " if is_last else "├─ "
129
+ prefix = "╰─ " if is_last else "├─ "
85
130
  label = f"[{group_name}]"
86
131
  panel_content.append_text(
87
132
  Text.assemble(
@@ -31,12 +31,20 @@ class TUIDisplay(DisplayABC):
31
31
  @override
32
32
  async def consume_event(self, event: events.Event) -> None:
33
33
  if isinstance(event, events.ReplayHistoryEvent):
34
- await self._renderer.execute(self._machine.begin_replay())
35
- for item in event.events:
36
- commands = self._machine.transition_replay(item)
37
- if commands:
38
- await self._renderer.execute(commands)
39
- await self._renderer.execute(self._machine.end_replay())
34
+ # Replay does not need streaming UI; disable bottom Live rendering to avoid
35
+ # repaint overhead and flicker while reconstructing history.
36
+ self._renderer.stop_bottom_live()
37
+ self._renderer.set_stream_renderable(None)
38
+ self._renderer.set_replay_mode(True)
39
+ try:
40
+ await self._renderer.execute(self._machine.begin_replay())
41
+ for item in event.events:
42
+ commands = self._machine.transition_replay(item)
43
+ if commands:
44
+ await self._renderer.execute(commands)
45
+ await self._renderer.execute(self._machine.end_replay())
46
+ finally:
47
+ self._renderer.set_replay_mode(False)
40
48
  return
41
49
 
42
50
  commands = self._machine.transition(event)
@@ -92,4 +100,4 @@ class TUIDisplay(DisplayABC):
92
100
  with contextlib.suppress(Exception):
93
101
  self._renderer.spinner_stop()
94
102
  with contextlib.suppress(Exception):
95
- self._renderer.stop_bottom_live()
103
+ self._renderer.stop_bottom_live()