klaude-code 1.2.1__py3-none-any.whl → 1.2.3__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 (140) hide show
  1. klaude_code/cli/main.py +9 -4
  2. klaude_code/cli/runtime.py +42 -43
  3. klaude_code/command/__init__.py +7 -5
  4. klaude_code/command/clear_cmd.py +6 -29
  5. klaude_code/command/command_abc.py +44 -8
  6. klaude_code/command/diff_cmd.py +33 -27
  7. klaude_code/command/export_cmd.py +18 -26
  8. klaude_code/command/help_cmd.py +10 -8
  9. klaude_code/command/model_cmd.py +11 -40
  10. klaude_code/command/{prompt-update-dev-doc.md → prompt-dev-docs-update.md} +3 -2
  11. klaude_code/command/{prompt-dev-doc.md → prompt-dev-docs.md} +3 -2
  12. klaude_code/command/prompt-init.md +2 -5
  13. klaude_code/command/prompt_command.py +6 -6
  14. klaude_code/command/refresh_cmd.py +4 -5
  15. klaude_code/command/registry.py +16 -19
  16. klaude_code/command/terminal_setup_cmd.py +12 -11
  17. klaude_code/config/__init__.py +4 -0
  18. klaude_code/config/config.py +25 -26
  19. klaude_code/config/list_model.py +8 -3
  20. klaude_code/config/select_model.py +1 -1
  21. klaude_code/const/__init__.py +1 -1
  22. klaude_code/core/__init__.py +0 -3
  23. klaude_code/core/agent.py +25 -50
  24. klaude_code/core/executor.py +268 -101
  25. klaude_code/core/prompt.py +12 -12
  26. klaude_code/core/{prompt → prompts}/prompt-gemini.md +1 -1
  27. klaude_code/core/reminders.py +76 -95
  28. klaude_code/core/task.py +21 -14
  29. klaude_code/core/tool/__init__.py +45 -11
  30. klaude_code/core/tool/file/apply_patch.py +5 -1
  31. klaude_code/core/tool/file/apply_patch_tool.py +11 -13
  32. klaude_code/core/tool/file/edit_tool.py +27 -23
  33. klaude_code/core/tool/file/multi_edit_tool.py +15 -17
  34. klaude_code/core/tool/file/read_tool.py +41 -36
  35. klaude_code/core/tool/file/write_tool.py +13 -15
  36. klaude_code/core/tool/memory/memory_tool.py +85 -68
  37. klaude_code/core/tool/memory/skill_tool.py +10 -12
  38. klaude_code/core/tool/shell/bash_tool.py +24 -22
  39. klaude_code/core/tool/shell/command_safety.py +12 -1
  40. klaude_code/core/tool/sub_agent_tool.py +11 -12
  41. klaude_code/core/tool/todo/todo_write_tool.py +21 -28
  42. klaude_code/core/tool/todo/update_plan_tool.py +14 -24
  43. klaude_code/core/tool/tool_abc.py +3 -4
  44. klaude_code/core/tool/tool_context.py +7 -7
  45. klaude_code/core/tool/tool_registry.py +30 -47
  46. klaude_code/core/tool/tool_runner.py +35 -43
  47. klaude_code/core/tool/truncation.py +14 -20
  48. klaude_code/core/tool/web/mermaid_tool.py +12 -14
  49. klaude_code/core/tool/web/web_fetch_tool.py +15 -17
  50. klaude_code/core/turn.py +19 -7
  51. klaude_code/llm/__init__.py +3 -4
  52. klaude_code/llm/anthropic/client.py +30 -46
  53. klaude_code/llm/anthropic/input.py +4 -11
  54. klaude_code/llm/client.py +29 -8
  55. klaude_code/llm/input_common.py +66 -36
  56. klaude_code/llm/openai_compatible/client.py +42 -84
  57. klaude_code/llm/openai_compatible/input.py +11 -16
  58. klaude_code/llm/openai_compatible/tool_call_accumulator.py +2 -2
  59. klaude_code/llm/openrouter/client.py +40 -289
  60. klaude_code/llm/openrouter/input.py +13 -35
  61. klaude_code/llm/openrouter/reasoning_handler.py +209 -0
  62. klaude_code/llm/registry.py +5 -75
  63. klaude_code/llm/responses/client.py +34 -55
  64. klaude_code/llm/responses/input.py +24 -26
  65. klaude_code/llm/usage.py +109 -0
  66. klaude_code/protocol/__init__.py +4 -0
  67. klaude_code/protocol/events.py +3 -2
  68. klaude_code/protocol/{llm_parameter.py → llm_param.py} +12 -32
  69. klaude_code/protocol/model.py +49 -4
  70. klaude_code/protocol/op.py +18 -16
  71. klaude_code/protocol/op_handler.py +28 -0
  72. klaude_code/{core → protocol}/sub_agent.py +7 -0
  73. klaude_code/session/export.py +150 -70
  74. klaude_code/session/session.py +28 -14
  75. klaude_code/session/templates/export_session.html +180 -42
  76. klaude_code/trace/__init__.py +2 -2
  77. klaude_code/trace/log.py +11 -5
  78. klaude_code/ui/__init__.py +91 -8
  79. klaude_code/ui/core/__init__.py +1 -0
  80. klaude_code/ui/core/display.py +103 -0
  81. klaude_code/ui/core/input.py +71 -0
  82. klaude_code/ui/modes/__init__.py +1 -0
  83. klaude_code/ui/modes/debug/__init__.py +1 -0
  84. klaude_code/ui/{base/debug_event_display.py → modes/debug/display.py} +9 -5
  85. klaude_code/ui/modes/exec/__init__.py +1 -0
  86. klaude_code/ui/{base/exec_display.py → modes/exec/display.py} +28 -2
  87. klaude_code/ui/{repl → modes/repl}/__init__.py +5 -6
  88. klaude_code/ui/modes/repl/clipboard.py +152 -0
  89. klaude_code/ui/modes/repl/completers.py +429 -0
  90. klaude_code/ui/modes/repl/display.py +60 -0
  91. klaude_code/ui/modes/repl/event_handler.py +375 -0
  92. klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
  93. klaude_code/ui/modes/repl/key_bindings.py +170 -0
  94. klaude_code/ui/{repl → modes/repl}/renderer.py +109 -132
  95. klaude_code/ui/renderers/assistant.py +21 -0
  96. klaude_code/ui/renderers/common.py +0 -16
  97. klaude_code/ui/renderers/developer.py +18 -18
  98. klaude_code/ui/renderers/diffs.py +36 -14
  99. klaude_code/ui/renderers/errors.py +1 -1
  100. klaude_code/ui/renderers/metadata.py +50 -27
  101. klaude_code/ui/renderers/sub_agent.py +43 -9
  102. klaude_code/ui/renderers/thinking.py +33 -1
  103. klaude_code/ui/renderers/tools.py +212 -20
  104. klaude_code/ui/renderers/user_input.py +19 -23
  105. klaude_code/ui/rich/__init__.py +1 -0
  106. klaude_code/ui/{rich_ext → rich}/searchable_text.py +3 -1
  107. klaude_code/ui/{renderers → rich}/status.py +29 -18
  108. klaude_code/ui/{base → rich}/theme.py +8 -2
  109. klaude_code/ui/terminal/__init__.py +1 -0
  110. klaude_code/ui/{base/terminal_color.py → terminal/color.py} +4 -1
  111. klaude_code/ui/{base/terminal_control.py → terminal/control.py} +1 -0
  112. klaude_code/ui/{base/terminal_notifier.py → terminal/notifier.py} +5 -2
  113. klaude_code/ui/utils/__init__.py +1 -0
  114. klaude_code/ui/{base/utils.py → utils/common.py} +35 -3
  115. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/METADATA +1 -1
  116. klaude_code-1.2.3.dist-info/RECORD +161 -0
  117. klaude_code/core/clipboard_manifest.py +0 -124
  118. klaude_code/llm/openrouter/tool_call_accumulator.py +0 -80
  119. klaude_code/ui/base/__init__.py +0 -1
  120. klaude_code/ui/base/display_abc.py +0 -36
  121. klaude_code/ui/base/input_abc.py +0 -20
  122. klaude_code/ui/repl/display.py +0 -36
  123. klaude_code/ui/repl/event_handler.py +0 -247
  124. klaude_code/ui/repl/input.py +0 -773
  125. klaude_code/ui/rich_ext/__init__.py +0 -1
  126. klaude_code-1.2.1.dist-info/RECORD +0 -151
  127. /klaude_code/core/{prompt → prompts}/prompt-claude-code.md +0 -0
  128. /klaude_code/core/{prompt → prompts}/prompt-codex.md +0 -0
  129. /klaude_code/core/{prompt → prompts}/prompt-subagent-explore.md +0 -0
  130. /klaude_code/core/{prompt → prompts}/prompt-subagent-oracle.md +0 -0
  131. /klaude_code/core/{prompt → prompts}/prompt-subagent-webfetch.md +0 -0
  132. /klaude_code/core/{prompt → prompts}/prompt-subagent.md +0 -0
  133. /klaude_code/ui/{base → core}/stage_manager.py +0 -0
  134. /klaude_code/ui/{rich_ext → rich}/live.py +0 -0
  135. /klaude_code/ui/{rich_ext → rich}/markdown.py +0 -0
  136. /klaude_code/ui/{rich_ext → rich}/quote.py +0 -0
  137. /klaude_code/ui/{base → terminal}/progress_bar.py +0 -0
  138. /klaude_code/ui/{base → utils}/debouncer.py +0 -0
  139. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/WHEEL +0 -0
  140. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,429 @@
1
+ """REPL completion handlers for @ file paths and / slash commands.
2
+
3
+ This module provides completers for the REPL input:
4
+ - _SlashCommandCompleter: Completes slash commands on the first line
5
+ - _AtFilesCompleter: Completes @path segments using fd or ripgrep
6
+ - _ComboCompleter: Combines both completers with priority logic
7
+
8
+ Public API:
9
+ - create_repl_completer(): Factory function to create the combined completer
10
+ - AT_TOKEN_PATTERN: Regex pattern for @token matching (used by key bindings)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import time
20
+ from collections.abc import Iterable
21
+ from pathlib import Path
22
+ from typing import NamedTuple
23
+
24
+ from prompt_toolkit.completion import Completer, Completion
25
+ from prompt_toolkit.document import Document
26
+ from prompt_toolkit.formatted_text import HTML
27
+
28
+ from klaude_code.command import get_commands
29
+
30
+ # Pattern to match @token for completion refresh (used by key bindings)
31
+ AT_TOKEN_PATTERN = re.compile(r"(^|\s)@(?P<frag>[^\s]*)$")
32
+
33
+
34
+ def create_repl_completer() -> Completer:
35
+ """Create and return the combined REPL completer.
36
+
37
+ Returns a completer that handles both @ file paths and / slash commands.
38
+ """
39
+ return _ComboCompleter()
40
+
41
+
42
+ class _CmdResult(NamedTuple):
43
+ """Result of running an external command."""
44
+
45
+ ok: bool
46
+ lines: list[str]
47
+
48
+
49
+ class _SlashCommandCompleter(Completer):
50
+ """Complete slash commands at the beginning of the first line.
51
+
52
+ Behavior:
53
+ - Only triggers when cursor is on first line and text matches /...
54
+ - Shows available slash commands with descriptions
55
+ - Inserts trailing space after completion
56
+ """
57
+
58
+ _SLASH_TOKEN_RE = re.compile(r"^/(?P<frag>\S*)$")
59
+
60
+ def get_completions(
61
+ self,
62
+ document: Document,
63
+ complete_event, # type: ignore[override]
64
+ ) -> Iterable[Completion]:
65
+ # Only complete on first line
66
+ if document.cursor_position_row != 0:
67
+ return iter([])
68
+
69
+ text_before = document.current_line_before_cursor
70
+ m = self._SLASH_TOKEN_RE.search(text_before)
71
+ if not m:
72
+ return iter([])
73
+
74
+ frag = m.group("frag")
75
+ token_start = len(text_before) - len(f"/{frag}")
76
+ start_position = token_start - len(text_before) # negative offset
77
+
78
+ # Get available commands
79
+ commands = get_commands()
80
+
81
+ # Filter commands that match the fragment
82
+ matched: list[tuple[str, object, str]] = []
83
+ for cmd_name, cmd_obj in sorted(commands.items(), key=lambda x: str(x[1].name)):
84
+ if cmd_name.startswith(frag):
85
+ hint = " [args]" if cmd_obj.support_addition_params else ""
86
+ matched.append((cmd_name, cmd_obj, hint))
87
+
88
+ if not matched:
89
+ return iter([])
90
+
91
+ # Calculate max width for alignment
92
+ # Find the longest command+hint length
93
+ max_len = max(len(name) + len(hint) for name, _, hint in matched)
94
+ # Set a minimum width (e.g. 20) and add some padding
95
+ align_width = max(max_len, 20) + 2
96
+
97
+ for cmd_name, cmd_obj, hint in matched:
98
+ label_len = len(cmd_name) + len(hint)
99
+ padding = " " * (align_width - label_len)
100
+
101
+ # Using HTML for formatting: bold command name, normal hint, gray summary
102
+ display_text = HTML(
103
+ f"<b>{cmd_name}</b>{hint}{padding}<style color='ansibrightblack'>— {cmd_obj.summary}</style>" # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
104
+ )
105
+ completion_text = f"/{cmd_name} "
106
+ yield Completion(
107
+ text=completion_text,
108
+ start_position=start_position,
109
+ display=display_text,
110
+ )
111
+
112
+ def is_slash_command_context(self, document: Document) -> bool:
113
+ """Check if current context is a slash command."""
114
+ if document.cursor_position_row != 0:
115
+ return False
116
+ text_before = document.current_line_before_cursor
117
+ return bool(self._SLASH_TOKEN_RE.search(text_before))
118
+
119
+
120
+ class _ComboCompleter(Completer):
121
+ """Combined completer that handles both @ file paths and / slash commands."""
122
+
123
+ def __init__(self) -> None:
124
+ self._at_completer = _AtFilesCompleter()
125
+ self._slash_completer = _SlashCommandCompleter()
126
+
127
+ def get_completions(
128
+ self,
129
+ document: Document,
130
+ complete_event, # type: ignore[override]
131
+ ) -> Iterable[Completion]:
132
+ # Try slash command completion first (only on first line)
133
+ if document.cursor_position_row == 0:
134
+ if self._slash_completer.is_slash_command_context(document):
135
+ yield from self._slash_completer.get_completions(document, complete_event)
136
+ return
137
+
138
+ # Fall back to @ file completion
139
+ yield from self._at_completer.get_completions(document, complete_event)
140
+
141
+
142
+ class _AtFilesCompleter(Completer):
143
+ """Complete @path segments using fd or ripgrep.
144
+
145
+ Behavior:
146
+ - Only triggers when the cursor is after an "@..." token (until whitespace).
147
+ - Completes paths relative to the current working directory.
148
+ - Uses `fd` when available (files and directories), falls back to `rg --files` (files only).
149
+ - Debounces external commands and caches results to avoid excessive spawning.
150
+ - Inserts a trailing space after completion to stop further triggering.
151
+ """
152
+
153
+ _AT_TOKEN_RE = AT_TOKEN_PATTERN
154
+
155
+ def __init__(
156
+ self,
157
+ debounce_sec: float = 0.25,
158
+ cache_ttl_sec: float = 10.0,
159
+ max_results: int = 20,
160
+ ):
161
+ self._debounce_sec = debounce_sec
162
+ self._cache_ttl = cache_ttl_sec
163
+ self._max_results = max_results
164
+
165
+ # Debounce/caching state
166
+ self._last_cmd_time: float = 0.0
167
+ self._last_query_key: str | None = None
168
+ self._last_results: list[str] = []
169
+ self._last_results_time: float = 0.0
170
+
171
+ # rg --files cache (used when fd is unavailable)
172
+ self._rg_file_list: list[str] | None = None
173
+ self._rg_file_list_time: float = 0.0
174
+
175
+ # Cache for ignored paths (gitignored files)
176
+ self._last_ignored_paths: set[str] = set()
177
+
178
+ # ---- prompt_toolkit API ----
179
+ def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
180
+ text_before = document.text_before_cursor
181
+ m = self._AT_TOKEN_RE.search(text_before)
182
+ if not m:
183
+ return [] # type: ignore[reportUnknownVariableType]
184
+
185
+ frag = m.group("frag") # text after '@' and before cursor (no spaces)
186
+ token_start_in_input = len(text_before) - len(f"@{frag}")
187
+
188
+ cwd = Path.cwd()
189
+
190
+ # If no fragment yet, show lightweight suggestions from current directory
191
+ if frag.strip() == "":
192
+ suggestions = self._suggest_for_empty_fragment(cwd)
193
+ if not suggestions:
194
+ return [] # type: ignore[reportUnknownVariableType]
195
+ start_position = token_start_in_input - len(text_before)
196
+ for s in suggestions[: self._max_results]:
197
+ yield Completion(text=f"@{s} ", start_position=start_position, display=s)
198
+ return [] # type: ignore[reportUnknownVariableType]
199
+
200
+ # Gather suggestions with debounce/caching based on search keyword
201
+ suggestions = self._complete_paths(cwd, frag)
202
+ if not suggestions:
203
+ return [] # type: ignore[reportUnknownVariableType]
204
+
205
+ # Prepare Completion objects. Replace from the '@' character.
206
+ start_position = token_start_in_input - len(text_before) # negative
207
+ for s in suggestions[: self._max_results]:
208
+ # Insert '@<path> ' so that subsequent typing does not keep triggering
209
+ yield Completion(text=f"@{s} ", start_position=start_position, display=s)
210
+
211
+ # ---- Core logic ----
212
+ def _complete_paths(self, cwd: Path, keyword: str) -> list[str]:
213
+ now = time.monotonic()
214
+ key_norm = keyword.lower()
215
+ query_key = f"{cwd.resolve()}::search::{key_norm}"
216
+
217
+ # Debounce: if called too soon again, filter last results
218
+ if self._last_results and self._last_query_key is not None:
219
+ prev = self._last_query_key
220
+ if self._same_scope(prev, query_key):
221
+ # Determine if query is narrowing or broadening
222
+ _, prev_kw = self._parse_query_key(prev)
223
+ _, cur_kw = self._parse_query_key(query_key)
224
+ is_narrowing = (
225
+ prev_kw is not None
226
+ and cur_kw is not None
227
+ and len(cur_kw) >= len(prev_kw)
228
+ and cur_kw.startswith(prev_kw)
229
+ )
230
+ if is_narrowing and (now - self._last_cmd_time) < self._debounce_sec:
231
+ # For narrowing, fast-filter previous results to avoid expensive calls
232
+ return self._filter_and_format(self._last_results, cwd, key_norm, self._last_ignored_paths)
233
+
234
+ # Cache TTL: reuse cached results for same query within TTL
235
+ if self._last_results and self._last_query_key == query_key and now - self._last_results_time < self._cache_ttl:
236
+ return self._filter_and_format(self._last_results, cwd, key_norm, self._last_ignored_paths)
237
+
238
+ # Prefer fd; otherwise fallback to rg --files
239
+ results: list[str] = []
240
+ ignored_paths: set[str] = set()
241
+ if self._has_cmd("fd"):
242
+ # Use fd to search anywhere in full path (files and directories), case-insensitive
243
+ results, ignored_paths = self._run_fd_search(cwd, key_norm)
244
+ elif self._has_cmd("rg"):
245
+ # Use rg to search only in current directory
246
+ if self._rg_file_list is None or now - self._rg_file_list_time > max(self._cache_ttl, 30.0):
247
+ cmd = ["rg", "--files", "--no-ignore", "--hidden"]
248
+ r = self._run_cmd(cmd, cwd=cwd) # Search from current directory
249
+ if r.ok:
250
+ self._rg_file_list = r.lines
251
+ self._rg_file_list_time = now
252
+ else:
253
+ self._rg_file_list = []
254
+ self._rg_file_list_time = now
255
+ # Filter by keyword
256
+ all_files = self._rg_file_list or []
257
+ kn = key_norm
258
+ results = [p for p in all_files if kn in p.lower()]
259
+ # For rg fallback, we don't distinguish ignored files (no priority sorting)
260
+ else:
261
+ return []
262
+
263
+ # Update caches
264
+ self._last_cmd_time = now
265
+ self._last_query_key = query_key
266
+ self._last_results = results
267
+ self._last_results_time = now
268
+ self._last_ignored_paths = ignored_paths
269
+ return self._filter_and_format(results, cwd, key_norm, ignored_paths)
270
+
271
+ def _filter_and_format(
272
+ self,
273
+ paths_from_root: list[str],
274
+ cwd: Path,
275
+ keyword_norm: str,
276
+ ignored_paths: set[str] | None = None,
277
+ ) -> list[str]:
278
+ # Filter to keyword (case-insensitive) and rank by:
279
+ # 1. Non-gitignored files first (is_ignored: 0 or 1)
280
+ # 2. Basename hit first, then path hit position, then length
281
+ # Since both fd and rg now search from current directory, all paths are relative to cwd
282
+ kn = keyword_norm
283
+ ignored_paths = ignored_paths or set()
284
+ out: list[tuple[str, tuple[int, int, int, int, int]]] = []
285
+ for p in paths_from_root:
286
+ pl = p.lower()
287
+ if kn not in pl:
288
+ continue
289
+
290
+ # Use path directly since it's already relative to current directory
291
+ rel_to_cwd = p.lstrip("./")
292
+ base = os.path.basename(p).lower()
293
+ base_pos = base.find(kn)
294
+ path_pos = pl.find(kn)
295
+ # Check if this path is in the ignored set (gitignored files)
296
+ is_ignored = 1 if rel_to_cwd in ignored_paths else 0
297
+ score = (
298
+ is_ignored,
299
+ 0 if base_pos != -1 else 1,
300
+ base_pos if base_pos != -1 else 10_000,
301
+ path_pos,
302
+ len(p),
303
+ )
304
+
305
+ # Append trailing slash for directories
306
+ full_path = cwd / rel_to_cwd
307
+ if full_path.is_dir() and not rel_to_cwd.endswith("/"):
308
+ rel_to_cwd = rel_to_cwd + "/"
309
+ out.append((rel_to_cwd, score))
310
+ # Sort by score
311
+ out.sort(key=lambda x: x[1])
312
+ # Unique while preserving order
313
+ seen: set[str] = set()
314
+ uniq: list[str] = []
315
+ for s, _ in out:
316
+ if s not in seen:
317
+ seen.add(s)
318
+ uniq.append(s)
319
+ return uniq
320
+
321
+ def _same_scope(self, prev_key: str, cur_key: str) -> bool:
322
+ # Consider same scope if they share the same base directory and one prefix startswith the other
323
+ try:
324
+ prev_root, prev_pref = prev_key.split("::", 1)
325
+ cur_root, cur_pref = cur_key.split("::", 1)
326
+ except ValueError:
327
+ return False
328
+ return prev_root == cur_root and (prev_pref.startswith(cur_pref) or cur_pref.startswith(prev_pref))
329
+
330
+ def _parse_query_key(self, key: str) -> tuple[str | None, str | None]:
331
+ try:
332
+ root, rest = key.split("::", 1)
333
+ tag, kw = rest.split("::", 1)
334
+ if tag != "search":
335
+ return root, None
336
+ return root, kw
337
+ except Exception:
338
+ return None, None
339
+
340
+ # ---- Utilities ----
341
+ def _run_fd_search(self, cwd: Path, keyword_norm: str) -> tuple[list[str], set[str]]:
342
+ """Run fd search and return (all_results, ignored_paths).
343
+
344
+ First runs fd without --no-ignore to get tracked files,
345
+ then runs with --no-ignore to get all files including gitignored ones.
346
+ Returns the combined results and a set of paths that are gitignored.
347
+ """
348
+ pattern = self._escape_regex(keyword_norm)
349
+ base_cmd = [
350
+ "fd",
351
+ "--color=never",
352
+ "--type",
353
+ "f",
354
+ "--type",
355
+ "d",
356
+ "--hidden",
357
+ "--full-path",
358
+ "-i",
359
+ "--max-results",
360
+ str(self._max_results * 3),
361
+ "--exclude",
362
+ ".git",
363
+ "--exclude",
364
+ ".venv",
365
+ "--exclude",
366
+ "node_modules",
367
+ pattern,
368
+ ".",
369
+ ]
370
+
371
+ # First run: get tracked (non-ignored) files
372
+ r_tracked = self._run_cmd(base_cmd, cwd=cwd)
373
+ tracked_paths: set[str] = set(p.lstrip("./") for p in r_tracked.lines) if r_tracked.ok else set()
374
+
375
+ # Second run: get all files including ignored ones
376
+ cmd_all = base_cmd.copy()
377
+ cmd_all.insert(2, "--no-ignore") # Insert after --color=never
378
+ r_all = self._run_cmd(cmd_all, cwd=cwd)
379
+ all_paths = r_all.lines if r_all.ok else []
380
+
381
+ # Calculate which paths are gitignored (in all but not in tracked)
382
+ ignored_paths = set(p.lstrip("./") for p in all_paths) - tracked_paths
383
+
384
+ return all_paths, ignored_paths
385
+
386
+ def _escape_regex(self, s: str) -> str:
387
+ # Escape for fd (regex by default). Keep '/' as is for path boundaries.
388
+ return re.escape(s).replace("/", "/")
389
+
390
+ def _has_cmd(self, name: str) -> bool:
391
+ return shutil.which(name) is not None
392
+
393
+ def _suggest_for_empty_fragment(self, cwd: Path) -> list[str]:
394
+ """Lightweight suggestions when user typed only '@': list cwd's children.
395
+
396
+ Avoids running external tools; shows immediate directories first, then files.
397
+ Filters out .git, .venv, and node_modules to reduce noise.
398
+ """
399
+ excluded = {".git", ".venv", "node_modules"}
400
+ items: list[str] = []
401
+ try:
402
+ for p in sorted(cwd.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
403
+ name = p.name
404
+ if name in excluded:
405
+ continue
406
+ rel = os.path.relpath(p, cwd)
407
+ if p.is_dir() and not rel.endswith("/"):
408
+ rel += "/"
409
+ items.append(rel)
410
+ except Exception:
411
+ return []
412
+ return items[: min(self._max_results, 100)]
413
+
414
+ def _run_cmd(self, cmd: list[str], cwd: Path | None = None) -> _CmdResult:
415
+ try:
416
+ p = subprocess.run(
417
+ cmd,
418
+ cwd=str(cwd) if cwd else None,
419
+ stdout=subprocess.PIPE,
420
+ stderr=subprocess.DEVNULL,
421
+ text=True,
422
+ timeout=1.5,
423
+ )
424
+ if p.returncode == 0:
425
+ lines = [ln.strip() for ln in p.stdout.splitlines() if ln.strip()]
426
+ return _CmdResult(True, lines)
427
+ return _CmdResult(False, [])
428
+ except Exception:
429
+ return _CmdResult(False, [])
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import override
4
+
5
+ from klaude_code.protocol import events
6
+ from klaude_code.ui.core.display import DisplayABC
7
+ from klaude_code.ui.modes.repl.event_handler import DisplayEventHandler
8
+ from klaude_code.ui.modes.repl.renderer import REPLRenderer
9
+ from klaude_code.ui.terminal.notifier import TerminalNotifier
10
+
11
+
12
+ class REPLDisplay(DisplayABC):
13
+ """
14
+ Interactive terminal display using Rich for rendering.
15
+
16
+ REPLDisplay provides a full-featured terminal UI with:
17
+ - Rich markdown rendering for assistant messages
18
+ - Syntax-highlighted code blocks and diffs
19
+ - Animated spinners for in-progress operations
20
+ - Tool call and result visualization
21
+ - OSC94 progress bar integration (for supported terminals)
22
+ - Desktop notifications on task completion
23
+
24
+ This is the primary display mode for interactive klaude-code sessions.
25
+ For non-interactive use, see ExecDisplay. For debugging, wrap with
26
+ DebugEventDisplay.
27
+
28
+ Lifecycle:
29
+ 1. start(): No-op (initialization happens in __init__)
30
+ 2. consume_event(): Delegates to DisplayEventHandler for event processing
31
+ 3. stop(): Stops the event handler and ensures spinner is cleaned up
32
+
33
+ Attributes:
34
+ renderer: The REPLRenderer instance for terminal output
35
+ notifier: TerminalNotifier for desktop notifications
36
+ event_handler: DisplayEventHandler that processes events
37
+ """
38
+
39
+ def __init__(self, theme: str | None = None, notifier: TerminalNotifier | None = None):
40
+ self.renderer = REPLRenderer(theme)
41
+ self.notifier = notifier or TerminalNotifier()
42
+ self.event_handler = DisplayEventHandler(self.renderer, notifier=self.notifier)
43
+
44
+ @override
45
+ async def consume_event(self, event: events.Event) -> None:
46
+ await self.event_handler.consume_event(event)
47
+
48
+ @override
49
+ async def start(self) -> None:
50
+ pass
51
+
52
+ @override
53
+ async def stop(self) -> None:
54
+ await self.event_handler.stop()
55
+ # Ensure any active spinner is stopped so Rich restores the cursor.
56
+ try:
57
+ self.renderer.spinner_stop()
58
+ except Exception:
59
+ # Spinner may already be stopped or not started; ignore.
60
+ pass