klaude-code 1.2.22__py3-none-any.whl → 1.2.23__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 (44) hide show
  1. klaude_code/command/status_cmd.py +1 -1
  2. klaude_code/const/__init__.py +8 -2
  3. klaude_code/core/manager/sub_agent_manager.py +1 -1
  4. klaude_code/core/reminders.py +51 -0
  5. klaude_code/core/task.py +37 -18
  6. klaude_code/core/tool/__init__.py +1 -4
  7. klaude_code/core/tool/skill/__init__.py +0 -0
  8. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -39
  9. klaude_code/protocol/model.py +2 -1
  10. klaude_code/session/export.py +1 -1
  11. klaude_code/session/store.py +4 -2
  12. klaude_code/skill/__init__.py +27 -0
  13. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  14. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  15. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  16. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  17. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  18. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +60 -24
  19. klaude_code/skill/manager.py +70 -0
  20. klaude_code/skill/system_skills.py +192 -0
  21. klaude_code/ui/modes/repl/completers.py +103 -3
  22. klaude_code/ui/modes/repl/event_handler.py +7 -3
  23. klaude_code/ui/modes/repl/input_prompt_toolkit.py +42 -3
  24. klaude_code/ui/renderers/assistant.py +7 -2
  25. klaude_code/ui/renderers/developer.py +12 -0
  26. klaude_code/ui/renderers/diffs.py +1 -1
  27. klaude_code/ui/renderers/metadata.py +4 -2
  28. klaude_code/ui/renderers/thinking.py +1 -1
  29. klaude_code/ui/renderers/tools.py +57 -32
  30. klaude_code/ui/renderers/user_input.py +32 -2
  31. klaude_code/ui/rich/markdown.py +22 -17
  32. klaude_code/ui/rich/status.py +1 -13
  33. klaude_code/ui/rich/theme.py +7 -5
  34. {klaude_code-1.2.22.dist-info → klaude_code-1.2.23.dist-info}/METADATA +18 -13
  35. {klaude_code-1.2.22.dist-info → klaude_code-1.2.23.dist-info}/RECORD +38 -35
  36. klaude_code/command/prompt-deslop.md +0 -14
  37. klaude_code/command/prompt-dev-docs-update.md +0 -56
  38. klaude_code/command/prompt-dev-docs.md +0 -46
  39. klaude_code/command/prompt-handoff.md +0 -33
  40. klaude_code/command/prompt-jj-workspace.md +0 -18
  41. klaude_code/core/tool/memory/__init__.py +0 -5
  42. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  43. {klaude_code-1.2.22.dist-info → klaude_code-1.2.23.dist-info}/WHEEL +0 -0
  44. {klaude_code-1.2.22.dist-info → klaude_code-1.2.23.dist-info}/entry_points.txt +0 -0
@@ -1,13 +1,15 @@
1
- """REPL completion handlers for @ file paths and / slash commands.
1
+ """REPL completion handlers for @ file paths, / slash commands, and $ skills.
2
2
 
3
3
  This module provides completers for the REPL input:
4
4
  - _SlashCommandCompleter: Completes slash commands on the first line
5
+ - _SkillCompleter: Completes skill names on the first line with $ prefix
5
6
  - _AtFilesCompleter: Completes @path segments using fd or ripgrep
6
- - _ComboCompleter: Combines both completers with priority logic
7
+ - _ComboCompleter: Combines all completers with priority logic
7
8
 
8
9
  Public API:
9
10
  - create_repl_completer(): Factory function to create the combined completer
10
11
  - AT_TOKEN_PATTERN: Regex pattern for @token matching (used by key bindings)
12
+ - SKILL_TOKEN_PATTERN: Regex pattern for $skill matching (used by key bindings)
11
13
  """
12
14
 
13
15
  from __future__ import annotations
@@ -34,6 +36,9 @@ from klaude_code.trace.log import DebugType, log_debug
34
36
  # single logical token.
35
37
  AT_TOKEN_PATTERN = re.compile(r'(^|\s)@(?P<frag>"[^"]*"|[^\s]*)$')
36
38
 
39
+ # Pattern to match $skill or ¥skill token for skill completion (used by key bindings).
40
+ SKILL_TOKEN_PATTERN = re.compile(r"^[$¥](?P<frag>\S*)$")
41
+
37
42
 
38
43
  def create_repl_completer() -> Completer:
39
44
  """Create and return the combined REPL completer.
@@ -121,12 +126,102 @@ class _SlashCommandCompleter(Completer):
121
126
  return bool(self._SLASH_TOKEN_RE.search(text_before))
122
127
 
123
128
 
129
+ class _SkillCompleter(Completer):
130
+ """Complete skill names at the beginning of the first line.
131
+
132
+ Behavior:
133
+ - Only triggers when cursor is on first line and text matches $ or ¥...
134
+ - Shows available skills with descriptions
135
+ - Inserts trailing space after completion
136
+ """
137
+
138
+ _SKILL_TOKEN_RE = SKILL_TOKEN_PATTERN
139
+
140
+ def get_completions(
141
+ self,
142
+ document: Document,
143
+ complete_event, # type: ignore[override]
144
+ ) -> Iterable[Completion]:
145
+ # Only complete on first line
146
+ if document.cursor_position_row != 0:
147
+ return
148
+
149
+ text_before = document.current_line_before_cursor
150
+ m = self._SKILL_TOKEN_RE.search(text_before)
151
+ if not m:
152
+ return
153
+
154
+ frag = m.group("frag").lower()
155
+ # Get the prefix character ($ or ¥)
156
+ prefix_char = text_before[0]
157
+ token_start = len(text_before) - len(f"{prefix_char}{m.group('frag')}")
158
+ start_position = token_start - len(text_before) # negative offset
159
+
160
+ # Get available skills from SkillTool
161
+ skills = self._get_available_skills()
162
+ if not skills:
163
+ return
164
+
165
+ # Filter skills that match the fragment (case-insensitive)
166
+ matched: list[tuple[str, str, str]] = [] # (name, description, location)
167
+ for name, desc, location in skills:
168
+ if frag in name.lower() or frag in desc.lower():
169
+ matched.append((name, desc, location))
170
+
171
+ if not matched:
172
+ return
173
+
174
+ # Calculate max width for alignment
175
+ max_name_len = max(len(name) for name, _, _ in matched)
176
+ align_width = max(max_name_len, 20) + 2
177
+
178
+ for name, desc, location in matched:
179
+ # Format: name [location] description
180
+ # Align location tags (max length is "project" = 7, plus brackets = 9)
181
+ padding_name = " " * (align_width - len(name))
182
+ location_tag = f"[{location}]".ljust(9)
183
+
184
+ # Using HTML for formatting: bold skill name, cyan location tag, gray description
185
+ display_text = HTML(
186
+ f"<b>{name}</b>{padding_name}<style color='ansicyan'>{location_tag}</style> "
187
+ f"<style color='ansibrightblack'>{desc}</style>"
188
+ )
189
+ completion_text = f"${name} "
190
+ yield Completion(
191
+ text=completion_text,
192
+ start_position=start_position,
193
+ display=display_text,
194
+ )
195
+
196
+ def _get_available_skills(self) -> list[tuple[str, str, str]]:
197
+ """Get available skills from skill module.
198
+
199
+ Returns:
200
+ List of (name, description, location) tuples
201
+ """
202
+ try:
203
+ # Import here to avoid circular imports
204
+ from klaude_code.skill import get_available_skills
205
+
206
+ return get_available_skills()
207
+ except Exception:
208
+ return []
209
+
210
+ def is_skill_context(self, document: Document) -> bool:
211
+ """Check if current context is a skill completion."""
212
+ if document.cursor_position_row != 0:
213
+ return False
214
+ text_before = document.current_line_before_cursor
215
+ return bool(self._SKILL_TOKEN_RE.search(text_before))
216
+
217
+
124
218
  class _ComboCompleter(Completer):
125
- """Combined completer that handles both @ file paths and / slash commands."""
219
+ """Combined completer that handles @ file paths, / slash commands, and $ skills."""
126
220
 
127
221
  def __init__(self) -> None:
128
222
  self._at_completer = _AtFilesCompleter()
129
223
  self._slash_completer = _SlashCommandCompleter()
224
+ self._skill_completer = _SkillCompleter()
130
225
 
131
226
  def get_completions(
132
227
  self,
@@ -138,6 +233,11 @@ class _ComboCompleter(Completer):
138
233
  yield from self._slash_completer.get_completions(document, complete_event)
139
234
  return
140
235
 
236
+ # Try skill completion (only on first line with $ prefix)
237
+ if document.cursor_position_row == 0 and self._skill_completer.is_skill_context(document):
238
+ yield from self._skill_completer.get_completions(document, complete_event)
239
+ return
240
+
141
241
  # Fall back to @ file completion
142
242
  yield from self._at_completer.get_completions(document, complete_event)
143
243
 
@@ -2,12 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
 
5
+ from rich.rule import Rule
5
6
  from rich.text import Text
6
7
 
7
8
  from klaude_code import const
8
9
  from klaude_code.protocol import events
9
10
  from klaude_code.ui.core.stage_manager import Stage, StageManager
10
11
  from klaude_code.ui.modes.repl.renderer import REPLRenderer
12
+ from klaude_code.ui.renderers.assistant import ASSISTANT_MESSAGE_MARK
11
13
  from klaude_code.ui.renderers.thinking import normalize_thinking_content
12
14
  from klaude_code.ui.rich.markdown import MarkdownStream, ThinkingMarkdown
13
15
  from klaude_code.ui.rich.theme import ThemeKey
@@ -327,7 +329,7 @@ class DisplayEventHandler:
327
329
  theme=self.renderer.themes.thinking_markdown_theme,
328
330
  console=self.renderer.console,
329
331
  spinner=self.renderer.spinner_renderable(),
330
- indent=2,
332
+ left_margin=const.MARKDOWN_LEFT_MARGIN,
331
333
  markdown_class=ThinkingMarkdown,
332
334
  )
333
335
  self.thinking_stream.start(mdstream)
@@ -358,8 +360,8 @@ class DisplayEventHandler:
358
360
  theme=self.renderer.themes.markdown_theme,
359
361
  console=self.renderer.console,
360
362
  spinner=self.renderer.spinner_renderable(),
361
- mark="➤",
362
- indent=2,
363
+ mark=ASSISTANT_MESSAGE_MARK,
364
+ left_margin=const.MARKDOWN_LEFT_MARGIN,
363
365
  )
364
366
  self.assistant_stream.start(mdstream)
365
367
  self.assistant_stream.append(event.content)
@@ -430,6 +432,8 @@ class DisplayEventHandler:
430
432
  emit_osc94(OSC94States.HIDDEN)
431
433
  self.spinner_status.reset()
432
434
  self.renderer.spinner_stop()
435
+ self.renderer.console.print(Rule(characters="-", style=ThemeKey.LINES))
436
+ self.renderer.print()
433
437
  await self.stage_manager.transition_to(Stage.WAITING)
434
438
  self._maybe_notify_task_finish(event)
435
439
 
@@ -7,6 +7,7 @@ from typing import NamedTuple, override
7
7
 
8
8
  from prompt_toolkit import PromptSession
9
9
  from prompt_toolkit.completion import ThreadedCompleter
10
+ from prompt_toolkit.cursor_shapes import CursorShape
10
11
  from prompt_toolkit.formatted_text import FormattedText
11
12
  from prompt_toolkit.history import FileHistory
12
13
  from prompt_toolkit.patch_stdout import patch_stdout
@@ -17,6 +18,8 @@ from klaude_code.ui.core.input import InputProviderABC
17
18
  from klaude_code.ui.modes.repl.clipboard import capture_clipboard_tag, copy_to_clipboard, extract_images_from_text
18
19
  from klaude_code.ui.modes.repl.completers import AT_TOKEN_PATTERN, create_repl_completer
19
20
  from klaude_code.ui.modes.repl.key_bindings import create_key_bindings
21
+ from klaude_code.ui.renderers.user_input import USER_MESSAGE_MARK
22
+ from klaude_code.ui.terminal.color import is_light_terminal_background
20
23
  from klaude_code.ui.utils.common import get_current_git_branch, show_path_with_tilde
21
24
 
22
25
 
@@ -32,16 +35,23 @@ class REPLStatusSnapshot(NamedTuple):
32
35
 
33
36
  COMPLETION_SELECTED = "#5869f7"
34
37
  COMPLETION_MENU = "ansibrightblack"
35
- INPUT_PROMPT_STYLE = "ansimagenta"
38
+ INPUT_PROMPT_STYLE = "ansimagenta bold"
39
+ PLACEHOLDER_TEXT_STYLE_DARK_BG = "fg:#5a5a5a italic"
40
+ PLACEHOLDER_TEXT_STYLE_LIGHT_BG = "fg:#7a7a7a italic"
41
+ PLACEHOLDER_TEXT_STYLE_UNKNOWN_BG = "fg:#8a8a8a italic"
42
+ PLACEHOLDER_SYMBOL_STYLE_DARK_BG = "bg:#2a2a2a fg:#5a5a5a"
43
+ PLACEHOLDER_SYMBOL_STYLE_LIGHT_BG = "bg:#e6e6e6 fg:#7a7a7a"
44
+ PLACEHOLDER_SYMBOL_STYLE_UNKNOWN_BG = "bg:#2a2a2a fg:#8a8a8a"
36
45
 
37
46
 
38
47
  class PromptToolkitInput(InputProviderABC):
39
48
  def __init__(
40
49
  self,
41
- prompt: str = "❯ ",
50
+ prompt: str = USER_MESSAGE_MARK,
42
51
  status_provider: Callable[[], REPLStatusSnapshot] | None = None,
43
52
  ): # ▌
44
53
  self._status_provider = status_provider
54
+ self._is_light_terminal_background = is_light_terminal_background(timeout=0.2)
45
55
 
46
56
  project = str(Path.cwd()).strip("/").replace("/", "-")
47
57
  history_path = Path.home() / ".klaude" / "projects" / project / "input" / "input_history.txt"
@@ -60,6 +70,7 @@ class PromptToolkitInput(InputProviderABC):
60
70
  [(INPUT_PROMPT_STYLE, prompt)],
61
71
  history=FileHistory(str(history_path)),
62
72
  multiline=True,
73
+ cursor=CursorShape.BEAM,
63
74
  prompt_continuation=[(INPUT_PROMPT_STYLE, " ")],
64
75
  key_bindings=kb,
65
76
  completer=ThreadedCompleter(create_repl_completer()),
@@ -144,6 +155,34 @@ class PromptToolkitInput(InputProviderABC):
144
155
  toolbar_text = left_text + padding + right_text
145
156
  return FormattedText([("#2c7eac", toolbar_text)])
146
157
 
158
+ def _render_input_placeholder(self) -> FormattedText:
159
+ if self._is_light_terminal_background is True:
160
+ text_style = PLACEHOLDER_TEXT_STYLE_LIGHT_BG
161
+ symbol_style = PLACEHOLDER_SYMBOL_STYLE_LIGHT_BG
162
+ elif self._is_light_terminal_background is False:
163
+ text_style = PLACEHOLDER_TEXT_STYLE_DARK_BG
164
+ symbol_style = PLACEHOLDER_SYMBOL_STYLE_DARK_BG
165
+ else:
166
+ text_style = PLACEHOLDER_TEXT_STYLE_UNKNOWN_BG
167
+ symbol_style = PLACEHOLDER_SYMBOL_STYLE_UNKNOWN_BG
168
+
169
+ return FormattedText(
170
+ [
171
+ (text_style, " " * 10),
172
+ (symbol_style, " @ "),
173
+ (text_style, " "),
174
+ (text_style, "files"),
175
+ (text_style, " "),
176
+ (symbol_style, " $ "),
177
+ (text_style, " "),
178
+ (text_style, "skills"),
179
+ (text_style, " "),
180
+ (symbol_style, " / "),
181
+ (text_style, " "),
182
+ (text_style, "commands"),
183
+ ]
184
+ )
185
+
147
186
  async def start(self) -> None:
148
187
  pass
149
188
 
@@ -154,7 +193,7 @@ class PromptToolkitInput(InputProviderABC):
154
193
  async def iter_inputs(self) -> AsyncIterator[UserInputPayload]:
155
194
  while True:
156
195
  with patch_stdout():
157
- line: str = await self._session.prompt_async()
196
+ line: str = await self._session.prompt_async(placeholder=self._render_input_placeholder())
158
197
 
159
198
  # Extract images referenced in the input text
160
199
  images = extract_images_from_text(line)
@@ -1,8 +1,13 @@
1
1
  from rich.console import RenderableType
2
+ from rich.padding import Padding
2
3
 
4
+ from klaude_code import const
3
5
  from klaude_code.ui.renderers.common import create_grid
4
6
  from klaude_code.ui.rich.markdown import NoInsetMarkdown
5
7
 
8
+ # UI markers
9
+ ASSISTANT_MESSAGE_MARK = "➤"
10
+
6
11
 
7
12
  def render_assistant_message(content: str, *, code_theme: str) -> RenderableType | None:
8
13
  """Render assistant message for replay history display.
@@ -15,7 +20,7 @@ def render_assistant_message(content: str, *, code_theme: str) -> RenderableType
15
20
 
16
21
  grid = create_grid()
17
22
  grid.add_row(
18
- "•",
19
- NoInsetMarkdown(stripped, code_theme=code_theme),
23
+ ASSISTANT_MESSAGE_MARK,
24
+ Padding(NoInsetMarkdown(stripped, code_theme=code_theme), (0, const.MARKDOWN_RIGHT_MARGIN, 0, 0)),
20
25
  )
21
26
  return grid
@@ -17,6 +17,7 @@ def need_render_developer_message(e: events.DeveloperMessageEvent) -> bool:
17
17
  or e.item.todo_use
18
18
  or e.item.at_files
19
19
  or e.item.user_image_count
20
+ or e.item.skill_name
20
21
  )
21
22
 
22
23
 
@@ -93,6 +94,17 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
93
94
  )
94
95
  parts.append(grid)
95
96
 
97
+ if sn := e.item.skill_name:
98
+ grid = create_grid()
99
+ grid.add_row(
100
+ Text(" +", style=ThemeKey.REMINDER),
101
+ Text.assemble(
102
+ ("Activated skill ", ThemeKey.REMINDER),
103
+ (sn, ThemeKey.REMINDER_BOLD),
104
+ ),
105
+ )
106
+ parts.append(grid)
107
+
96
108
  return Group(*parts) if parts else Text("")
97
109
 
98
110
 
@@ -208,7 +208,7 @@ def render_diff_panel(
208
208
  diff_text: str,
209
209
  *,
210
210
  show_file_name: bool = True,
211
- heading: str = "Git Diff",
211
+ heading: str = "DIFF",
212
212
  indent: int = 2,
213
213
  ) -> RenderableType:
214
214
  lines = diff_text.splitlines()
@@ -45,7 +45,7 @@ def _render_task_metadata_block(
45
45
  currency_symbol = "¥" if currency == "CNY" else "$"
46
46
 
47
47
  # First column: mark only
48
- mark = Text("└", style=ThemeKey.METADATA_DIM) if is_sub_agent else Text("", style=ThemeKey.METADATA_BOLD)
48
+ mark = Text("└", style=ThemeKey.METADATA_DIM) if is_sub_agent else Text("", style=ThemeKey.METADATA)
49
49
 
50
50
  # Second column: model@provider / tokens / cost / ...
51
51
  content = Text()
@@ -151,7 +151,9 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
151
151
  """Render task metadata including main agent and sub-agents, aggregated by model+provider."""
152
152
  renderables: list[RenderableType] = []
153
153
 
154
- renderables.append(_render_task_metadata_block(e.metadata.main, is_sub_agent=False, show_context_and_time=True))
154
+ renderables.append(
155
+ _render_task_metadata_block(e.metadata.main_agent, is_sub_agent=False, show_context_and_time=True)
156
+ )
155
157
 
156
158
  # Aggregate by (model_name, provider), sorted by total_cost descending
157
159
  sorted_items = model.TaskMetadata.aggregate_by_model(e.metadata.sub_agent_task_metadata)
@@ -9,7 +9,7 @@ from klaude_code.ui.rich.theme import ThemeKey
9
9
 
10
10
 
11
11
  def thinking_prefix() -> Text:
12
- return Text.from_markup("[not italic]⸫[/not italic] Thinking …", style=ThemeKey.THINKING)
12
+ return Text.from_markup("[not italic]⸫[/not italic] Thinking …", style=ThemeKey.THINKING_BOLD)
13
13
 
14
14
 
15
15
  def normalize_thinking_content(content: str) -> str:
@@ -13,6 +13,24 @@ from klaude_code.ui.renderers import diffs as r_diffs
13
13
  from klaude_code.ui.renderers.common import create_grid, truncate_display
14
14
  from klaude_code.ui.rich.theme import ThemeKey
15
15
 
16
+ # Tool markers (Unicode symbols for UI display)
17
+ MARK_GENERIC = "⚒"
18
+ MARK_BASH = "→"
19
+ MARK_PLAN = "▪"
20
+ MARK_READ = "←"
21
+ MARK_EDIT = "±"
22
+ MARK_WRITE = "+"
23
+ MARK_MERMAID = "⧉"
24
+ MARK_WEB_FETCH = "←"
25
+ MARK_WEB_SEARCH = ""
26
+ MARK_DONE = "✔"
27
+ MARK_SKILL = "✪"
28
+
29
+ # Todo status markers
30
+ MARK_TODO_PENDING = "▢"
31
+ MARK_TODO_IN_PROGRESS = "◉"
32
+ MARK_TODO_COMPLETED = "✔"
33
+
16
34
 
17
35
  def is_sub_agent_tool(tool_name: str) -> bool:
18
36
  return _is_sub_agent_tool(tool_name)
@@ -30,7 +48,7 @@ def render_path(path: str, style: str, is_directory: bool = False) -> Text:
30
48
  return Text(path, style=style)
31
49
 
32
50
 
33
- def render_generic_tool_call(tool_name: str, arguments: str, markup: str = "•") -> RenderableType:
51
+ def render_generic_tool_call(tool_name: str, arguments: str, markup: str = MARK_GENERIC) -> RenderableType:
34
52
  grid = create_grid()
35
53
 
36
54
  tool_name_column = Text.assemble((markup, ThemeKey.TOOL_MARK), " ", (tool_name, ThemeKey.TOOL_NAME))
@@ -60,7 +78,7 @@ def render_generic_tool_call(tool_name: str, arguments: str, markup: str = "•"
60
78
 
61
79
  def render_bash_tool_call(arguments: str) -> RenderableType:
62
80
  grid = create_grid()
63
- tool_name_column = Text.assemble((">", ThemeKey.TOOL_MARK), " ", ("Bash", ThemeKey.TOOL_NAME))
81
+ tool_name_column = Text.assemble((MARK_BASH, ThemeKey.TOOL_MARK), " ", ("Bash", ThemeKey.TOOL_NAME))
64
82
 
65
83
  try:
66
84
  payload_raw: Any = json.loads(arguments) if arguments else {}
@@ -103,7 +121,7 @@ def render_bash_tool_call(arguments: str) -> RenderableType:
103
121
 
104
122
  def render_update_plan_tool_call(arguments: str) -> RenderableType:
105
123
  grid = create_grid()
106
- tool_name_column = Text.assemble(("◎", ThemeKey.TOOL_MARK), " ", ("Update Plan", ThemeKey.TOOL_NAME))
124
+ tool_name_column = Text.assemble((MARK_PLAN, ThemeKey.TOOL_MARK), " ", ("Update Plan", ThemeKey.TOOL_NAME))
107
125
  explanation_column = Text("")
108
126
 
109
127
  if arguments:
@@ -160,13 +178,13 @@ def render_read_tool_call(arguments: str) -> RenderableType:
160
178
  style=ThemeKey.INVALID_TOOL_CALL_ARGS,
161
179
  )
162
180
  )
163
- grid.add_row(Text("←", ThemeKey.TOOL_MARK), render_result)
181
+ grid.add_row(Text(MARK_READ, ThemeKey.TOOL_MARK), render_result)
164
182
  return grid
165
183
 
166
184
 
167
185
  def render_edit_tool_call(arguments: str) -> RenderableType:
168
186
  grid = create_grid()
169
- tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Edit", ThemeKey.TOOL_NAME))
187
+ tool_name_column = Text.assemble((MARK_EDIT, ThemeKey.TOOL_MARK), " ", ("Edit", ThemeKey.TOOL_NAME))
170
188
  try:
171
189
  json_dict = json.loads(arguments)
172
190
  file_path = json_dict.get("file_path")
@@ -185,10 +203,10 @@ def render_write_tool_call(arguments: str) -> RenderableType:
185
203
  try:
186
204
  json_dict = json.loads(arguments)
187
205
  file_path = json_dict.get("file_path")
188
- tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
206
+ tool_name_column = Text.assemble((MARK_WRITE, ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
189
207
  arguments_column = render_path(file_path, ThemeKey.TOOL_PARAM_FILE_PATH)
190
208
  except json.JSONDecodeError:
191
- tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
209
+ tool_name_column = Text.assemble((MARK_WRITE, ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
192
210
  arguments_column = Text(
193
211
  arguments.strip()[: const.INVALID_TOOL_CALL_MAX_LENGTH],
194
212
  style=ThemeKey.INVALID_TOOL_CALL_ARGS,
@@ -199,7 +217,7 @@ def render_write_tool_call(arguments: str) -> RenderableType:
199
217
 
200
218
  def render_apply_patch_tool_call(arguments: str) -> RenderableType:
201
219
  grid = create_grid()
202
- tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Apply Patch", ThemeKey.TOOL_NAME))
220
+ tool_name_column = Text.assemble((MARK_EDIT, ThemeKey.TOOL_MARK), " ", ("Apply Patch", ThemeKey.TOOL_NAME))
203
221
 
204
222
  try:
205
223
  payload = json.loads(arguments)
@@ -215,9 +233,27 @@ def render_apply_patch_tool_call(arguments: str) -> RenderableType:
215
233
  arguments_column = Text("", ThemeKey.TOOL_PARAM)
216
234
 
217
235
  if isinstance(patch_content, str):
218
- lines = [line for line in patch_content.splitlines() if line and not line.startswith("*** Begin Patch")]
219
- if lines:
220
- arguments_column = Text(lines[0][: const.INVALID_TOOL_CALL_MAX_LENGTH], ThemeKey.TOOL_PARAM)
236
+ update_count = 0
237
+ add_count = 0
238
+ delete_count = 0
239
+ for line in patch_content.splitlines():
240
+ if line.startswith("*** Update File:"):
241
+ update_count += 1
242
+ elif line.startswith("*** Add File:"):
243
+ add_count += 1
244
+ elif line.startswith("*** Delete File:"):
245
+ delete_count += 1
246
+
247
+ parts: list[str] = []
248
+ if update_count > 0:
249
+ parts.append(f"Update File × {update_count}" if update_count > 1 else "Update File")
250
+ if add_count > 0:
251
+ parts.append(f"Add File × {add_count}" if add_count > 1 else "Add File")
252
+ if delete_count > 0:
253
+ parts.append(f"Delete File × {delete_count}" if delete_count > 1 else "Delete File")
254
+
255
+ if parts:
256
+ arguments_column = Text(", ".join(parts), ThemeKey.TOOL_PARAM)
221
257
  else:
222
258
  arguments_column = Text(
223
259
  str(patch_content)[: const.INVALID_TOOL_CALL_MAX_LENGTH],
@@ -229,34 +265,24 @@ def render_apply_patch_tool_call(arguments: str) -> RenderableType:
229
265
 
230
266
 
231
267
  def render_todo(tr: events.ToolResultEvent) -> RenderableType:
232
- if not isinstance(tr.ui_extra, model.TodoListUIExtra):
233
- return Text.assemble(
234
- (" ✘", ThemeKey.ERROR_BOLD),
235
- " ",
236
- Text("(no content)" if tr.ui_extra is None else "(invalid ui_extra)", style=ThemeKey.ERROR),
237
- )
238
-
268
+ assert isinstance(tr.ui_extra, model.TodoListUIExtra)
239
269
  ui_extra = tr.ui_extra.todo_list
240
270
  todo_grid = create_grid()
241
271
  for todo in ui_extra.todos:
242
272
  is_new_completed = todo.content in ui_extra.new_completed
243
273
  match todo.status:
244
274
  case "pending":
245
- mark = "▢"
275
+ mark = MARK_TODO_PENDING
246
276
  mark_style = ThemeKey.TODO_PENDING_MARK
247
277
  text_style = ThemeKey.TODO_PENDING
248
278
  case "in_progress":
249
- mark = "◉"
279
+ mark = MARK_TODO_IN_PROGRESS
250
280
  mark_style = ThemeKey.TODO_IN_PROGRESS_MARK
251
281
  text_style = ThemeKey.TODO_IN_PROGRESS
252
282
  case "completed":
253
- mark = "✔"
283
+ mark = MARK_TODO_COMPLETED
254
284
  mark_style = ThemeKey.TODO_NEW_COMPLETED_MARK if is_new_completed else ThemeKey.TODO_COMPLETED_MARK
255
285
  text_style = ThemeKey.TODO_NEW_COMPLETED if is_new_completed else ThemeKey.TODO_COMPLETED
256
- case _:
257
- mark = "?"
258
- mark_style = ThemeKey.TODO_PENDING_MARK
259
- text_style = ThemeKey.TODO_PENDING
260
286
  text = Text(todo.content)
261
287
  text.stylize(text_style)
262
288
  todo_grid.add_row(Text(mark, style=mark_style), text)
@@ -280,7 +306,7 @@ def _extract_mermaid_link(
280
306
 
281
307
  def render_mermaid_tool_call(arguments: str) -> RenderableType:
282
308
  grid = create_grid()
283
- tool_name_column = Text.assemble(("⧉", ThemeKey.TOOL_MARK), " ", ("Mermaid", ThemeKey.TOOL_NAME))
309
+ tool_name_column = Text.assemble((MARK_MERMAID, ThemeKey.TOOL_MARK), " ", ("Mermaid", ThemeKey.TOOL_NAME))
284
310
  summary = Text("", ThemeKey.TOOL_PARAM)
285
311
 
286
312
  try:
@@ -320,7 +346,7 @@ def _truncate_url(url: str, max_length: int = 400) -> str:
320
346
 
321
347
  def render_web_fetch_tool_call(arguments: str) -> RenderableType:
322
348
  grid = create_grid()
323
- tool_name_column = Text.assemble(("↓", ThemeKey.TOOL_MARK), " ", ("Fetch", ThemeKey.TOOL_NAME))
349
+ tool_name_column = Text.assemble((MARK_WEB_FETCH, ThemeKey.TOOL_MARK), " ", ("Fetch", ThemeKey.TOOL_NAME))
324
350
 
325
351
  try:
326
352
  payload: dict[str, str] = json.loads(arguments)
@@ -341,7 +367,7 @@ def render_web_fetch_tool_call(arguments: str) -> RenderableType:
341
367
 
342
368
  def render_web_search_tool_call(arguments: str) -> RenderableType:
343
369
  grid = create_grid()
344
- tool_name_column = Text.assemble(("◉", ThemeKey.TOOL_MARK), " ", ("Search", ThemeKey.TOOL_NAME))
370
+ tool_name_column = Text.assemble((MARK_WEB_SEARCH, ThemeKey.TOOL_MARK), " ", ("Web Search", ThemeKey.TOOL_NAME))
345
371
 
346
372
  try:
347
373
  payload: dict[str, Any] = json.loads(arguments)
@@ -418,7 +444,7 @@ def get_truncation_info(tr: events.ToolResultEvent) -> model.TruncationUIExtra |
418
444
 
419
445
  def render_report_back_tool_call() -> RenderableType:
420
446
  grid = create_grid()
421
- tool_name_column = Text.assemble(("✔", ThemeKey.TOOL_MARK), " ", ("Report Back", ThemeKey.TOOL_NAME))
447
+ tool_name_column = Text.assemble((MARK_DONE, ThemeKey.TOOL_MARK), " ", ("Report Back", ThemeKey.TOOL_NAME))
422
448
  grid.add_row(tool_name_column, "")
423
449
  return grid
424
450
 
@@ -474,19 +500,18 @@ def render_tool_call(e: events.ToolCallEvent) -> RenderableType | None:
474
500
  return render_edit_tool_call(e.arguments)
475
501
  case tools.WRITE:
476
502
  return render_write_tool_call(e.arguments)
477
-
478
503
  case tools.BASH:
479
504
  return render_bash_tool_call(e.arguments)
480
505
  case tools.APPLY_PATCH:
481
506
  return render_apply_patch_tool_call(e.arguments)
482
507
  case tools.TODO_WRITE:
483
- return render_generic_tool_call("Update Todos", "", "◎")
508
+ return render_generic_tool_call("Update Todos", "", MARK_PLAN)
484
509
  case tools.UPDATE_PLAN:
485
510
  return render_update_plan_tool_call(e.arguments)
486
511
  case tools.MERMAID:
487
512
  return render_mermaid_tool_call(e.arguments)
488
513
  case tools.SKILL:
489
- return render_generic_tool_call(e.tool_name, e.arguments, "◈")
514
+ return render_generic_tool_call(e.tool_name, e.arguments, MARK_SKILL)
490
515
  case tools.REPORT_BACK:
491
516
  return render_report_back_tool_call()
492
517
  case tools.WEB_FETCH:
@@ -4,6 +4,7 @@ from rich.console import Group, RenderableType
4
4
  from rich.text import Text
5
5
 
6
6
  from klaude_code.command import is_slash_command_name
7
+ from klaude_code.skill import get_available_skills
7
8
  from klaude_code.ui.renderers.common import create_grid
8
9
  from klaude_code.ui.rich.theme import ThemeKey
9
10
 
@@ -12,6 +13,11 @@ from klaude_code.ui.rich.theme import ThemeKey
12
13
  # patterns such as foo@bar.com as file references.
13
14
  AT_FILE_RENDER_PATTERN = re.compile(r'(?<!\S)@("([^"]+)"|\S+)')
14
15
 
16
+ # Match $skill or ¥skill pattern at the beginning of the first line
17
+ SKILL_RENDER_PATTERN = re.compile(r"^[$¥](\S+)")
18
+
19
+ USER_MESSAGE_MARK = "❯ "
20
+
15
21
 
16
22
  def render_at_pattern(
17
23
  text: str,
@@ -38,15 +44,24 @@ def render_at_pattern(
38
44
  return result
39
45
 
40
46
 
47
+ def _is_valid_skill_name(name: str) -> bool:
48
+ """Check if a skill name is valid (exists in loaded skills)."""
49
+ short = name.split(":")[-1] if ":" in name else name
50
+ available_skills = get_available_skills()
51
+ return any(skill_name in (name, short) for skill_name, _, _ in available_skills)
52
+
53
+
41
54
  def render_user_input(content: str) -> RenderableType:
42
55
  """Render a user message as a group of quoted lines with styles.
43
56
 
44
57
  - Highlights slash command on the first line if recognized
58
+ - Highlights $skill pattern on the first line if recognized
45
59
  - Highlights @file patterns in all lines
46
60
  """
47
61
  lines = content.strip().split("\n")
48
62
  renderables: list[RenderableType] = []
49
63
  has_command = False
64
+ command_style: str | None = None
50
65
  for i, line in enumerate(lines):
51
66
  line_text = render_at_pattern(line)
52
67
 
@@ -54,6 +69,7 @@ def render_user_input(content: str) -> RenderableType:
54
69
  splits = line.split(" ", maxsplit=1)
55
70
  if is_slash_command_name(splits[0][1:]):
56
71
  has_command = True
72
+ command_style = ThemeKey.USER_INPUT_SLASH_COMMAND
57
73
  line_text = Text.assemble(
58
74
  (f"{splits[0]}", ThemeKey.USER_INPUT_SLASH_COMMAND),
59
75
  " ",
@@ -62,13 +78,27 @@ def render_user_input(content: str) -> RenderableType:
62
78
  renderables.append(line_text)
63
79
  continue
64
80
 
81
+ if i == 0 and (line.startswith("$") or line.startswith("¥")):
82
+ m = SKILL_RENDER_PATTERN.match(line)
83
+ if m and _is_valid_skill_name(m.group(1)):
84
+ has_command = True
85
+ command_style = ThemeKey.USER_INPUT_SKILL
86
+ skill_token = m.group(0) # e.g. "$skill-name"
87
+ rest = line[len(skill_token) :]
88
+ line_text = Text.assemble(
89
+ (skill_token, ThemeKey.USER_INPUT_SKILL),
90
+ render_at_pattern(rest) if rest else Text(""),
91
+ )
92
+ renderables.append(line_text)
93
+ continue
94
+
65
95
  renderables.append(line_text)
66
96
  grid = create_grid()
67
97
  grid.padding = (0, 0)
68
98
  mark = (
69
- Text("❯ ", style=ThemeKey.USER_INPUT_PROMPT)
99
+ Text(USER_MESSAGE_MARK, style=ThemeKey.USER_INPUT_PROMPT)
70
100
  if not has_command
71
- else Text(" ", style=ThemeKey.USER_INPUT_SLASH_COMMAND)
101
+ else Text(" ", style=command_style or ThemeKey.USER_INPUT_SLASH_COMMAND)
72
102
  )
73
103
  grid.add_row(mark, Group(*renderables))
74
104
  return grid