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.
- klaude_code/auth/__init__.py +24 -0
- klaude_code/auth/codex/__init__.py +20 -0
- klaude_code/auth/codex/exceptions.py +17 -0
- klaude_code/auth/codex/jwt_utils.py +45 -0
- klaude_code/auth/codex/oauth.py +229 -0
- klaude_code/auth/codex/token_manager.py +84 -0
- klaude_code/cli/auth_cmd.py +73 -0
- klaude_code/cli/config_cmd.py +91 -0
- klaude_code/cli/cost_cmd.py +338 -0
- klaude_code/cli/debug.py +78 -0
- klaude_code/cli/list_model.py +307 -0
- klaude_code/cli/main.py +233 -134
- klaude_code/cli/runtime.py +309 -117
- klaude_code/{version.py → cli/self_update.py} +114 -5
- klaude_code/cli/session_cmd.py +37 -21
- klaude_code/command/__init__.py +88 -27
- klaude_code/command/clear_cmd.py +8 -7
- klaude_code/command/command_abc.py +31 -31
- klaude_code/command/debug_cmd.py +79 -0
- klaude_code/command/export_cmd.py +19 -53
- klaude_code/command/export_online_cmd.py +154 -0
- klaude_code/command/fork_session_cmd.py +267 -0
- klaude_code/command/help_cmd.py +7 -8
- klaude_code/command/model_cmd.py +60 -10
- klaude_code/command/model_select.py +84 -0
- klaude_code/command/prompt-jj-describe.md +32 -0
- klaude_code/command/prompt_command.py +19 -11
- klaude_code/command/refresh_cmd.py +8 -10
- klaude_code/command/registry.py +139 -40
- klaude_code/command/release_notes_cmd.py +84 -0
- klaude_code/command/resume_cmd.py +111 -0
- klaude_code/command/status_cmd.py +104 -60
- klaude_code/command/terminal_setup_cmd.py +7 -9
- klaude_code/command/thinking_cmd.py +98 -0
- klaude_code/config/__init__.py +14 -6
- klaude_code/config/assets/__init__.py +1 -0
- klaude_code/config/assets/builtin_config.yaml +303 -0
- klaude_code/config/builtin_config.py +38 -0
- klaude_code/config/config.py +378 -109
- klaude_code/config/select_model.py +117 -53
- klaude_code/config/thinking.py +269 -0
- klaude_code/{const/__init__.py → const.py} +50 -19
- klaude_code/core/agent.py +20 -28
- klaude_code/core/executor.py +327 -112
- klaude_code/core/manager/__init__.py +2 -4
- klaude_code/core/manager/llm_clients.py +1 -15
- klaude_code/core/manager/llm_clients_builder.py +10 -11
- klaude_code/core/manager/sub_agent_manager.py +37 -6
- klaude_code/core/prompt.py +63 -44
- klaude_code/core/prompts/prompt-claude-code.md +2 -13
- klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
- klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
- klaude_code/core/prompts/prompt-codex.md +9 -42
- klaude_code/core/prompts/prompt-minimal.md +12 -0
- klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
- klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
- klaude_code/core/reminders.py +283 -95
- klaude_code/core/task.py +113 -75
- klaude_code/core/tool/__init__.py +24 -31
- klaude_code/core/tool/file/_utils.py +36 -0
- klaude_code/core/tool/file/apply_patch.py +17 -25
- klaude_code/core/tool/file/apply_patch_tool.py +57 -77
- klaude_code/core/tool/file/diff_builder.py +151 -0
- klaude_code/core/tool/file/edit_tool.py +50 -63
- klaude_code/core/tool/file/move_tool.md +41 -0
- klaude_code/core/tool/file/move_tool.py +435 -0
- klaude_code/core/tool/file/read_tool.md +1 -1
- klaude_code/core/tool/file/read_tool.py +86 -86
- klaude_code/core/tool/file/write_tool.py +59 -69
- klaude_code/core/tool/report_back_tool.py +84 -0
- klaude_code/core/tool/shell/bash_tool.py +265 -22
- klaude_code/core/tool/shell/command_safety.py +3 -6
- klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
- klaude_code/core/tool/sub_agent_tool.py +13 -2
- klaude_code/core/tool/todo/todo_write_tool.md +0 -157
- klaude_code/core/tool/todo/todo_write_tool.py +1 -1
- klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
- klaude_code/core/tool/todo/update_plan_tool.py +1 -1
- klaude_code/core/tool/tool_abc.py +18 -0
- klaude_code/core/tool/tool_context.py +27 -12
- klaude_code/core/tool/tool_registry.py +7 -7
- klaude_code/core/tool/tool_runner.py +44 -36
- klaude_code/core/tool/truncation.py +29 -14
- klaude_code/core/tool/web/mermaid_tool.md +43 -0
- klaude_code/core/tool/web/mermaid_tool.py +2 -5
- klaude_code/core/tool/web/web_fetch_tool.md +1 -1
- klaude_code/core/tool/web/web_fetch_tool.py +112 -22
- klaude_code/core/tool/web/web_search_tool.md +23 -0
- klaude_code/core/tool/web/web_search_tool.py +130 -0
- klaude_code/core/turn.py +168 -66
- klaude_code/llm/__init__.py +2 -10
- klaude_code/llm/anthropic/client.py +190 -178
- klaude_code/llm/anthropic/input.py +39 -15
- klaude_code/llm/bedrock/__init__.py +3 -0
- klaude_code/llm/bedrock/client.py +60 -0
- klaude_code/llm/client.py +7 -21
- klaude_code/llm/codex/__init__.py +5 -0
- klaude_code/llm/codex/client.py +149 -0
- klaude_code/llm/google/__init__.py +3 -0
- klaude_code/llm/google/client.py +309 -0
- klaude_code/llm/google/input.py +215 -0
- klaude_code/llm/input_common.py +3 -9
- klaude_code/llm/openai_compatible/client.py +72 -164
- klaude_code/llm/openai_compatible/input.py +6 -4
- klaude_code/llm/openai_compatible/stream.py +273 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
- klaude_code/llm/openrouter/client.py +89 -160
- klaude_code/llm/openrouter/input.py +18 -30
- klaude_code/llm/openrouter/reasoning.py +118 -0
- klaude_code/llm/registry.py +39 -7
- klaude_code/llm/responses/client.py +184 -171
- klaude_code/llm/responses/input.py +20 -1
- klaude_code/llm/usage.py +17 -12
- klaude_code/protocol/commands.py +17 -1
- klaude_code/protocol/events.py +31 -4
- klaude_code/protocol/llm_param.py +13 -10
- klaude_code/protocol/model.py +232 -29
- klaude_code/protocol/op.py +90 -1
- klaude_code/protocol/op_handler.py +35 -1
- klaude_code/protocol/sub_agent/__init__.py +117 -0
- klaude_code/protocol/sub_agent/explore.py +63 -0
- klaude_code/protocol/sub_agent/oracle.py +91 -0
- klaude_code/protocol/sub_agent/task.py +61 -0
- klaude_code/protocol/sub_agent/web.py +79 -0
- klaude_code/protocol/tools.py +4 -2
- klaude_code/session/__init__.py +2 -2
- klaude_code/session/codec.py +71 -0
- klaude_code/session/export.py +293 -86
- klaude_code/session/selector.py +89 -67
- klaude_code/session/session.py +320 -309
- klaude_code/session/store.py +220 -0
- klaude_code/session/templates/export_session.html +595 -83
- klaude_code/session/templates/mermaid_viewer.html +926 -0
- klaude_code/skill/__init__.py +27 -0
- klaude_code/skill/assets/deslop/SKILL.md +17 -0
- klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
- klaude_code/skill/assets/handoff/SKILL.md +39 -0
- klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
- klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
- klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
- klaude_code/skill/manager.py +70 -0
- klaude_code/skill/system_skills.py +192 -0
- klaude_code/trace/__init__.py +20 -2
- klaude_code/trace/log.py +150 -5
- klaude_code/ui/__init__.py +4 -9
- klaude_code/ui/core/input.py +1 -1
- klaude_code/ui/core/stage_manager.py +7 -7
- klaude_code/ui/modes/debug/display.py +2 -1
- klaude_code/ui/modes/repl/__init__.py +3 -48
- klaude_code/ui/modes/repl/clipboard.py +5 -5
- klaude_code/ui/modes/repl/completers.py +487 -123
- klaude_code/ui/modes/repl/display.py +5 -4
- klaude_code/ui/modes/repl/event_handler.py +370 -117
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
- klaude_code/ui/modes/repl/key_bindings.py +146 -23
- klaude_code/ui/modes/repl/renderer.py +189 -99
- klaude_code/ui/renderers/assistant.py +9 -2
- klaude_code/ui/renderers/bash_syntax.py +178 -0
- klaude_code/ui/renderers/common.py +78 -0
- klaude_code/ui/renderers/developer.py +104 -48
- klaude_code/ui/renderers/diffs.py +87 -6
- klaude_code/ui/renderers/errors.py +11 -6
- klaude_code/ui/renderers/mermaid_viewer.py +57 -0
- klaude_code/ui/renderers/metadata.py +112 -76
- klaude_code/ui/renderers/sub_agent.py +92 -7
- klaude_code/ui/renderers/thinking.py +40 -18
- klaude_code/ui/renderers/tools.py +405 -227
- klaude_code/ui/renderers/user_input.py +73 -13
- klaude_code/ui/rich/__init__.py +10 -1
- klaude_code/ui/rich/cjk_wrap.py +228 -0
- klaude_code/ui/rich/code_panel.py +131 -0
- klaude_code/ui/rich/live.py +17 -0
- klaude_code/ui/rich/markdown.py +305 -170
- klaude_code/ui/rich/searchable_text.py +10 -13
- klaude_code/ui/rich/status.py +190 -49
- klaude_code/ui/rich/theme.py +135 -39
- klaude_code/ui/terminal/__init__.py +55 -0
- klaude_code/ui/terminal/color.py +1 -1
- klaude_code/ui/terminal/control.py +13 -22
- klaude_code/ui/terminal/notifier.py +44 -4
- klaude_code/ui/terminal/selector.py +658 -0
- klaude_code/ui/utils/common.py +0 -18
- klaude_code-1.8.0.dist-info/METADATA +377 -0
- klaude_code-1.8.0.dist-info/RECORD +219 -0
- {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
- klaude_code/command/diff_cmd.py +0 -138
- klaude_code/command/prompt-dev-docs-update.md +0 -56
- klaude_code/command/prompt-dev-docs.md +0 -46
- klaude_code/config/list_model.py +0 -162
- klaude_code/core/manager/agent_manager.py +0 -127
- klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
- klaude_code/core/tool/file/multi_edit_tool.md +0 -42
- klaude_code/core/tool/file/multi_edit_tool.py +0 -199
- klaude_code/core/tool/memory/memory_tool.md +0 -16
- klaude_code/core/tool/memory/memory_tool.py +0 -462
- klaude_code/llm/openrouter/reasoning_handler.py +0 -209
- klaude_code/protocol/sub_agent.py +0 -348
- klaude_code/ui/utils/debouncer.py +0 -42
- klaude_code-1.2.6.dist-info/METADATA +0 -178
- klaude_code-1.2.6.dist-info/RECORD +0 -167
- /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
- /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
- /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
- {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
|
|
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
|
|
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
|
|
28
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
27
29
|
|
|
28
|
-
from klaude_code.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
83
|
-
for
|
|
84
|
-
if
|
|
85
|
-
hint = " [
|
|
86
|
-
matched.append((
|
|
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
|
|
110
|
+
return
|
|
90
111
|
|
|
91
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 =
|
|
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
|
-
#
|
|
176
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
209
|
-
yield Completion(
|
|
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
|
-
|
|
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
|
|
383
|
+
return self._filter_and_format(self._last_results, cwd, key_norm)
|
|
237
384
|
|
|
238
|
-
# Prefer
|
|
385
|
+
# Prefer git index (fast in large repos), then fd, then rg --files.
|
|
239
386
|
results: list[str] = []
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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.
|
|
269
|
-
return self._filter_and_format(results, cwd, key_norm
|
|
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.
|
|
280
|
-
# 2.
|
|
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
|
-
|
|
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
|
-
#
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
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],
|
|
342
|
-
"""Run fd search and return (
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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(
|
|
598
|
+
str(max_results),
|
|
361
599
|
"--exclude",
|
|
362
600
|
".git",
|
|
363
601
|
"--exclude",
|
|
364
602
|
".venv",
|
|
365
603
|
"--exclude",
|
|
366
604
|
"node_modules",
|
|
367
|
-
|
|
605
|
+
keyword_norm,
|
|
368
606
|
".",
|
|
369
607
|
]
|
|
370
608
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
#
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
|
735
|
+
except OSError:
|
|
411
736
|
return []
|
|
412
737
|
return items[: min(self._max_results, 100)]
|
|
413
738
|
|
|
414
|
-
def
|
|
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=
|
|
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, [])
|