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.
Files changed (60) hide show
  1. jarvis/__init__.py +1 -1
  2. jarvis/jarvis_agent/__init__.py +33 -5
  3. jarvis/jarvis_agent/config_editor.py +5 -1
  4. jarvis/jarvis_agent/edit_file_handler.py +15 -9
  5. jarvis/jarvis_agent/jarvis.py +99 -3
  6. jarvis/jarvis_agent/memory_manager.py +3 -3
  7. jarvis/jarvis_agent/share_manager.py +3 -1
  8. jarvis/jarvis_agent/shell_input_handler.py +17 -2
  9. jarvis/jarvis_agent/task_analyzer.py +0 -1
  10. jarvis/jarvis_agent/task_manager.py +15 -5
  11. jarvis/jarvis_agent/tool_executor.py +2 -2
  12. jarvis/jarvis_code_agent/code_agent.py +39 -16
  13. jarvis/jarvis_git_utils/git_commiter.py +3 -6
  14. jarvis/jarvis_mcp/sse_mcp_client.py +9 -3
  15. jarvis/jarvis_mcp/streamable_mcp_client.py +15 -5
  16. jarvis/jarvis_memory_organizer/memory_organizer.py +1 -1
  17. jarvis/jarvis_methodology/main.py +4 -4
  18. jarvis/jarvis_multi_agent/__init__.py +3 -3
  19. jarvis/jarvis_platform/ai8.py +0 -4
  20. jarvis/jarvis_platform/base.py +12 -7
  21. jarvis/jarvis_platform/kimi.py +18 -6
  22. jarvis/jarvis_platform/tongyi.py +18 -5
  23. jarvis/jarvis_platform/yuanbao.py +10 -3
  24. jarvis/jarvis_platform_manager/main.py +21 -7
  25. jarvis/jarvis_platform_manager/service.py +4 -3
  26. jarvis/jarvis_rag/cli.py +61 -22
  27. jarvis/jarvis_rag/embedding_manager.py +10 -3
  28. jarvis/jarvis_rag/llm_interface.py +4 -1
  29. jarvis/jarvis_rag/query_rewriter.py +3 -1
  30. jarvis/jarvis_rag/rag_pipeline.py +11 -3
  31. jarvis/jarvis_rag/retriever.py +151 -2
  32. jarvis/jarvis_smart_shell/main.py +60 -19
  33. jarvis/jarvis_stats/cli.py +12 -9
  34. jarvis/jarvis_stats/stats.py +17 -11
  35. jarvis/jarvis_stats/storage.py +23 -6
  36. jarvis/jarvis_tools/cli/main.py +63 -29
  37. jarvis/jarvis_tools/edit_file.py +3 -4
  38. jarvis/jarvis_tools/file_analyzer.py +0 -1
  39. jarvis/jarvis_tools/generate_new_tool.py +3 -3
  40. jarvis/jarvis_tools/read_code.py +0 -1
  41. jarvis/jarvis_tools/read_webpage.py +14 -4
  42. jarvis/jarvis_tools/registry.py +0 -3
  43. jarvis/jarvis_tools/retrieve_memory.py +0 -1
  44. jarvis/jarvis_tools/save_memory.py +0 -1
  45. jarvis/jarvis_tools/search_web.py +0 -2
  46. jarvis/jarvis_tools/sub_agent.py +197 -0
  47. jarvis/jarvis_tools/sub_code_agent.py +194 -0
  48. jarvis/jarvis_tools/virtual_tty.py +21 -13
  49. jarvis/jarvis_utils/clipboard.py +1 -1
  50. jarvis/jarvis_utils/config.py +35 -5
  51. jarvis/jarvis_utils/input.py +528 -41
  52. jarvis/jarvis_utils/methodology.py +3 -1
  53. jarvis/jarvis_utils/output.py +218 -129
  54. jarvis/jarvis_utils/utils.py +480 -170
  55. {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/METADATA +10 -2
  56. {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/RECORD +60 -58
  57. {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/WHEEL +0 -0
  58. {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/entry_points.txt +0 -0
  59. {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/licenses/LICENSE +0 -0
  60. {jarvis_ai_assistant-0.3.19.dist-info → jarvis_ai_assistant-0.3.21.dist-info}/top_level.txt +0 -0
@@ -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({"prompt": "ansicyan", "bottom-toolbar": "fg:#888888"})
74
- prompt = FormattedText([("class:prompt", f"👤 ❯ {tip}")])
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
- at_positions = [i for i, char in enumerate(text) if char == "@"]
191
- if not at_positions:
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
- current_at_pos = at_positions[-1]
194
- if cursor_pos <= current_at_pos:
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
- text_after_at = text[current_at_pos + 1 : cursor_pos]
197
- if " " in text_after_at:
266
+
267
+ text_after = text[current_pos + 1 : cursor_pos]
268
+ if " " in text_after:
198
269
  return
199
270
 
200
- file_path = text_after_at.strip()
201
- replace_length = len(text_after_at) + 1
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
- import subprocess
219
-
220
- result = subprocess.run(
221
- ["git", "ls-files"],
222
- stdout=subprocess.PIPE,
223
- stderr=subprocess.PIPE,
224
- text=True,
225
- )
226
- if result.returncode == 0:
227
- all_completions.extend(
228
- [
229
- (path, "File")
230
- for path in result.stdout.splitlines()
231
- if path.strip()
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 file_path:
329
+ if token:
238
330
  scored_items = process.extract(
239
- file_path,
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 text, score in scored_items:
248
- display_text = f"{text} ({score}%)" if score < 100 else text
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"'{text}'",
342
+ text=f"'{t}'",
251
343
  start_position=-replace_length,
252
344
  display=display_text,
253
- display_meta=completion_map.get(text, ""),
345
+ display_meta=completion_map.get(t, ""),
254
346
  )
255
347
  else:
256
- for text, desc in all_completions[: self.max_suggestions]:
348
+ for t, desc in all_completions[: self.max_suggestions]:
257
349
  yield Completion(
258
- text=f"'{text}'",
350
+ text=f"'{t}'",
259
351
  start_position=-replace_length,
260
- display=text,
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(tip: str) -> str:
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=lambda: None,
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(tip)
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("\n输入已取消", OutputType.INFO)
952
+ PrettyOutput.print("输入已取消", OutputType.INFO)
466
953
  return user_input