abyss-cli 0.1.0__tar.gz
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.
- abyss_cli-0.1.0/PKG-INFO +11 -0
- abyss_cli-0.1.0/pyproject.toml +26 -0
- abyss_cli-0.1.0/setup.cfg +4 -0
- abyss_cli-0.1.0/setup.py +29 -0
- abyss_cli-0.1.0/src/abyss/__init__.py +3 -0
- abyss_cli-0.1.0/src/abyss/ansi_menu.py +559 -0
- abyss_cli-0.1.0/src/abyss/api_client.py +123 -0
- abyss_cli-0.1.0/src/abyss/commands/__init__.py +12 -0
- abyss_cli-0.1.0/src/abyss/commands/slash.py +72 -0
- abyss_cli-0.1.0/src/abyss/config.py +121 -0
- abyss_cli-0.1.0/src/abyss/custom_input.py +382 -0
- abyss_cli-0.1.0/src/abyss/extensions/__init__.py +21 -0
- abyss_cli-0.1.0/src/abyss/extensions/cli.py +160 -0
- abyss_cli-0.1.0/src/abyss/extensions/installer.py +452 -0
- abyss_cli-0.1.0/src/abyss/extensions/registry.py +119 -0
- abyss_cli-0.1.0/src/abyss/extensions/url_parser.py +86 -0
- abyss_cli-0.1.0/src/abyss/hooks/__init__.py +12 -0
- abyss_cli-0.1.0/src/abyss/hooks/runner.py +144 -0
- abyss_cli-0.1.0/src/abyss/logger.py +218 -0
- abyss_cli-0.1.0/src/abyss/main.py +763 -0
- abyss_cli-0.1.0/src/abyss/mcp/__init__.py +13 -0
- abyss_cli-0.1.0/src/abyss/mcp/manager.py +189 -0
- abyss_cli-0.1.0/src/abyss/prompts/__init__.py +26 -0
- abyss_cli-0.1.0/src/abyss/session.py +79 -0
- abyss_cli-0.1.0/src/abyss/skills/__init__.py +12 -0
- abyss_cli-0.1.0/src/abyss/skills/loader.py +150 -0
- abyss_cli-0.1.0/src/abyss/tools/__init__.py +20 -0
- abyss_cli-0.1.0/src/abyss/tools/base.py +45 -0
- abyss_cli-0.1.0/src/abyss/tools/file_edit.py +48 -0
- abyss_cli-0.1.0/src/abyss/tools/file_read.py +54 -0
- abyss_cli-0.1.0/src/abyss/tools/file_write.py +44 -0
- abyss_cli-0.1.0/src/abyss/tools/registry.py +107 -0
- abyss_cli-0.1.0/src/abyss/tools/shell_exec.py +181 -0
- abyss_cli-0.1.0/src/abyss/tools/web_search.py +63 -0
- abyss_cli-0.1.0/src/abyss_cli.egg-info/PKG-INFO +11 -0
- abyss_cli-0.1.0/src/abyss_cli.egg-info/SOURCES.txt +79 -0
- abyss_cli-0.1.0/src/abyss_cli.egg-info/dependency_links.txt +1 -0
- abyss_cli-0.1.0/src/abyss_cli.egg-info/entry_points.txt +2 -0
- abyss_cli-0.1.0/src/abyss_cli.egg-info/requires.txt +4 -0
- abyss_cli-0.1.0/src/abyss_cli.egg-info/top_level.txt +1 -0
- abyss_cli-0.1.0/test/test_abyss_interactive.py +125 -0
- abyss_cli-0.1.0/test/test_ansi.py +80 -0
- abyss_cli-0.1.0/test/test_ansi2.py +79 -0
- abyss_cli-0.1.0/test/test_ansi3.py +111 -0
- abyss_cli-0.1.0/test/test_ansi_menu_max_visible.py +80 -0
- abyss_cli-0.1.0/test/test_ansi_prompt_clear_line.py +124 -0
- abyss_cli-0.1.0/test/test_ansi_prompt_history.py +208 -0
- abyss_cli-0.1.0/test/test_ansi_prompt_line_clear.py +166 -0
- abyss_cli-0.1.0/test/test_ansi_prompt_menu.py +188 -0
- abyss_cli-0.1.0/test/test_ansi_select.py +270 -0
- abyss_cli-0.1.0/test/test_api.py +60 -0
- abyss_cli-0.1.0/test/test_batch_execution_rule.py +44 -0
- abyss_cli-0.1.0/test/test_config_delete.py +92 -0
- abyss_cli-0.1.0/test/test_custom_input.py +16 -0
- abyss_cli-0.1.0/test/test_debug.py +103 -0
- abyss_cli-0.1.0/test/test_debug_position.py +118 -0
- abyss_cli-0.1.0/test/test_edge_cases.py +208 -0
- abyss_cli-0.1.0/test/test_execute_tool_arg_validation.py +91 -0
- abyss_cli-0.1.0/test/test_extension_cli.py +272 -0
- abyss_cli-0.1.0/test/test_extension_registry.py +128 -0
- abyss_cli-0.1.0/test/test_help_command.py +117 -0
- abyss_cli-0.1.0/test/test_hook_runner.py +145 -0
- abyss_cli-0.1.0/test/test_input_edge_cases.py +81 -0
- abyss_cli-0.1.0/test/test_mcp_manager.py +191 -0
- abyss_cli-0.1.0/test/test_misc.py +57 -0
- abyss_cli-0.1.0/test/test_multi_round_ui.py +124 -0
- abyss_cli-0.1.0/test/test_no_tight_polling.py +26 -0
- abyss_cli-0.1.0/test/test_package_installer.py +435 -0
- abyss_cli-0.1.0/test/test_rich_completer.py +295 -0
- abyss_cli-0.1.0/test/test_session_compress.py +100 -0
- abyss_cli-0.1.0/test/test_shell.py +30 -0
- abyss_cli-0.1.0/test/test_shell_retry_encoding.py +81 -0
- abyss_cli-0.1.0/test/test_shell_safety.py +65 -0
- abyss_cli-0.1.0/test/test_shell_timeout_fix.py +155 -0
- abyss_cli-0.1.0/test/test_show_reasoning_persist.py +101 -0
- abyss_cli-0.1.0/test/test_skill_loader.py +162 -0
- abyss_cli-0.1.0/test/test_slash_commands.py +125 -0
- abyss_cli-0.1.0/test/test_tool_registry.py +117 -0
- abyss_cli-0.1.0/test/test_unbounded_rounds.py +45 -0
- abyss_cli-0.1.0/test/test_url_parser.py +107 -0
- abyss_cli-0.1.0/test/test_web_search_rules.py +54 -0
abyss_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: abyss-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 终端AI开发助手
|
|
5
|
+
Author: Abyss Team
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: openai>=1.0.0
|
|
8
|
+
Requires-Dist: click>=8.0.0
|
|
9
|
+
Requires-Dist: prompt-toolkit>=3.0.0
|
|
10
|
+
Requires-Dist: ddgs>=9.0.0
|
|
11
|
+
Dynamic: requires-python
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=45", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "abyss-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "终端AI开发助手"
|
|
9
|
+
authors = [{name = "Abyss Team"}]
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"openai>=1.0.0",
|
|
13
|
+
"click>=8.0.0",
|
|
14
|
+
"prompt-toolkit>=3.0.0",
|
|
15
|
+
"ddgs>=9.0.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
abyss = "abyss.main:cli"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
packages = [
|
|
23
|
+
"abyss", "abyss.tools", "abyss.prompts",
|
|
24
|
+
"abyss.commands", "abyss.extensions",
|
|
25
|
+
"abyss.hooks", "abyss.mcp", "abyss.skills",
|
|
26
|
+
]
|
abyss_cli-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
打包配置文件
|
|
4
|
+
"""
|
|
5
|
+
from setuptools import setup
|
|
6
|
+
|
|
7
|
+
setup(
|
|
8
|
+
name="abyss-cli",
|
|
9
|
+
version="0.1.0",
|
|
10
|
+
packages=[
|
|
11
|
+
"abyss", "abyss.tools", "abyss.prompts",
|
|
12
|
+
"abyss.commands", "abyss.extensions",
|
|
13
|
+
"abyss.hooks", "abyss.mcp", "abyss.skills",
|
|
14
|
+
],
|
|
15
|
+
package_dir={"": "src"},
|
|
16
|
+
install_requires=[
|
|
17
|
+
"openai>=1.0.0",
|
|
18
|
+
"click>=8.0.0",
|
|
19
|
+
"ddgs>=9.0.0",
|
|
20
|
+
],
|
|
21
|
+
entry_points={
|
|
22
|
+
"console_scripts": [
|
|
23
|
+
"abyss=abyss.main:cli",
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
python_requires=">=3.9",
|
|
27
|
+
description="终端AI开发助手",
|
|
28
|
+
author="Abyss Team",
|
|
29
|
+
)
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
自定义 ANSI 补全菜单 — 完全绕过 prompt_toolkit 的灰色背景问题
|
|
4
|
+
使用纯 ANSI 转义码渲染,保留 prompt_toolkit 的行编辑能力。
|
|
5
|
+
|
|
6
|
+
用法:
|
|
7
|
+
from .ansi_menu import ansi_prompt
|
|
8
|
+
user_input = ansi_prompt("> ")
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import msvcrt
|
|
13
|
+
import time
|
|
14
|
+
from typing import List, Tuple, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ─── 模糊匹配 ──────────────────────────────────────────────
|
|
18
|
+
def _fuzzy_match(search: str, target: str) -> bool:
|
|
19
|
+
if not search:
|
|
20
|
+
return True
|
|
21
|
+
si = 0
|
|
22
|
+
for ch in target:
|
|
23
|
+
if si < len(search) and ch.lower() == search[si].lower():
|
|
24
|
+
si += 1
|
|
25
|
+
return si == len(search)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─── 命令列表 ──────────────────────────────────────────────
|
|
29
|
+
COMMANDS = [
|
|
30
|
+
("/help", "显示帮助"),
|
|
31
|
+
("/clear", "清空上下文"),
|
|
32
|
+
("/exit", "退出"),
|
|
33
|
+
("/quit", "退出"),
|
|
34
|
+
("/config", "查看当前配置"),
|
|
35
|
+
("/config set api-key ", "设置 API Key"),
|
|
36
|
+
("/config set model ", "设置模型"),
|
|
37
|
+
("/config set thinking ", "开关思考模式 (on/off)"),
|
|
38
|
+
("/config set max-tokens ", "设置最大 token 数"),
|
|
39
|
+
("/model", "查看/切换模型"),
|
|
40
|
+
("/thinking", "查看/开关思考模式"),
|
|
41
|
+
("/show-reasoning", "开关思考过程显示"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ─── 输入历史(↑↓ 翻看用)────────────────────────────────
|
|
46
|
+
# 模块级变量,跨多次 ansi_prompt 调用共享。
|
|
47
|
+
HISTORY: list = []
|
|
48
|
+
HISTORY_MAX = 100
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ─── 补全菜单最大可见行数(根据终端高度动态计算)────────────
|
|
52
|
+
# 旧版本写死 8,COMMANDS 列表有 12 个 → 后 4 个被截断看不见。
|
|
53
|
+
# 现在根据 os.get_terminal_size().lines 算:留 3 行给 prompt / spinner / status。
|
|
54
|
+
_MIN_VISIBLE = 3
|
|
55
|
+
_RESERVED_LINES = 3
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compute_max_visible() -> int:
|
|
59
|
+
"""根据终端高度算补全菜单最大可见行数。
|
|
60
|
+
- 大屏:尽量显示所有项(避免截断)
|
|
61
|
+
- 小屏:至少留 3 行供 prompt 等使用
|
|
62
|
+
- 非 TTY 环境:fallback 到 20(够所有命令 + 文件)
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
term_h = os.get_terminal_size().lines
|
|
66
|
+
except (OSError, AttributeError):
|
|
67
|
+
return 20
|
|
68
|
+
return max(_MIN_VISIBLE, term_h - _RESERVED_LINES)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── 补全数据源 ───────────────────────────────────────
|
|
72
|
+
def get_file_completions(partial: str) -> List[Tuple[str, str]]:
|
|
73
|
+
cwd = os.getcwd()
|
|
74
|
+
try:
|
|
75
|
+
entries = sorted(os.listdir(cwd))
|
|
76
|
+
except OSError:
|
|
77
|
+
return []
|
|
78
|
+
results = []
|
|
79
|
+
search = partial.lower()
|
|
80
|
+
for name in entries:
|
|
81
|
+
full = os.path.join(cwd, name)
|
|
82
|
+
if not os.path.isfile(full):
|
|
83
|
+
continue
|
|
84
|
+
if name.startswith("."):
|
|
85
|
+
continue
|
|
86
|
+
if _fuzzy_match(search, name):
|
|
87
|
+
try:
|
|
88
|
+
size = os.path.getsize(full)
|
|
89
|
+
if size < 1024:
|
|
90
|
+
meta = f"{size}B"
|
|
91
|
+
elif size < 1024 * 1024:
|
|
92
|
+
meta = f"{size // 1024}KB"
|
|
93
|
+
else:
|
|
94
|
+
meta = f"{size // (1024 * 1024)}MB"
|
|
95
|
+
except OSError:
|
|
96
|
+
meta = ""
|
|
97
|
+
results.append((name, meta))
|
|
98
|
+
return results
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_command_completions(partial: str) -> List[Tuple[str, str]]:
|
|
102
|
+
results = []
|
|
103
|
+
for cmd_name, cmd_desc in COMMANDS:
|
|
104
|
+
if _fuzzy_match(partial, cmd_name):
|
|
105
|
+
results.append((cmd_name, cmd_desc))
|
|
106
|
+
return results
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ─── @ 上下文检测 ─────────────────────────────────────────
|
|
110
|
+
def find_at_context(text_before_cursor: str) -> Optional[Tuple[int, str]]:
|
|
111
|
+
at_positions = []
|
|
112
|
+
for i, ch in enumerate(text_before_cursor):
|
|
113
|
+
if ch == "@":
|
|
114
|
+
if i > 0 and text_before_cursor[i - 1] not in (" ", "\n", "\t"):
|
|
115
|
+
continue
|
|
116
|
+
at_positions.append(i)
|
|
117
|
+
if not at_positions:
|
|
118
|
+
return None
|
|
119
|
+
last_at = at_positions[-1]
|
|
120
|
+
partial = text_before_cursor[last_at + 1:]
|
|
121
|
+
if " " in partial:
|
|
122
|
+
return None
|
|
123
|
+
return last_at, partial
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ─── ANSI 颜色工具 ─────────────────────────────────────
|
|
127
|
+
def ansi_white(s: str) -> str:
|
|
128
|
+
return f"\x1b[97m{s}\x1b[0m"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def ansi_selected(s: str) -> str:
|
|
132
|
+
return f"\x1b[7;97m{s}\x1b[0m"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def ansi_dim(s: str) -> str:
|
|
136
|
+
return f"\x1b[2m{s}\x1b[0m"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ansi_move_up(n: int) -> str:
|
|
140
|
+
return f"\x1b[{n}A" if n > 0 else ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def ansi_move_down(n: int) -> str:
|
|
144
|
+
return f"\x1b[{n}B" if n > 0 else ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def ansi_move_left(n: int) -> str:
|
|
148
|
+
return f"\x1b[{n}D" if n > 0 else ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def ansi_move_right(n: int) -> str:
|
|
152
|
+
return f"\x1b[{n}C" if n > 0 else ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def ansi_clear_line() -> str:
|
|
156
|
+
return "\r\x1b[K"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def ansi_erase_lines_below(count: int) -> str:
|
|
160
|
+
out = ""
|
|
161
|
+
for _ in range(count):
|
|
162
|
+
out += "\r\x1b[K\n"
|
|
163
|
+
out += ansi_move_up(count)
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ─── 交互式选择器 ──────────────────────────────────────
|
|
168
|
+
def ansi_select(prompt: str, options: list, default_idx: int = 0) -> "str | None":
|
|
169
|
+
"""上下键选择 + Enter 确认 + Esc 取消。
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
prompt: 提示语(显示在选项上方)
|
|
173
|
+
options: 选项列表(字符串)
|
|
174
|
+
default_idx: 初始高亮项索引(默认 0)
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
选中的字符串;按 Esc 取消返回 None。
|
|
178
|
+
"""
|
|
179
|
+
if not options:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
selected = max(0, min(default_idx, len(options) - 1))
|
|
183
|
+
|
|
184
|
+
def render():
|
|
185
|
+
"""重绘整个选择区域:prompt + 选项列表 + 高亮当前"""
|
|
186
|
+
# 第一行先清掉
|
|
187
|
+
sys.stdout.write('\r\x1b[K')
|
|
188
|
+
sys.stdout.write(f" \033[1m{prompt}\033[0m\n")
|
|
189
|
+
for i, opt in enumerate(options):
|
|
190
|
+
sys.stdout.write('\r\x1b[K')
|
|
191
|
+
if i == selected:
|
|
192
|
+
# 选中行:反白 + 箭头
|
|
193
|
+
sys.stdout.write(f" \x1b[7;97m> {opt}\x1b[0m\n")
|
|
194
|
+
else:
|
|
195
|
+
sys.stdout.write(f" {opt}\n")
|
|
196
|
+
# 把光标移回 prompt 行(len(options) + 1:1 行 prompt + N 行选项)。
|
|
197
|
+
# 旧代码用 len(options) 会落到第一选项行,下次 render() 在选项行写新 prompt,
|
|
198
|
+
# 旧 prompt 永远不被清掉 → 屏幕上堆积多个 prompt。
|
|
199
|
+
sys.stdout.write(ansi_move_up(len(options) + 1))
|
|
200
|
+
sys.stdout.flush()
|
|
201
|
+
|
|
202
|
+
render()
|
|
203
|
+
|
|
204
|
+
while True:
|
|
205
|
+
try:
|
|
206
|
+
key = msvcrt.getwch()
|
|
207
|
+
except (OSError, ValueError):
|
|
208
|
+
time.sleep(0.01)
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# Ctrl+C
|
|
212
|
+
if key == '\x03':
|
|
213
|
+
sys.stdout.write(ansi_erase_lines_below(len(options) + 1))
|
|
214
|
+
raise KeyboardInterrupt
|
|
215
|
+
|
|
216
|
+
# 特殊键
|
|
217
|
+
if key == '\x00' or key == '\xe0':
|
|
218
|
+
special = msvcrt.getwch()
|
|
219
|
+
if special == 'H': # 上箭头
|
|
220
|
+
selected = max(0, selected - 1)
|
|
221
|
+
render()
|
|
222
|
+
elif special == 'P': # 下箭头
|
|
223
|
+
selected = min(len(options) - 1, selected + 1)
|
|
224
|
+
render()
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
# Esc 取消
|
|
228
|
+
if key == '\x1b':
|
|
229
|
+
sys.stdout.write(ansi_erase_lines_below(len(options) + 1))
|
|
230
|
+
sys.stdout.write('\r\x1b[K')
|
|
231
|
+
sys.stdout.flush()
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
# Enter 确认
|
|
235
|
+
if key == '\r' or key == '\n':
|
|
236
|
+
# 渲染最终状态(保持高亮在选中行)
|
|
237
|
+
sys.stdout.write(ansi_erase_lines_below(len(options) + 1))
|
|
238
|
+
sys.stdout.write('\r\x1b[K')
|
|
239
|
+
sys.stdout.write(f" \033[1m{prompt}\033[0m \033[32m> {options[selected]}\033[0m\n")
|
|
240
|
+
sys.stdout.flush()
|
|
241
|
+
return options[selected]
|
|
242
|
+
|
|
243
|
+
# 其他键忽略
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ── 主输入函数 ────────────────────────────────────────────
|
|
247
|
+
def ansi_prompt(prompt_str: str = "> ") -> str:
|
|
248
|
+
"""
|
|
249
|
+
自定义输入循环,支持 / 和 @ 补全菜单(ANSI 渲染)。
|
|
250
|
+
返回用户输入的字符串。
|
|
251
|
+
Ctrl+C 抛出 KeyboardInterrupt。
|
|
252
|
+
"""
|
|
253
|
+
# Windows: 清空控制台输入缓冲,避免上次输出污染导致首次按键被吞
|
|
254
|
+
if sys.platform == "win32":
|
|
255
|
+
try:
|
|
256
|
+
import ctypes
|
|
257
|
+
STD_INPUT_HANDLE = -10
|
|
258
|
+
handle = ctypes.windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)
|
|
259
|
+
ctypes.windll.kernel32.FlushConsoleInputBuffer(handle)
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
buf: list = list(prompt_str)
|
|
264
|
+
cursor_pos: int = len(buf)
|
|
265
|
+
prev_line_len: int = len(buf)
|
|
266
|
+
completions: List[Tuple[str, str]] = []
|
|
267
|
+
selected_idx: int = 0
|
|
268
|
+
showing_menu: bool = False
|
|
269
|
+
max_visible: int = compute_max_visible() # 根据终端高度动态算,避免截断
|
|
270
|
+
menu_row_count: int = 0
|
|
271
|
+
max_rows_ever: int = 0 # 追踪渲染过的最大行数,确保清除干净
|
|
272
|
+
# 历史翻看:history_idx = -1 表示不在导航态(停在草稿)
|
|
273
|
+
history_idx: int = -1
|
|
274
|
+
history_draft: str = "" # 进入导航前保存的当前 buffer,便于 ↓ 回到
|
|
275
|
+
|
|
276
|
+
def redraw_line():
|
|
277
|
+
"""重绘当前输入行。
|
|
278
|
+
关键:必须 \r\x1b[K(回行首 + 清行尾),不能只 \r。
|
|
279
|
+
否则本行原本有内容(如上一轮的 ',请重试)' 残留)会被新输入部分覆盖,
|
|
280
|
+
用户看到 '> /show-reasoning,请重试)' 这种新旧混杂的乱象。
|
|
281
|
+
"""
|
|
282
|
+
nonlocal cursor_pos, buf, prev_line_len
|
|
283
|
+
# 回行首 + 清行尾:本行原内容(残留的 ',请重试)' 之类)彻底抹掉
|
|
284
|
+
sys.stdout.write('\r\x1b[K')
|
|
285
|
+
line = ''.join(buf)
|
|
286
|
+
sys.stdout.write(line)
|
|
287
|
+
prev_line_len = len(buf)
|
|
288
|
+
target_col = cursor_pos
|
|
289
|
+
current_col = len(buf)
|
|
290
|
+
if target_col < current_col:
|
|
291
|
+
sys.stdout.write(ansi_move_left(current_col - target_col))
|
|
292
|
+
sys.stdout.flush()
|
|
293
|
+
|
|
294
|
+
def clear_menu_area():
|
|
295
|
+
"""清除菜单区域的所有行,光标回到输入行 cursor_pos 位置。
|
|
296
|
+
只在已有菜单空间内操作(假定 show_menu 已用 \\n 创建了空间),使用 \\x1b[B 避免额外滚动。
|
|
297
|
+
"""
|
|
298
|
+
nonlocal menu_row_count
|
|
299
|
+
if menu_row_count > 0:
|
|
300
|
+
# \x1b[B 到菜单首行(空间已由 show_menu 首次激活时创建)
|
|
301
|
+
sys.stdout.write('\x1b[B')
|
|
302
|
+
for i in range(menu_row_count):
|
|
303
|
+
sys.stdout.write('\r\x1b[K')
|
|
304
|
+
if i < menu_row_count - 1:
|
|
305
|
+
sys.stdout.write('\x1b[B')
|
|
306
|
+
# 上移回输入行
|
|
307
|
+
sys.stdout.write(ansi_move_up(menu_row_count))
|
|
308
|
+
sys.stdout.write('\r')
|
|
309
|
+
sys.stdout.write(ansi_move_right(cursor_pos))
|
|
310
|
+
sys.stdout.flush()
|
|
311
|
+
menu_row_count = 0
|
|
312
|
+
|
|
313
|
+
def hide_menu():
|
|
314
|
+
nonlocal showing_menu, menu_row_count, max_rows_ever, completions, selected_idx
|
|
315
|
+
# 用 menu_row_count 判断是否有菜单需要清除(showing_menu 可能已被 update_completions 置 False)
|
|
316
|
+
if menu_row_count > 0:
|
|
317
|
+
clear_menu_area()
|
|
318
|
+
showing_menu = False
|
|
319
|
+
completions = []
|
|
320
|
+
selected_idx = 0
|
|
321
|
+
max_rows_ever = 0 # 重置,下次激活重新滚动创建空间
|
|
322
|
+
|
|
323
|
+
def show_menu():
|
|
324
|
+
nonlocal showing_menu, menu_row_count, completions, selected_idx, max_rows_ever
|
|
325
|
+
if not completions:
|
|
326
|
+
hide_menu()
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
showing_menu = True
|
|
330
|
+
visible_count = min(len(completions), max_visible)
|
|
331
|
+
start_idx = 0
|
|
332
|
+
if selected_idx >= max_visible:
|
|
333
|
+
start_idx = selected_idx - max_visible + 1
|
|
334
|
+
|
|
335
|
+
# 仅在需要更多空间时滚动(首次激活或匹配项增多)
|
|
336
|
+
if visible_count > max_rows_ever:
|
|
337
|
+
extra = visible_count - max_rows_ever
|
|
338
|
+
for _ in range(extra):
|
|
339
|
+
sys.stdout.write('\n')
|
|
340
|
+
sys.stdout.write(ansi_move_up(extra))
|
|
341
|
+
max_rows_ever = visible_count
|
|
342
|
+
# 菜单已存在:清除旧内容(在已有空间内,用 \x1b[B 避免额外滚动)
|
|
343
|
+
elif menu_row_count > 0:
|
|
344
|
+
sys.stdout.write('\x1b[B')
|
|
345
|
+
for i in range(menu_row_count):
|
|
346
|
+
sys.stdout.write('\r\x1b[K')
|
|
347
|
+
if i < menu_row_count - 1:
|
|
348
|
+
sys.stdout.write('\x1b[B')
|
|
349
|
+
sys.stdout.write(ansi_move_up(menu_row_count))
|
|
350
|
+
sys.stdout.write('\r')
|
|
351
|
+
sys.stdout.write(ansi_move_right(cursor_pos))
|
|
352
|
+
|
|
353
|
+
# 重绘输入行
|
|
354
|
+
sys.stdout.write('\r\x1b[K' + ''.join(buf))
|
|
355
|
+
|
|
356
|
+
# 绘制菜单项(在已有空间内,用 \x1b[B)
|
|
357
|
+
sys.stdout.write('\x1b[B')
|
|
358
|
+
for i in range(visible_count):
|
|
359
|
+
idx = start_idx + i
|
|
360
|
+
if idx >= len(completions):
|
|
361
|
+
break
|
|
362
|
+
name, meta = completions[idx]
|
|
363
|
+
is_selected = (idx == selected_idx)
|
|
364
|
+
if is_selected:
|
|
365
|
+
line = f" {ansi_selected(name)} {ansi_dim(meta)}"
|
|
366
|
+
else:
|
|
367
|
+
line = f" {ansi_white(name)} {ansi_dim(meta)}"
|
|
368
|
+
sys.stdout.write('\r\x1b[K' + line)
|
|
369
|
+
if i < visible_count - 1:
|
|
370
|
+
sys.stdout.write('\x1b[B')
|
|
371
|
+
|
|
372
|
+
# 上移回输入行
|
|
373
|
+
sys.stdout.write(ansi_move_up(visible_count))
|
|
374
|
+
sys.stdout.write('\r')
|
|
375
|
+
sys.stdout.write(ansi_move_right(cursor_pos))
|
|
376
|
+
sys.stdout.flush()
|
|
377
|
+
|
|
378
|
+
menu_row_count = visible_count
|
|
379
|
+
|
|
380
|
+
def update_completions():
|
|
381
|
+
nonlocal completions, showing_menu, selected_idx
|
|
382
|
+
text_before = ''.join(buf[len(prompt_str):cursor_pos])
|
|
383
|
+
|
|
384
|
+
at_ctx = find_at_context(text_before)
|
|
385
|
+
if at_ctx:
|
|
386
|
+
_, partial = at_ctx
|
|
387
|
+
new_comps = get_file_completions(partial)
|
|
388
|
+
if new_comps:
|
|
389
|
+
completions = new_comps
|
|
390
|
+
selected_idx = 0
|
|
391
|
+
showing_menu = True
|
|
392
|
+
return
|
|
393
|
+
else:
|
|
394
|
+
completions = []
|
|
395
|
+
showing_menu = False
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
if text_before.startswith('/'):
|
|
399
|
+
new_comps = get_command_completions(text_before)
|
|
400
|
+
if new_comps:
|
|
401
|
+
completions = new_comps
|
|
402
|
+
selected_idx = 0
|
|
403
|
+
showing_menu = True
|
|
404
|
+
return
|
|
405
|
+
else:
|
|
406
|
+
completions = []
|
|
407
|
+
showing_menu = False
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
completions = []
|
|
411
|
+
showing_menu = False
|
|
412
|
+
|
|
413
|
+
# 打印初始提示符
|
|
414
|
+
sys.stdout.write(prompt_str)
|
|
415
|
+
sys.stdout.flush()
|
|
416
|
+
|
|
417
|
+
while True:
|
|
418
|
+
# 直接阻塞读取 msvcrt.getwch(),不要用 kbhit() + 紧密轮询。
|
|
419
|
+
# 5ms 紧密轮询会和 Windows 中文 IME 冲突,导致 IME 提交字符后
|
|
420
|
+
# 程序仍然读不到 Enter,从而"按回车无反应、卡死"。
|
|
421
|
+
# getwch() 在 Windows 上 Ctrl+C 不会自动抛异常,需靠 \x03 字符判断(已处理)。
|
|
422
|
+
try:
|
|
423
|
+
key = msvcrt.getwch()
|
|
424
|
+
except (OSError, ValueError):
|
|
425
|
+
time.sleep(0.01)
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
# 特殊键(两字节序列)
|
|
429
|
+
if key == '\x00' or key == '\xe0':
|
|
430
|
+
special = msvcrt.getwch()
|
|
431
|
+
if special == 'H': # 上箭头
|
|
432
|
+
if showing_menu and completions:
|
|
433
|
+
selected_idx = max(0, selected_idx - 1)
|
|
434
|
+
show_menu()
|
|
435
|
+
elif HISTORY:
|
|
436
|
+
# 历史翻看:↑ 调到更早一条
|
|
437
|
+
if history_idx == -1:
|
|
438
|
+
# 第一次按 ↑:保存当前 buffer 为草稿
|
|
439
|
+
history_draft = ''.join(buf[len(prompt_str):])
|
|
440
|
+
history_idx = max(0, history_idx - 1) if history_idx != -1 else len(HISTORY) - 1
|
|
441
|
+
recall = HISTORY[history_idx]
|
|
442
|
+
buf = list(prompt_str) + list(recall)
|
|
443
|
+
cursor_pos = len(buf)
|
|
444
|
+
redraw_line()
|
|
445
|
+
continue
|
|
446
|
+
elif special == 'P': # 下箭头
|
|
447
|
+
if showing_menu and completions:
|
|
448
|
+
vis = min(len(completions), max_visible)
|
|
449
|
+
selected_idx = min(vis - 1, selected_idx + 1)
|
|
450
|
+
show_menu()
|
|
451
|
+
elif history_idx != -1:
|
|
452
|
+
# 历史翻看:↓ 调到更新一条(越界则回草稿)
|
|
453
|
+
history_idx += 1
|
|
454
|
+
if history_idx >= len(HISTORY):
|
|
455
|
+
# 越界:恢复草稿
|
|
456
|
+
buf = list(prompt_str) + list(history_draft)
|
|
457
|
+
cursor_pos = len(buf)
|
|
458
|
+
history_idx = -1
|
|
459
|
+
else:
|
|
460
|
+
recall = HISTORY[history_idx]
|
|
461
|
+
buf = list(prompt_str) + list(recall)
|
|
462
|
+
cursor_pos = len(buf)
|
|
463
|
+
redraw_line()
|
|
464
|
+
continue
|
|
465
|
+
elif special == 'K': # 左箭头
|
|
466
|
+
if cursor_pos > len(prompt_str):
|
|
467
|
+
cursor_pos -= 1
|
|
468
|
+
redraw_line()
|
|
469
|
+
continue
|
|
470
|
+
elif special == 'M': # 右箭头
|
|
471
|
+
if cursor_pos < len(buf):
|
|
472
|
+
cursor_pos += 1
|
|
473
|
+
redraw_line()
|
|
474
|
+
continue
|
|
475
|
+
continue
|
|
476
|
+
|
|
477
|
+
# Ctrl+C
|
|
478
|
+
if key == '\x03':
|
|
479
|
+
sys.stdout.write('\r\x1b[K\n')
|
|
480
|
+
raise KeyboardInterrupt
|
|
481
|
+
|
|
482
|
+
# Enter
|
|
483
|
+
if key == '\r' or key == '\n':
|
|
484
|
+
if showing_menu and completions:
|
|
485
|
+
sel_text = completions[selected_idx][0]
|
|
486
|
+
text_before = ''.join(buf[len(prompt_str):cursor_pos])
|
|
487
|
+
at_ctx = find_at_context(text_before)
|
|
488
|
+
if at_ctx:
|
|
489
|
+
start = len(prompt_str) + at_ctx[0]
|
|
490
|
+
elif text_before.startswith('/'):
|
|
491
|
+
start = len(prompt_str)
|
|
492
|
+
else:
|
|
493
|
+
start = len(prompt_str)
|
|
494
|
+
buf[start:cursor_pos] = list(sel_text)
|
|
495
|
+
cursor_pos = start + len(sel_text)
|
|
496
|
+
hide_menu()
|
|
497
|
+
redraw_line()
|
|
498
|
+
else:
|
|
499
|
+
result = ''.join(buf[len(prompt_str):])
|
|
500
|
+
# 入历史(非空 + 不与最后一条重复)
|
|
501
|
+
if result and (not HISTORY or HISTORY[-1] != result):
|
|
502
|
+
HISTORY.append(result)
|
|
503
|
+
if len(HISTORY) > HISTORY_MAX:
|
|
504
|
+
HISTORY.pop(0)
|
|
505
|
+
# 重置历史导航状态
|
|
506
|
+
history_idx = -1
|
|
507
|
+
history_draft = ""
|
|
508
|
+
sys.stdout.write('\n')
|
|
509
|
+
sys.stdout.flush()
|
|
510
|
+
return result
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
# Backspace
|
|
514
|
+
if key == '\x08' or key == '\x7f':
|
|
515
|
+
if cursor_pos > len(prompt_str):
|
|
516
|
+
cursor_pos -= 1
|
|
517
|
+
buf.pop(cursor_pos)
|
|
518
|
+
redraw_line()
|
|
519
|
+
update_completions()
|
|
520
|
+
if showing_menu:
|
|
521
|
+
show_menu()
|
|
522
|
+
else:
|
|
523
|
+
hide_menu()
|
|
524
|
+
continue
|
|
525
|
+
|
|
526
|
+
# Tab - 确认补全
|
|
527
|
+
if key == '\t':
|
|
528
|
+
if showing_menu and completions:
|
|
529
|
+
sel_text = completions[selected_idx][0]
|
|
530
|
+
text_before = ''.join(buf[len(prompt_str):cursor_pos])
|
|
531
|
+
at_ctx = find_at_context(text_before)
|
|
532
|
+
if at_ctx:
|
|
533
|
+
start = len(prompt_str) + at_ctx[0]
|
|
534
|
+
elif text_before.startswith('/'):
|
|
535
|
+
start = len(prompt_str)
|
|
536
|
+
else:
|
|
537
|
+
start = len(prompt_str)
|
|
538
|
+
buf[start:cursor_pos] = list(sel_text)
|
|
539
|
+
cursor_pos = start + len(sel_text)
|
|
540
|
+
hide_menu()
|
|
541
|
+
redraw_line()
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
# Escape - 关闭菜单
|
|
545
|
+
if key == '\x1b':
|
|
546
|
+
if showing_menu:
|
|
547
|
+
hide_menu()
|
|
548
|
+
redraw_line()
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
# 普通可打印字符
|
|
552
|
+
if len(key) == 1 and key.isprintable():
|
|
553
|
+
buf.insert(cursor_pos, key)
|
|
554
|
+
cursor_pos += 1
|
|
555
|
+
redraw_line()
|
|
556
|
+
update_completions()
|
|
557
|
+
if showing_menu:
|
|
558
|
+
show_menu()
|
|
559
|
+
continue
|