jarvis-ai-assistant 0.1.222__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. jarvis/__init__.py +1 -1
  2. jarvis/jarvis_agent/__init__.py +1143 -245
  3. jarvis/jarvis_agent/agent_manager.py +97 -0
  4. jarvis/jarvis_agent/builtin_input_handler.py +12 -10
  5. jarvis/jarvis_agent/config_editor.py +57 -0
  6. jarvis/jarvis_agent/edit_file_handler.py +392 -99
  7. jarvis/jarvis_agent/event_bus.py +48 -0
  8. jarvis/jarvis_agent/events.py +157 -0
  9. jarvis/jarvis_agent/file_context_handler.py +79 -0
  10. jarvis/jarvis_agent/file_methodology_manager.py +117 -0
  11. jarvis/jarvis_agent/jarvis.py +1117 -147
  12. jarvis/jarvis_agent/main.py +78 -34
  13. jarvis/jarvis_agent/memory_manager.py +195 -0
  14. jarvis/jarvis_agent/methodology_share_manager.py +174 -0
  15. jarvis/jarvis_agent/prompt_manager.py +82 -0
  16. jarvis/jarvis_agent/prompts.py +46 -9
  17. jarvis/jarvis_agent/protocols.py +4 -1
  18. jarvis/jarvis_agent/rewrite_file_handler.py +141 -0
  19. jarvis/jarvis_agent/run_loop.py +146 -0
  20. jarvis/jarvis_agent/session_manager.py +9 -9
  21. jarvis/jarvis_agent/share_manager.py +228 -0
  22. jarvis/jarvis_agent/shell_input_handler.py +23 -3
  23. jarvis/jarvis_agent/stdio_redirect.py +295 -0
  24. jarvis/jarvis_agent/task_analyzer.py +212 -0
  25. jarvis/jarvis_agent/task_manager.py +154 -0
  26. jarvis/jarvis_agent/task_planner.py +496 -0
  27. jarvis/jarvis_agent/tool_executor.py +8 -4
  28. jarvis/jarvis_agent/tool_share_manager.py +139 -0
  29. jarvis/jarvis_agent/user_interaction.py +42 -0
  30. jarvis/jarvis_agent/utils.py +54 -0
  31. jarvis/jarvis_agent/web_bridge.py +189 -0
  32. jarvis/jarvis_agent/web_output_sink.py +53 -0
  33. jarvis/jarvis_agent/web_server.py +751 -0
  34. jarvis/jarvis_c2rust/__init__.py +26 -0
  35. jarvis/jarvis_c2rust/cli.py +613 -0
  36. jarvis/jarvis_c2rust/collector.py +258 -0
  37. jarvis/jarvis_c2rust/library_replacer.py +1122 -0
  38. jarvis/jarvis_c2rust/llm_module_agent.py +1300 -0
  39. jarvis/jarvis_c2rust/optimizer.py +960 -0
  40. jarvis/jarvis_c2rust/scanner.py +1681 -0
  41. jarvis/jarvis_c2rust/transpiler.py +2325 -0
  42. jarvis/jarvis_code_agent/build_validation_config.py +133 -0
  43. jarvis/jarvis_code_agent/code_agent.py +1605 -178
  44. jarvis/jarvis_code_agent/code_analyzer/__init__.py +62 -0
  45. jarvis/jarvis_code_agent/code_analyzer/base_language.py +74 -0
  46. jarvis/jarvis_code_agent/code_analyzer/build_validator/__init__.py +44 -0
  47. jarvis/jarvis_code_agent/code_analyzer/build_validator/base.py +102 -0
  48. jarvis/jarvis_code_agent/code_analyzer/build_validator/cmake.py +59 -0
  49. jarvis/jarvis_code_agent/code_analyzer/build_validator/detector.py +125 -0
  50. jarvis/jarvis_code_agent/code_analyzer/build_validator/fallback.py +69 -0
  51. jarvis/jarvis_code_agent/code_analyzer/build_validator/go.py +38 -0
  52. jarvis/jarvis_code_agent/code_analyzer/build_validator/java_gradle.py +44 -0
  53. jarvis/jarvis_code_agent/code_analyzer/build_validator/java_maven.py +38 -0
  54. jarvis/jarvis_code_agent/code_analyzer/build_validator/makefile.py +50 -0
  55. jarvis/jarvis_code_agent/code_analyzer/build_validator/nodejs.py +93 -0
  56. jarvis/jarvis_code_agent/code_analyzer/build_validator/python.py +129 -0
  57. jarvis/jarvis_code_agent/code_analyzer/build_validator/rust.py +54 -0
  58. jarvis/jarvis_code_agent/code_analyzer/build_validator/validator.py +154 -0
  59. jarvis/jarvis_code_agent/code_analyzer/build_validator.py +43 -0
  60. jarvis/jarvis_code_agent/code_analyzer/context_manager.py +363 -0
  61. jarvis/jarvis_code_agent/code_analyzer/context_recommender.py +18 -0
  62. jarvis/jarvis_code_agent/code_analyzer/dependency_analyzer.py +132 -0
  63. jarvis/jarvis_code_agent/code_analyzer/file_ignore.py +330 -0
  64. jarvis/jarvis_code_agent/code_analyzer/impact_analyzer.py +781 -0
  65. jarvis/jarvis_code_agent/code_analyzer/language_registry.py +185 -0
  66. jarvis/jarvis_code_agent/code_analyzer/language_support.py +89 -0
  67. jarvis/jarvis_code_agent/code_analyzer/languages/__init__.py +31 -0
  68. jarvis/jarvis_code_agent/code_analyzer/languages/c_cpp_language.py +231 -0
  69. jarvis/jarvis_code_agent/code_analyzer/languages/go_language.py +183 -0
  70. jarvis/jarvis_code_agent/code_analyzer/languages/python_language.py +219 -0
  71. jarvis/jarvis_code_agent/code_analyzer/languages/rust_language.py +209 -0
  72. jarvis/jarvis_code_agent/code_analyzer/llm_context_recommender.py +451 -0
  73. jarvis/jarvis_code_agent/code_analyzer/symbol_extractor.py +77 -0
  74. jarvis/jarvis_code_agent/code_analyzer/tree_sitter_extractor.py +48 -0
  75. jarvis/jarvis_code_agent/lint.py +275 -13
  76. jarvis/jarvis_code_agent/utils.py +142 -0
  77. jarvis/jarvis_code_analysis/checklists/loader.py +20 -6
  78. jarvis/jarvis_code_analysis/code_review.py +583 -548
  79. jarvis/jarvis_data/config_schema.json +339 -28
  80. jarvis/jarvis_git_squash/main.py +22 -13
  81. jarvis/jarvis_git_utils/git_commiter.py +171 -55
  82. jarvis/jarvis_mcp/sse_mcp_client.py +22 -15
  83. jarvis/jarvis_mcp/stdio_mcp_client.py +4 -4
  84. jarvis/jarvis_mcp/streamable_mcp_client.py +36 -16
  85. jarvis/jarvis_memory_organizer/memory_organizer.py +753 -0
  86. jarvis/jarvis_methodology/main.py +48 -63
  87. jarvis/jarvis_multi_agent/__init__.py +302 -43
  88. jarvis/jarvis_multi_agent/main.py +70 -24
  89. jarvis/jarvis_platform/ai8.py +40 -23
  90. jarvis/jarvis_platform/base.py +210 -49
  91. jarvis/jarvis_platform/human.py +11 -1
  92. jarvis/jarvis_platform/kimi.py +82 -76
  93. jarvis/jarvis_platform/openai.py +73 -1
  94. jarvis/jarvis_platform/registry.py +8 -15
  95. jarvis/jarvis_platform/tongyi.py +115 -101
  96. jarvis/jarvis_platform/yuanbao.py +89 -63
  97. jarvis/jarvis_platform_manager/main.py +194 -132
  98. jarvis/jarvis_platform_manager/service.py +122 -86
  99. jarvis/jarvis_rag/cli.py +156 -53
  100. jarvis/jarvis_rag/embedding_manager.py +155 -12
  101. jarvis/jarvis_rag/llm_interface.py +10 -13
  102. jarvis/jarvis_rag/query_rewriter.py +63 -12
  103. jarvis/jarvis_rag/rag_pipeline.py +222 -40
  104. jarvis/jarvis_rag/reranker.py +26 -3
  105. jarvis/jarvis_rag/retriever.py +270 -14
  106. jarvis/jarvis_sec/__init__.py +3605 -0
  107. jarvis/jarvis_sec/checkers/__init__.py +32 -0
  108. jarvis/jarvis_sec/checkers/c_checker.py +2680 -0
  109. jarvis/jarvis_sec/checkers/rust_checker.py +1108 -0
  110. jarvis/jarvis_sec/cli.py +116 -0
  111. jarvis/jarvis_sec/report.py +257 -0
  112. jarvis/jarvis_sec/status.py +264 -0
  113. jarvis/jarvis_sec/types.py +20 -0
  114. jarvis/jarvis_sec/workflow.py +219 -0
  115. jarvis/jarvis_smart_shell/main.py +405 -137
  116. jarvis/jarvis_stats/__init__.py +13 -0
  117. jarvis/jarvis_stats/cli.py +387 -0
  118. jarvis/jarvis_stats/stats.py +711 -0
  119. jarvis/jarvis_stats/storage.py +612 -0
  120. jarvis/jarvis_stats/visualizer.py +282 -0
  121. jarvis/jarvis_tools/ask_user.py +1 -0
  122. jarvis/jarvis_tools/base.py +18 -2
  123. jarvis/jarvis_tools/clear_memory.py +239 -0
  124. jarvis/jarvis_tools/cli/main.py +220 -144
  125. jarvis/jarvis_tools/execute_script.py +52 -12
  126. jarvis/jarvis_tools/file_analyzer.py +17 -12
  127. jarvis/jarvis_tools/generate_new_tool.py +46 -24
  128. jarvis/jarvis_tools/read_code.py +277 -18
  129. jarvis/jarvis_tools/read_symbols.py +141 -0
  130. jarvis/jarvis_tools/read_webpage.py +86 -13
  131. jarvis/jarvis_tools/registry.py +294 -90
  132. jarvis/jarvis_tools/retrieve_memory.py +227 -0
  133. jarvis/jarvis_tools/save_memory.py +194 -0
  134. jarvis/jarvis_tools/search_web.py +62 -28
  135. jarvis/jarvis_tools/sub_agent.py +205 -0
  136. jarvis/jarvis_tools/sub_code_agent.py +217 -0
  137. jarvis/jarvis_tools/virtual_tty.py +330 -62
  138. jarvis/jarvis_utils/builtin_replace_map.py +4 -5
  139. jarvis/jarvis_utils/clipboard.py +90 -0
  140. jarvis/jarvis_utils/config.py +607 -50
  141. jarvis/jarvis_utils/embedding.py +3 -0
  142. jarvis/jarvis_utils/fzf.py +57 -0
  143. jarvis/jarvis_utils/git_utils.py +251 -29
  144. jarvis/jarvis_utils/globals.py +174 -17
  145. jarvis/jarvis_utils/http.py +58 -79
  146. jarvis/jarvis_utils/input.py +899 -153
  147. jarvis/jarvis_utils/methodology.py +210 -83
  148. jarvis/jarvis_utils/output.py +220 -137
  149. jarvis/jarvis_utils/utils.py +1906 -135
  150. jarvis_ai_assistant-0.7.0.dist-info/METADATA +465 -0
  151. jarvis_ai_assistant-0.7.0.dist-info/RECORD +192 -0
  152. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/entry_points.txt +8 -2
  153. jarvis/jarvis_git_details/main.py +0 -265
  154. jarvis/jarvis_platform/oyi.py +0 -357
  155. jarvis/jarvis_tools/edit_file.py +0 -255
  156. jarvis/jarvis_tools/rewrite_file.py +0 -195
  157. jarvis_ai_assistant-0.1.222.dist-info/METADATA +0 -767
  158. jarvis_ai_assistant-0.1.222.dist-info/RECORD +0 -110
  159. /jarvis/{jarvis_git_details → jarvis_memory_organizer}/__init__.py +0 -0
  160. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/WHEEL +0 -0
  161. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/licenses/LICENSE +0 -0
  162. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/top_level.txt +0 -0
@@ -8,99 +8,270 @@
8
8
  - 带有模糊匹配的文件路径补全
9
9
  - 用于输入控制的自定义键绑定
10
10
  """
11
- from colorama import Fore # type: ignore
12
- from colorama import Style as ColoramaStyle # type: ignore
13
- from fuzzywuzzy import process # type: ignore
14
- from prompt_toolkit import PromptSession # type: ignore
11
+ import os
12
+ import sys
13
+ import base64
14
+ from typing import Iterable, List, Optional
15
+ import wcwidth
16
+
17
+ from colorama import Fore
18
+ from colorama import Style as ColoramaStyle
19
+ from fuzzywuzzy import process
20
+ from prompt_toolkit import PromptSession
21
+ from prompt_toolkit.application import Application, run_in_terminal
22
+ from prompt_toolkit.completion import CompleteEvent
15
23
  from prompt_toolkit.completion import (
16
- CompleteEvent,
17
24
  Completer,
18
- Completion, # type: ignore
25
+ Completion,
19
26
  PathCompleter,
20
- ) # type: ignore
21
- from prompt_toolkit.document import Document # type: ignore
22
- from prompt_toolkit.formatted_text import FormattedText # type: ignore
23
- from prompt_toolkit.key_binding import KeyBindings # type: ignore
24
- from prompt_toolkit.styles import Style as PromptStyle # type: ignore
27
+ )
28
+ from prompt_toolkit.document import Document
29
+ from prompt_toolkit.formatted_text import FormattedText
30
+ from prompt_toolkit.history import FileHistory
31
+ from prompt_toolkit.key_binding import KeyBindings
32
+ from prompt_toolkit.enums import DEFAULT_BUFFER
33
+ from prompt_toolkit.filters import has_focus
34
+ from prompt_toolkit.layout.containers import Window
35
+ from prompt_toolkit.layout.controls import FormattedTextControl
36
+ from prompt_toolkit.layout.layout import Layout
37
+ from prompt_toolkit.styles import Style as PromptStyle
25
38
 
26
- from jarvis.jarvis_utils.config import get_replace_map
39
+ from jarvis.jarvis_utils.clipboard import copy_to_clipboard
40
+ from jarvis.jarvis_utils.config import get_data_dir, get_replace_map
41
+ from jarvis.jarvis_utils.globals import get_message_history
27
42
  from jarvis.jarvis_utils.output import OutputType, PrettyOutput
28
43
  from jarvis.jarvis_utils.tag import ot
29
- from jarvis.jarvis_utils.utils import copy_to_clipboard
44
+
45
+ # Sentinel value to indicate that Ctrl+O was pressed
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__::"
53
+
54
+ # Persistent hint marker for multiline input (shown only once across runs)
55
+ _MULTILINE_HINT_MARK_FILE = os.path.join(get_data_dir(), "multiline_enter_hint_shown")
56
+
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)
30
71
 
31
72
 
32
- def get_single_line_input(tip: str) -> str:
73
+ def _calc_prompt_rows(prev_text: str) -> int:
33
74
  """
34
- 获取支持历史记录的单行输入。
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)
35
84
 
36
- 参数:
37
- tip: 要显示的提示信息
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)
38
104
 
39
- 返回:
40
- str: 用户的输入
105
+
106
+ def _multiline_hint_already_shown() -> bool:
107
+ """Check if the multiline Enter hint has been shown before (persisted)."""
108
+ try:
109
+ return os.path.exists(_MULTILINE_HINT_MARK_FILE)
110
+ except Exception:
111
+ return False
112
+
113
+
114
+ def _mark_multiline_hint_shown() -> None:
115
+ """Persist that the multiline Enter hint has been shown."""
116
+ try:
117
+ os.makedirs(os.path.dirname(_MULTILINE_HINT_MARK_FILE), exist_ok=True)
118
+ with open(_MULTILINE_HINT_MARK_FILE, "w", encoding="utf-8") as f:
119
+ f.write("1")
120
+ except Exception:
121
+ # Non-critical persistence failure; ignore to avoid breaking input flow
122
+ pass
123
+
124
+
125
+ def get_single_line_input(tip: str, default: str = "") -> str:
126
+ """
127
+ 获取支持历史记录的单行输入。
41
128
  """
42
129
  session: PromptSession = PromptSession(history=None)
130
+ style = PromptStyle.from_dict(
131
+ {"prompt": "ansicyan", "bottom-toolbar": "fg:#888888"}
132
+ )
133
+ prompt = FormattedText([("class:prompt", f"👤 > {tip}")])
134
+ return session.prompt(prompt, default=default, style=style)
135
+
136
+
137
+ def get_choice(tip: str, choices: List[str]) -> str:
138
+ """
139
+ 提供一个可滚动的选择列表供用户选择。
140
+ """
141
+ if not choices:
142
+ raise ValueError("Choices cannot be empty.")
143
+
144
+ try:
145
+ terminal_height = os.get_terminal_size().lines
146
+ except OSError:
147
+ terminal_height = 25 # 如果无法确定终端大小,则使用默认高度
148
+
149
+ # 为提示和缓冲区保留行
150
+ max_visible_choices = max(5, terminal_height - 4)
151
+
152
+ bindings = KeyBindings()
153
+ selected_index = 0
154
+ start_index = 0
155
+
156
+ @bindings.add("up")
157
+ def _(event):
158
+ nonlocal selected_index, start_index
159
+ selected_index = (selected_index - 1 + len(choices)) % len(choices)
160
+ if selected_index < start_index:
161
+ start_index = selected_index
162
+ elif selected_index == len(choices) - 1: # 支持从第一项上翻到最后一项时滚动
163
+ start_index = max(0, len(choices) - max_visible_choices)
164
+ event.app.invalidate()
165
+
166
+ @bindings.add("down")
167
+ def _(event):
168
+ nonlocal selected_index, start_index
169
+ selected_index = (selected_index + 1) % len(choices)
170
+ if selected_index >= start_index + max_visible_choices:
171
+ start_index = selected_index - max_visible_choices + 1
172
+ elif selected_index == 0: # 支持从最后一项下翻到第一项时滚动
173
+ start_index = 0
174
+ event.app.invalidate()
175
+
176
+ @bindings.add("enter")
177
+ def _(event):
178
+ event.app.exit(result=choices[selected_index])
179
+
180
+ def get_prompt_tokens():
181
+ tokens = [("class:question", f"{tip} (使用上下箭头选择, Enter确认)\n")]
182
+
183
+ end_index = min(start_index + max_visible_choices, len(choices))
184
+ visible_choices_slice = choices[start_index:end_index]
185
+
186
+ if start_index > 0:
187
+ tokens.append(("class:indicator", " ... (更多选项在上方) ...\n"))
188
+
189
+ for i, choice in enumerate(visible_choices_slice, start=start_index):
190
+ if i == selected_index:
191
+ tokens.append(("class:selected", f"> {choice}\n"))
192
+ else:
193
+ tokens.append(("", f" {choice}\n"))
194
+
195
+ if end_index < len(choices):
196
+ tokens.append(("class:indicator", " ... (更多选项在下方) ...\n"))
197
+
198
+ return FormattedText(tokens)
199
+
43
200
  style = PromptStyle.from_dict(
44
201
  {
45
- "prompt": "ansicyan",
202
+ "question": "bold",
203
+ "selected": "bg:#696969 #ffffff",
204
+ "indicator": "fg:gray",
46
205
  }
47
206
  )
48
- return session.prompt(f"{tip}", style=style)
207
+
208
+ layout = Layout(
209
+ container=Window(
210
+ content=FormattedTextControl(
211
+ text=get_prompt_tokens,
212
+ focusable=True,
213
+ key_bindings=bindings,
214
+ )
215
+ )
216
+ )
217
+
218
+ app: Application = Application(
219
+ layout=layout,
220
+ key_bindings=bindings,
221
+ style=style,
222
+ mouse_support=True,
223
+ full_screen=True,
224
+ )
225
+
226
+ try:
227
+ result = app.run()
228
+ return result if result is not None else ""
229
+ except (KeyboardInterrupt, EOFError):
230
+ return ""
49
231
 
50
232
 
51
233
  class FileCompleter(Completer):
52
234
  """
53
235
  带有模糊匹配的文件路径自定义补全器。
54
-
55
- 属性:
56
- path_completer: 基础路径补全器
57
- max_suggestions: 显示的最大建议数量
58
- min_score: 建议的最小匹配分数
59
236
  """
60
237
 
61
238
  def __init__(self):
62
- """使用默认设置初始化文件补全器。"""
63
239
  self.path_completer = PathCompleter()
64
240
  self.max_suggestions = 10
65
241
  self.min_score = 10
66
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
67
247
 
68
- def get_completions(self, document: Document, _: CompleteEvent) -> Completion: # type: ignore
69
- """
70
- 生成带有模糊匹配的文件路径补全建议。
71
-
72
- 参数:
73
- document: 当前正在编辑的文档
74
- complete_event: 补全事件
75
-
76
- 生成:
77
- Completion: 建议的补全项
78
- """
248
+ def get_completions(
249
+ self, document: Document, _: CompleteEvent
250
+ ) -> Iterable[Completion]:
79
251
  text = document.text_before_cursor
80
252
  cursor_pos = document.cursor_position
81
- # 查找文本中的所有@位置
82
- at_positions = [i for i, char in enumerate(text) if char == "@"]
83
- 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:
84
257
  return
85
- # 获取最后一个@位置
86
- current_at_pos = at_positions[-1]
87
- # 如果光标不在最后一个@之后,则不补全
88
- 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:
89
265
  return
90
- # 检查@之后是否有空格
91
- text_after_at = text[current_at_pos + 1 : cursor_pos]
92
- if " " in text_after_at:
266
+
267
+ text_after = text[current_pos + 1 : cursor_pos]
268
+ if " " in text_after:
93
269
  return
94
270
 
95
- # 获取当前@之后的文本
96
- file_path = text_after_at.strip()
97
- # 计算替换长度
98
- replace_length = len(text_after_at) + 1
271
+ token = text_after.strip()
272
+ replace_length = len(text_after) + 1
99
273
 
100
- # 获取所有可能的补全项
101
274
  all_completions = []
102
-
103
- # 1. 添加特殊标记
104
275
  all_completions.extend(
105
276
  [(ot(tag), self._get_description(tag)) for tag in self.replace_map.keys()]
106
277
  )
@@ -114,63 +285,75 @@ class FileCompleter(Completer):
114
285
  ]
115
286
  )
116
287
 
117
- # 2. 添加文件列表
288
+ # File path candidates
118
289
  try:
119
- import subprocess
290
+ if current_sym == "@":
291
+ import subprocess
120
292
 
121
- result = subprocess.run(
122
- ["git", "ls-files"],
123
- stdout=subprocess.PIPE,
124
- stderr=subprocess.PIPE,
125
- text=True,
126
- )
127
- if result.returncode == 0:
128
- all_completions.extend(
129
- [
130
- (path, "File")
131
- for path in result.stdout.splitlines()
132
- if path.strip()
133
- ]
134
- )
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])
135
326
  except Exception:
136
327
  pass
137
328
 
138
- # 统一过滤和排序
139
- if file_path:
140
- # 使用模糊匹配过滤
329
+ if token:
141
330
  scored_items = process.extract(
142
- file_path,
331
+ token,
143
332
  [item[0] for item in all_completions],
144
333
  limit=self.max_suggestions,
145
334
  )
146
335
  scored_items = [
147
336
  (item[0], item[1]) for item in scored_items if item[1] > self.min_score
148
337
  ]
149
- # 创建映射以便查找描述
150
338
  completion_map = {item[0]: item[1] for item in all_completions}
151
- # 生成补全项
152
- for text, score in scored_items:
153
- display_text = text
154
- if score < 100:
155
- display_text = f"{text} ({score}%)"
339
+ for t, score in scored_items:
340
+ display_text = f"{t} ({score}%)" if score < 100 else t
156
341
  yield Completion(
157
- text=f"'{text}'",
342
+ text=f"'{t}'",
158
343
  start_position=-replace_length,
159
344
  display=display_text,
160
- display_meta=completion_map.get(text, ""),
161
- ) # type: ignore
345
+ display_meta=completion_map.get(t, ""),
346
+ )
162
347
  else:
163
- # 没有输入时返回前max_suggestions个建议
164
- for text, desc in all_completions[: self.max_suggestions]:
348
+ for t, desc in all_completions[: self.max_suggestions]:
165
349
  yield Completion(
166
- text=f"'{text}'",
350
+ text=f"'{t}'",
167
351
  start_position=-replace_length,
168
- display=text,
352
+ display=t,
169
353
  display_meta=desc,
170
- ) # type: ignore
354
+ )
171
355
 
172
356
  def _get_description(self, tag: str) -> str:
173
- """获取标记的描述信息"""
174
357
  if tag in self.replace_map:
175
358
  return (
176
359
  self.replace_map[tag].get("description", tag) + "(Append)"
@@ -180,17 +363,51 @@ class FileCompleter(Completer):
180
363
  return tag
181
364
 
182
365
 
183
- def user_confirm(tip: str, default: bool = True) -> bool:
184
- """提示用户确认是/否问题
366
+ # ---------------------
367
+ # 公共判定辅助函数(按当前Agent优先)
368
+ # ---------------------
369
+ def _get_current_agent_for_input():
370
+ try:
371
+ import jarvis.jarvis_utils.globals as g
372
+ current_name = getattr(g, "current_agent_name", "")
373
+ if current_name:
374
+ return g.get_agent(current_name)
375
+ except Exception:
376
+ pass
377
+ return None
185
378
 
186
- 参数:
187
- tip: 显示给用户的消息
188
- default: 用户直接回车时的默认响应
379
+ def _is_non_interactive_for_current_agent() -> bool:
380
+ try:
381
+ from jarvis.jarvis_utils.config import is_non_interactive
382
+ ag = _get_current_agent_for_input()
383
+ try:
384
+ return bool(getattr(ag, "non_interactive", False)) if ag else bool(is_non_interactive())
385
+ except Exception:
386
+ return bool(is_non_interactive())
387
+ except Exception:
388
+ return False
189
389
 
190
- 返回:
191
- bool: 用户确认返回True,否则返回False
192
- """
390
+ def _is_auto_complete_for_current_agent() -> bool:
193
391
  try:
392
+ from jarvis.jarvis_utils.config import GLOBAL_CONFIG_DATA
393
+ ag = _get_current_agent_for_input()
394
+ if ag is not None and hasattr(ag, "auto_complete"):
395
+ try:
396
+ return bool(getattr(ag, "auto_complete", False))
397
+ except Exception:
398
+ pass
399
+ env_v = os.getenv("JARVIS_AUTO_COMPLETE")
400
+ if env_v is not None:
401
+ return str(env_v).strip().lower() in ("1", "true", "yes", "on")
402
+ return bool(GLOBAL_CONFIG_DATA.get("JARVIS_AUTO_COMPLETE", False))
403
+ except Exception:
404
+ return False
405
+
406
+ def user_confirm(tip: str, default: bool = True) -> bool:
407
+ """提示用户确认是/否问题(按当前Agent优先判断非交互)"""
408
+ try:
409
+ if _is_non_interactive_for_current_agent():
410
+ return default
194
411
  suffix = "[Y/n]" if default else "[y/N]"
195
412
  ret = get_single_line_input(f"{tip} {suffix}: ")
196
413
  return default if ret == "" else ret.lower() == "y"
@@ -198,86 +415,615 @@ def user_confirm(tip: str, default: bool = True) -> bool:
198
415
  return False
199
416
 
200
417
 
201
- def get_multiline_input(tip: str) -> str:
418
+ def _show_history_and_copy():
202
419
  """
203
- 获取带有增强补全和确认功能的多行输入。
420
+ Displays message history and handles copying to clipboard.
421
+ This function uses standard I/O and is safe to call outside a prompt session.
422
+ """
423
+
424
+ history = get_message_history()
425
+ if not history:
426
+ PrettyOutput.print("没有可复制的消息", OutputType.INFO)
427
+ return
428
+
429
+ # 为避免 PrettyOutput 在循环中为每行加框,先拼接后统一打印
430
+ lines = []
431
+ lines.append("\n" + "=" * 20 + " 消息历史记录 " + "=" * 20)
432
+ for i, msg in enumerate(history):
433
+ cleaned_msg = msg.replace("\n", r"\n")
434
+ display_msg = (
435
+ (cleaned_msg[:70] + "...") if len(cleaned_msg) > 70 else cleaned_msg
436
+ )
437
+ lines.append(f" {i + 1}: {display_msg.strip()}")
438
+ lines.append("=" * 58 + "\n")
439
+ PrettyOutput.print("\n".join(lines), OutputType.INFO)
440
+
441
+ while True:
442
+ try:
443
+ prompt_text = f"{Fore.CYAN}请输入要复制的条目序号 (或输入c取消, 直接回车选择最后一条): {ColoramaStyle.RESET_ALL}"
444
+ choice_str = input(prompt_text)
445
+
446
+ if not choice_str: # User pressed Enter
447
+ if not history:
448
+ PrettyOutput.print("没有历史记录可供选择。", OutputType.INFO)
449
+ break
450
+ choice = len(history) - 1
451
+ elif choice_str.lower() == "c":
452
+ PrettyOutput.print("已取消", OutputType.INFO)
453
+ break
454
+ else:
455
+ choice = int(choice_str) - 1
456
+
457
+ if 0 <= choice < len(history):
458
+ selected_msg = history[choice]
459
+ copy_to_clipboard(selected_msg)
460
+ PrettyOutput.print(
461
+ f"已复制消息: {selected_msg[:70]}...", OutputType.SUCCESS
462
+ )
463
+ break
464
+ else:
465
+ PrettyOutput.print("无效的序号,请重试。", OutputType.WARNING)
466
+ except ValueError:
467
+ PrettyOutput.print("无效的输入,请输入数字。", OutputType.WARNING)
468
+ except (KeyboardInterrupt, EOFError):
469
+ PrettyOutput.print("\n操作取消", OutputType.INFO)
470
+ break
204
471
 
205
- 参数:
206
- tip: 要显示的提示信息
207
472
 
208
- 返回:
209
- str: 用户的输入,如果取消则返回空字符串
473
+ def _get_multiline_input_internal(
474
+ tip: str, preset: Optional[str] = None, preset_cursor: Optional[int] = None
475
+ ) -> str:
476
+ """
477
+ Internal function to get multiline input using prompt_toolkit.
478
+ Returns a sentinel value if Ctrl+O is pressed.
210
479
  """
211
- # 显示输入说明
212
- PrettyOutput.section(
213
- "用户输入 - 使用 @ 触发文件补全,Tab 选择补全项,Ctrl+J 提交,Ctrl+O 复制最后一条消息,按 Ctrl+C 取消输入",
214
- OutputType.USER,
215
- )
216
- print(f"{Fore.GREEN}{tip}{ColoramaStyle.RESET_ALL}")
217
- # 配置键绑定
218
480
  bindings = KeyBindings()
219
481
 
482
+ # Show a one-time hint on the first Enter press in this invocation (disabled; using inlay toolbar instead)
483
+ first_enter_hint_shown = True
484
+
220
485
  @bindings.add("enter")
221
486
  def _(event):
222
- """处理回车键以进行补全或换行。"""
487
+ nonlocal first_enter_hint_shown
488
+ if not first_enter_hint_shown and not _multiline_hint_already_shown():
489
+ first_enter_hint_shown = True
490
+
491
+ def _show_notice():
492
+ PrettyOutput.print(
493
+ "提示:当前支持多行输入。输入完成请使用 Ctrl+J 确认;Enter 仅用于换行。",
494
+ OutputType.INFO,
495
+ )
496
+ try:
497
+ input("按回车继续...")
498
+ except Exception:
499
+ pass
500
+ # Persist the hint so it won't be shown again in future runs
501
+ try:
502
+ _mark_multiline_hint_shown()
503
+ except Exception:
504
+ pass
505
+
506
+ run_in_terminal(_show_notice)
507
+ return
508
+
223
509
  if event.current_buffer.complete_state:
224
- event.current_buffer.apply_completion(
225
- event.current_buffer.complete_state.current_completion
226
- )
510
+ completion = event.current_buffer.complete_state.current_completion
511
+ if completion:
512
+ event.current_buffer.apply_completion(completion)
513
+ else:
514
+ event.current_buffer.insert_text("\n")
227
515
  else:
228
516
  event.current_buffer.insert_text("\n")
229
517
 
230
- @bindings.add("c-j")
518
+ @bindings.add("c-j", filter=has_focus(DEFAULT_BUFFER))
231
519
  def _(event):
232
- """处理Ctrl+J以提交输入。"""
233
520
  event.current_buffer.validate_and_handle()
234
521
 
235
- @bindings.add("c-o")
522
+ @bindings.add("c-o", filter=has_focus(DEFAULT_BUFFER))
236
523
  def _(event):
237
- """处理Ctrl+O以复制最后一条消息到剪贴板。"""
238
- from jarvis.jarvis_utils.globals import get_last_message
524
+ """Handle Ctrl+O by exiting the prompt and returning the sentinel value."""
525
+ event.app.exit(result=CTRL_O_SENTINEL)
239
526
 
240
- last_msg = get_last_message()
241
- if last_msg:
242
- print(f"{last_msg}")
243
- copy_to_clipboard(last_msg)
244
- else:
245
- PrettyOutput.print("没有可复制的消息", OutputType.INFO)
527
+ @bindings.add("c-t", filter=has_focus(DEFAULT_BUFFER), eager=True)
528
+ def _(event):
529
+ """Return a shell command like '!bash' for upper input_handler to execute."""
246
530
 
247
- event.app.invalidate()
531
+ def _gen_shell_cmd() -> str: # type: ignore
532
+ try:
533
+ import os
534
+ import shutil
535
+
536
+ if os.name == "nt":
537
+ # Prefer PowerShell if available, otherwise fallback to cmd
538
+ for name in ("pwsh", "powershell", "cmd"):
539
+ if name == "cmd" or shutil.which(name):
540
+ if name == "cmd":
541
+ # Keep session open with /K and set env for the spawned shell
542
+ return "!cmd /K set JARVIS_TERMINAL=1"
543
+ else:
544
+ # PowerShell or pwsh: set env then remain in session
545
+ return f"!{name} -NoExit -Command \"$env:JARVIS_TERMINAL='1'\""
546
+ else:
547
+ shell_path = os.environ.get("SHELL", "")
548
+ if shell_path:
549
+ base = os.path.basename(shell_path)
550
+ if base:
551
+ return f"!env JARVIS_TERMINAL=1 {base}"
552
+ for name in ("fish", "zsh", "bash", "sh"):
553
+ if shutil.which(name):
554
+ return f"!env JARVIS_TERMINAL=1 {name}"
555
+ return "!env JARVIS_TERMINAL=1 bash"
556
+ except Exception:
557
+ return "!env JARVIS_TERMINAL=1 bash"
558
+
559
+ # Append a special marker to indicate no-confirm execution in shell_input_handler
560
+ event.app.exit(result=_gen_shell_cmd() + " # JARVIS-NOCONFIRM")
561
+
562
+ @bindings.add("@", filter=has_focus(DEFAULT_BUFFER), eager=True)
563
+ def _(event):
564
+ """
565
+ 使用 @ 触发 fzf(当 fzf 存在);否则仅插入 @ 以启用内置补全
566
+ 逻辑:
567
+ - 若检测到系统存在 fzf,则先插入 '@',随后请求外层运行 fzf 并在返回后进行替换/插入
568
+ - 若不存在 fzf 或发生异常,则直接插入 '@'
569
+ """
570
+ try:
571
+ import shutil
572
+
573
+ buf = event.current_buffer
574
+ if shutil.which("fzf") is None:
575
+ buf.insert_text("@")
576
+ return
577
+ # 先插入 '@',以便外层根据最后一个 '@' 进行片段替换
578
+ buf.insert_text("@")
579
+ doc = buf.document
580
+ text = doc.text
581
+ cursor = doc.cursor_position
582
+ payload = (
583
+ f"{cursor}:{base64.b64encode(text.encode('utf-8')).decode('ascii')}"
584
+ )
585
+ event.app.exit(result=FZF_REQUEST_SENTINEL_PREFIX + payload)
586
+ return
587
+ except Exception:
588
+ try:
589
+ event.current_buffer.insert_text("@")
590
+ except Exception:
591
+ pass
592
+
593
+ @bindings.add("#", filter=has_focus(DEFAULT_BUFFER), eager=True)
594
+ def _(event):
595
+ """
596
+ 使用 # 触发 fzf(当 fzf 存在),以“全量文件模式”进行选择(排除 .git);否则仅插入 # 启用内置补全
597
+ """
598
+ try:
599
+ import shutil
600
+
601
+ buf = event.current_buffer
602
+ if shutil.which("fzf") is None:
603
+ buf.insert_text("#")
604
+ return
605
+ # 先插入 '#'
606
+ buf.insert_text("#")
607
+ doc = buf.document
608
+ text = doc.text
609
+ cursor = doc.cursor_position
610
+ payload = (
611
+ f"{cursor}:{base64.b64encode(text.encode('utf-8')).decode('ascii')}"
612
+ )
613
+ event.app.exit(result=FZF_REQUEST_ALL_SENTINEL_PREFIX + payload)
614
+ return
615
+ except Exception:
616
+ try:
617
+ event.current_buffer.insert_text("#")
618
+ except Exception:
619
+ pass
248
620
 
249
- # 配置提示会话
250
621
  style = PromptStyle.from_dict(
251
622
  {
252
- "prompt": "ansicyan",
623
+ "prompt": "ansibrightmagenta bold",
624
+ "bottom-toolbar": "bg:#4b145b #ffd6ff bold",
625
+ "bt.tip": "bold fg:#ff5f87",
626
+ "bt.sep": "fg:#ffb3de",
627
+ "bt.key": "bg:#d7005f #ffffff bold",
628
+ "bt.label": "fg:#ffd6ff",
253
629
  }
254
630
  )
255
- try:
256
- import os
257
-
258
- from prompt_toolkit.history import FileHistory # type: ignore
259
-
260
- from jarvis.jarvis_utils.config import get_data_dir
261
-
262
- # 获取数据目录路径
263
- history_dir = get_data_dir()
264
- # 初始化带历史记录的会话
265
- session: PromptSession = PromptSession(
266
- history=FileHistory(os.path.join(history_dir, "multiline_input_history")),
267
- completer=FileCompleter(),
268
- key_bindings=bindings,
269
- complete_while_typing=True,
270
- multiline=True,
271
- vi_mode=False,
272
- mouse_support=False,
631
+
632
+ def _bottom_toolbar():
633
+ return FormattedText(
634
+ [
635
+ ("class:bt.tip", f" {tip} "),
636
+ ("class:bt.sep", " • "),
637
+ ("class:bt.label", "快捷键: "),
638
+ ("class:bt.key", "@"),
639
+ ("class:bt.label", " 文件补全 "),
640
+ ("class:bt.sep", " • "),
641
+ ("class:bt.key", "Tab"),
642
+ ("class:bt.label", " 选择 "),
643
+ ("class:bt.sep", " • "),
644
+ ("class:bt.key", "Ctrl+J"),
645
+ ("class:bt.label", " 确认 "),
646
+ ("class:bt.sep", " • "),
647
+ ("class:bt.key", "Ctrl+O"),
648
+ ("class:bt.label", " 历史复制 "),
649
+ ("class:bt.sep", " • "),
650
+ ("class:bt.key", "Ctrl+T"),
651
+ ("class:bt.label", " 终端(!SHELL) "),
652
+ ("class:bt.sep", " • "),
653
+ ("class:bt.key", "Ctrl+C/D"),
654
+ ("class:bt.label", " 取消 "),
655
+ ]
273
656
  )
274
- prompt = FormattedText([("class:prompt", ">>> ")])
275
- # 获取输入
276
- text = session.prompt(
657
+
658
+ history_dir = get_data_dir()
659
+ session: PromptSession = PromptSession(
660
+ history=FileHistory(os.path.join(history_dir, "multiline_input_history")),
661
+ completer=FileCompleter(),
662
+ key_bindings=bindings,
663
+ complete_while_typing=True,
664
+ multiline=True,
665
+ vi_mode=False,
666
+ mouse_support=False,
667
+ )
668
+
669
+ # Tip is shown in bottom toolbar; avoid extra print
670
+ prompt = FormattedText([("class:prompt", "👤 > ")])
671
+
672
+ def _pre_run():
673
+ try:
674
+ from prompt_toolkit.application.current import get_app as _ga
675
+
676
+ app = _ga()
677
+ buf = app.current_buffer
678
+ if preset is not None and preset_cursor is not None:
679
+ cp = max(0, min(len(buf.text), preset_cursor))
680
+ buf.cursor_position = cp
681
+ except Exception:
682
+ pass
683
+
684
+ try:
685
+ return session.prompt(
277
686
  prompt,
278
687
  style=style,
688
+ pre_run=_pre_run,
689
+ bottom_toolbar=_bottom_toolbar,
690
+ default=(preset or ""),
279
691
  ).strip()
280
- return text
281
- except KeyboardInterrupt:
282
- PrettyOutput.print("输入已取消", OutputType.INFO)
692
+ except (KeyboardInterrupt, EOFError):
283
693
  return ""
694
+
695
+
696
+ def get_multiline_input(tip: str, print_on_empty: bool = True) -> str:
697
+ """
698
+ 获取带有增强补全和确认功能的多行输入。
699
+ 此函数处理控制流,允许在不破坏终端状态的情况下处理历史记录复制。
700
+
701
+ 参数:
702
+ tip: 提示文本,将显示在底部工具栏中
703
+ print_on_empty: 当输入为空字符串时,是否打印“输入已取消”提示。默认打印。
704
+ """
705
+ preset: Optional[str] = None
706
+ preset_cursor: Optional[int] = None
707
+ while True:
708
+ # 基于“当前Agent”精确判断非交互与自动完成,避免多Agent相互干扰
709
+ if _is_non_interactive_for_current_agent():
710
+ # 在多Agent系统中,无论是否启用自动完成,均提示可用智能体并建议使用 SEND_MESSAGE 转移控制权
711
+ hint = ""
712
+ try:
713
+ ag = _get_current_agent_for_input()
714
+ ohs = getattr(ag, "output_handler", [])
715
+ available_agents: List[str] = []
716
+ for oh in (ohs or []):
717
+ cfgs = getattr(oh, "agents_config", None)
718
+ if isinstance(cfgs, list):
719
+ for c in cfgs:
720
+ try:
721
+ name = c.get("name")
722
+ except Exception:
723
+ name = None
724
+ if isinstance(name, str) and name.strip():
725
+ available_agents.append(name.strip())
726
+ if available_agents:
727
+ # 去重但保留顺序
728
+ seen = set()
729
+ ordered = []
730
+ for n in available_agents:
731
+ if n not in seen:
732
+ seen.add(n)
733
+ ordered.append(n)
734
+ hint = "\n当前可用智能体: " + ", ".join(ordered) + f"\n如需将任务交给其他智能体,请使用 {ot('SEND_MESSAGE')} 块。"
735
+ except Exception:
736
+ hint = ""
737
+ if _is_auto_complete_for_current_agent():
738
+ base_msg = "我无法与你交互,所有的事情你都自我决策,如果无法决策,就完成任务。输出" + ot("!!!COMPLETE!!!")
739
+ return base_msg + hint
740
+ else:
741
+ return "我无法与你交互,所有的事情你都自我决策" + hint
742
+ user_input = _get_multiline_input_internal(
743
+ tip, preset=preset, preset_cursor=preset_cursor
744
+ )
745
+
746
+ if user_input == CTRL_O_SENTINEL:
747
+ _show_history_and_copy()
748
+ tip = "请继续输入(或按Ctrl+J确认):"
749
+ continue
750
+ elif isinstance(user_input, str) and user_input.startswith(
751
+ FZF_REQUEST_SENTINEL_PREFIX
752
+ ):
753
+ # Handle fzf request outside the prompt, then prefill new text.
754
+ try:
755
+ payload = user_input[len(FZF_REQUEST_SENTINEL_PREFIX) :]
756
+ sep_index = payload.find(":")
757
+ cursor = int(payload[:sep_index])
758
+ text = base64.b64decode(
759
+ payload[sep_index + 1 :].encode("ascii")
760
+ ).decode("utf-8")
761
+ except Exception:
762
+ # Malformed payload; just continue without change.
763
+ preset = None
764
+ tip = "FZF 预填失败,继续输入:"
765
+ continue
766
+
767
+ # Run fzf to get a file selection synchronously (outside prompt)
768
+ selected_path = ""
769
+ try:
770
+ import shutil
771
+ import subprocess
772
+
773
+ if shutil.which("fzf") is None:
774
+ PrettyOutput.print(
775
+ "未检测到 fzf,无法打开文件选择器。", OutputType.WARNING
776
+ )
777
+ else:
778
+ files = []
779
+ try:
780
+ r = subprocess.run(
781
+ ["git", "ls-files"],
782
+ stdout=subprocess.PIPE,
783
+ stderr=subprocess.PIPE,
784
+ text=True,
785
+ )
786
+ if r.returncode == 0:
787
+ files = [
788
+ line for line in r.stdout.splitlines() if line.strip()
789
+ ]
790
+ except Exception:
791
+ files = []
792
+
793
+ if not files:
794
+ import os as _os
795
+
796
+ for root, _, fnames in _os.walk(".", followlinks=False):
797
+ for name in fnames:
798
+ files.append(
799
+ _os.path.relpath(_os.path.join(root, name), ".")
800
+ )
801
+ if len(files) > 10000:
802
+ break
803
+
804
+ if not files:
805
+ PrettyOutput.print("未找到可选择的文件。", OutputType.INFO)
806
+ else:
807
+ try:
808
+ specials = [
809
+ ot("Summary"),
810
+ ot("Clear"),
811
+ ot("ToolUsage"),
812
+ ot("ReloadConfig"),
813
+ ot("SaveSession"),
814
+ ]
815
+ except Exception:
816
+ specials = []
817
+ try:
818
+ replace_map = get_replace_map()
819
+ builtin_tags = [
820
+ ot(tag)
821
+ for tag in replace_map.keys()
822
+ if isinstance(tag, str) and tag.strip()
823
+ ]
824
+ except Exception:
825
+ builtin_tags = []
826
+ items = (
827
+ [s for s in specials if isinstance(s, str) and s.strip()]
828
+ + builtin_tags
829
+ + files
830
+ )
831
+ proc = subprocess.run(
832
+ [
833
+ "fzf",
834
+ "--prompt",
835
+ "Files> ",
836
+ "--height",
837
+ "40%",
838
+ "--border",
839
+ ],
840
+ input="\n".join(items),
841
+ stdout=subprocess.PIPE,
842
+ stderr=subprocess.PIPE,
843
+ text=True,
844
+ )
845
+ sel = proc.stdout.strip()
846
+ if sel:
847
+ selected_path = sel
848
+ except Exception as e:
849
+ PrettyOutput.print(f"FZF 执行失败: {e}", OutputType.ERROR)
850
+
851
+ # Compute new text based on selection (or keep original if none)
852
+ if selected_path:
853
+ text_before = text[:cursor]
854
+ last_at = text_before.rfind("@")
855
+ if last_at != -1 and " " not in text_before[last_at + 1 :]:
856
+ # Replace @... segment
857
+ inserted = f"'{selected_path}'"
858
+ new_text = text[:last_at] + inserted + text[cursor:]
859
+ new_cursor = last_at + len(inserted)
860
+ else:
861
+ # Plain insert
862
+ inserted = f"'{selected_path}'"
863
+ new_text = text[:cursor] + inserted + text[cursor:]
864
+ new_cursor = cursor + len(inserted)
865
+ preset = new_text
866
+ preset_cursor = new_cursor
867
+ tip = "已插入文件,继续编辑或按Ctrl+J确认:"
868
+ else:
869
+ # No selection; keep original text and cursor
870
+ preset = text
871
+ preset_cursor = cursor
872
+ tip = "未选择文件或已取消,继续编辑:"
873
+ # 清除上一条输入行(多行安全),避免多清,保守仅按提示行估算
874
+ try:
875
+ rows_total = _calc_prompt_rows(text)
876
+ for _ in range(rows_total):
877
+ sys.stdout.write("\x1b[1A") # 光标上移一行
878
+ sys.stdout.write("\x1b[2K\r") # 清除整行
879
+ sys.stdout.flush()
880
+ except Exception:
881
+ pass
882
+ continue
883
+ elif isinstance(user_input, str) and user_input.startswith(
884
+ FZF_REQUEST_ALL_SENTINEL_PREFIX
885
+ ):
886
+ # Handle fzf request (all-files mode, excluding .git) outside the prompt, then prefill new text.
887
+ try:
888
+ payload = user_input[len(FZF_REQUEST_ALL_SENTINEL_PREFIX) :]
889
+ sep_index = payload.find(":")
890
+ cursor = int(payload[:sep_index])
891
+ text = base64.b64decode(
892
+ payload[sep_index + 1 :].encode("ascii")
893
+ ).decode("utf-8")
894
+ except Exception:
895
+ # Malformed payload; just continue without change.
896
+ preset = None
897
+ tip = "FZF 预填失败,继续输入:"
898
+ continue
899
+
900
+ # Run fzf to get a file selection synchronously (outside prompt) with all files (exclude .git)
901
+ selected_path = ""
902
+ try:
903
+ import shutil
904
+ import subprocess
905
+
906
+ if shutil.which("fzf") is None:
907
+ PrettyOutput.print(
908
+ "未检测到 fzf,无法打开文件选择器。", OutputType.WARNING
909
+ )
910
+ else:
911
+ files = []
912
+ try:
913
+ import os as _os
914
+
915
+ for root, dirs, fnames in _os.walk(".", followlinks=False):
916
+ # Exclude .git directories
917
+ dirs[:] = [d for d in dirs if d != ".git"]
918
+ for name in fnames:
919
+ files.append(
920
+ _os.path.relpath(_os.path.join(root, name), ".")
921
+ )
922
+ if len(files) > 10000:
923
+ break
924
+ if len(files) > 10000:
925
+ break
926
+ except Exception:
927
+ files = []
928
+
929
+ if not files:
930
+ PrettyOutput.print("未找到可选择的文件。", OutputType.INFO)
931
+ else:
932
+ try:
933
+ specials = [
934
+ ot("Summary"),
935
+ ot("Clear"),
936
+ ot("ToolUsage"),
937
+ ot("ReloadConfig"),
938
+ ot("SaveSession"),
939
+ ]
940
+ except Exception:
941
+ specials = []
942
+ try:
943
+ replace_map = get_replace_map()
944
+ builtin_tags = [
945
+ ot(tag)
946
+ for tag in replace_map.keys()
947
+ if isinstance(tag, str) and tag.strip()
948
+ ]
949
+ except Exception:
950
+ builtin_tags = []
951
+ items = (
952
+ [s for s in specials if isinstance(s, str) and s.strip()]
953
+ + builtin_tags
954
+ + files
955
+ )
956
+ proc = subprocess.run(
957
+ [
958
+ "fzf",
959
+ "--prompt",
960
+ "Files(all)> ",
961
+ "--height",
962
+ "40%",
963
+ "--border",
964
+ ],
965
+ input="\n".join(items),
966
+ stdout=subprocess.PIPE,
967
+ stderr=subprocess.PIPE,
968
+ text=True,
969
+ )
970
+ sel = proc.stdout.strip()
971
+ if sel:
972
+ selected_path = sel
973
+ except Exception as e:
974
+ PrettyOutput.print(f"FZF 执行失败: {e}", OutputType.ERROR)
975
+
976
+ # Compute new text based on selection (or keep original if none)
977
+ if selected_path:
978
+ text_before = text[:cursor]
979
+ last_hash = text_before.rfind("#")
980
+ if last_hash != -1 and " " not in text_before[last_hash + 1 :]:
981
+ # Replace #... segment
982
+ inserted = f"'{selected_path}'"
983
+ new_text = text[:last_hash] + inserted + text[cursor:]
984
+ new_cursor = last_hash + len(inserted)
985
+ else:
986
+ # Plain insert
987
+ inserted = f"'{selected_path}'"
988
+ new_text = text[:cursor] + inserted + text[cursor:]
989
+ new_cursor = cursor + len(inserted)
990
+ preset = new_text
991
+ preset_cursor = new_cursor
992
+ tip = "已插入文件,继续编辑或按Ctrl+J确认:"
993
+ else:
994
+ # No selection; keep original text and cursor
995
+ preset = text
996
+ preset_cursor = cursor
997
+ tip = "未选择文件或已取消,继续编辑:"
998
+ # 清除上一条输入行(多行安全),避免多清,保守仅按提示行估算
999
+ try:
1000
+ rows_total = _calc_prompt_rows(text)
1001
+ for _ in range(rows_total):
1002
+ sys.stdout.write("\x1b[1A")
1003
+ sys.stdout.write("\x1b[2K\r")
1004
+ sys.stdout.flush()
1005
+ except Exception:
1006
+ pass
1007
+ continue
1008
+ elif isinstance(user_input, str) and user_input.startswith(
1009
+ FZF_INSERT_SENTINEL_PREFIX
1010
+ ):
1011
+ # 从哨兵载荷中提取新文本,作为下次进入提示的预填内容
1012
+ preset = user_input[len(FZF_INSERT_SENTINEL_PREFIX) :]
1013
+ preset_cursor = len(preset)
1014
+
1015
+ # 清除上一条输入行(多行安全),避免多清,保守仅按提示行估算
1016
+ try:
1017
+ rows_total = _calc_prompt_rows(preset)
1018
+ for _ in range(rows_total):
1019
+ sys.stdout.write("\x1b[1A")
1020
+ sys.stdout.write("\x1b[2K\r")
1021
+ sys.stdout.flush()
1022
+ except Exception:
1023
+ pass
1024
+ tip = "已插入文件,继续编辑或按Ctrl+J确认:"
1025
+ continue
1026
+ else:
1027
+ if not user_input and print_on_empty:
1028
+ PrettyOutput.print("输入已取消", OutputType.INFO)
1029
+ return user_input