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.
- klaude_code/cli/main.py +9 -4
- klaude_code/cli/runtime.py +42 -43
- klaude_code/command/__init__.py +7 -5
- klaude_code/command/clear_cmd.py +6 -29
- klaude_code/command/command_abc.py +44 -8
- klaude_code/command/diff_cmd.py +33 -27
- klaude_code/command/export_cmd.py +18 -26
- klaude_code/command/help_cmd.py +10 -8
- klaude_code/command/model_cmd.py +11 -40
- klaude_code/command/{prompt-update-dev-doc.md → prompt-dev-docs-update.md} +3 -2
- klaude_code/command/{prompt-dev-doc.md → prompt-dev-docs.md} +3 -2
- klaude_code/command/prompt-init.md +2 -5
- klaude_code/command/prompt_command.py +6 -6
- klaude_code/command/refresh_cmd.py +4 -5
- klaude_code/command/registry.py +16 -19
- klaude_code/command/terminal_setup_cmd.py +12 -11
- klaude_code/config/__init__.py +4 -0
- klaude_code/config/config.py +25 -26
- klaude_code/config/list_model.py +8 -3
- klaude_code/config/select_model.py +1 -1
- klaude_code/const/__init__.py +1 -1
- klaude_code/core/__init__.py +0 -3
- klaude_code/core/agent.py +25 -50
- klaude_code/core/executor.py +268 -101
- klaude_code/core/prompt.py +12 -12
- klaude_code/core/{prompt → prompts}/prompt-gemini.md +1 -1
- klaude_code/core/reminders.py +76 -95
- klaude_code/core/task.py +21 -14
- klaude_code/core/tool/__init__.py +45 -11
- klaude_code/core/tool/file/apply_patch.py +5 -1
- klaude_code/core/tool/file/apply_patch_tool.py +11 -13
- klaude_code/core/tool/file/edit_tool.py +27 -23
- klaude_code/core/tool/file/multi_edit_tool.py +15 -17
- klaude_code/core/tool/file/read_tool.py +41 -36
- klaude_code/core/tool/file/write_tool.py +13 -15
- klaude_code/core/tool/memory/memory_tool.py +85 -68
- klaude_code/core/tool/memory/skill_tool.py +10 -12
- klaude_code/core/tool/shell/bash_tool.py +24 -22
- klaude_code/core/tool/shell/command_safety.py +12 -1
- klaude_code/core/tool/sub_agent_tool.py +11 -12
- klaude_code/core/tool/todo/todo_write_tool.py +21 -28
- klaude_code/core/tool/todo/update_plan_tool.py +14 -24
- klaude_code/core/tool/tool_abc.py +3 -4
- klaude_code/core/tool/tool_context.py +7 -7
- klaude_code/core/tool/tool_registry.py +30 -47
- klaude_code/core/tool/tool_runner.py +35 -43
- klaude_code/core/tool/truncation.py +14 -20
- klaude_code/core/tool/web/mermaid_tool.py +12 -14
- klaude_code/core/tool/web/web_fetch_tool.py +15 -17
- klaude_code/core/turn.py +19 -7
- klaude_code/llm/__init__.py +3 -4
- klaude_code/llm/anthropic/client.py +30 -46
- klaude_code/llm/anthropic/input.py +4 -11
- klaude_code/llm/client.py +29 -8
- klaude_code/llm/input_common.py +66 -36
- klaude_code/llm/openai_compatible/client.py +42 -84
- klaude_code/llm/openai_compatible/input.py +11 -16
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +2 -2
- klaude_code/llm/openrouter/client.py +40 -289
- klaude_code/llm/openrouter/input.py +13 -35
- klaude_code/llm/openrouter/reasoning_handler.py +209 -0
- klaude_code/llm/registry.py +5 -75
- klaude_code/llm/responses/client.py +34 -55
- klaude_code/llm/responses/input.py +24 -26
- klaude_code/llm/usage.py +109 -0
- klaude_code/protocol/__init__.py +4 -0
- klaude_code/protocol/events.py +3 -2
- klaude_code/protocol/{llm_parameter.py → llm_param.py} +12 -32
- klaude_code/protocol/model.py +49 -4
- klaude_code/protocol/op.py +18 -16
- klaude_code/protocol/op_handler.py +28 -0
- klaude_code/{core → protocol}/sub_agent.py +7 -0
- klaude_code/session/export.py +150 -70
- klaude_code/session/session.py +28 -14
- klaude_code/session/templates/export_session.html +180 -42
- klaude_code/trace/__init__.py +2 -2
- klaude_code/trace/log.py +11 -5
- klaude_code/ui/__init__.py +91 -8
- klaude_code/ui/core/__init__.py +1 -0
- klaude_code/ui/core/display.py +103 -0
- klaude_code/ui/core/input.py +71 -0
- klaude_code/ui/modes/__init__.py +1 -0
- klaude_code/ui/modes/debug/__init__.py +1 -0
- klaude_code/ui/{base/debug_event_display.py → modes/debug/display.py} +9 -5
- klaude_code/ui/modes/exec/__init__.py +1 -0
- klaude_code/ui/{base/exec_display.py → modes/exec/display.py} +28 -2
- klaude_code/ui/{repl → modes/repl}/__init__.py +5 -6
- klaude_code/ui/modes/repl/clipboard.py +152 -0
- klaude_code/ui/modes/repl/completers.py +429 -0
- klaude_code/ui/modes/repl/display.py +60 -0
- klaude_code/ui/modes/repl/event_handler.py +375 -0
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
- klaude_code/ui/modes/repl/key_bindings.py +170 -0
- klaude_code/ui/{repl → modes/repl}/renderer.py +109 -132
- klaude_code/ui/renderers/assistant.py +21 -0
- klaude_code/ui/renderers/common.py +0 -16
- klaude_code/ui/renderers/developer.py +18 -18
- klaude_code/ui/renderers/diffs.py +36 -14
- klaude_code/ui/renderers/errors.py +1 -1
- klaude_code/ui/renderers/metadata.py +50 -27
- klaude_code/ui/renderers/sub_agent.py +43 -9
- klaude_code/ui/renderers/thinking.py +33 -1
- klaude_code/ui/renderers/tools.py +212 -20
- klaude_code/ui/renderers/user_input.py +19 -23
- klaude_code/ui/rich/__init__.py +1 -0
- klaude_code/ui/{rich_ext → rich}/searchable_text.py +3 -1
- klaude_code/ui/{renderers → rich}/status.py +29 -18
- klaude_code/ui/{base → rich}/theme.py +8 -2
- klaude_code/ui/terminal/__init__.py +1 -0
- klaude_code/ui/{base/terminal_color.py → terminal/color.py} +4 -1
- klaude_code/ui/{base/terminal_control.py → terminal/control.py} +1 -0
- klaude_code/ui/{base/terminal_notifier.py → terminal/notifier.py} +5 -2
- klaude_code/ui/utils/__init__.py +1 -0
- klaude_code/ui/{base/utils.py → utils/common.py} +35 -3
- {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/METADATA +1 -1
- klaude_code-1.2.3.dist-info/RECORD +161 -0
- klaude_code/core/clipboard_manifest.py +0 -124
- klaude_code/llm/openrouter/tool_call_accumulator.py +0 -80
- klaude_code/ui/base/__init__.py +0 -1
- klaude_code/ui/base/display_abc.py +0 -36
- klaude_code/ui/base/input_abc.py +0 -20
- klaude_code/ui/repl/display.py +0 -36
- klaude_code/ui/repl/event_handler.py +0 -247
- klaude_code/ui/repl/input.py +0 -773
- klaude_code/ui/rich_ext/__init__.py +0 -1
- klaude_code-1.2.1.dist-info/RECORD +0 -151
- /klaude_code/core/{prompt → prompts}/prompt-claude-code.md +0 -0
- /klaude_code/core/{prompt → prompts}/prompt-codex.md +0 -0
- /klaude_code/core/{prompt → prompts}/prompt-subagent-explore.md +0 -0
- /klaude_code/core/{prompt → prompts}/prompt-subagent-oracle.md +0 -0
- /klaude_code/core/{prompt → prompts}/prompt-subagent-webfetch.md +0 -0
- /klaude_code/core/{prompt → prompts}/prompt-subagent.md +0 -0
- /klaude_code/ui/{base → core}/stage_manager.py +0 -0
- /klaude_code/ui/{rich_ext → rich}/live.py +0 -0
- /klaude_code/ui/{rich_ext → rich}/markdown.py +0 -0
- /klaude_code/ui/{rich_ext → rich}/quote.py +0 -0
- /klaude_code/ui/{base → terminal}/progress_bar.py +0 -0
- /klaude_code/ui/{base → utils}/debouncer.py +0 -0
- {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/entry_points.txt +0 -0
klaude_code/ui/repl/input.py
DELETED
|
@@ -1,773 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import re
|
|
5
|
-
import shutil
|
|
6
|
-
import subprocess
|
|
7
|
-
import time
|
|
8
|
-
import uuid
|
|
9
|
-
from collections.abc import AsyncIterator, Callable, Iterable
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from typing import NamedTuple, cast, override
|
|
12
|
-
|
|
13
|
-
from PIL import Image, ImageGrab
|
|
14
|
-
from prompt_toolkit import PromptSession
|
|
15
|
-
from prompt_toolkit.buffer import Buffer
|
|
16
|
-
from prompt_toolkit.completion import Completer, Completion, ThreadedCompleter
|
|
17
|
-
from prompt_toolkit.document import Document
|
|
18
|
-
from prompt_toolkit.filters import Condition
|
|
19
|
-
from prompt_toolkit.formatted_text import HTML, FormattedText
|
|
20
|
-
from prompt_toolkit.history import FileHistory
|
|
21
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
22
|
-
from prompt_toolkit.patch_stdout import patch_stdout
|
|
23
|
-
from prompt_toolkit.styles import Style
|
|
24
|
-
|
|
25
|
-
from klaude_code.command import get_commands
|
|
26
|
-
from klaude_code.core.clipboard_manifest import (
|
|
27
|
-
CLIPBOARD_IMAGES_DIR,
|
|
28
|
-
ClipboardManifest,
|
|
29
|
-
ClipboardManifestEntry,
|
|
30
|
-
next_session_token,
|
|
31
|
-
persist_clipboard_manifest,
|
|
32
|
-
)
|
|
33
|
-
from klaude_code.ui.base.input_abc import InputProviderABC
|
|
34
|
-
from klaude_code.ui.base.utils import get_current_git_branch, show_path_with_tilde
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class REPLStatusSnapshot(NamedTuple):
|
|
38
|
-
"""Snapshot of REPL status for bottom toolbar display."""
|
|
39
|
-
|
|
40
|
-
model_name: str
|
|
41
|
-
context_usage_percent: float | None
|
|
42
|
-
llm_calls: int
|
|
43
|
-
tool_calls: int
|
|
44
|
-
update_message: str | None = None
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
kb = KeyBindings()
|
|
48
|
-
|
|
49
|
-
COMPLETION_SELECTED = "#5869f7"
|
|
50
|
-
COMPLETION_MENU = "ansibrightblack"
|
|
51
|
-
INPUT_PROMPT_STYLE = "ansimagenta"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class ClipboardCaptureState:
|
|
55
|
-
def __init__(self, images_dir: Path | None = None, session_token: str | None = None):
|
|
56
|
-
self._images_dir = images_dir or CLIPBOARD_IMAGES_DIR
|
|
57
|
-
self._session_token = session_token or next_session_token()
|
|
58
|
-
self._pending: list[ClipboardManifestEntry] = []
|
|
59
|
-
self._counter = 1
|
|
60
|
-
|
|
61
|
-
def capture_from_clipboard(self) -> str | None:
|
|
62
|
-
try:
|
|
63
|
-
clipboard_data = ImageGrab.grabclipboard()
|
|
64
|
-
except Exception:
|
|
65
|
-
return None
|
|
66
|
-
if not isinstance(clipboard_data, Image.Image):
|
|
67
|
-
return None
|
|
68
|
-
try:
|
|
69
|
-
self._images_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
-
except Exception:
|
|
71
|
-
return None
|
|
72
|
-
filename = f"clipboard_{uuid.uuid4().hex[:8]}.png"
|
|
73
|
-
path = self._images_dir / filename
|
|
74
|
-
try:
|
|
75
|
-
clipboard_data.save(path, "PNG")
|
|
76
|
-
except Exception:
|
|
77
|
-
return None
|
|
78
|
-
tag = f"[Image #{self._counter}]"
|
|
79
|
-
self._counter += 1
|
|
80
|
-
saved_entry = ClipboardManifestEntry(tag=tag, path=str(path), saved_at_ts=time.time())
|
|
81
|
-
self._pending.append(saved_entry)
|
|
82
|
-
return tag
|
|
83
|
-
|
|
84
|
-
def flush_manifest(self) -> ClipboardManifest | None:
|
|
85
|
-
if not self._pending:
|
|
86
|
-
return None
|
|
87
|
-
manifest = ClipboardManifest(
|
|
88
|
-
entries=list(self._pending),
|
|
89
|
-
created_at_ts=time.time(),
|
|
90
|
-
source_id=self._session_token,
|
|
91
|
-
)
|
|
92
|
-
self._pending = []
|
|
93
|
-
self._counter = 1
|
|
94
|
-
return manifest
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
_clipboard_state = ClipboardCaptureState()
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
@kb.add("c-v")
|
|
101
|
-
def _(event): # type: ignore
|
|
102
|
-
"""Paste image from clipboard as [Image #N]."""
|
|
103
|
-
tag = _clipboard_state.capture_from_clipboard()
|
|
104
|
-
if tag:
|
|
105
|
-
try:
|
|
106
|
-
event.current_buffer.insert_text(tag) # pyright: ignore[reportUnknownMemberType]
|
|
107
|
-
except Exception:
|
|
108
|
-
pass
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
@kb.add("enter")
|
|
112
|
-
def _(event): # type: ignore
|
|
113
|
-
buf = event.current_buffer # type: ignore
|
|
114
|
-
doc = buf.document # type: ignore
|
|
115
|
-
|
|
116
|
-
# If VS Code/Windsurf/Cursor sent a "\\" sentinel before Enter (Shift+Enter mapping),
|
|
117
|
-
# treat it as a request for a newline instead of submit.
|
|
118
|
-
# This allows Shift+Enter to insert a newline in our multiline prompt.
|
|
119
|
-
try:
|
|
120
|
-
if doc.text_before_cursor.endswith("\\"): # type: ignore[reportUnknownMemberType]
|
|
121
|
-
buf.delete_before_cursor() # remove the sentinel backslash # type: ignore[reportUnknownMemberType]
|
|
122
|
-
buf.insert_text("\n") # type: ignore[reportUnknownMemberType]
|
|
123
|
-
return
|
|
124
|
-
except Exception:
|
|
125
|
-
# Fall through to default behavior if anything goes wrong
|
|
126
|
-
pass
|
|
127
|
-
|
|
128
|
-
# If the entire buffer is whitespace-only, insert a newline rather than submitting.
|
|
129
|
-
if len(buf.text.strip()) == 0: # type: ignore
|
|
130
|
-
buf.insert_text("\n") # type: ignore
|
|
131
|
-
return
|
|
132
|
-
|
|
133
|
-
manifest = _clipboard_state.flush_manifest()
|
|
134
|
-
if manifest:
|
|
135
|
-
try:
|
|
136
|
-
persist_clipboard_manifest(manifest)
|
|
137
|
-
except Exception:
|
|
138
|
-
pass
|
|
139
|
-
|
|
140
|
-
buf.validate_and_handle() # type: ignore
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
@kb.add("c-j")
|
|
144
|
-
def _(event): # type: ignore
|
|
145
|
-
event.current_buffer.insert_text("\n") # type: ignore
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
@kb.add("c")
|
|
149
|
-
def _(event): # type: ignore
|
|
150
|
-
"""Copy selected text to system clipboard, or insert 'c' if no selection."""
|
|
151
|
-
buf = event.current_buffer # type: ignore
|
|
152
|
-
if buf.selection_state: # type: ignore[reportUnknownMemberType]
|
|
153
|
-
doc = buf.document # type: ignore[reportUnknownMemberType]
|
|
154
|
-
start, end = doc.selection_range() # type: ignore[reportUnknownMemberType]
|
|
155
|
-
selected_text: str = doc.text[start:end] # type: ignore[reportUnknownMemberType]
|
|
156
|
-
|
|
157
|
-
if selected_text:
|
|
158
|
-
_copy_to_clipboard(selected_text) # type: ignore[reportUnknownArgumentType]
|
|
159
|
-
buf.exit_selection() # type: ignore[reportUnknownMemberType]
|
|
160
|
-
else:
|
|
161
|
-
buf.insert_text("c") # type: ignore[reportUnknownMemberType]
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def _copy_to_clipboard(text: str) -> None:
|
|
165
|
-
"""Copy text to system clipboard using platform-specific commands."""
|
|
166
|
-
import sys
|
|
167
|
-
|
|
168
|
-
try:
|
|
169
|
-
if sys.platform == "darwin":
|
|
170
|
-
subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
|
|
171
|
-
elif sys.platform == "win32":
|
|
172
|
-
subprocess.run(["clip"], input=text.encode("utf-16"), check=True)
|
|
173
|
-
else:
|
|
174
|
-
# Linux: try xclip first, then xsel
|
|
175
|
-
if shutil.which("xclip"):
|
|
176
|
-
subprocess.run(["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True)
|
|
177
|
-
elif shutil.which("xsel"):
|
|
178
|
-
subprocess.run(["xsel", "--clipboard", "--input"], input=text.encode("utf-8"), check=True)
|
|
179
|
-
except Exception:
|
|
180
|
-
pass
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
@kb.add("backspace")
|
|
184
|
-
def _(event): # type: ignore
|
|
185
|
-
"""Ensure completions refresh on backspace when editing an @token.
|
|
186
|
-
|
|
187
|
-
We delete the character before cursor (default behavior), then explicitly
|
|
188
|
-
trigger completion refresh if the caret is still within an @... token.
|
|
189
|
-
"""
|
|
190
|
-
buf = event.current_buffer # type: ignore
|
|
191
|
-
# Handle selection: cut selection if present, otherwise delete one character
|
|
192
|
-
if buf.selection_state: # type: ignore[reportUnknownMemberType]
|
|
193
|
-
buf.cut_selection() # type: ignore[reportUnknownMemberType]
|
|
194
|
-
else:
|
|
195
|
-
buf.delete_before_cursor() # type: ignore[reportUnknownMemberType]
|
|
196
|
-
# If the token pattern still applies, refresh completion popup
|
|
197
|
-
try:
|
|
198
|
-
text_before = buf.document.text_before_cursor # type: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
199
|
-
# Check for both @ tokens and / tokens (slash commands on first line only)
|
|
200
|
-
should_refresh = False
|
|
201
|
-
if _AtFilesCompleter._AT_TOKEN_RE.search(text_before): # type: ignore[reportPrivateUsage, reportUnknownArgumentType]
|
|
202
|
-
should_refresh = True
|
|
203
|
-
elif buf.document.cursor_position_row == 0: # type: ignore[reportUnknownMemberType]
|
|
204
|
-
# Check for slash command pattern without accessing protected attribute
|
|
205
|
-
text_before_str = cast(str, text_before or "")
|
|
206
|
-
if text_before_str.strip().startswith("/") and " " not in text_before_str:
|
|
207
|
-
should_refresh = True
|
|
208
|
-
|
|
209
|
-
if should_refresh:
|
|
210
|
-
buf.start_completion(select_first=False) # type: ignore[reportUnknownMemberType]
|
|
211
|
-
except Exception:
|
|
212
|
-
pass
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
@kb.add("left")
|
|
216
|
-
def _(event): # type: ignore
|
|
217
|
-
"""Support wrapping to previous line when pressing left at column 0."""
|
|
218
|
-
buf = event.current_buffer # type: ignore
|
|
219
|
-
try:
|
|
220
|
-
doc = buf.document # type: ignore[reportUnknownMemberType]
|
|
221
|
-
row = cast(int, doc.cursor_position_row) # type: ignore[reportUnknownMemberType]
|
|
222
|
-
col = cast(int, doc.cursor_position_col) # type: ignore[reportUnknownMemberType]
|
|
223
|
-
|
|
224
|
-
# At the beginning of a non-first line: jump to previous line end.
|
|
225
|
-
if col == 0 and row > 0:
|
|
226
|
-
lines = cast(list[str], doc.lines) # type: ignore[reportUnknownMemberType]
|
|
227
|
-
prev_row = row - 1
|
|
228
|
-
if 0 <= prev_row < len(lines):
|
|
229
|
-
prev_line = lines[prev_row]
|
|
230
|
-
new_index = doc.translate_row_col_to_index(prev_row, len(prev_line)) # type: ignore[reportUnknownMemberType]
|
|
231
|
-
buf.cursor_position = new_index # type: ignore[reportUnknownMemberType]
|
|
232
|
-
return
|
|
233
|
-
|
|
234
|
-
# Default behavior: move one character left when possible.
|
|
235
|
-
if doc.cursor_position > 0: # type: ignore[reportUnknownMemberType]
|
|
236
|
-
buf.cursor_left() # type: ignore[reportUnknownMemberType]
|
|
237
|
-
except Exception:
|
|
238
|
-
pass
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
@kb.add("right")
|
|
242
|
-
def _(event): # type: ignore
|
|
243
|
-
"""Support wrapping to next line when pressing right at line end."""
|
|
244
|
-
buf = event.current_buffer # type: ignore
|
|
245
|
-
try:
|
|
246
|
-
doc = buf.document # type: ignore[reportUnknownMemberType]
|
|
247
|
-
row = cast(int, doc.cursor_position_row) # type: ignore[reportUnknownMemberType]
|
|
248
|
-
col = cast(int, doc.cursor_position_col) # type: ignore[reportUnknownMemberType]
|
|
249
|
-
lines = cast(list[str], doc.lines) # type: ignore[reportUnknownMemberType]
|
|
250
|
-
|
|
251
|
-
current_line = lines[row] if 0 <= row < len(lines) else ""
|
|
252
|
-
at_line_end = col >= len(current_line)
|
|
253
|
-
is_last_line = row >= len(lines) - 1 if lines else True
|
|
254
|
-
|
|
255
|
-
# At end of a non-last line: jump to next line start.
|
|
256
|
-
if at_line_end and not is_last_line:
|
|
257
|
-
next_row = row + 1
|
|
258
|
-
new_index = doc.translate_row_col_to_index(next_row, 0) # type: ignore[reportUnknownMemberType]
|
|
259
|
-
buf.cursor_position = new_index # type: ignore[reportUnknownMemberType]
|
|
260
|
-
return
|
|
261
|
-
|
|
262
|
-
# Default behavior: move one character right when possible.
|
|
263
|
-
if doc.cursor_position < len(doc.text): # type: ignore[reportUnknownMemberType]
|
|
264
|
-
buf.cursor_right() # type: ignore[reportUnknownMemberType]
|
|
265
|
-
except Exception:
|
|
266
|
-
pass
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
class PromptToolkitInput(InputProviderABC):
|
|
270
|
-
def __init__(self, prompt: str = "❯ ", status_provider: Callable[[], REPLStatusSnapshot] | None = None): # ▌
|
|
271
|
-
self._status_provider = status_provider
|
|
272
|
-
|
|
273
|
-
# Mouse is disabled by default; only enabled when input becomes multi-line.
|
|
274
|
-
self._mouse_enabled: bool = False
|
|
275
|
-
|
|
276
|
-
project = str(Path.cwd()).strip("/").replace("/", "-")
|
|
277
|
-
history_path = Path.home() / ".klaude" / "projects" / f"{project}" / "input_history.txt"
|
|
278
|
-
|
|
279
|
-
if not history_path.parent.exists():
|
|
280
|
-
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
281
|
-
if not history_path.exists():
|
|
282
|
-
history_path.touch()
|
|
283
|
-
|
|
284
|
-
mouse_support_filter = Condition(lambda: self._mouse_enabled)
|
|
285
|
-
|
|
286
|
-
self._session: PromptSession[str] = PromptSession(
|
|
287
|
-
[(INPUT_PROMPT_STYLE, prompt)],
|
|
288
|
-
history=FileHistory(history_path),
|
|
289
|
-
multiline=True,
|
|
290
|
-
prompt_continuation=[(INPUT_PROMPT_STYLE, " ")],
|
|
291
|
-
key_bindings=kb,
|
|
292
|
-
completer=ThreadedCompleter(_ComboCompleter()),
|
|
293
|
-
complete_while_typing=True,
|
|
294
|
-
erase_when_done=True,
|
|
295
|
-
bottom_toolbar=self._render_bottom_toolbar,
|
|
296
|
-
mouse_support=mouse_support_filter,
|
|
297
|
-
style=Style.from_dict(
|
|
298
|
-
{
|
|
299
|
-
"completion-menu": "bg:default",
|
|
300
|
-
"completion-menu.border": "bg:default",
|
|
301
|
-
"scrollbar.background": "bg:default",
|
|
302
|
-
"scrollbar.button": "bg:default",
|
|
303
|
-
"completion-menu.completion": f"bg:default fg:{COMPLETION_MENU}",
|
|
304
|
-
"completion-menu.meta.completion": f"bg:default fg:{COMPLETION_MENU}",
|
|
305
|
-
"completion-menu.completion.current": f"noreverse bg:default fg:{COMPLETION_SELECTED} bold",
|
|
306
|
-
"completion-menu.meta.completion.current": f"bg:default fg:{COMPLETION_SELECTED} bold",
|
|
307
|
-
}
|
|
308
|
-
),
|
|
309
|
-
)
|
|
310
|
-
|
|
311
|
-
try:
|
|
312
|
-
self._session.default_buffer.on_text_changed += self._on_buffer_text_changed
|
|
313
|
-
except Exception:
|
|
314
|
-
# If we can't hook the buffer events for any reason, fall back to static behavior.
|
|
315
|
-
pass
|
|
316
|
-
|
|
317
|
-
def _render_bottom_toolbar(self) -> FormattedText:
|
|
318
|
-
"""Render bottom toolbar with working directory, git branch on left, model name and context usage on right.
|
|
319
|
-
|
|
320
|
-
If an update is available, only show the update message on the left side.
|
|
321
|
-
"""
|
|
322
|
-
# Check for update message first
|
|
323
|
-
update_message: str | None = None
|
|
324
|
-
if self._status_provider:
|
|
325
|
-
try:
|
|
326
|
-
status = self._status_provider()
|
|
327
|
-
update_message = status.update_message
|
|
328
|
-
except Exception:
|
|
329
|
-
pass
|
|
330
|
-
|
|
331
|
-
# If update available, show only the update message
|
|
332
|
-
if update_message:
|
|
333
|
-
left_text = " " + update_message
|
|
334
|
-
try:
|
|
335
|
-
terminal_width = shutil.get_terminal_size().columns
|
|
336
|
-
padding = " " * max(0, terminal_width - len(left_text))
|
|
337
|
-
except Exception:
|
|
338
|
-
padding = ""
|
|
339
|
-
toolbar_text = left_text + padding
|
|
340
|
-
return FormattedText([("#ansiyellow", toolbar_text)])
|
|
341
|
-
|
|
342
|
-
# Normal mode: Left side: path and git branch
|
|
343
|
-
left_parts: list[str] = []
|
|
344
|
-
left_parts.append(show_path_with_tilde())
|
|
345
|
-
|
|
346
|
-
git_branch = get_current_git_branch()
|
|
347
|
-
if git_branch:
|
|
348
|
-
left_parts.append(git_branch)
|
|
349
|
-
|
|
350
|
-
# Right side: status info
|
|
351
|
-
right_parts: list[str] = []
|
|
352
|
-
if self._status_provider:
|
|
353
|
-
try:
|
|
354
|
-
status = self._status_provider()
|
|
355
|
-
model_name = status.model_name or "N/A"
|
|
356
|
-
right_parts.append(model_name)
|
|
357
|
-
|
|
358
|
-
# Add context if available
|
|
359
|
-
if status.context_usage_percent is not None:
|
|
360
|
-
right_parts.append(f"context {status.context_usage_percent:.1f}%")
|
|
361
|
-
except Exception:
|
|
362
|
-
pass
|
|
363
|
-
|
|
364
|
-
# Build left and right text with borders
|
|
365
|
-
left_text = " " + " · ".join(left_parts)
|
|
366
|
-
right_text = (" · ".join(right_parts) + " ") if right_parts else " "
|
|
367
|
-
|
|
368
|
-
# Calculate padding
|
|
369
|
-
try:
|
|
370
|
-
terminal_width = shutil.get_terminal_size().columns
|
|
371
|
-
used_width = len(left_text) + len(right_text)
|
|
372
|
-
padding = " " * max(0, terminal_width - used_width)
|
|
373
|
-
except Exception:
|
|
374
|
-
padding = ""
|
|
375
|
-
|
|
376
|
-
# Build result with style
|
|
377
|
-
toolbar_text = left_text + padding + right_text
|
|
378
|
-
return FormattedText([("#ansiblue", toolbar_text)])
|
|
379
|
-
|
|
380
|
-
async def start(self) -> None:
|
|
381
|
-
pass
|
|
382
|
-
|
|
383
|
-
async def stop(self) -> None:
|
|
384
|
-
pass
|
|
385
|
-
|
|
386
|
-
@override
|
|
387
|
-
async def iter_inputs(self) -> AsyncIterator[str]:
|
|
388
|
-
while True:
|
|
389
|
-
# For each new prompt, start with mouse disabled so users can select history.
|
|
390
|
-
self._mouse_enabled = False
|
|
391
|
-
with patch_stdout():
|
|
392
|
-
line: str = await self._session.prompt_async()
|
|
393
|
-
yield line
|
|
394
|
-
|
|
395
|
-
def _on_buffer_text_changed(self, buf: Buffer) -> None:
|
|
396
|
-
"""Toggle mouse support based on current buffer content.
|
|
397
|
-
|
|
398
|
-
Mouse stays disabled when input is empty. It is enabled only when
|
|
399
|
-
the user has entered more than one line of text.
|
|
400
|
-
"""
|
|
401
|
-
try:
|
|
402
|
-
text = buf.text
|
|
403
|
-
except Exception:
|
|
404
|
-
return
|
|
405
|
-
self._mouse_enabled = self._should_enable_mouse(text)
|
|
406
|
-
|
|
407
|
-
def _should_enable_mouse(self, text: str) -> bool:
|
|
408
|
-
"""Return True when mouse support should be enabled for current input."""
|
|
409
|
-
if not text.strip():
|
|
410
|
-
return False
|
|
411
|
-
# Enable mouse only when input spans multiple lines.
|
|
412
|
-
return "\n" in text
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
class _CmdResult(NamedTuple):
|
|
416
|
-
ok: bool
|
|
417
|
-
lines: list[str]
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
class _SlashCommandCompleter(Completer):
|
|
421
|
-
"""Complete slash commands at the beginning of the first line.
|
|
422
|
-
|
|
423
|
-
Behavior:
|
|
424
|
-
- Only triggers when cursor is on first line and text matches /...
|
|
425
|
-
- Shows available slash commands with descriptions
|
|
426
|
-
- Inserts trailing space after completion
|
|
427
|
-
"""
|
|
428
|
-
|
|
429
|
-
_SLASH_TOKEN_RE = re.compile(r"^/(?P<frag>\S*)$")
|
|
430
|
-
|
|
431
|
-
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
|
|
432
|
-
# Only complete on first line
|
|
433
|
-
if document.cursor_position_row != 0:
|
|
434
|
-
return iter([])
|
|
435
|
-
|
|
436
|
-
text_before = document.current_line_before_cursor
|
|
437
|
-
m = self._SLASH_TOKEN_RE.search(text_before)
|
|
438
|
-
if not m:
|
|
439
|
-
return iter([])
|
|
440
|
-
|
|
441
|
-
frag = m.group("frag")
|
|
442
|
-
token_start = len(text_before) - len(f"/{frag}")
|
|
443
|
-
start_position = token_start - len(text_before) # negative offset
|
|
444
|
-
|
|
445
|
-
# Get available commands
|
|
446
|
-
commands = get_commands()
|
|
447
|
-
|
|
448
|
-
# Filter commands that match the fragment
|
|
449
|
-
matched: list[tuple[str, object, str]] = []
|
|
450
|
-
for cmd_name, cmd_obj in sorted(commands.items(), key=lambda x: str(x[1].name)):
|
|
451
|
-
if cmd_name.startswith(frag):
|
|
452
|
-
hint = " [args]" if cmd_obj.support_addition_params else ""
|
|
453
|
-
matched.append((cmd_name, cmd_obj, hint))
|
|
454
|
-
|
|
455
|
-
if not matched:
|
|
456
|
-
return iter([])
|
|
457
|
-
|
|
458
|
-
# Calculate max width for alignment
|
|
459
|
-
# Find the longest command+hint length
|
|
460
|
-
max_len = max(len(name) + len(hint) for name, _, hint in matched)
|
|
461
|
-
# Set a minimum width (e.g. 20) and add some padding
|
|
462
|
-
align_width = max(max_len, 20) + 2
|
|
463
|
-
|
|
464
|
-
for cmd_name, cmd_obj, hint in matched:
|
|
465
|
-
label_len = len(cmd_name) + len(hint)
|
|
466
|
-
padding = " " * (align_width - label_len)
|
|
467
|
-
|
|
468
|
-
# Using HTML for formatting: bold command name, normal hint, gray summary
|
|
469
|
-
display_text = HTML(
|
|
470
|
-
f"<b>{cmd_name}</b>{hint}{padding}<style color='ansibrightblack'>— {cmd_obj.summary}</style>" # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
|
|
471
|
-
)
|
|
472
|
-
completion_text = f"/{cmd_name} "
|
|
473
|
-
yield Completion(text=completion_text, start_position=start_position, display=display_text)
|
|
474
|
-
|
|
475
|
-
def is_slash_command_context(self, document: Document) -> bool:
|
|
476
|
-
"""Check if current context is a slash command."""
|
|
477
|
-
if document.cursor_position_row != 0:
|
|
478
|
-
return False
|
|
479
|
-
text_before = document.current_line_before_cursor
|
|
480
|
-
return bool(self._SLASH_TOKEN_RE.search(text_before))
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
class _ComboCompleter(Completer):
|
|
484
|
-
"""Combined completer that handles both @ file paths and / slash commands."""
|
|
485
|
-
|
|
486
|
-
def __init__(self) -> None:
|
|
487
|
-
self._at_completer = _AtFilesCompleter()
|
|
488
|
-
self._slash_completer = _SlashCommandCompleter()
|
|
489
|
-
|
|
490
|
-
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
|
|
491
|
-
# Try slash command completion first (only on first line)
|
|
492
|
-
if document.cursor_position_row == 0:
|
|
493
|
-
if self._slash_completer.is_slash_command_context(document):
|
|
494
|
-
yield from self._slash_completer.get_completions(document, complete_event)
|
|
495
|
-
return
|
|
496
|
-
|
|
497
|
-
# Fall back to @ file completion
|
|
498
|
-
yield from self._at_completer.get_completions(document, complete_event)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
class _AtFilesCompleter(Completer):
|
|
502
|
-
"""Complete @path segments using fd or ripgrep.
|
|
503
|
-
|
|
504
|
-
Behavior:
|
|
505
|
-
- Only triggers when the cursor is after an "@..." token (until whitespace).
|
|
506
|
-
- Completes paths relative to the current working directory.
|
|
507
|
-
- Uses `fd` when available (files and directories), falls back to `rg --files` (files only).
|
|
508
|
-
- Debounces external commands and caches results to avoid excessive spawning.
|
|
509
|
-
- Inserts a trailing space after completion to stop further triggering.
|
|
510
|
-
"""
|
|
511
|
-
|
|
512
|
-
_AT_TOKEN_RE = re.compile(r"(^|\s)@(?P<frag>[^\s]*)$")
|
|
513
|
-
|
|
514
|
-
def __init__(self, debounce_sec: float = 0.25, cache_ttl_sec: float = 10.0, max_results: int = 20):
|
|
515
|
-
self._debounce_sec = debounce_sec
|
|
516
|
-
self._cache_ttl = cache_ttl_sec
|
|
517
|
-
self._max_results = max_results
|
|
518
|
-
|
|
519
|
-
# Debounce/caching state
|
|
520
|
-
self._last_cmd_time: float = 0.0
|
|
521
|
-
self._last_query_key: str | None = None
|
|
522
|
-
self._last_results: list[str] = []
|
|
523
|
-
self._last_results_time: float = 0.0
|
|
524
|
-
|
|
525
|
-
# rg --files cache (used when fd is unavailable)
|
|
526
|
-
self._rg_file_list: list[str] | None = None
|
|
527
|
-
self._rg_file_list_time: float = 0.0
|
|
528
|
-
|
|
529
|
-
# Cache for ignored paths (gitignored files)
|
|
530
|
-
self._last_ignored_paths: set[str] = set()
|
|
531
|
-
|
|
532
|
-
# ---- prompt_toolkit API ----
|
|
533
|
-
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
|
|
534
|
-
text_before = document.text_before_cursor
|
|
535
|
-
m = self._AT_TOKEN_RE.search(text_before)
|
|
536
|
-
if not m:
|
|
537
|
-
return [] # type: ignore[reportUnknownVariableType]
|
|
538
|
-
|
|
539
|
-
frag = m.group("frag") # text after '@' and before cursor (no spaces)
|
|
540
|
-
token_start_in_input = len(text_before) - len(f"@{frag}")
|
|
541
|
-
|
|
542
|
-
cwd = Path.cwd()
|
|
543
|
-
|
|
544
|
-
# If no fragment yet, show lightweight suggestions from current directory
|
|
545
|
-
if frag.strip() == "":
|
|
546
|
-
suggestions = self._suggest_for_empty_fragment(cwd)
|
|
547
|
-
if not suggestions:
|
|
548
|
-
return [] # type: ignore[reportUnknownVariableType]
|
|
549
|
-
start_position = token_start_in_input - len(text_before)
|
|
550
|
-
for s in suggestions[: self._max_results]:
|
|
551
|
-
yield Completion(text=f"@{s} ", start_position=start_position, display=s)
|
|
552
|
-
return [] # type: ignore[reportUnknownVariableType]
|
|
553
|
-
|
|
554
|
-
# Gather suggestions with debounce/caching based on search keyword
|
|
555
|
-
suggestions = self._complete_paths(cwd, frag)
|
|
556
|
-
if not suggestions:
|
|
557
|
-
return [] # type: ignore[reportUnknownVariableType]
|
|
558
|
-
|
|
559
|
-
# Prepare Completion objects. Replace from the '@' character.
|
|
560
|
-
start_position = token_start_in_input - len(text_before) # negative
|
|
561
|
-
for s in suggestions[: self._max_results]:
|
|
562
|
-
# Insert '@<path> ' so that subsequent typing does not keep triggering
|
|
563
|
-
yield Completion(text=f"@{s} ", start_position=start_position, display=s)
|
|
564
|
-
|
|
565
|
-
# ---- Core logic ----
|
|
566
|
-
def _complete_paths(self, cwd: Path, keyword: str) -> list[str]:
|
|
567
|
-
now = time.monotonic()
|
|
568
|
-
key_norm = keyword.lower()
|
|
569
|
-
query_key = f"{cwd.resolve()}::search::{key_norm}"
|
|
570
|
-
|
|
571
|
-
# Debounce: if called too soon again, filter last results
|
|
572
|
-
if self._last_results and self._last_query_key is not None:
|
|
573
|
-
prev = self._last_query_key
|
|
574
|
-
if self._same_scope(prev, query_key):
|
|
575
|
-
# Determine if query is narrowing or broadening
|
|
576
|
-
_, prev_kw = self._parse_query_key(prev)
|
|
577
|
-
_, cur_kw = self._parse_query_key(query_key)
|
|
578
|
-
is_narrowing = (
|
|
579
|
-
prev_kw is not None
|
|
580
|
-
and cur_kw is not None
|
|
581
|
-
and len(cur_kw) >= len(prev_kw)
|
|
582
|
-
and cur_kw.startswith(prev_kw)
|
|
583
|
-
)
|
|
584
|
-
if is_narrowing and (now - self._last_cmd_time) < self._debounce_sec:
|
|
585
|
-
# For narrowing, fast-filter previous results to avoid expensive calls
|
|
586
|
-
return self._filter_and_format(self._last_results, cwd, key_norm, self._last_ignored_paths)
|
|
587
|
-
|
|
588
|
-
# Cache TTL: reuse cached results for same query within TTL
|
|
589
|
-
if self._last_results and self._last_query_key == query_key and now - self._last_results_time < self._cache_ttl:
|
|
590
|
-
return self._filter_and_format(self._last_results, cwd, key_norm, self._last_ignored_paths)
|
|
591
|
-
|
|
592
|
-
# Prefer fd; otherwise fallback to rg --files
|
|
593
|
-
results: list[str] = []
|
|
594
|
-
ignored_paths: set[str] = set()
|
|
595
|
-
if self._has_cmd("fd"):
|
|
596
|
-
# Use fd to search anywhere in full path (files and directories), case-insensitive
|
|
597
|
-
results, ignored_paths = self._run_fd_search(cwd, key_norm)
|
|
598
|
-
elif self._has_cmd("rg"):
|
|
599
|
-
# Use rg to search only in current directory
|
|
600
|
-
if self._rg_file_list is None or now - self._rg_file_list_time > max(self._cache_ttl, 30.0):
|
|
601
|
-
cmd = ["rg", "--files", "--no-ignore", "--hidden"]
|
|
602
|
-
r = self._run_cmd(cmd, cwd=cwd) # Search from current directory
|
|
603
|
-
if r.ok:
|
|
604
|
-
self._rg_file_list = r.lines
|
|
605
|
-
self._rg_file_list_time = now
|
|
606
|
-
else:
|
|
607
|
-
self._rg_file_list = []
|
|
608
|
-
self._rg_file_list_time = now
|
|
609
|
-
# Filter by keyword
|
|
610
|
-
all_files = self._rg_file_list or []
|
|
611
|
-
kn = key_norm
|
|
612
|
-
results = [p for p in all_files if kn in p.lower()]
|
|
613
|
-
# For rg fallback, we don't distinguish ignored files (no priority sorting)
|
|
614
|
-
else:
|
|
615
|
-
return []
|
|
616
|
-
|
|
617
|
-
# Update caches
|
|
618
|
-
self._last_cmd_time = now
|
|
619
|
-
self._last_query_key = query_key
|
|
620
|
-
self._last_results = results
|
|
621
|
-
self._last_results_time = now
|
|
622
|
-
self._last_ignored_paths = ignored_paths
|
|
623
|
-
return self._filter_and_format(results, cwd, key_norm, ignored_paths)
|
|
624
|
-
|
|
625
|
-
def _filter_and_format(
|
|
626
|
-
self, paths_from_root: list[str], cwd: Path, keyword_norm: str, ignored_paths: set[str] | None = None
|
|
627
|
-
) -> list[str]:
|
|
628
|
-
# Filter to keyword (case-insensitive) and rank by:
|
|
629
|
-
# 1. Non-gitignored files first (is_ignored: 0 or 1)
|
|
630
|
-
# 2. Basename hit first, then path hit position, then length
|
|
631
|
-
# Since both fd and rg now search from current directory, all paths are relative to cwd
|
|
632
|
-
kn = keyword_norm
|
|
633
|
-
ignored_paths = ignored_paths or set()
|
|
634
|
-
out: list[tuple[str, tuple[int, int, int, int, int]]] = []
|
|
635
|
-
for p in paths_from_root:
|
|
636
|
-
pl = p.lower()
|
|
637
|
-
if kn not in pl:
|
|
638
|
-
continue
|
|
639
|
-
|
|
640
|
-
# Use path directly since it's already relative to current directory
|
|
641
|
-
rel_to_cwd = p.lstrip("./")
|
|
642
|
-
base = os.path.basename(p).lower()
|
|
643
|
-
base_pos = base.find(kn)
|
|
644
|
-
path_pos = pl.find(kn)
|
|
645
|
-
# Check if this path is in the ignored set (gitignored files)
|
|
646
|
-
is_ignored = 1 if rel_to_cwd in ignored_paths else 0
|
|
647
|
-
score = (is_ignored, 0 if base_pos != -1 else 1, base_pos if base_pos != -1 else 10_000, path_pos, len(p))
|
|
648
|
-
|
|
649
|
-
# Append trailing slash for directories
|
|
650
|
-
full_path = cwd / rel_to_cwd
|
|
651
|
-
if full_path.is_dir() and not rel_to_cwd.endswith("/"):
|
|
652
|
-
rel_to_cwd = rel_to_cwd + "/"
|
|
653
|
-
out.append((rel_to_cwd, score))
|
|
654
|
-
# Sort by score
|
|
655
|
-
out.sort(key=lambda x: x[1])
|
|
656
|
-
# Unique while preserving order
|
|
657
|
-
seen: set[str] = set()
|
|
658
|
-
uniq: list[str] = []
|
|
659
|
-
for s, _ in out:
|
|
660
|
-
if s not in seen:
|
|
661
|
-
seen.add(s)
|
|
662
|
-
uniq.append(s)
|
|
663
|
-
return uniq
|
|
664
|
-
|
|
665
|
-
def _same_scope(self, prev_key: str, cur_key: str) -> bool:
|
|
666
|
-
# Consider same scope if they share the same base directory and one prefix startswith the other
|
|
667
|
-
try:
|
|
668
|
-
prev_root, prev_pref = prev_key.split("::", 1)
|
|
669
|
-
cur_root, cur_pref = cur_key.split("::", 1)
|
|
670
|
-
except ValueError:
|
|
671
|
-
return False
|
|
672
|
-
return prev_root == cur_root and (prev_pref.startswith(cur_pref) or cur_pref.startswith(prev_pref))
|
|
673
|
-
|
|
674
|
-
def _parse_query_key(self, key: str) -> tuple[str | None, str | None]:
|
|
675
|
-
try:
|
|
676
|
-
root, rest = key.split("::", 1)
|
|
677
|
-
tag, kw = rest.split("::", 1)
|
|
678
|
-
if tag != "search":
|
|
679
|
-
return root, None
|
|
680
|
-
return root, kw
|
|
681
|
-
except Exception:
|
|
682
|
-
return None, None
|
|
683
|
-
|
|
684
|
-
# ---- Utilities ----
|
|
685
|
-
def _run_fd_search(self, cwd: Path, keyword_norm: str) -> tuple[list[str], set[str]]:
|
|
686
|
-
"""Run fd search and return (all_results, ignored_paths).
|
|
687
|
-
|
|
688
|
-
First runs fd without --no-ignore to get tracked files,
|
|
689
|
-
then runs with --no-ignore to get all files including gitignored ones.
|
|
690
|
-
Returns the combined results and a set of paths that are gitignored.
|
|
691
|
-
"""
|
|
692
|
-
pattern = self._escape_regex(keyword_norm)
|
|
693
|
-
base_cmd = [
|
|
694
|
-
"fd",
|
|
695
|
-
"--color=never",
|
|
696
|
-
"--type",
|
|
697
|
-
"f",
|
|
698
|
-
"--type",
|
|
699
|
-
"d",
|
|
700
|
-
"--hidden",
|
|
701
|
-
"--full-path",
|
|
702
|
-
"-i",
|
|
703
|
-
"--max-results",
|
|
704
|
-
str(self._max_results * 3),
|
|
705
|
-
"--exclude",
|
|
706
|
-
".git",
|
|
707
|
-
"--exclude",
|
|
708
|
-
".venv",
|
|
709
|
-
"--exclude",
|
|
710
|
-
"node_modules",
|
|
711
|
-
pattern,
|
|
712
|
-
".",
|
|
713
|
-
]
|
|
714
|
-
|
|
715
|
-
# First run: get tracked (non-ignored) files
|
|
716
|
-
r_tracked = self._run_cmd(base_cmd, cwd=cwd)
|
|
717
|
-
tracked_paths: set[str] = set(p.lstrip("./") for p in r_tracked.lines) if r_tracked.ok else set()
|
|
718
|
-
|
|
719
|
-
# Second run: get all files including ignored ones
|
|
720
|
-
cmd_all = base_cmd.copy()
|
|
721
|
-
cmd_all.insert(2, "--no-ignore") # Insert after --color=never
|
|
722
|
-
r_all = self._run_cmd(cmd_all, cwd=cwd)
|
|
723
|
-
all_paths = r_all.lines if r_all.ok else []
|
|
724
|
-
|
|
725
|
-
# Calculate which paths are gitignored (in all but not in tracked)
|
|
726
|
-
ignored_paths = set(p.lstrip("./") for p in all_paths) - tracked_paths
|
|
727
|
-
|
|
728
|
-
return all_paths, ignored_paths
|
|
729
|
-
|
|
730
|
-
def _escape_regex(self, s: str) -> str:
|
|
731
|
-
# Escape for fd (regex by default). Keep '/' as is for path boundaries.
|
|
732
|
-
return re.escape(s).replace("/", "/")
|
|
733
|
-
|
|
734
|
-
def _has_cmd(self, name: str) -> bool:
|
|
735
|
-
return shutil.which(name) is not None
|
|
736
|
-
|
|
737
|
-
def _suggest_for_empty_fragment(self, cwd: Path) -> list[str]:
|
|
738
|
-
"""Lightweight suggestions when user typed only '@': list cwd's children.
|
|
739
|
-
|
|
740
|
-
Avoids running external tools; shows immediate directories first, then files.
|
|
741
|
-
Filters out .git, .venv, and node_modules to reduce noise.
|
|
742
|
-
"""
|
|
743
|
-
excluded = {".git", ".venv", "node_modules"}
|
|
744
|
-
items: list[str] = []
|
|
745
|
-
try:
|
|
746
|
-
for p in sorted(cwd.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
|
|
747
|
-
name = p.name
|
|
748
|
-
if name in excluded:
|
|
749
|
-
continue
|
|
750
|
-
rel = os.path.relpath(p, cwd)
|
|
751
|
-
if p.is_dir() and not rel.endswith("/"):
|
|
752
|
-
rel += "/"
|
|
753
|
-
items.append(rel)
|
|
754
|
-
except Exception:
|
|
755
|
-
return []
|
|
756
|
-
return items[: min(self._max_results, 100)]
|
|
757
|
-
|
|
758
|
-
def _run_cmd(self, cmd: list[str], cwd: Path | None = None) -> _CmdResult:
|
|
759
|
-
try:
|
|
760
|
-
p = subprocess.run(
|
|
761
|
-
cmd,
|
|
762
|
-
cwd=str(cwd) if cwd else None,
|
|
763
|
-
stdout=subprocess.PIPE,
|
|
764
|
-
stderr=subprocess.DEVNULL,
|
|
765
|
-
text=True,
|
|
766
|
-
timeout=1.5,
|
|
767
|
-
)
|
|
768
|
-
if p.returncode == 0:
|
|
769
|
-
lines = [ln.strip() for ln in p.stdout.splitlines() if ln.strip()]
|
|
770
|
-
return _CmdResult(True, lines)
|
|
771
|
-
return _CmdResult(False, [])
|
|
772
|
-
except Exception:
|
|
773
|
-
return _CmdResult(False, [])
|