jarvis-ai-assistant 0.3.19__py3-none-any.whl → 0.3.21__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.
- jarvis/__init__.py +1 -1
- jarvis/jarvis_agent/__init__.py +33 -5
- jarvis/jarvis_agent/config_editor.py +5 -1
- jarvis/jarvis_agent/edit_file_handler.py +15 -9
- jarvis/jarvis_agent/jarvis.py +99 -3
- jarvis/jarvis_agent/memory_manager.py +3 -3
- jarvis/jarvis_agent/share_manager.py +3 -1
- jarvis/jarvis_agent/shell_input_handler.py +17 -2
- jarvis/jarvis_agent/task_analyzer.py +0 -1
- jarvis/jarvis_agent/task_manager.py +15 -5
- jarvis/jarvis_agent/tool_executor.py +2 -2
- jarvis/jarvis_code_agent/code_agent.py +39 -16
- jarvis/jarvis_git_utils/git_commiter.py +3 -6
- jarvis/jarvis_mcp/sse_mcp_client.py +9 -3
- jarvis/jarvis_mcp/streamable_mcp_client.py +15 -5
- jarvis/jarvis_memory_organizer/memory_organizer.py +1 -1
- jarvis/jarvis_methodology/main.py +4 -4
- jarvis/jarvis_multi_agent/__init__.py +3 -3
- jarvis/jarvis_platform/ai8.py +0 -4
- jarvis/jarvis_platform/base.py +12 -7
- jarvis/jarvis_platform/kimi.py +18 -6
- jarvis/jarvis_platform/tongyi.py +18 -5
- jarvis/jarvis_platform/yuanbao.py +10 -3
- jarvis/jarvis_platform_manager/main.py +21 -7
- jarvis/jarvis_platform_manager/service.py +4 -3
- jarvis/jarvis_rag/cli.py +61 -22
- jarvis/jarvis_rag/embedding_manager.py +10 -3
- jarvis/jarvis_rag/llm_interface.py +4 -1
- jarvis/jarvis_rag/query_rewriter.py +3 -1
- jarvis/jarvis_rag/rag_pipeline.py +11 -3
- jarvis/jarvis_rag/retriever.py +151 -2
- jarvis/jarvis_smart_shell/main.py +60 -19
- jarvis/jarvis_stats/cli.py +12 -9
- jarvis/jarvis_stats/stats.py +17 -11
- jarvis/jarvis_stats/storage.py +23 -6
- jarvis/jarvis_tools/cli/main.py +63 -29
- jarvis/jarvis_tools/edit_file.py +3 -4
- jarvis/jarvis_tools/file_analyzer.py +0 -1
- jarvis/jarvis_tools/generate_new_tool.py +3 -3
- jarvis/jarvis_tools/read_code.py +0 -1
- jarvis/jarvis_tools/read_webpage.py +14 -4
- jarvis/jarvis_tools/registry.py +0 -3
- jarvis/jarvis_tools/retrieve_memory.py +0 -1
- jarvis/jarvis_tools/save_memory.py +0 -1
- jarvis/jarvis_tools/search_web.py +0 -2
- jarvis/jarvis_tools/sub_agent.py +197 -0
- jarvis/jarvis_tools/sub_code_agent.py +194 -0
- jarvis/jarvis_tools/virtual_tty.py +21 -13
- jarvis/jarvis_utils/clipboard.py +1 -1
- jarvis/jarvis_utils/config.py +35 -5
- jarvis/jarvis_utils/input.py +528 -41
- jarvis/jarvis_utils/methodology.py +3 -1
- jarvis/jarvis_utils/output.py +218 -129
- jarvis/jarvis_utils/utils.py +480 -170
- {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/METADATA +10 -2
- {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/RECORD +60 -58
- {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/WHEEL +0 -0
- {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/entry_points.txt +0 -0
- {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/licenses/LICENSE +0 -0
- {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/top_level.txt +0 -0
jarvis/jarvis_utils/input.py
CHANGED
@@ -9,7 +9,10 @@
|
|
9
9
|
- 用于输入控制的自定义键绑定
|
10
10
|
"""
|
11
11
|
import os
|
12
|
+
import sys
|
13
|
+
import base64
|
12
14
|
from typing import Iterable, List
|
15
|
+
import wcwidth
|
13
16
|
|
14
17
|
from colorama import Fore
|
15
18
|
from colorama import Style as ColoramaStyle
|
@@ -41,11 +44,65 @@ from jarvis.jarvis_utils.tag import ot
|
|
41
44
|
|
42
45
|
# Sentinel value to indicate that Ctrl+O was pressed
|
43
46
|
CTRL_O_SENTINEL = "__CTRL_O_PRESSED__"
|
47
|
+
# Sentinel prefix to indicate that Ctrl+F (fzf) inserted content should prefill next prompt
|
48
|
+
FZF_INSERT_SENTINEL_PREFIX = "__FZF_INSERT__::"
|
49
|
+
# Sentinel to request running fzf outside the prompt and then prefill next prompt
|
50
|
+
FZF_REQUEST_SENTINEL_PREFIX = "__FZF_REQUEST__::"
|
51
|
+
# Sentinel to request running fzf outside the prompt for all-files mode (exclude .git)
|
52
|
+
FZF_REQUEST_ALL_SENTINEL_PREFIX = "__FZF_REQUEST_ALL__::"
|
44
53
|
|
45
54
|
# Persistent hint marker for multiline input (shown only once across runs)
|
46
55
|
_MULTILINE_HINT_MARK_FILE = os.path.join(get_data_dir(), "multiline_enter_hint_shown")
|
47
56
|
|
48
57
|
|
58
|
+
def _display_width(s: str) -> int:
|
59
|
+
"""Calculate printable width of a string in terminal columns (handles wide chars)."""
|
60
|
+
try:
|
61
|
+
w = 0
|
62
|
+
for ch in s:
|
63
|
+
cw = wcwidth.wcwidth(ch)
|
64
|
+
if cw is None or cw < 0:
|
65
|
+
# Fallback for unknown width chars (e.g. emoji on some terminals)
|
66
|
+
cw = 1
|
67
|
+
w += cw
|
68
|
+
return w
|
69
|
+
except Exception:
|
70
|
+
return len(s)
|
71
|
+
|
72
|
+
|
73
|
+
def _calc_prompt_rows(prev_text: str) -> int:
|
74
|
+
"""
|
75
|
+
Estimate how many terminal rows the previous prompt occupied.
|
76
|
+
Considers prompt prefix and soft-wrapping across terminal columns.
|
77
|
+
"""
|
78
|
+
try:
|
79
|
+
cols = os.get_terminal_size().columns
|
80
|
+
except Exception:
|
81
|
+
cols = 80
|
82
|
+
prefix = "👤 > "
|
83
|
+
prefix_w = _display_width(prefix)
|
84
|
+
|
85
|
+
if prev_text is None:
|
86
|
+
return 1
|
87
|
+
|
88
|
+
lines = prev_text.splitlines()
|
89
|
+
if not lines:
|
90
|
+
lines = [""]
|
91
|
+
# If the text ends with a newline, there is a visible empty line at the end.
|
92
|
+
if prev_text.endswith("\n"):
|
93
|
+
lines.append("")
|
94
|
+
total_rows = 0
|
95
|
+
for i, line in enumerate(lines):
|
96
|
+
lw = _display_width(line)
|
97
|
+
if i == 0:
|
98
|
+
width = prefix_w + lw
|
99
|
+
else:
|
100
|
+
width = lw
|
101
|
+
rows = max(1, (width + cols - 1) // cols)
|
102
|
+
total_rows += rows
|
103
|
+
return max(1, total_rows)
|
104
|
+
|
105
|
+
|
49
106
|
def _multiline_hint_already_shown() -> bool:
|
50
107
|
"""Check if the multiline Enter hint has been shown before (persisted)."""
|
51
108
|
try:
|
@@ -70,8 +127,10 @@ def get_single_line_input(tip: str, default: str = "") -> str:
|
|
70
127
|
获取支持历史记录的单行输入。
|
71
128
|
"""
|
72
129
|
session: PromptSession = PromptSession(history=None)
|
73
|
-
style = PromptStyle.from_dict(
|
74
|
-
|
130
|
+
style = PromptStyle.from_dict(
|
131
|
+
{"prompt": "ansicyan", "bottom-toolbar": "fg:#888888"}
|
132
|
+
)
|
133
|
+
prompt = FormattedText([("class:prompt", f"👤 > {tip}")])
|
75
134
|
return session.prompt(prompt, default=default, style=style)
|
76
135
|
|
77
136
|
|
@@ -181,24 +240,36 @@ class FileCompleter(Completer):
|
|
181
240
|
self.max_suggestions = 10
|
182
241
|
self.min_score = 10
|
183
242
|
self.replace_map = get_replace_map()
|
243
|
+
# Caches for file lists to avoid repeated expensive scans
|
244
|
+
self._git_files_cache = None
|
245
|
+
self._all_files_cache = None
|
246
|
+
self._max_walk_files = 10000
|
184
247
|
|
185
248
|
def get_completions(
|
186
249
|
self, document: Document, _: CompleteEvent
|
187
250
|
) -> Iterable[Completion]:
|
188
251
|
text = document.text_before_cursor
|
189
252
|
cursor_pos = document.cursor_position
|
190
|
-
|
191
|
-
|
253
|
+
|
254
|
+
# Support both '@' (git files) and '#' (all files excluding .git)
|
255
|
+
sym_positions = [(i, ch) for i, ch in enumerate(text) if ch in ("@", "#")]
|
256
|
+
if not sym_positions:
|
192
257
|
return
|
193
|
-
|
194
|
-
|
258
|
+
current_pos = None
|
259
|
+
current_sym = None
|
260
|
+
for i, ch in sym_positions:
|
261
|
+
if i < cursor_pos:
|
262
|
+
current_pos = i
|
263
|
+
current_sym = ch
|
264
|
+
if current_pos is None:
|
195
265
|
return
|
196
|
-
|
197
|
-
|
266
|
+
|
267
|
+
text_after = text[current_pos + 1 : cursor_pos]
|
268
|
+
if " " in text_after:
|
198
269
|
return
|
199
270
|
|
200
|
-
|
201
|
-
replace_length = len(
|
271
|
+
token = text_after.strip()
|
272
|
+
replace_length = len(text_after) + 1
|
202
273
|
|
203
274
|
all_completions = []
|
204
275
|
all_completions.extend(
|
@@ -214,29 +285,50 @@ class FileCompleter(Completer):
|
|
214
285
|
]
|
215
286
|
)
|
216
287
|
|
288
|
+
# File path candidates
|
217
289
|
try:
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
290
|
+
if current_sym == "@":
|
291
|
+
import subprocess
|
292
|
+
|
293
|
+
if self._git_files_cache is None:
|
294
|
+
result = subprocess.run(
|
295
|
+
["git", "ls-files"],
|
296
|
+
stdout=subprocess.PIPE,
|
297
|
+
stderr=subprocess.PIPE,
|
298
|
+
text=True,
|
299
|
+
)
|
300
|
+
if result.returncode == 0:
|
301
|
+
self._git_files_cache = [
|
302
|
+
p for p in result.stdout.splitlines() if p.strip()
|
303
|
+
]
|
304
|
+
else:
|
305
|
+
self._git_files_cache = []
|
306
|
+
paths = self._git_files_cache or []
|
307
|
+
else:
|
308
|
+
import os as _os
|
309
|
+
|
310
|
+
if self._all_files_cache is None:
|
311
|
+
files: list[str] = []
|
312
|
+
for root, dirs, fnames in _os.walk(".", followlinks=False):
|
313
|
+
# Exclude .git directory
|
314
|
+
dirs[:] = [d for d in dirs if d != ".git"]
|
315
|
+
for name in fnames:
|
316
|
+
files.append(
|
317
|
+
_os.path.relpath(_os.path.join(root, name), ".")
|
318
|
+
)
|
319
|
+
if len(files) > self._max_walk_files:
|
320
|
+
break
|
321
|
+
if len(files) > self._max_walk_files:
|
322
|
+
break
|
323
|
+
self._all_files_cache = files
|
324
|
+
paths = self._all_files_cache or []
|
325
|
+
all_completions.extend([(path, "File") for path in paths])
|
234
326
|
except Exception:
|
235
327
|
pass
|
236
328
|
|
237
|
-
if
|
329
|
+
if token:
|
238
330
|
scored_items = process.extract(
|
239
|
-
|
331
|
+
token,
|
240
332
|
[item[0] for item in all_completions],
|
241
333
|
limit=self.max_suggestions,
|
242
334
|
)
|
@@ -244,20 +336,20 @@ class FileCompleter(Completer):
|
|
244
336
|
(item[0], item[1]) for item in scored_items if item[1] > self.min_score
|
245
337
|
]
|
246
338
|
completion_map = {item[0]: item[1] for item in all_completions}
|
247
|
-
for
|
248
|
-
display_text = f"{
|
339
|
+
for t, score in scored_items:
|
340
|
+
display_text = f"{t} ({score}%)" if score < 100 else t
|
249
341
|
yield Completion(
|
250
|
-
text=f"'{
|
342
|
+
text=f"'{t}'",
|
251
343
|
start_position=-replace_length,
|
252
344
|
display=display_text,
|
253
|
-
display_meta=completion_map.get(
|
345
|
+
display_meta=completion_map.get(t, ""),
|
254
346
|
)
|
255
347
|
else:
|
256
|
-
for
|
348
|
+
for t, desc in all_completions[: self.max_suggestions]:
|
257
349
|
yield Completion(
|
258
|
-
text=f"'{
|
350
|
+
text=f"'{t}'",
|
259
351
|
start_position=-replace_length,
|
260
|
-
display=
|
352
|
+
display=t,
|
261
353
|
display_meta=desc,
|
262
354
|
)
|
263
355
|
|
@@ -333,7 +425,9 @@ def _show_history_and_copy():
|
|
333
425
|
break
|
334
426
|
|
335
427
|
|
336
|
-
def _get_multiline_input_internal(
|
428
|
+
def _get_multiline_input_internal(
|
429
|
+
tip: str, preset: str | None = None, preset_cursor: int | None = None
|
430
|
+
) -> str:
|
337
431
|
"""
|
338
432
|
Internal function to get multiline input using prompt_toolkit.
|
339
433
|
Returns a sentinel value if Ctrl+O is pressed.
|
@@ -385,6 +479,100 @@ def _get_multiline_input_internal(tip: str) -> str:
|
|
385
479
|
"""Handle Ctrl+O by exiting the prompt and returning the sentinel value."""
|
386
480
|
event.app.exit(result=CTRL_O_SENTINEL)
|
387
481
|
|
482
|
+
@bindings.add("c-t", filter=has_focus(DEFAULT_BUFFER))
|
483
|
+
def _(event):
|
484
|
+
"""Return a shell command like '!bash' for upper input_handler to execute."""
|
485
|
+
|
486
|
+
def _gen_shell_cmd() -> str: # type: ignore
|
487
|
+
try:
|
488
|
+
import os
|
489
|
+
import shutil
|
490
|
+
|
491
|
+
if os.name == "nt":
|
492
|
+
# Prefer PowerShell if available, otherwise fallback to cmd
|
493
|
+
for name in ("pwsh", "powershell", "cmd"):
|
494
|
+
if name == "cmd" or shutil.which(name):
|
495
|
+
if name == "cmd":
|
496
|
+
# Keep session open with /K and set env for the spawned shell
|
497
|
+
return "!cmd /K set JARVIS_TERMINAL=1"
|
498
|
+
else:
|
499
|
+
# PowerShell or pwsh: set env then remain in session
|
500
|
+
return f"!{name} -NoExit -Command \"$env:JARVIS_TERMINAL='1'\""
|
501
|
+
else:
|
502
|
+
shell_path = os.environ.get("SHELL", "")
|
503
|
+
if shell_path:
|
504
|
+
base = os.path.basename(shell_path)
|
505
|
+
if base:
|
506
|
+
return f"!env JARVIS_TERMINAL=1 {base}"
|
507
|
+
for name in ("fish", "zsh", "bash", "sh"):
|
508
|
+
if shutil.which(name):
|
509
|
+
return f"!env JARVIS_TERMINAL=1 {name}"
|
510
|
+
return "!env JARVIS_TERMINAL=1 bash"
|
511
|
+
except Exception:
|
512
|
+
return "!env JARVIS_TERMINAL=1 bash"
|
513
|
+
|
514
|
+
# Append a special marker to indicate no-confirm execution in shell_input_handler
|
515
|
+
event.app.exit(result=_gen_shell_cmd() + " # JARVIS-NOCONFIRM")
|
516
|
+
|
517
|
+
@bindings.add("@", filter=has_focus(DEFAULT_BUFFER), eager=True)
|
518
|
+
def _(event):
|
519
|
+
"""
|
520
|
+
使用 @ 触发 fzf(当 fzf 存在);否则仅插入 @ 以启用内置补全
|
521
|
+
逻辑:
|
522
|
+
- 若检测到系统存在 fzf,则先插入 '@',随后请求外层运行 fzf 并在返回后进行替换/插入
|
523
|
+
- 若不存在 fzf 或发生异常,则直接插入 '@'
|
524
|
+
"""
|
525
|
+
try:
|
526
|
+
import shutil
|
527
|
+
|
528
|
+
buf = event.current_buffer
|
529
|
+
if shutil.which("fzf") is None:
|
530
|
+
buf.insert_text("@")
|
531
|
+
return
|
532
|
+
# 先插入 '@',以便外层根据最后一个 '@' 进行片段替换
|
533
|
+
buf.insert_text("@")
|
534
|
+
doc = buf.document
|
535
|
+
text = doc.text
|
536
|
+
cursor = doc.cursor_position
|
537
|
+
payload = (
|
538
|
+
f"{cursor}:{base64.b64encode(text.encode('utf-8')).decode('ascii')}"
|
539
|
+
)
|
540
|
+
event.app.exit(result=FZF_REQUEST_SENTINEL_PREFIX + payload)
|
541
|
+
return
|
542
|
+
except Exception:
|
543
|
+
try:
|
544
|
+
event.current_buffer.insert_text("@")
|
545
|
+
except Exception:
|
546
|
+
pass
|
547
|
+
|
548
|
+
@bindings.add("#", filter=has_focus(DEFAULT_BUFFER), eager=True)
|
549
|
+
def _(event):
|
550
|
+
"""
|
551
|
+
使用 # 触发 fzf(当 fzf 存在),以“全量文件模式”进行选择(排除 .git);否则仅插入 # 启用内置补全
|
552
|
+
"""
|
553
|
+
try:
|
554
|
+
import shutil
|
555
|
+
|
556
|
+
buf = event.current_buffer
|
557
|
+
if shutil.which("fzf") is None:
|
558
|
+
buf.insert_text("#")
|
559
|
+
return
|
560
|
+
# 先插入 '#'
|
561
|
+
buf.insert_text("#")
|
562
|
+
doc = buf.document
|
563
|
+
text = doc.text
|
564
|
+
cursor = doc.cursor_position
|
565
|
+
payload = (
|
566
|
+
f"{cursor}:{base64.b64encode(text.encode('utf-8')).decode('ascii')}"
|
567
|
+
)
|
568
|
+
event.app.exit(result=FZF_REQUEST_ALL_SENTINEL_PREFIX + payload)
|
569
|
+
return
|
570
|
+
except Exception:
|
571
|
+
try:
|
572
|
+
event.current_buffer.insert_text("#")
|
573
|
+
except Exception:
|
574
|
+
pass
|
575
|
+
|
388
576
|
style = PromptStyle.from_dict(
|
389
577
|
{
|
390
578
|
"prompt": "ansibrightmagenta bold",
|
@@ -414,11 +602,17 @@ def _get_multiline_input_internal(tip: str) -> str:
|
|
414
602
|
("class:bt.key", "Ctrl+O"),
|
415
603
|
("class:bt.label", " 历史复制 "),
|
416
604
|
("class:bt.sep", " • "),
|
605
|
+
("class:bt.key", "@"),
|
606
|
+
("class:bt.label", " FZF文件 "),
|
607
|
+
("class:bt.sep", " • "),
|
608
|
+
("class:bt.key", "Ctrl+T"),
|
609
|
+
("class:bt.label", " 终端(!SHELL) "),
|
610
|
+
("class:bt.sep", " • "),
|
417
611
|
("class:bt.key", "Ctrl+C/D"),
|
418
612
|
("class:bt.label", " 取消 "),
|
419
613
|
]
|
420
614
|
)
|
421
|
-
|
615
|
+
|
422
616
|
history_dir = get_data_dir()
|
423
617
|
session: PromptSession = PromptSession(
|
424
618
|
history=FileHistory(os.path.join(history_dir, "multiline_input_history")),
|
@@ -431,14 +625,27 @@ def _get_multiline_input_internal(tip: str) -> str:
|
|
431
625
|
)
|
432
626
|
|
433
627
|
# Tip is shown in bottom toolbar; avoid extra print
|
434
|
-
prompt = FormattedText([("class:prompt", "👤
|
628
|
+
prompt = FormattedText([("class:prompt", "👤 > ")])
|
629
|
+
|
630
|
+
def _pre_run():
|
631
|
+
try:
|
632
|
+
from prompt_toolkit.application.current import get_app as _ga
|
633
|
+
|
634
|
+
app = _ga()
|
635
|
+
buf = app.current_buffer
|
636
|
+
if preset is not None and preset_cursor is not None:
|
637
|
+
cp = max(0, min(len(buf.text), preset_cursor))
|
638
|
+
buf.cursor_position = cp
|
639
|
+
except Exception:
|
640
|
+
pass
|
435
641
|
|
436
642
|
try:
|
437
643
|
return session.prompt(
|
438
644
|
prompt,
|
439
645
|
style=style,
|
440
|
-
pre_run=
|
646
|
+
pre_run=_pre_run,
|
441
647
|
bottom_toolbar=_bottom_toolbar,
|
648
|
+
default=(preset or ""),
|
442
649
|
).strip()
|
443
650
|
except (KeyboardInterrupt, EOFError):
|
444
651
|
return ""
|
@@ -453,14 +660,294 @@ def get_multiline_input(tip: str, print_on_empty: bool = True) -> str:
|
|
453
660
|
tip: 提示文本,将显示在底部工具栏中
|
454
661
|
print_on_empty: 当输入为空字符串时,是否打印“输入已取消”提示。默认打印。
|
455
662
|
"""
|
663
|
+
preset: str | None = None
|
664
|
+
preset_cursor: int | None = None
|
456
665
|
while True:
|
457
|
-
user_input = _get_multiline_input_internal(
|
666
|
+
user_input = _get_multiline_input_internal(
|
667
|
+
tip, preset=preset, preset_cursor=preset_cursor
|
668
|
+
)
|
458
669
|
|
459
670
|
if user_input == CTRL_O_SENTINEL:
|
460
671
|
_show_history_and_copy()
|
461
672
|
tip = "请继续输入(或按Ctrl+J确认):"
|
462
673
|
continue
|
674
|
+
elif isinstance(user_input, str) and user_input.startswith(
|
675
|
+
FZF_REQUEST_SENTINEL_PREFIX
|
676
|
+
):
|
677
|
+
# Handle fzf request outside the prompt, then prefill new text.
|
678
|
+
try:
|
679
|
+
payload = user_input[len(FZF_REQUEST_SENTINEL_PREFIX) :]
|
680
|
+
sep_index = payload.find(":")
|
681
|
+
cursor = int(payload[:sep_index])
|
682
|
+
text = base64.b64decode(
|
683
|
+
payload[sep_index + 1 :].encode("ascii")
|
684
|
+
).decode("utf-8")
|
685
|
+
except Exception:
|
686
|
+
# Malformed payload; just continue without change.
|
687
|
+
preset = None
|
688
|
+
tip = "FZF 预填失败,继续输入:"
|
689
|
+
continue
|
690
|
+
|
691
|
+
# Run fzf to get a file selection synchronously (outside prompt)
|
692
|
+
selected_path = ""
|
693
|
+
try:
|
694
|
+
import shutil
|
695
|
+
import subprocess
|
696
|
+
|
697
|
+
if shutil.which("fzf") is None:
|
698
|
+
PrettyOutput.print(
|
699
|
+
"未检测到 fzf,无法打开文件选择器。", OutputType.WARNING
|
700
|
+
)
|
701
|
+
else:
|
702
|
+
files: list[str] = []
|
703
|
+
try:
|
704
|
+
r = subprocess.run(
|
705
|
+
["git", "ls-files"],
|
706
|
+
stdout=subprocess.PIPE,
|
707
|
+
stderr=subprocess.PIPE,
|
708
|
+
text=True,
|
709
|
+
)
|
710
|
+
if r.returncode == 0:
|
711
|
+
files = [
|
712
|
+
line for line in r.stdout.splitlines() if line.strip()
|
713
|
+
]
|
714
|
+
except Exception:
|
715
|
+
files = []
|
716
|
+
|
717
|
+
if not files:
|
718
|
+
import os as _os
|
719
|
+
|
720
|
+
for root, _, fnames in _os.walk(".", followlinks=False):
|
721
|
+
for name in fnames:
|
722
|
+
files.append(
|
723
|
+
_os.path.relpath(_os.path.join(root, name), ".")
|
724
|
+
)
|
725
|
+
if len(files) > 10000:
|
726
|
+
break
|
727
|
+
|
728
|
+
if not files:
|
729
|
+
PrettyOutput.print("未找到可选择的文件。", OutputType.INFO)
|
730
|
+
else:
|
731
|
+
try:
|
732
|
+
specials = [
|
733
|
+
ot("Summary"),
|
734
|
+
ot("Clear"),
|
735
|
+
ot("ToolUsage"),
|
736
|
+
ot("ReloadConfig"),
|
737
|
+
ot("SaveSession"),
|
738
|
+
]
|
739
|
+
except Exception:
|
740
|
+
specials = []
|
741
|
+
try:
|
742
|
+
replace_map = get_replace_map()
|
743
|
+
builtin_tags = [
|
744
|
+
ot(tag)
|
745
|
+
for tag in replace_map.keys()
|
746
|
+
if isinstance(tag, str) and tag.strip()
|
747
|
+
]
|
748
|
+
except Exception:
|
749
|
+
builtin_tags = []
|
750
|
+
items = (
|
751
|
+
[s for s in specials if isinstance(s, str) and s.strip()]
|
752
|
+
+ builtin_tags
|
753
|
+
+ files
|
754
|
+
)
|
755
|
+
proc = subprocess.run(
|
756
|
+
[
|
757
|
+
"fzf",
|
758
|
+
"--prompt",
|
759
|
+
"Files> ",
|
760
|
+
"--height",
|
761
|
+
"40%",
|
762
|
+
"--border",
|
763
|
+
],
|
764
|
+
input="\n".join(items),
|
765
|
+
stdout=subprocess.PIPE,
|
766
|
+
stderr=subprocess.PIPE,
|
767
|
+
text=True,
|
768
|
+
)
|
769
|
+
sel = proc.stdout.strip()
|
770
|
+
if sel:
|
771
|
+
selected_path = sel
|
772
|
+
except Exception as e:
|
773
|
+
PrettyOutput.print(f"FZF 执行失败: {e}", OutputType.ERROR)
|
774
|
+
|
775
|
+
# Compute new text based on selection (or keep original if none)
|
776
|
+
if selected_path:
|
777
|
+
text_before = text[:cursor]
|
778
|
+
last_at = text_before.rfind("@")
|
779
|
+
if last_at != -1 and " " not in text_before[last_at + 1 :]:
|
780
|
+
# Replace @... segment
|
781
|
+
inserted = f"'{selected_path}'"
|
782
|
+
new_text = text[:last_at] + inserted + text[cursor:]
|
783
|
+
new_cursor = last_at + len(inserted)
|
784
|
+
else:
|
785
|
+
# Plain insert
|
786
|
+
inserted = f"'{selected_path}'"
|
787
|
+
new_text = text[:cursor] + inserted + text[cursor:]
|
788
|
+
new_cursor = cursor + len(inserted)
|
789
|
+
preset = new_text
|
790
|
+
preset_cursor = new_cursor
|
791
|
+
tip = "已插入文件,继续编辑或按Ctrl+J确认:"
|
792
|
+
else:
|
793
|
+
# No selection; keep original text and cursor
|
794
|
+
preset = text
|
795
|
+
preset_cursor = cursor
|
796
|
+
tip = "未选择文件或已取消,继续编辑:"
|
797
|
+
# 清除上一条输入行(多行安全),避免多清,保守仅按提示行估算
|
798
|
+
try:
|
799
|
+
rows_total = _calc_prompt_rows(text)
|
800
|
+
for _ in range(rows_total):
|
801
|
+
sys.stdout.write("\x1b[1A") # 光标上移一行
|
802
|
+
sys.stdout.write("\x1b[2K\r") # 清除整行
|
803
|
+
sys.stdout.flush()
|
804
|
+
except Exception:
|
805
|
+
pass
|
806
|
+
continue
|
807
|
+
elif isinstance(user_input, str) and user_input.startswith(
|
808
|
+
FZF_REQUEST_ALL_SENTINEL_PREFIX
|
809
|
+
):
|
810
|
+
# Handle fzf request (all-files mode, excluding .git) outside the prompt, then prefill new text.
|
811
|
+
try:
|
812
|
+
payload = user_input[len(FZF_REQUEST_ALL_SENTINEL_PREFIX) :]
|
813
|
+
sep_index = payload.find(":")
|
814
|
+
cursor = int(payload[:sep_index])
|
815
|
+
text = base64.b64decode(
|
816
|
+
payload[sep_index + 1 :].encode("ascii")
|
817
|
+
).decode("utf-8")
|
818
|
+
except Exception:
|
819
|
+
# Malformed payload; just continue without change.
|
820
|
+
preset = None
|
821
|
+
tip = "FZF 预填失败,继续输入:"
|
822
|
+
continue
|
823
|
+
|
824
|
+
# Run fzf to get a file selection synchronously (outside prompt) with all files (exclude .git)
|
825
|
+
selected_path = ""
|
826
|
+
try:
|
827
|
+
import shutil
|
828
|
+
import subprocess
|
829
|
+
|
830
|
+
if shutil.which("fzf") is None:
|
831
|
+
PrettyOutput.print(
|
832
|
+
"未检测到 fzf,无法打开文件选择器。", OutputType.WARNING
|
833
|
+
)
|
834
|
+
else:
|
835
|
+
files: list[str] = []
|
836
|
+
try:
|
837
|
+
import os as _os
|
838
|
+
|
839
|
+
for root, dirs, fnames in _os.walk(".", followlinks=False):
|
840
|
+
# Exclude .git directories
|
841
|
+
dirs[:] = [d for d in dirs if d != ".git"]
|
842
|
+
for name in fnames:
|
843
|
+
files.append(
|
844
|
+
_os.path.relpath(_os.path.join(root, name), ".")
|
845
|
+
)
|
846
|
+
if len(files) > 10000:
|
847
|
+
break
|
848
|
+
if len(files) > 10000:
|
849
|
+
break
|
850
|
+
except Exception:
|
851
|
+
files = []
|
852
|
+
|
853
|
+
if not files:
|
854
|
+
PrettyOutput.print("未找到可选择的文件。", OutputType.INFO)
|
855
|
+
else:
|
856
|
+
try:
|
857
|
+
specials = [
|
858
|
+
ot("Summary"),
|
859
|
+
ot("Clear"),
|
860
|
+
ot("ToolUsage"),
|
861
|
+
ot("ReloadConfig"),
|
862
|
+
ot("SaveSession"),
|
863
|
+
]
|
864
|
+
except Exception:
|
865
|
+
specials = []
|
866
|
+
try:
|
867
|
+
replace_map = get_replace_map()
|
868
|
+
builtin_tags = [
|
869
|
+
ot(tag)
|
870
|
+
for tag in replace_map.keys()
|
871
|
+
if isinstance(tag, str) and tag.strip()
|
872
|
+
]
|
873
|
+
except Exception:
|
874
|
+
builtin_tags = []
|
875
|
+
items = (
|
876
|
+
[s for s in specials if isinstance(s, str) and s.strip()]
|
877
|
+
+ builtin_tags
|
878
|
+
+ files
|
879
|
+
)
|
880
|
+
proc = subprocess.run(
|
881
|
+
[
|
882
|
+
"fzf",
|
883
|
+
"--prompt",
|
884
|
+
"Files(all)> ",
|
885
|
+
"--height",
|
886
|
+
"40%",
|
887
|
+
"--border",
|
888
|
+
],
|
889
|
+
input="\n".join(items),
|
890
|
+
stdout=subprocess.PIPE,
|
891
|
+
stderr=subprocess.PIPE,
|
892
|
+
text=True,
|
893
|
+
)
|
894
|
+
sel = proc.stdout.strip()
|
895
|
+
if sel:
|
896
|
+
selected_path = sel
|
897
|
+
except Exception as e:
|
898
|
+
PrettyOutput.print(f"FZF 执行失败: {e}", OutputType.ERROR)
|
899
|
+
|
900
|
+
# Compute new text based on selection (or keep original if none)
|
901
|
+
if selected_path:
|
902
|
+
text_before = text[:cursor]
|
903
|
+
last_hash = text_before.rfind("#")
|
904
|
+
if last_hash != -1 and " " not in text_before[last_hash + 1 :]:
|
905
|
+
# Replace #... segment
|
906
|
+
inserted = f"'{selected_path}'"
|
907
|
+
new_text = text[:last_hash] + inserted + text[cursor:]
|
908
|
+
new_cursor = last_hash + len(inserted)
|
909
|
+
else:
|
910
|
+
# Plain insert
|
911
|
+
inserted = f"'{selected_path}'"
|
912
|
+
new_text = text[:cursor] + inserted + text[cursor:]
|
913
|
+
new_cursor = cursor + len(inserted)
|
914
|
+
preset = new_text
|
915
|
+
preset_cursor = new_cursor
|
916
|
+
tip = "已插入文件,继续编辑或按Ctrl+J确认:"
|
917
|
+
else:
|
918
|
+
# No selection; keep original text and cursor
|
919
|
+
preset = text
|
920
|
+
preset_cursor = cursor
|
921
|
+
tip = "未选择文件或已取消,继续编辑:"
|
922
|
+
# 清除上一条输入行(多行安全),避免多清,保守仅按提示行估算
|
923
|
+
try:
|
924
|
+
rows_total = _calc_prompt_rows(text)
|
925
|
+
for _ in range(rows_total):
|
926
|
+
sys.stdout.write("\x1b[1A")
|
927
|
+
sys.stdout.write("\x1b[2K\r")
|
928
|
+
sys.stdout.flush()
|
929
|
+
except Exception:
|
930
|
+
pass
|
931
|
+
continue
|
932
|
+
elif isinstance(user_input, str) and user_input.startswith(
|
933
|
+
FZF_INSERT_SENTINEL_PREFIX
|
934
|
+
):
|
935
|
+
# 从哨兵载荷中提取新文本,作为下次进入提示的预填内容
|
936
|
+
preset = user_input[len(FZF_INSERT_SENTINEL_PREFIX) :]
|
937
|
+
preset_cursor = len(preset)
|
938
|
+
|
939
|
+
# 清除上一条输入行(多行安全),避免多清,保守仅按提示行估算
|
940
|
+
try:
|
941
|
+
rows_total = _calc_prompt_rows(preset)
|
942
|
+
for _ in range(rows_total):
|
943
|
+
sys.stdout.write("\x1b[1A")
|
944
|
+
sys.stdout.write("\x1b[2K\r")
|
945
|
+
sys.stdout.flush()
|
946
|
+
except Exception:
|
947
|
+
pass
|
948
|
+
tip = "已插入文件,继续编辑或按Ctrl+J确认:"
|
949
|
+
continue
|
463
950
|
else:
|
464
951
|
if not user_input and print_on_empty:
|
465
|
-
PrettyOutput.print("
|
952
|
+
PrettyOutput.print("输入已取消", OutputType.INFO)
|
466
953
|
return user_input
|