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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
632
|
+
drop = min(stable_trailing_blank, live_leading_blank)
|
|
633
|
+
if drop > 0:
|
|
634
|
+
live_lines = live_lines[drop:]
|
|
706
635
|
|
|
707
|
-
|
|
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
|
|
263
|
-
ThemeKey.USER_INPUT_AT_PATTERN.value: palette.purple,
|
|
264
|
-
ThemeKey.USER_INPUT_SLASH_COMMAND.value: "bold
|
|
265
|
-
ThemeKey.USER_INPUT_SKILL.value: "bold
|
|
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.
|
|
270
|
-
ThemeKey.METADATA_DIM.value: "dim " + palette.
|
|
271
|
-
ThemeKey.METADATA_BOLD.value: "bold " + palette.
|
|
272
|
-
ThemeKey.METADATA_ITALIC.value: "italic " + palette.
|
|
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.
|
|
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,
|
|
@@ -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
|
|
8
|
-
from klaude_code.tui.components.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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("
|
|
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 = "
|
|
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 = "
|
|
129
|
+
prefix = "╰─ " if is_last else "├─ "
|
|
85
130
|
label = f"[{group_name}]"
|
|
86
131
|
panel_content.append_text(
|
|
87
132
|
Text.assemble(
|
klaude_code/tui/display.py
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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()
|