klaude-code 2.9.0__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 (69) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/auth/antigravity/oauth.py +33 -29
  3. klaude_code/auth/claude/oauth.py +34 -49
  4. klaude_code/cli/cost_cmd.py +4 -4
  5. klaude_code/cli/list_model.py +1 -2
  6. klaude_code/config/assets/builtin_config.yaml +17 -0
  7. klaude_code/const.py +4 -3
  8. klaude_code/core/agent_profile.py +2 -5
  9. klaude_code/core/bash_mode.py +276 -0
  10. klaude_code/core/executor.py +40 -7
  11. klaude_code/core/manager/llm_clients.py +1 -0
  12. klaude_code/core/manager/llm_clients_builder.py +2 -2
  13. klaude_code/core/memory.py +140 -0
  14. klaude_code/core/reminders.py +17 -89
  15. klaude_code/core/task.py +1 -1
  16. klaude_code/core/tool/file/read_tool.py +13 -2
  17. klaude_code/core/tool/shell/bash_tool.py +1 -1
  18. klaude_code/core/turn.py +10 -4
  19. klaude_code/llm/bedrock_anthropic/__init__.py +3 -0
  20. klaude_code/llm/input_common.py +18 -0
  21. klaude_code/llm/{codex → openai_codex}/__init__.py +1 -1
  22. klaude_code/llm/{codex → openai_codex}/client.py +3 -3
  23. klaude_code/llm/openai_compatible/client.py +3 -1
  24. klaude_code/llm/openai_compatible/stream.py +19 -9
  25. klaude_code/llm/{responses → openai_responses}/client.py +1 -1
  26. klaude_code/llm/registry.py +3 -3
  27. klaude_code/llm/stream_parts.py +3 -1
  28. klaude_code/llm/usage.py +1 -1
  29. klaude_code/protocol/events.py +17 -1
  30. klaude_code/protocol/message.py +1 -0
  31. klaude_code/protocol/model.py +14 -1
  32. klaude_code/protocol/op.py +12 -0
  33. klaude_code/protocol/op_handler.py +5 -0
  34. klaude_code/session/session.py +22 -1
  35. klaude_code/tui/command/resume_cmd.py +1 -1
  36. klaude_code/tui/commands.py +15 -0
  37. klaude_code/tui/components/bash_syntax.py +4 -0
  38. klaude_code/tui/components/command_output.py +4 -5
  39. klaude_code/tui/components/developer.py +1 -3
  40. klaude_code/tui/components/diffs.py +3 -2
  41. klaude_code/tui/components/metadata.py +23 -26
  42. klaude_code/tui/components/rich/code_panel.py +31 -16
  43. klaude_code/tui/components/rich/markdown.py +44 -28
  44. klaude_code/tui/components/rich/status.py +2 -2
  45. klaude_code/tui/components/rich/theme.py +28 -16
  46. klaude_code/tui/components/tools.py +23 -0
  47. klaude_code/tui/components/user_input.py +49 -58
  48. klaude_code/tui/components/welcome.py +47 -2
  49. klaude_code/tui/display.py +15 -7
  50. klaude_code/tui/input/completers.py +8 -0
  51. klaude_code/tui/input/key_bindings.py +37 -1
  52. klaude_code/tui/input/prompt_toolkit.py +58 -31
  53. klaude_code/tui/machine.py +87 -49
  54. klaude_code/tui/renderer.py +148 -30
  55. klaude_code/tui/runner.py +22 -0
  56. klaude_code/tui/terminal/image.py +24 -3
  57. klaude_code/tui/terminal/notifier.py +11 -12
  58. klaude_code/tui/terminal/selector.py +1 -1
  59. klaude_code/ui/terminal/title.py +4 -2
  60. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/METADATA +1 -1
  61. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/RECORD +67 -66
  62. klaude_code/llm/bedrock/__init__.py +0 -3
  63. klaude_code/tui/components/assistant.py +0 -2
  64. /klaude_code/llm/{bedrock → bedrock_anthropic}/client.py +0 -0
  65. /klaude_code/llm/{codex → openai_codex}/prompt_sync.py +0 -0
  66. /klaude_code/llm/{responses → openai_responses}/__init__.py +0 -0
  67. /klaude_code/llm/{responses → openai_responses}/input.py +0 -0
  68. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/WHEEL +0 -0
  69. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/entry_points.txt +0 -0
@@ -14,7 +14,6 @@ from rich import box
14
14
  from rich._loop import loop_first
15
15
  from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
16
16
  from rich.markdown import CodeBlock, Heading, ImageItem, ListItem, Markdown, MarkdownElement, TableElement
17
- from rich.rule import Rule
18
17
  from rich.segment import Segment
19
18
  from rich.style import Style, StyleType
20
19
  from rich.syntax import Syntax
@@ -23,12 +22,10 @@ from rich.text import Text
23
22
  from rich.theme import Theme
24
23
 
25
24
  from klaude_code.const import (
26
- MARKDOWN_RIGHT_MARGIN,
27
25
  MARKDOWN_STREAM_LIVE_REPAINT_ENABLED,
28
26
  MARKDOWN_STREAM_SYNCHRONIZED_OUTPUT_ENABLED,
29
27
  UI_REFRESH_RATE_FPS,
30
28
  )
31
- from klaude_code.tui.components.rich.code_panel import CodePanel
32
29
 
33
30
  _THINKING_HTML_BLOCK_RE = re.compile(
34
31
  r"\A\s*<thinking>\s*\n?(?P<body>.*?)(?:\n\s*)?</thinking>\s*\Z",
@@ -92,10 +89,15 @@ class ThinkingHTMLBlock(MarkdownElement):
92
89
 
93
90
 
94
91
  class NoInsetCodeBlock(CodeBlock):
95
- """A code block with syntax highlighting and no padding."""
92
+ """A code block with syntax highlighting using markdown fence style."""
96
93
 
97
94
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
98
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))
99
101
  syntax = Syntax(
100
102
  code,
101
103
  self.lexer_name,
@@ -103,16 +105,21 @@ class NoInsetCodeBlock(CodeBlock):
103
105
  word_wrap=True,
104
106
  padding=(0, 0),
105
107
  )
106
- yield CodePanel(syntax, border_style="markdown.code.border")
108
+ yield syntax
109
+ yield Text("```", style=fence_style)
107
110
 
108
111
 
109
112
  class ThinkingCodeBlock(CodeBlock):
110
- """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."""
111
114
 
112
115
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
113
116
  code = str(self.text).rstrip()
114
- text = Text(code, "markdown.code.block")
115
- 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)
116
123
 
117
124
 
118
125
  class Divider(MarkdownElement):
@@ -120,7 +127,8 @@ class Divider(MarkdownElement):
120
127
 
121
128
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
122
129
  style = console.get_style("markdown.hr", default="none")
123
- yield Rule(style=style, characters="-")
130
+ width = min(options.max_width, 100)
131
+ yield Text("-" * width, style=style)
124
132
 
125
133
 
126
134
  class MarkdownTable(TableElement):
@@ -153,7 +161,8 @@ class LeftHeading(Heading):
153
161
  h1_text = text.assemble((" ", "markdown.h1"), text, (" ", "markdown.h1"))
154
162
  yield h1_text
155
163
  elif self.tag == "h2":
156
- text.stylize(Style(bold=True, underline=False))
164
+ h2_style = console.get_style("markdown.h2", default="bold")
165
+ text.stylize(h2_style + Style(underline=False))
157
166
  yield text
158
167
  else:
159
168
  yield text
@@ -288,7 +297,7 @@ class MarkdownStream:
288
297
  mark: str | None = None,
289
298
  mark_style: StyleType | None = None,
290
299
  left_margin: int = 0,
291
- right_margin: int = MARKDOWN_RIGHT_MARGIN,
300
+ right_margin: int = 0,
292
301
  markdown_class: Callable[..., Markdown] | None = None,
293
302
  image_callback: Callable[[str], None] | None = None,
294
303
  ) -> None:
@@ -324,10 +333,10 @@ class MarkdownStream:
324
333
 
325
334
  self.theme = theme
326
335
  self.console = console
327
- self.mark: str | None = mark
328
- self.mark_style: StyleType | None = mark_style
329
-
330
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
331
340
 
332
341
  self.right_margin: int = max(right_margin, 0)
333
342
  self.markdown_class: Callable[..., Markdown] = markdown_class or NoInsetMarkdown
@@ -512,12 +521,17 @@ class MarkdownStream:
512
521
 
513
522
  collected_images = getattr(markdown, "collected_images", [])
514
523
 
515
- # Split rendered output into lines, strip trailing spaces, and apply left margin.
516
524
  lines = output.splitlines(keepends=True)
517
- 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
518
533
  processed_lines: list[str] = []
519
534
  mark_applied = False
520
- use_mark = apply_mark and bool(self.mark) and self.left_margin >= 2
521
535
 
522
536
  # Pre-render styled mark if needed
523
537
  styled_mark: str | None = None
@@ -538,7 +552,7 @@ class MarkdownStream:
538
552
  if use_mark and not mark_applied and stripped:
539
553
  stripped = f"{styled_mark} {stripped}"
540
554
  mark_applied = True
541
- elif indent_prefix:
555
+ else:
542
556
  stripped = indent_prefix + stripped
543
557
 
544
558
  if line.endswith("\n"):
@@ -595,8 +609,11 @@ class MarkdownStream:
595
609
 
596
610
  live_text_to_set: Text | None = None
597
611
  if not final and MARKDOWN_STREAM_LIVE_REPAINT_ENABLED and self._live_sink is not None:
598
- apply_mark_live = self._stable_source_line_count == 0
599
- live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_live)
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)
600
617
 
601
618
  if self._stable_rendered_lines:
602
619
  stable_trailing_blank = 0
@@ -619,6 +636,13 @@ class MarkdownStream:
619
636
  live_text_to_set = Text.from_ansi("".join(live_lines))
620
637
 
621
638
  with self._synchronized_output():
639
+ # Update/clear live area first to avoid blank padding when stable block appears
640
+ if final:
641
+ if self._live_sink is not None:
642
+ self._live_sink(None)
643
+ elif live_text_to_set is not None and self._live_sink is not None:
644
+ self._live_sink(live_text_to_set)
645
+
622
646
  if stable_chunk_to_print:
623
647
  self.console.print(Text.from_ansi(stable_chunk_to_print), end="\n")
624
648
 
@@ -626,13 +650,5 @@ class MarkdownStream:
626
650
  for img_path in new_images:
627
651
  self._image_callback(img_path)
628
652
 
629
- if final:
630
- if self._live_sink is not None:
631
- self._live_sink(None)
632
- return
633
-
634
- if live_text_to_set is not None and self._live_sink is not None:
635
- self._live_sink(live_text_to_set)
636
-
637
653
  elapsed = time.time() - start
638
654
  self.min_delay = min(max(elapsed * 6, 1.0 / 30), 0.5)
@@ -277,7 +277,7 @@ def truncate_left(text: Text, max_cells: int, *, console: Console, ellipsis: str
277
277
  if cell_len(text.plain) <= max_cells:
278
278
  return text
279
279
 
280
- ellipsis_cells = cell_len(ellipsis)
280
+ ellipsis_cells = cell_len(ellipsis) + 1 # +1 for trailing space
281
281
  if max_cells <= ellipsis_cells:
282
282
  # Not enough space to show any meaningful suffix.
283
283
  clipped = Text(ellipsis, style=text.style)
@@ -307,7 +307,7 @@ def truncate_left(text: Text, max_cells: int, *, console: Console, ellipsis: str
307
307
  except Exception:
308
308
  ellipsis_style = suffix.style or text.style
309
309
 
310
- return Text.assemble(Text(ellipsis, style=ellipsis_style), suffix)
310
+ return Text.assemble(Text(ellipsis + " ", style=ellipsis_style), suffix)
311
311
 
312
312
 
313
313
  class ShimmerStatusText:
@@ -21,6 +21,7 @@ class Palette:
21
21
  grey_green: str
22
22
  purple: str
23
23
  lavender: str
24
+ black: str
24
25
  diff_add: str
25
26
  diff_add_char: str
26
27
  diff_remove: str
@@ -38,6 +39,7 @@ class Palette:
38
39
  red_background: str
39
40
  grey_background: str
40
41
  yellow_background: str
42
+ user_message_background: str
41
43
 
42
44
 
43
45
  LIGHT_PALETTE = Palette(
@@ -55,6 +57,7 @@ LIGHT_PALETTE = Palette(
55
57
  grey_green="#96a096",
56
58
  purple="#5f5fb7",
57
59
  lavender="#7878b0",
60
+ black="#101827",
58
61
  diff_add="#2e5a32 on #dafbe1",
59
62
  diff_add_char="#2e5a32 on #aceebb",
60
63
  diff_remove="#82071e on #ffecec",
@@ -71,6 +74,7 @@ LIGHT_PALETTE = Palette(
71
74
  red_background="#f9ecec",
72
75
  grey_background="#f0f0f0",
73
76
  yellow_background="#f9f9ec",
77
+ user_message_background="#f0f0f0",
74
78
  )
75
79
 
76
80
  DARK_PALETTE = Palette(
@@ -88,6 +92,7 @@ DARK_PALETTE = Palette(
88
92
  grey_green="#6d8672",
89
93
  purple="#afbafe",
90
94
  lavender="#9898b8",
95
+ black="white",
91
96
  diff_add="#c8e6c9 on #1b3928",
92
97
  diff_add_char="#c8e6c9 on #2d6b42",
93
98
  diff_remove="#ffcdd2 on #3d1f23",
@@ -104,6 +109,7 @@ DARK_PALETTE = Palette(
104
109
  red_background="#3d1f23",
105
110
  grey_background="#2a2d30",
106
111
  yellow_background="#3d3a1a",
112
+ user_message_background="#2a2d30",
107
113
  )
108
114
 
109
115
 
@@ -113,6 +119,7 @@ class ThemeKey(str, Enum):
113
119
 
114
120
  # CODE
115
121
  CODE_BACKGROUND = "code_background"
122
+ CODE_PANEL_TITLE = "code_panel.title"
116
123
 
117
124
  # PANEL
118
125
  SUB_AGENT_RESULT_PANEL = "panel.sub_agent_result"
@@ -255,18 +262,18 @@ def get_theme(theme: str | None = None) -> Themes:
255
262
  ThemeKey.ERROR_DIM.value: "dim " + palette.red,
256
263
  ThemeKey.INTERRUPT.value: palette.red,
257
264
  # USER_INPUT
258
- ThemeKey.USER_INPUT.value: palette.magenta,
259
- ThemeKey.USER_INPUT_PROMPT.value: "bold " + palette.magenta,
260
- ThemeKey.USER_INPUT_AT_PATTERN.value: palette.purple,
261
- ThemeKey.USER_INPUT_SLASH_COMMAND.value: "bold " + palette.blue,
262
- 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}",
263
270
  # ASSISTANT
264
271
  ThemeKey.ASSISTANT_MESSAGE_MARK.value: "bold",
265
272
  # METADATA
266
- ThemeKey.METADATA.value: palette.lavender,
267
- ThemeKey.METADATA_DIM.value: "dim " + palette.lavender,
268
- ThemeKey.METADATA_BOLD.value: "bold " + palette.lavender,
269
- 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,
270
277
  # STATUS
271
278
  ThemeKey.STATUS_SPINNER.value: palette.blue,
272
279
  ThemeKey.STATUS_TEXT.value: palette.blue,
@@ -301,9 +308,9 @@ def get_theme(theme: str | None = None) -> Themes:
301
308
  ThemeKey.BASH_HEREDOC_DELIMITER.value: "bold " + palette.grey1,
302
309
  # THINKING
303
310
  ThemeKey.THINKING.value: "italic " + palette.grey2,
304
- ThemeKey.THINKING_BOLD.value: "bold italic " + palette.grey1,
311
+ ThemeKey.THINKING_BOLD.value: "italic " + palette.grey1,
305
312
  # COMPACTION
306
- ThemeKey.COMPACTION_SUMMARY.value: "italic " + palette.grey1,
313
+ ThemeKey.COMPACTION_SUMMARY.value: palette.grey1,
307
314
  # TODO_ITEM
308
315
  ThemeKey.TODO_EXPLANATION.value: palette.grey1 + " italic",
309
316
  ThemeKey.TODO_PENDING_MARK.value: "bold " + palette.grey1,
@@ -345,11 +352,13 @@ def get_theme(theme: str | None = None) -> Themes:
345
352
  "markdown.thinking": "italic " + palette.grey2,
346
353
  "markdown.thinking.tag": palette.grey2,
347
354
  "markdown.code.border": palette.grey3,
355
+ "markdown.code.fence": palette.grey3,
356
+ "markdown.code.fence.title": palette.grey1,
348
357
  # Used by ThinkingMarkdown when rendering `<thinking>` blocks.
349
358
  "markdown.code.block": palette.grey1,
350
- "markdown.h1": "bold reverse",
359
+ "markdown.h1": "bold reverse " + palette.black,
351
360
  "markdown.h1.border": palette.grey3,
352
- "markdown.h2": "bold underline",
361
+ "markdown.h2": "bold underline " + palette.black,
353
362
  "markdown.h3": "bold " + palette.grey1,
354
363
  "markdown.h4": "bold " + palette.grey2,
355
364
  "markdown.hr": palette.grey3,
@@ -359,15 +368,18 @@ def get_theme(theme: str | None = None) -> Themes:
359
368
  "markdown.link_url": "underline " + palette.blue,
360
369
  "markdown.table.border": palette.grey2,
361
370
  "markdown.checkbox.checked": palette.green,
371
+ "markdown.block_quote": palette.cyan,
362
372
  }
363
373
  ),
364
374
  thinking_markdown_theme=Theme(
365
375
  styles={
366
376
  # THINKING (used for left-side mark in thinking output)
367
377
  ThemeKey.THINKING.value: "italic " + palette.grey2,
368
- ThemeKey.THINKING_BOLD.value: "bold italic " + palette.grey1,
378
+ ThemeKey.THINKING_BOLD.value: "italic " + palette.grey1,
379
+ "markdown.strong": "italic " + palette.grey1,
369
380
  "markdown.code": palette.grey1 + " italic on " + palette.code_background,
370
- "markdown.code.block": palette.grey1,
381
+ "markdown.code.block": palette.grey2,
382
+ "markdown.code.fence": palette.grey3,
371
383
  "markdown.code.border": palette.grey3,
372
384
  "markdown.thinking.tag": palette.grey2 + " dim",
373
385
  "markdown.h1": "bold reverse",
@@ -379,9 +391,9 @@ def get_theme(theme: str | None = None) -> Themes:
379
391
  "markdown.item.number": palette.grey2,
380
392
  "markdown.link": "underline " + palette.blue,
381
393
  "markdown.link_url": "underline " + palette.blue,
382
- "markdown.strong": "bold italic " + palette.grey1,
383
394
  "markdown.table.border": palette.grey2,
384
395
  "markdown.checkbox.checked": palette.green,
396
+ "markdown.block_quote": palette.grey1,
385
397
  }
386
398
  ),
387
399
  code_theme=palette.code_theme,
@@ -10,8 +10,10 @@ from rich.text import Text
10
10
 
11
11
  from klaude_code.const import (
12
12
  BASH_OUTPUT_PANEL_THRESHOLD,
13
+ DIFF_PREFIX_WIDTH,
13
14
  INVALID_TOOL_CALL_MAX_LENGTH,
14
15
  QUERY_DISPLAY_TRUNCATE_LENGTH,
16
+ TAB_EXPAND_WIDTH,
15
17
  URL_TRUNCATE_MAX_LENGTH,
16
18
  WEB_SEARCH_DEFAULT_MAX_RESULTS,
17
19
  )
@@ -387,6 +389,24 @@ def render_generic_tool_result(result: str, *, is_error: bool = False) -> Render
387
389
  return text
388
390
 
389
391
 
392
+ def render_read_preview(ui_extra: model.ReadPreviewUIExtra) -> RenderableType:
393
+ """Render read preview with line numbers aligned to diff style."""
394
+ grid = create_grid()
395
+ grid.padding = (0, 0)
396
+
397
+ for line in ui_extra.lines:
398
+ prefix = f"{line.line_no:>{DIFF_PREFIX_WIDTH}} "
399
+ content = line.content.expandtabs(TAB_EXPAND_WIDTH)
400
+ grid.add_row(Text(prefix, ThemeKey.TOOL_RESULT), Text(content, ThemeKey.TOOL_RESULT))
401
+
402
+ if ui_extra.remaining_lines > 0:
403
+ remaining_prefix = f"{'⋮':>{DIFF_PREFIX_WIDTH}} "
404
+ remaining_text = Text(f"(more {ui_extra.remaining_lines} lines)", ThemeKey.TOOL_RESULT_TRUNCATED)
405
+ grid.add_row(Text(remaining_prefix, ThemeKey.TOOL_RESULT_TRUNCATED), remaining_text)
406
+
407
+ return grid
408
+
409
+
390
410
  def _extract_mermaid_link(
391
411
  ui_extra: model.ToolResultUIExtra | None,
392
412
  ) -> model.MermaidLinkUIExtra | None:
@@ -545,6 +565,7 @@ _TOOL_ACTIVE_FORM: dict[str, str] = {
545
565
  tools.WEB_SEARCH: "Searching Web",
546
566
  tools.REPORT_BACK: "Reporting",
547
567
  tools.IMAGE_GEN: "Generating Image",
568
+ tools.TASK: "Spawning Task",
548
569
  }
549
570
 
550
571
 
@@ -678,6 +699,8 @@ def render_tool_result(
678
699
 
679
700
  match e.tool_name:
680
701
  case tools.READ:
702
+ if isinstance(e.ui_extra, model.ReadPreviewUIExtra):
703
+ return wrap(render_read_preview(e.ui_extra))
681
704
  return None
682
705
  case tools.EDIT:
683
706
  return wrap(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""))
@@ -1,20 +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
- from klaude_code.skill import get_available_skills
7
- from klaude_code.tui.components.common import create_grid
7
+ from klaude_code.const import TAB_EXPAND_WIDTH
8
+ from klaude_code.skill import list_skill_names
9
+ from klaude_code.tui.components.bash_syntax import highlight_bash_command
8
10
  from klaude_code.tui.components.rich.theme import ThemeKey
9
11
 
10
- # 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
11
13
  # or immediately after whitespace, to avoid treating mid-word email-like
12
14
  # patterns such as foo@bar.com as file references.
13
- AT_FILE_RENDER_PATTERN = re.compile(r'(?<!\S)@("([^"]+)"|\S+)')
14
-
15
- # Match $skill or ¥skill pattern inline (at start of line or after whitespace)
16
- SKILL_RENDER_PATTERN = re.compile(r"(?<!\S)[$¥](\S+)")
17
-
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+))')
18
17
  USER_MESSAGE_MARK = "❯ "
19
18
 
20
19
 
@@ -23,85 +22,77 @@ def render_at_and_skill_patterns(
23
22
  at_style: str = ThemeKey.USER_INPUT_AT_PATTERN,
24
23
  skill_style: str = ThemeKey.USER_INPUT_SKILL,
25
24
  other_style: str = ThemeKey.USER_INPUT,
25
+ available_skill_names: set[str] | None = None,
26
26
  ) -> Text:
27
27
  """Render text with highlighted @file and $skill patterns."""
28
- has_at = "@" in text
29
- has_skill = "$" in text or "\u00a5" in text # $ or ¥
30
-
31
- if not has_at and not has_skill:
32
- return Text(text, style=other_style)
33
-
34
- # Collect all matches with their styles
35
- matches: list[tuple[int, int, str]] = [] # (start, end, style)
36
-
37
- if has_at:
38
- for match in AT_FILE_RENDER_PATTERN.finditer(text):
39
- matches.append((match.start(), match.end(), at_style))
40
-
41
- if has_skill:
42
- for match in SKILL_RENDER_PATTERN.finditer(text):
43
- skill_name = match.group(1)
44
- if _is_valid_skill_name(skill_name):
45
- matches.append((match.start(), match.end(), skill_style))
46
-
47
- if not matches:
48
- return Text(text, style=other_style)
49
-
50
- # Sort by start position
51
- 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
52
34
 
53
- result = Text("")
54
- last_end = 0
55
- for start, end, style in matches:
56
- if start < last_end:
57
- continue # Skip overlapping matches
58
- if start > last_end:
59
- result.append_text(Text(text[last_end:start], other_style))
60
- result.append_text(Text(text[start:end], style))
61
- last_end = end
35
+ if available_skill_names is None:
36
+ available_skill_names = set(list_skill_names())
62
37
 
63
- if last_end < len(text):
64
- 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())
65
41
 
66
42
  return result
67
43
 
68
44
 
69
- def _is_valid_skill_name(name: str) -> bool:
70
- """Check if a skill name is valid (exists in loaded skills)."""
71
- short = name.split(":")[-1] if ":" in name else name
72
- available_skills = get_available_skills()
73
- return any(skill_name in (name, short) for skill_name, _, _ in available_skills)
74
-
75
-
76
45
  def render_user_input(content: str) -> RenderableType:
77
46
  """Render a user message as a group of quoted lines with styles.
78
47
 
79
48
  - Highlights slash command token on the first line
80
49
  - Highlights @file and $skill patterns in all lines
50
+ - Wrapped in a Panel for block-style background
81
51
  """
82
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
+
83
57
  renderables: list[RenderableType] = []
84
58
  for i, line in enumerate(lines):
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())
85
73
  # Handle slash command on first line
86
74
  if i == 0 and line.startswith("/"):
87
75
  splits = line.split(" ", maxsplit=1)
88
76
  line_text = Text.assemble(
89
77
  (splits[0], ThemeKey.USER_INPUT_SLASH_COMMAND),
90
78
  " ",
91
- 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(""),
92
82
  )
93
83
  renderables.append(line_text)
94
84
  continue
95
85
 
96
86
  # Render @file and $skill patterns
97
- renderables.append(render_at_and_skill_patterns(line))
87
+ renderables.append(render_at_and_skill_patterns(line, available_skill_names=available_skill_names))
98
88
 
99
- grid = create_grid()
100
- grid.padding = (0, 0)
101
- mark = Text(USER_MESSAGE_MARK, style=ThemeKey.USER_INPUT_PROMPT)
102
- grid.add_row(mark, Group(*renderables))
103
- return grid
89
+ return Padding(
90
+ Group(*renderables),
91
+ pad=(0, 1),
92
+ style=ThemeKey.USER_INPUT,
93
+ expand=False,
94
+ )
104
95
 
105
96
 
106
97
  def render_interrupt() -> RenderableType:
107
- 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()