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