klaude-code 1.2.6__py3-none-any.whl → 1.8.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 (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +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
@@ -17,26 +19,39 @@ import re
17
19
  import shutil
18
20
  import subprocess
19
21
  import time
20
- from collections.abc import Iterable
22
+ from collections.abc import Callable, Iterable
21
23
  from pathlib import Path
22
24
  from typing import NamedTuple
23
25
 
24
26
  from prompt_toolkit.completion import Completer, Completion
25
27
  from prompt_toolkit.document import Document
26
- from prompt_toolkit.formatted_text import HTML
28
+ from prompt_toolkit.formatted_text import FormattedText
27
29
 
28
- from klaude_code.command import get_commands
30
+ from klaude_code.protocol.commands import CommandInfo
31
+ from klaude_code.trace.log import DebugType, log_debug
29
32
 
30
- # Pattern to match @token for completion refresh (used by key bindings)
31
- AT_TOKEN_PATTERN = re.compile(r"(^|\s)@(?P<frag>[^\s]*)$")
33
+ # Pattern to match @token for completion refresh (used by key bindings).
34
+ # Supports both plain tokens like `@src/file.py` and quoted tokens like
35
+ # `@"path with spaces/file.py"` so that filenames with spaces remain a
36
+ # single logical token.
37
+ AT_TOKEN_PATTERN = re.compile(r'(^|\s)@(?P<frag>"[^"]*"|[^\s]*)$')
32
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*)$")
33
41
 
34
- def create_repl_completer() -> Completer:
42
+
43
+ def create_repl_completer(
44
+ command_info_provider: Callable[[], list[CommandInfo]] | None = None,
45
+ ) -> Completer:
35
46
  """Create and return the combined REPL completer.
36
47
 
48
+ Args:
49
+ command_info_provider: Optional callable that returns command metadata.
50
+ If None, slash command completion is disabled.
51
+
37
52
  Returns a completer that handles both @ file paths and / slash commands.
38
53
  """
39
- return _ComboCompleter()
54
+ return _ComboCompleter(command_info_provider=command_info_provider)
40
55
 
41
56
 
42
57
  class _CmdResult(NamedTuple):
@@ -57,6 +72,9 @@ class _SlashCommandCompleter(Completer):
57
72
 
58
73
  _SLASH_TOKEN_RE = re.compile(r"^/(?P<frag>\S*)$")
59
74
 
75
+ def __init__(self, command_info_provider: Callable[[], list[CommandInfo]] | None = None) -> None:
76
+ self._command_info_provider = command_info_provider
77
+
60
78
  def get_completions(
61
79
  self,
62
80
  document: Document,
@@ -64,49 +82,42 @@ class _SlashCommandCompleter(Completer):
64
82
  ) -> Iterable[Completion]:
65
83
  # Only complete on first line
66
84
  if document.cursor_position_row != 0:
67
- return iter([])
85
+ return
86
+
87
+ if self._command_info_provider is None:
88
+ return
68
89
 
69
90
  text_before = document.current_line_before_cursor
70
91
  m = self._SLASH_TOKEN_RE.search(text_before)
71
92
  if not m:
72
- return iter([])
93
+ return
73
94
 
74
95
  frag = m.group("frag")
75
96
  token_start = len(text_before) - len(f"/{frag}")
76
97
  start_position = token_start - len(text_before) # negative offset
77
98
 
78
- # Get available commands
79
- commands = get_commands()
99
+ # Get available commands from provider
100
+ command_infos = self._command_info_provider()
80
101
 
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))
102
+ # Filter commands that match the fragment (preserve registration order)
103
+ matched: list[tuple[str, CommandInfo, str]] = []
104
+ for cmd_info in command_infos:
105
+ if cmd_info.name.startswith(frag):
106
+ hint = f" [{cmd_info.placeholder}]" if cmd_info.support_addition_params else ""
107
+ matched.append((cmd_info.name, cmd_info, hint))
87
108
 
88
109
  if not matched:
89
- return iter([])
110
+ return
90
111
 
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
- )
112
+ for cmd_name, cmd_info, hint in matched:
105
113
  completion_text = f"/{cmd_name} "
114
+ # Use FormattedText to style the hint (placeholder) in bright black
115
+ display = FormattedText([("", cmd_name), ("ansibrightblack", hint)]) if hint else cmd_name
106
116
  yield Completion(
107
117
  text=completion_text,
108
118
  start_position=start_position,
109
- display=display_text,
119
+ display=display,
120
+ display_meta=cmd_info.summary,
110
121
  )
111
122
 
112
123
  def is_slash_command_context(self, document: Document) -> bool:
@@ -117,12 +128,94 @@ class _SlashCommandCompleter(Completer):
117
128
  return bool(self._SLASH_TOKEN_RE.search(text_before))
118
129
 
119
130
 
131
+ class _SkillCompleter(Completer):
132
+ """Complete skill names at the beginning of the first line.
133
+
134
+ Behavior:
135
+ - Only triggers when cursor is on first line and text matches $ or ¥...
136
+ - Shows available skills with descriptions
137
+ - Inserts trailing space after completion
138
+ """
139
+
140
+ _SKILL_TOKEN_RE = SKILL_TOKEN_PATTERN
141
+
142
+ def get_completions(
143
+ self,
144
+ document: Document,
145
+ complete_event, # type: ignore[override]
146
+ ) -> Iterable[Completion]:
147
+ # Only complete on first line
148
+ if document.cursor_position_row != 0:
149
+ return
150
+
151
+ text_before = document.current_line_before_cursor
152
+ m = self._SKILL_TOKEN_RE.search(text_before)
153
+ if not m:
154
+ return
155
+
156
+ frag = m.group("frag").lower()
157
+ # Get the prefix character ($ or ¥)
158
+ prefix_char = text_before[0]
159
+ token_start = len(text_before) - len(f"{prefix_char}{m.group('frag')}")
160
+ start_position = token_start - len(text_before) # negative offset
161
+
162
+ # Get available skills from SkillTool
163
+ skills = self._get_available_skills()
164
+ if not skills:
165
+ return
166
+
167
+ # Filter skills that match the fragment (case-insensitive)
168
+ matched: list[tuple[str, str, str]] = [] # (name, description, location)
169
+ for name, desc, location in skills:
170
+ if frag in name.lower() or frag in desc.lower():
171
+ matched.append((name, desc, location))
172
+
173
+ if not matched:
174
+ return
175
+
176
+ # Calculate max location length for alignment
177
+ max_loc_len = max(len(loc) for _, _, loc in matched)
178
+
179
+ for name, desc, location in matched:
180
+ completion_text = f"${name} "
181
+ # Pad location to align descriptions
182
+ padded_location = f"[{location}]".ljust(max_loc_len + 2) # +2 for brackets
183
+ yield Completion(
184
+ text=completion_text,
185
+ start_position=start_position,
186
+ display=name,
187
+ display_meta=f"{padded_location} {desc}",
188
+ )
189
+
190
+ def _get_available_skills(self) -> list[tuple[str, str, str]]:
191
+ """Get available skills from skill module.
192
+
193
+ Returns:
194
+ List of (name, description, location) tuples
195
+ """
196
+ try:
197
+ # Import here to avoid circular imports
198
+ from klaude_code.skill import get_available_skills
199
+
200
+ return get_available_skills()
201
+ except (ImportError, RuntimeError):
202
+ return []
203
+
204
+ def is_skill_context(self, document: Document) -> bool:
205
+ """Check if current context is a skill completion."""
206
+ if document.cursor_position_row != 0:
207
+ return False
208
+ text_before = document.current_line_before_cursor
209
+ return bool(self._SKILL_TOKEN_RE.search(text_before))
210
+
211
+
120
212
  class _ComboCompleter(Completer):
121
- """Combined completer that handles both @ file paths and / slash commands."""
213
+ """Combined completer that handles @ file paths, / slash commands, and $ skills."""
122
214
 
123
- def __init__(self) -> None:
215
+ def __init__(self, command_info_provider: Callable[[], list[CommandInfo]] | None = None) -> None:
124
216
  self._at_completer = _AtFilesCompleter()
125
- self._slash_completer = _SlashCommandCompleter()
217
+ self._slash_completer = _SlashCommandCompleter(command_info_provider=command_info_provider)
218
+ self._skill_completer = _SkillCompleter()
126
219
 
127
220
  def get_completions(
128
221
  self,
@@ -130,10 +223,14 @@ class _ComboCompleter(Completer):
130
223
  complete_event, # type: ignore[override]
131
224
  ) -> Iterable[Completion]:
132
225
  # 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
226
+ if document.cursor_position_row == 0 and self._slash_completer.is_slash_command_context(document):
227
+ yield from self._slash_completer.get_completions(document, complete_event)
228
+ return
229
+
230
+ # Try skill completion (only on first line with $ prefix)
231
+ if document.cursor_position_row == 0 and self._skill_completer.is_skill_context(document):
232
+ yield from self._skill_completer.get_completions(document, complete_event)
233
+ return
137
234
 
138
235
  # Fall back to @ file completion
139
236
  yield from self._at_completer.get_completions(document, complete_event)
@@ -155,7 +252,7 @@ class _AtFilesCompleter(Completer):
155
252
  def __init__(
156
253
  self,
157
254
  debounce_sec: float = 0.25,
158
- cache_ttl_sec: float = 10.0,
255
+ cache_ttl_sec: float = 60.0,
159
256
  max_results: int = 20,
160
257
  ):
161
258
  self._debounce_sec = debounce_sec
@@ -167,13 +264,26 @@ class _AtFilesCompleter(Completer):
167
264
  self._last_query_key: str | None = None
168
265
  self._last_results: list[str] = []
169
266
  self._last_results_time: float = 0.0
267
+ self._last_results_truncated: bool = False
170
268
 
171
269
  # rg --files cache (used when fd is unavailable)
172
270
  self._rg_file_list: list[str] | None = None
173
271
  self._rg_file_list_time: float = 0.0
272
+ self._rg_file_list_cwd: Path | None = None
273
+
274
+ # git ls-files cache (preferred when inside a git repo)
275
+ self._git_repo_root: Path | None = None
276
+ self._git_repo_root_time: float = 0.0
277
+ self._git_repo_root_cwd: Path | None = None
278
+
279
+ self._git_file_list: list[str] | None = None
280
+ self._git_file_list_lower: list[str] | None = None
281
+ self._git_file_list_time: float = 0.0
282
+ self._git_file_list_cwd: Path | None = None
174
283
 
175
- # Cache for ignored paths (gitignored files)
176
- self._last_ignored_paths: set[str] = set()
284
+ # Command timeout is intentionally higher than a keypress cadence.
285
+ # We rely on caching/narrowing to avoid calling fd repeatedly.
286
+ self._cmd_timeout_sec: float = 3.0
177
287
 
178
288
  # ---- prompt_toolkit API ----
179
289
  def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
@@ -182,31 +292,50 @@ class _AtFilesCompleter(Completer):
182
292
  if not m:
183
293
  return [] # type: ignore[reportUnknownVariableType]
184
294
 
185
- frag = m.group("frag") # text after '@' and before cursor (no spaces)
295
+ frag = m.group("frag") # raw text after '@' and before cursor (may be quoted)
296
+ # Normalize fragment for search: support optional quoting syntax @"...".
297
+ is_quoted = frag.startswith('"')
298
+ search_frag = frag
299
+ if is_quoted:
300
+ # Drop leading quote; if user already closed the quote, drop trailing quote as well.
301
+ search_frag = search_frag[1:]
302
+ if search_frag.endswith('"'):
303
+ search_frag = search_frag[:-1]
304
+
186
305
  token_start_in_input = len(text_before) - len(f"@{frag}")
187
306
 
188
307
  cwd = Path.cwd()
189
308
 
190
309
  # If no fragment yet, show lightweight suggestions from current directory
191
- if frag.strip() == "":
310
+ if search_frag.strip() == "":
192
311
  suggestions = self._suggest_for_empty_fragment(cwd)
193
312
  if not suggestions:
194
313
  return [] # type: ignore[reportUnknownVariableType]
195
314
  start_position = token_start_in_input - len(text_before)
196
315
  for s in suggestions[: self._max_results]:
197
- yield Completion(text=f"@{s} ", start_position=start_position, display=s)
316
+ yield Completion(
317
+ text=self._format_completion_text(s, is_quoted=is_quoted),
318
+ start_position=start_position,
319
+ display=self._format_display_label(s, 0),
320
+ display_meta=s,
321
+ )
198
322
  return [] # type: ignore[reportUnknownVariableType]
199
323
 
200
324
  # Gather suggestions with debounce/caching based on search keyword
201
- suggestions = self._complete_paths(cwd, frag)
325
+ suggestions = self._complete_paths(cwd, search_frag)
202
326
  if not suggestions:
203
327
  return [] # type: ignore[reportUnknownVariableType]
204
328
 
205
329
  # Prepare Completion objects. Replace from the '@' character.
206
330
  start_position = token_start_in_input - len(text_before) # negative
207
331
  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)
332
+ # Insert formatted text (with quoting when needed) so that subsequent typing does not keep triggering
333
+ yield Completion(
334
+ text=self._format_completion_text(s, is_quoted=is_quoted),
335
+ start_position=start_position,
336
+ display=self._format_display_label(s, 0),
337
+ display_meta=s,
338
+ )
210
339
 
211
340
  # ---- Core logic ----
212
341
  def _complete_paths(self, cwd: Path, keyword: str) -> list[str]:
@@ -214,6 +343,8 @@ class _AtFilesCompleter(Completer):
214
343
  key_norm = keyword.lower()
215
344
  query_key = f"{cwd.resolve()}::search::{key_norm}"
216
345
 
346
+ max_scan_results = self._max_results * 3
347
+
217
348
  # Debounce: if called too soon again, filter last results
218
349
  if self._last_results and self._last_query_key is not None:
219
350
  prev = self._last_query_key
@@ -227,85 +358,139 @@ class _AtFilesCompleter(Completer):
227
358
  and len(cur_kw) >= len(prev_kw)
228
359
  and cur_kw.startswith(prev_kw)
229
360
  )
361
+
362
+ # If the previous result set was not truncated, it is a complete
363
+ # superset for any narrower prefix. Reuse it even if the user
364
+ # pauses between keystrokes.
365
+ if (
366
+ is_narrowing
367
+ and not self._last_results_truncated
368
+ and now - self._last_results_time < self._cache_ttl
369
+ ):
370
+ return self._filter_and_format(self._last_results, cwd, key_norm)
371
+
230
372
  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)
373
+ # For rapid narrowing, fast-filter previous results to avoid expensive calls
374
+ # If the previous result set was truncated (e.g., for a 1-char query),
375
+ # filtering it can legitimately produce an empty set even when matches
376
+ # exist elsewhere. Fall back to a real search in that case.
377
+ filtered = self._filter_and_format(self._last_results, cwd, key_norm)
378
+ if filtered:
379
+ return filtered
233
380
 
234
381
  # Cache TTL: reuse cached results for same query within TTL
235
382
  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)
383
+ return self._filter_and_format(self._last_results, cwd, key_norm)
237
384
 
238
- # Prefer fd; otherwise fallback to rg --files
385
+ # Prefer git index (fast in large repos), then fd, then rg --files.
239
386
  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 []
387
+ truncated = False
388
+
389
+ # For very short keywords, prefer fd's early-exit behavior.
390
+ # For keywords >= 2 chars, using git's file list is typically faster
391
+ # than scanning the filesystem repeatedly.
392
+ if len(key_norm) >= 2:
393
+ results, truncated = self._git_paths_for_keyword(cwd, key_norm, max_results=max_scan_results)
394
+
395
+ if not results:
396
+ if self._has_cmd("fd"):
397
+ # First, get immediate children matching the keyword (depth=0).
398
+ # fd's traversal order is not depth-first, so --max-results may
399
+ # truncate shallow matches. We ensure depth=0 items are always included.
400
+ immediate = self._get_immediate_matches(cwd, key_norm)
401
+ # Use fd to search anywhere in full path (files and directories), case-insensitive
402
+ fd_results, truncated = self._run_fd_search(cwd, key_norm, max_results=max_scan_results)
403
+ # Merge: immediate matches first, then fd results (deduped in _filter_and_format)
404
+ results = immediate + fd_results
405
+ elif self._has_cmd("rg"):
406
+ # Use rg to search only in current directory
407
+ rg_cache_ttl = max(self._cache_ttl, 30.0)
408
+ if (
409
+ self._rg_file_list is None
410
+ or self._rg_file_list_cwd != cwd
411
+ or now - self._rg_file_list_time > rg_cache_ttl
412
+ ):
413
+ cmd = [
414
+ "rg",
415
+ "--files",
416
+ "--hidden",
417
+ "--glob",
418
+ "!**/.git/**",
419
+ "--glob",
420
+ "!**/.venv/**",
421
+ "--glob",
422
+ "!**/node_modules/**",
423
+ ]
424
+ r = self._run_cmd(cmd, cwd=cwd, timeout_sec=self._cmd_timeout_sec) # Search from current directory
425
+ if r.ok:
426
+ self._rg_file_list = r.lines
427
+ self._rg_file_list_time = now
428
+ self._rg_file_list_cwd = cwd
429
+ else:
430
+ self._rg_file_list = []
431
+ self._rg_file_list_time = now
432
+ self._rg_file_list_cwd = cwd
433
+ # Filter by keyword
434
+ all_files = self._rg_file_list or []
435
+ kn = key_norm
436
+ results = [p for p in all_files if kn in p.lower()]
437
+ # For rg fallback, we don't implement any priority sorting.
438
+ else:
439
+ return []
262
440
 
263
441
  # Update caches
264
442
  self._last_cmd_time = now
265
443
  self._last_query_key = query_key
266
444
  self._last_results = results
267
445
  self._last_results_time = now
268
- self._last_ignored_paths = ignored_paths
269
- return self._filter_and_format(results, cwd, key_norm, ignored_paths)
446
+ self._last_results_truncated = truncated
447
+ return self._filter_and_format(results, cwd, key_norm)
270
448
 
271
449
  def _filter_and_format(
272
450
  self,
273
451
  paths_from_root: list[str],
274
452
  cwd: Path,
275
453
  keyword_norm: str,
276
- ignored_paths: set[str] | None = None,
277
454
  ) -> list[str]:
278
455
  # 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
456
+ # 1. Hidden files (starting with .) are deprioritized
457
+ # 2. Paths containing "test" are deprioritized
458
+ # 3. Directory depth (shallower first)
459
+ # 4. Basename hit first, then path hit position, then length
281
460
  # Since both fd and rg now search from current directory, all paths are relative to cwd
282
461
  kn = keyword_norm
283
- ignored_paths = ignored_paths or set()
284
- out: list[tuple[str, tuple[int, int, int, int, int]]] = []
462
+ out: list[tuple[str, tuple[int, int, int, int, int, int, int]]] = []
285
463
  for p in paths_from_root:
286
464
  pl = p.lower()
287
465
  if kn not in pl:
288
466
  continue
289
467
 
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()
468
+ # Most tools return paths relative to cwd. Some include a leading
469
+ # './' prefix; strip that exact prefix only.
470
+ #
471
+ # Do not use lstrip('./') here: it would also remove the leading '.'
472
+ # from dotfiles/directories like '.claude/'.
473
+ rel_to_cwd = p.removeprefix("./").removeprefix(".\\")
474
+ base = os.path.basename(rel_to_cwd.rstrip("/")).lower()
293
475
  base_pos = base.find(kn)
294
476
  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
477
+ depth = rel_to_cwd.rstrip("/").count("/")
478
+
479
+ # Deprioritize hidden files/directories (any path segment starting with .)
480
+ is_hidden = any(seg.startswith(".") for seg in rel_to_cwd.split("/") if seg)
481
+ # Deprioritize paths containing "test"
482
+ has_test = "test" in pl
483
+
297
484
  score = (
298
- is_ignored,
485
+ 1 if is_hidden else 0,
486
+ 1 if has_test else 0,
487
+ depth,
299
488
  0 if base_pos != -1 else 1,
300
489
  base_pos if base_pos != -1 else 10_000,
301
490
  path_pos,
302
491
  len(p),
303
492
  )
304
493
 
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
494
  out.append((rel_to_cwd, score))
310
495
  # Sort by score
311
496
  out.sort(key=lambda x: x[1])
@@ -316,8 +501,62 @@ class _AtFilesCompleter(Completer):
316
501
  if s not in seen:
317
502
  seen.add(s)
318
503
  uniq.append(s)
504
+
505
+ # Append trailing slash for directories, but avoid excessive stats.
506
+ # For large candidate lists, only stat the most relevant prefixes.
507
+ stat_limit = min(len(uniq), max(self._max_results * 3, 60))
508
+ for idx in range(stat_limit):
509
+ s = uniq[idx]
510
+ if s.endswith("/"):
511
+ continue
512
+ try:
513
+ if (cwd / s).is_dir():
514
+ uniq[idx] = f"{s}/"
515
+ except OSError:
516
+ continue
319
517
  return uniq
320
518
 
519
+ def _format_completion_text(self, suggestion: str, *, is_quoted: bool) -> str:
520
+ """Format completion insertion text for a given suggestion.
521
+
522
+ Paths that contain whitespace are always wrapped in quotes so that they
523
+ can be parsed correctly by the @-file reader. If the user explicitly
524
+ started a quoted token (e.g. @"foo), we preserve quoting even when the
525
+ suggested path itself does not contain spaces.
526
+ """
527
+ needs_quotes = any(ch.isspace() for ch in suggestion)
528
+ if needs_quotes or is_quoted:
529
+ return f'@"{suggestion}" '
530
+ return f"@{suggestion} "
531
+
532
+ def _format_display_label(self, suggestion: str, align_width: int) -> str:
533
+ """Format visible label for a completion option.
534
+
535
+ Keep this unstyled so that the completion menu's selection style can
536
+ fully override the selected row.
537
+ """
538
+
539
+ _ = align_width
540
+ return self._display_name(suggestion)
541
+
542
+ def _display_align_width(self, suggestions: list[str]) -> int:
543
+ """Calculate alignment width for display labels."""
544
+
545
+ return max((len(self._display_name(s)) for s in suggestions), default=0)
546
+
547
+ def _display_name(self, suggestion: str) -> str:
548
+ """Return the basename (with trailing slash for directories) for display."""
549
+
550
+ if not suggestion:
551
+ return suggestion
552
+
553
+ is_dir = suggestion.endswith("/")
554
+ stripped = suggestion.rstrip("/")
555
+ base = stripped.split("/")[-1] if stripped else suggestion
556
+ if is_dir:
557
+ return f"{base}/"
558
+ return base
559
+
321
560
  def _same_scope(self, prev_key: str, cur_key: str) -> bool:
322
561
  # Consider same scope if they share the same base directory and one prefix startswith the other
323
562
  try:
@@ -334,19 +573,17 @@ class _AtFilesCompleter(Completer):
334
573
  if tag != "search":
335
574
  return root, None
336
575
  return root, kw
337
- except Exception:
576
+ except ValueError:
338
577
  return None, None
339
578
 
340
579
  # ---- 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).
580
+ def _run_fd_search(self, cwd: Path, keyword_norm: str, *, max_results: int) -> tuple[list[str], bool]:
581
+ """Run fd search and return (results, truncated).
343
582
 
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.
583
+ Note: This is called in the prompt_toolkit completion path, so avoid
584
+ doing extra background scans here.
347
585
  """
348
- pattern = self._escape_regex(keyword_norm)
349
- base_cmd = [
586
+ cmd = [
350
587
  "fd",
351
588
  "--color=never",
352
589
  "--type",
@@ -356,36 +593,115 @@ class _AtFilesCompleter(Completer):
356
593
  "--hidden",
357
594
  "--full-path",
358
595
  "-i",
596
+ "-F",
359
597
  "--max-results",
360
- str(self._max_results * 3),
598
+ str(max_results),
361
599
  "--exclude",
362
600
  ".git",
363
601
  "--exclude",
364
602
  ".venv",
365
603
  "--exclude",
366
604
  "node_modules",
367
- pattern,
605
+ keyword_norm,
368
606
  ".",
369
607
  ]
370
608
 
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()
609
+ r = self._run_cmd(cmd, cwd=cwd, timeout_sec=self._cmd_timeout_sec)
610
+ lines = r.lines if r.ok else []
611
+ return lines, (len(lines) >= max_results)
612
+
613
+ def _git_paths_for_keyword(self, cwd: Path, keyword_norm: str, *, max_results: int) -> tuple[list[str], bool]:
614
+ """Get path suggestions from the git index (fast for large repos).
615
+
616
+ Returns (candidates, truncated). "truncated" is True when we
617
+ intentionally stop early to keep per-keystroke costs bounded.
618
+ """
619
+ repo_root = self._get_git_repo_root(cwd)
620
+ if repo_root is None:
621
+ return [], False
622
+
623
+ now = time.monotonic()
624
+ git_cache_ttl = max(self._cache_ttl, 30.0)
625
+ if (
626
+ self._git_file_list is None
627
+ or self._git_file_list_cwd != cwd
628
+ or now - self._git_file_list_time > git_cache_ttl
629
+ ):
630
+ cmd = ["git", "ls-files", "-co", "--exclude-standard"]
631
+ r = self._run_cmd(cmd, cwd=repo_root, timeout_sec=self._cmd_timeout_sec)
632
+ if not r.ok:
633
+ self._git_file_list = []
634
+ self._git_file_list_lower = []
635
+ self._git_file_list_time = now
636
+ self._git_file_list_cwd = cwd
637
+ else:
638
+ cwd_resolved = cwd.resolve()
639
+ root_resolved = repo_root.resolve()
640
+ files: list[str] = []
641
+ files_lower: list[str] = []
642
+ for rel in r.lines:
643
+ abs_path = root_resolved / rel
644
+ try:
645
+ rel_to_cwd = abs_path.relative_to(cwd_resolved)
646
+ except ValueError:
647
+ continue
648
+ rel_posix = rel_to_cwd.as_posix()
649
+ files.append(rel_posix)
650
+ files_lower.append(rel_posix.lower())
651
+ self._git_file_list = files
652
+ self._git_file_list_lower = files_lower
653
+ self._git_file_list_time = now
654
+ self._git_file_list_cwd = cwd
655
+
656
+ all_files = self._git_file_list or []
657
+ all_files_lower = self._git_file_list_lower or []
658
+ kn = keyword_norm
374
659
 
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 []
660
+ # Bound per-keystroke work: stop scanning once enough matches are found.
661
+ matching_files: list[str] = []
662
+ scan_truncated = False
663
+ for p, pl in zip(all_files, all_files_lower, strict=False):
664
+ if kn in pl:
665
+ matching_files.append(p)
666
+ if len(matching_files) >= max_results:
667
+ scan_truncated = True
668
+ break
669
+
670
+ # Also include parent directories of matching files so users can
671
+ # complete into a folder, similar to fd's directory results.
672
+ dir_candidates: set[str] = set()
673
+ for p in matching_files[: max_results * 3]:
674
+ parent = os.path.dirname(p)
675
+ while parent and parent != ".":
676
+ dir_candidates.add(f"{parent}/")
677
+ parent = os.path.dirname(parent)
678
+
679
+ dir_list = sorted(dir_candidates)
680
+ dir_truncated = False
681
+ if len(dir_list) > max_results:
682
+ dir_list = dir_list[:max_results]
683
+ dir_truncated = True
684
+
685
+ candidates = matching_files + dir_list
686
+ truncated = scan_truncated or dir_truncated
687
+ return candidates, truncated
688
+
689
+ def _get_git_repo_root(self, cwd: Path) -> Path | None:
690
+ if not self._has_cmd("git"):
691
+ return None
380
692
 
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
693
+ now = time.monotonic()
694
+ ttl = max(self._cache_ttl, 30.0)
695
+ if self._git_repo_root_cwd == cwd and now - self._git_repo_root_time < ttl:
696
+ return self._git_repo_root
383
697
 
384
- return all_paths, ignored_paths
698
+ r = self._run_cmd(["git", "rev-parse", "--show-toplevel"], cwd=cwd, timeout_sec=0.5)
699
+ root = Path(r.lines[0]) if r.ok and r.lines else None
385
700
 
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("/", "/")
701
+ self._git_repo_root = root
702
+ self._git_repo_root_time = now
703
+ self._git_repo_root_cwd = cwd
704
+ return root
389
705
 
390
706
  def _has_cmd(self, name: str) -> bool:
391
707
  return shutil.which(name) is not None
@@ -395,11 +711,20 @@ class _AtFilesCompleter(Completer):
395
711
 
396
712
  Avoids running external tools; shows immediate directories first, then files.
397
713
  Filters out .git, .venv, and node_modules to reduce noise.
714
+ Hidden files and paths containing "test" are deprioritized.
398
715
  """
399
716
  excluded = {".git", ".venv", "node_modules"}
400
717
  items: list[str] = []
401
718
  try:
402
- for p in sorted(cwd.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
719
+ # Sort by: hidden files last, test paths last, directories first, then name
720
+ def sort_key(p: Path) -> tuple[int, int, int, str]:
721
+ name = p.name
722
+ is_hidden = name.startswith(".")
723
+ has_test = "test" in name.lower()
724
+ is_file = not p.is_dir()
725
+ return (1 if is_hidden else 0, 1 if has_test else 0, 1 if is_file else 0, name.lower())
726
+
727
+ for p in sorted(cwd.iterdir(), key=sort_key):
403
728
  name = p.name
404
729
  if name in excluded:
405
730
  continue
@@ -407,23 +732,62 @@ class _AtFilesCompleter(Completer):
407
732
  if p.is_dir() and not rel.endswith("/"):
408
733
  rel += "/"
409
734
  items.append(rel)
410
- except Exception:
735
+ except OSError:
411
736
  return []
412
737
  return items[: min(self._max_results, 100)]
413
738
 
414
- def _run_cmd(self, cmd: list[str], cwd: Path | None = None) -> _CmdResult:
739
+ def _get_immediate_matches(self, cwd: Path, keyword_norm: str) -> list[str]:
740
+ """Get immediate children of cwd that match the keyword (case-insensitive).
741
+
742
+ This ensures depth=0 matches are always included, even when fd's
743
+ --max-results truncates before reaching them.
744
+ """
745
+ excluded = {".git", ".venv", "node_modules"}
746
+ items: list[str] = []
747
+ try:
748
+ for p in cwd.iterdir():
749
+ name = p.name
750
+ if name in excluded:
751
+ continue
752
+ if keyword_norm in name.lower():
753
+ rel = name
754
+ if p.is_dir():
755
+ rel += "/"
756
+ items.append(rel)
757
+ except OSError:
758
+ return []
759
+ return items
760
+
761
+ def _run_cmd(self, cmd: list[str], cwd: Path | None = None, *, timeout_sec: float) -> _CmdResult:
762
+ cmd_str = " ".join(cmd)
763
+ start = time.monotonic()
415
764
  try:
416
765
  p = subprocess.run(
417
766
  cmd,
418
767
  cwd=str(cwd) if cwd else None,
768
+ stdin=subprocess.DEVNULL,
419
769
  stdout=subprocess.PIPE,
420
770
  stderr=subprocess.DEVNULL,
421
771
  text=True,
422
- timeout=1.5,
772
+ timeout=timeout_sec,
423
773
  )
774
+ elapsed_ms = (time.monotonic() - start) * 1000
424
775
  if p.returncode == 0:
425
776
  lines = [ln.strip() for ln in p.stdout.splitlines() if ln.strip()]
777
+ log_debug(
778
+ f"[completer] cmd={cmd_str} elapsed={elapsed_ms:.1f}ms results={len(lines)}",
779
+ debug_type=DebugType.EXECUTION,
780
+ )
426
781
  return _CmdResult(True, lines)
782
+ log_debug(
783
+ f"[completer] cmd={cmd_str} elapsed={elapsed_ms:.1f}ms returncode={p.returncode}",
784
+ debug_type=DebugType.EXECUTION,
785
+ )
427
786
  return _CmdResult(False, [])
428
- except Exception:
787
+ except Exception as e:
788
+ elapsed_ms = (time.monotonic() - start) * 1000
789
+ log_debug(
790
+ f"[completer] cmd={cmd_str} elapsed={elapsed_ms:.1f}ms error={e!r}",
791
+ debug_type=DebugType.EXECUTION,
792
+ )
429
793
  return _CmdResult(False, [])