nexforge-cli 0.2.1__tar.gz → 0.2.3__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.
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/PKG-INFO +1 -1
- nexforge_cli-0.2.3/doc//345/257/271/346/257/224/345/210/206/346/236/220.md +76 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/pyproject.toml +1 -1
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/screens/chat.py +30 -10
- nexforge_cli-0.2.3/src/nexforge/ui/widgets/apikey_modal.py +84 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/messages.py +139 -134
- nexforge_cli-0.2.1/src/nexforge/ui/widgets/apikey_modal.py +0 -65
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/.gitignore +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/= +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/INSTALL.md +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/README.md +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/agent.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/cost.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/edit.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/err.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/file.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/model.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/ok.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/read.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/running.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/search.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/shell.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/thinking.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/web.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/assets/icons/write.svg +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/config.example.toml +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/devlog/2026-07-07.md +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//344/274/232/350/257/235/347/256/241/347/220/206.md" +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//345/221/275/344/273/244/344/270/216/345/277/253/346/215/267/351/224/256.md" +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//345/256/211/350/243/205/344/270/216/351/205/215/347/275/256.md" +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//345/267/245/345/205/267/345/217/202/350/200/203.md" +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//346/200/273/350/247/210.md" +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//346/235/203/351/231/220/347/263/273/347/273/237.md" +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//350/256/241/345/210/222/346/250/241/345/274/217.md" +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//351/253/230/347/272/247/345/212/237/350/203/275.md" +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/scripts/publish.sh +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/scripts/smoke_provider.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/scripts/smoke_tui.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/__init__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/__main__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/app.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/cli.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/config.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/core/__init__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/core/agent.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/core/jobs.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/core/permissions.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/core/router.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/core/session.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/core/types.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/icons.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/mcp/__init__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/provider/__init__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/provider/balance.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/provider/deepseek.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/skills/loader.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/store/__init__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/store/plan_store.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/store/session_store.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/theme.tcss +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/__init__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/_paths.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/background.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/base.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/edit_file.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/fs_ops.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/git.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/list_dir.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/multi_edit.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/read_file.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/registry.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/search_content.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/search_files.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/shell.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/web.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/tools/write_file.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/__init__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/screens/__init__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/__init__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/banner.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/confirm_modal.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/diff_modal.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/mode_warning.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/multiline_modal.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/multiline_prompt.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/plan_sidebar.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/sidebar.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/statusbar.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/web/__init__.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/web/server.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/src/nexforge/web/static/index.html +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/tests/test_approval.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/tests/test_config.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/tests/test_icons.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/tests/test_m2_tools.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/tests/test_sidebar.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/tests/test_sidebar_actions.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/tests/test_tools.py +0 -0
- {nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/tests/test_tui.py +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# NexForgeCLI vs 其他 AI 编码智能体横向对比
|
|
2
|
+
|
|
3
|
+
## 总览
|
|
4
|
+
|
|
5
|
+
| | **NexForgeCLI** | **Claude Code** | **Codex CLI** | **Cursor** | **Windsurf** | **Aider** |
|
|
6
|
+
|---|---|---|---|---|---|---|
|
|
7
|
+
| **界面** | TUI(Textual)+CLI | 终端 TUI | 终端 CLI(Node) | VS Code 分支 | VS Code 扩展 | 终端 TUI+CLI |
|
|
8
|
+
| **模型** | DeepSeek V4 | Claude 3.5/4 | GPT-4o/o-series | 多模型 | 多模型 | 多模型 |
|
|
9
|
+
| **安装** | `pip install` | `npm install -g` | `npx` | 安装包(~400MB) | VS Code 市场 | `pip install` |
|
|
10
|
+
| **开源** | ✅ MIT | ❌ | ✅ Apache | ❌ | ❌ | ✅ Apache |
|
|
11
|
+
| **跨平台** | Win/Mac/Linux | Mac/Linux(WSL) | Mac/Linux(?) | 全平台 | 全平台 | 全平台 |
|
|
12
|
+
| **大小** | ~95KB | ~50MB npm | ~? | ~400MB | ~500MB | ~3MB |
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 工具能力
|
|
17
|
+
|
|
18
|
+
| | **NexForgeCLI** | **Claude Code** | **Codex CLI** | **Cursor** | **Windsurf** | **Aider** |
|
|
19
|
+
|---|---|---|---|---|---|---|
|
|
20
|
+
| **文件读取** | ✅ 行范围 | ✅ | ✅ | ✅ IDE原生 | ✅ IDE原生 | ✅ |
|
|
21
|
+
| **文件编辑** | ✅ SEARCH/REPLACE | ✅ SEARCH/REPLACE | ✅ | ✅ IDE直接 | ✅ IDE直接 | ✅ SEARCH/REPLACE |
|
|
22
|
+
| **批量编辑** | ✅ multi_edit | — | — | ✅ 多光标 | ✅ 多光标 | — |
|
|
23
|
+
| **Shell** | ✅ 超时+危险拦截 | ✅ | ✅ | ✅ 集成终端 | ✅ 集成终端 | ✅ |
|
|
24
|
+
| **后台任务** | ✅ run_background | — | — | ✅ 终端多标签 | ✅ 终端多标签 | — |
|
|
25
|
+
| **Web 抓取** | ✅ web_fetch | — | — | — | — | ❌(靠 curl) |
|
|
26
|
+
| **文件搜索** | ✅ grep+文件名 | ✅ | ✅ | ✅ VS搜索 | ✅ VS搜索 | ❌(靠 shell) |
|
|
27
|
+
| **Git** | ✅ 三状态+可选commit | ✅ 完整 | ✅ 完整 | ✅ 内建VCS | ✅ 内建VCS | ✅ 完整 |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 权限与安全
|
|
32
|
+
|
|
33
|
+
| | **NexForgeCLI** | **Claude Code** | **Codex CLI** | **Cursor** | **Aider** |
|
|
34
|
+
|---|---|---|---|---|---|
|
|
35
|
+
| **权限分级** | 🛡safe/⚡supervised/🔥auto | 逐命令审批(Y/n) | 工具级审批 | 审批确认 | `--yes`全跳过 |
|
|
36
|
+
| **危险命令拦截** | ✅ 内置黑名单(rm -rf /等) | ⚠ Shell警告 | — | ✅ IDE沙箱 | ❌ |
|
|
37
|
+
| **死循环检测** | ✅ 连续3次同工具失败→注入提示 | — | — | — | — |
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 会话与上下文
|
|
42
|
+
|
|
43
|
+
| | **NexForgeCLI** | **Claude Code** | **Codex CLI** | **Aider** |
|
|
44
|
+
|---|---|---|---|---|
|
|
45
|
+
| **会话保存** | ✅ 自动保存MD文件 | ✅ .claude/sessions/ | ✅ .codex/sessions/ | ✅ .md文件 |
|
|
46
|
+
| **会话管理** | ✅ 侧边栏:新建/切/删/改名 | ✅ ls/resume命令 | — | `--restore` |
|
|
47
|
+
| **会话恢复** | ✅ 启动自动恢复最近 | ✅ 可恢复 | ✅ 可恢复 | ✅ 可恢复 |
|
|
48
|
+
| **上下文窗口** | **1M tokens**(DeepSeek V4) | 200K(Claude) | 128K(GPT-4o) | 模型决定 |
|
|
49
|
+
| **计划模式** | ✅ /plan只读讨论 | — | — | — |
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 模型路由与优化
|
|
54
|
+
|
|
55
|
+
| | **NexForgeCLI** | **Claude Code** | **Codex CLI** | **Cursor** | **Aider** |
|
|
56
|
+
|---|---|---|---|---|---|
|
|
57
|
+
| **自动路由** | ✅ Flash→Pro(关键词+上下文+失败次数) | ❌ | ❌ | ❌ | ❌ |
|
|
58
|
+
| **思考模式** | ✅ thinking流式+可折叠面板 | ✅ 扩展思考 | ✅ o-series推理 | ❌ | ❌ |
|
|
59
|
+
| **KV Cache计费** | ✅ 缓存命中/未命中拆分 | — | — | — | — |
|
|
60
|
+
| **采样参数** | ✅ thinking=off时可用 | — | — | — | — |
|
|
61
|
+
| **JSON模式** | ✅ /json切换 | — | — | — | — |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 独有亮点
|
|
66
|
+
|
|
67
|
+
1. **后台任务管理** —— 启动/监控/杀后台进程(`run_background`等5个工具)
|
|
68
|
+
2. **自动路由** —— 客户端智能判断,小任务用 Flash 省钱,大任务自动升 Pro
|
|
69
|
+
3. **死循环保护** —— 同一工具连续失败3次自动停止+提示
|
|
70
|
+
4. **三级权限** —— safe/supervised/auto 允许用户按场景自由控制
|
|
71
|
+
5. **DeepSeek KV Cache 精确计费** —— 区分缓存命中/未命中,费用更准确
|
|
72
|
+
6. **自建 Web 面板** —— 零外部依赖,浏览器管理会话/计划/配置
|
|
73
|
+
7. **计划模式** —— `/plan` 只读讨论,先规划再执行
|
|
74
|
+
8. **Markdown 会话** —— 人类可读的会话文件,可直接编辑
|
|
75
|
+
9. **跨 Agent 协作** —— `/import` 读取其他智能体的项目数据
|
|
76
|
+
10. **轻量级** —— pip 包仅 95KB,无 Node.js 依赖
|
|
@@ -79,6 +79,7 @@ class ChatScreen(Screen):
|
|
|
79
79
|
("f2", "multiline_input", "多行"),
|
|
80
80
|
("f4", "copy_last", "复制"),
|
|
81
81
|
("f3", "external_edit", "编辑器"),
|
|
82
|
+
("ctrl+c", "copy_last", "复制"),
|
|
82
83
|
]
|
|
83
84
|
|
|
84
85
|
def __init__(
|
|
@@ -99,6 +100,7 @@ class ChatScreen(Screen):
|
|
|
99
100
|
self._session_name: str = ""
|
|
100
101
|
self._follow: bool = True # 流式时自动跟底,用户上滚后暂停
|
|
101
102
|
self._scroll_pending: bool = False # 滚动节流:同一刷新周期只排一次
|
|
103
|
+
self._scroll_safety_active: bool = False # 安全定时器防重
|
|
102
104
|
self._agent_running: bool = False
|
|
103
105
|
|
|
104
106
|
@property
|
|
@@ -128,16 +130,18 @@ class ChatScreen(Screen):
|
|
|
128
130
|
# 加载技能
|
|
129
131
|
self._skills = discover_skills(Path.cwd())
|
|
130
132
|
if not self.config.resolve_api_key():
|
|
131
|
-
# 弹出 API Key 输入面板
|
|
132
|
-
|
|
133
|
-
key = await self.app.push_screen_wait(ApiKeyModal())
|
|
133
|
+
# 弹出 API Key 输入面板(延迟到屏幕完挂载后)
|
|
134
|
+
def _on_key(key: str | None) -> None:
|
|
134
135
|
if key:
|
|
135
136
|
from nexforge.config import save_api_key
|
|
136
137
|
save_api_key(key)
|
|
137
138
|
self.config.provider.api_key = key
|
|
138
139
|
self._init_provider(key)
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
self._refresh_status()
|
|
141
|
+
self._note("✅ API Key 已保存并验证通过!", scroll=True)
|
|
142
|
+
else:
|
|
143
|
+
self._note("⚠ 跳过 API Key 配置,稍后可用 /key 设置。", scroll=True)
|
|
144
|
+
self.set_timer(0.1, lambda: self.app.push_screen(ApiKeyModal(), callback=_on_key))
|
|
141
145
|
else:
|
|
142
146
|
self._init_provider()
|
|
143
147
|
self._refresh_status()
|
|
@@ -317,9 +321,6 @@ class ChatScreen(Screen):
|
|
|
317
321
|
else:
|
|
318
322
|
self._note(f"未知命令:{text}(/help 查看)", error=True)
|
|
319
323
|
self._refresh_status()
|
|
320
|
-
# 延迟滚动:等 SystemNote 被 call_later 挂载完成后再滚底
|
|
321
|
-
log = self._log
|
|
322
|
-
self.set_timer(0.15, lambda: log.scroll_end(animate=False))
|
|
323
324
|
|
|
324
325
|
# ---- Agent 事件流 -----------------------------------------------------
|
|
325
326
|
|
|
@@ -399,9 +400,19 @@ class ChatScreen(Screen):
|
|
|
399
400
|
|
|
400
401
|
def _do() -> None:
|
|
401
402
|
self._scroll_pending = False
|
|
403
|
+
self._scroll_safety_active = False
|
|
402
404
|
log.scroll_end(animate=False)
|
|
403
405
|
|
|
404
406
|
self.call_after_refresh(_do)
|
|
407
|
+
# 安全超时:如果回调500ms后仍未执行(可能因widget移除被丢弃),强制复位
|
|
408
|
+
if not self._scroll_safety_active:
|
|
409
|
+
self._scroll_safety_active = True
|
|
410
|
+
self.set_timer(0.5, self._reset_scroll_safety)
|
|
411
|
+
|
|
412
|
+
def _reset_scroll_safety(self) -> None:
|
|
413
|
+
"""安全定时器回调:强制复位 flags。"""
|
|
414
|
+
self._scroll_pending = False
|
|
415
|
+
self._scroll_safety_active = False
|
|
405
416
|
|
|
406
417
|
def _set_follow(self, on: bool) -> None:
|
|
407
418
|
"""on=True 时强制滚到底(新回合/回底键);on=False 不做事。
|
|
@@ -413,6 +424,15 @@ class ChatScreen(Screen):
|
|
|
413
424
|
self.call_after_refresh(lambda: log.scroll_end(animate=False))
|
|
414
425
|
except Exception:
|
|
415
426
|
pass
|
|
427
|
+
"""on=True 时强制滚到底(新回合/回底键);on=False 不做事。
|
|
428
|
+
不再用 anchor(),避免与鼠标拖动拉锄导致卡死。"""
|
|
429
|
+
self._follow = on
|
|
430
|
+
if on:
|
|
431
|
+
try:
|
|
432
|
+
log = self._log
|
|
433
|
+
self.call_after_refresh(lambda: log.scroll_end(animate=False))
|
|
434
|
+
except Exception:
|
|
435
|
+
pass
|
|
416
436
|
|
|
417
437
|
def _note(self, text: str, *, error: bool = False, scroll: bool = False) -> None:
|
|
418
438
|
if scroll:
|
|
@@ -540,8 +560,8 @@ class ChatScreen(Screen):
|
|
|
540
560
|
self.agent = AgentLoop(
|
|
541
561
|
self.config, self.provider, self.session, approve=self._ask_approve
|
|
542
562
|
)
|
|
543
|
-
except
|
|
544
|
-
self._note(
|
|
563
|
+
except Exception as exc:
|
|
564
|
+
self._note(f"初始化失败:{exc}", error=True)
|
|
545
565
|
|
|
546
566
|
def _auto_fetch_balance(self) -> None:
|
|
547
567
|
"""启动时自动查询余额(不阻塞)。"""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""API Key 输入面板 —— 首次使用或 /key 无参时弹出。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Input, Label, Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiKeyModal(ModalScreen[str | None]):
|
|
12
|
+
DEFAULT_CSS = """
|
|
13
|
+
ApiKeyModal { align: center middle; }
|
|
14
|
+
ApiKeyModal > Vertical {
|
|
15
|
+
width: 55; background: #1a1a24;
|
|
16
|
+
border: round #6D5EF5; padding: 1 2;
|
|
17
|
+
}
|
|
18
|
+
#apikey-title { color: #22D3EE; text-style: bold; margin-bottom: 1; }
|
|
19
|
+
#apikey-body { height: auto; margin-bottom: 1; }
|
|
20
|
+
#apikey-input { margin-bottom: 1; }
|
|
21
|
+
#apikey-status { height: auto; margin-bottom: 1; }
|
|
22
|
+
#apikey-buttons { height: auto; align: center bottom; }
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
super().__init__()
|
|
27
|
+
self._verified = False
|
|
28
|
+
self._key = ""
|
|
29
|
+
|
|
30
|
+
def compose(self) -> ComposeResult:
|
|
31
|
+
with Vertical():
|
|
32
|
+
yield Label("配置 DeepSeek API Key", id="apikey-title")
|
|
33
|
+
yield Label(
|
|
34
|
+
"请输入你的 DeepSeek API Key(以 sk- 开头)。\n"
|
|
35
|
+
"Key 将安全保存在本地凭据文件中,不会上传。\n\n"
|
|
36
|
+
"[dim]获取地址: https://platform.deepseek.com/api_keys[/]",
|
|
37
|
+
id="apikey-body",
|
|
38
|
+
)
|
|
39
|
+
yield Input(placeholder="sk-…", id="apikey-input", password=True)
|
|
40
|
+
yield Static("", id="apikey-status")
|
|
41
|
+
with Vertical(id="apikey-buttons"):
|
|
42
|
+
yield Button("验证 Key", variant="primary", id="btn-apikey-verify")
|
|
43
|
+
yield Button("保存并开始", variant="success", id="btn-apikey-save", disabled=True)
|
|
44
|
+
yield Button("跳过(稍后配置)", id="btn-apikey-skip")
|
|
45
|
+
|
|
46
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
47
|
+
bid = event.button.id
|
|
48
|
+
if bid == "btn-apikey-verify":
|
|
49
|
+
key = self.query_one("#apikey-input", Input).value.strip()
|
|
50
|
+
status = self.query_one("#apikey-status", Static)
|
|
51
|
+
if not key.startswith("sk-"):
|
|
52
|
+
status.update("[red]❌ Key 必须以 sk- 开头。[/]")
|
|
53
|
+
return
|
|
54
|
+
status.update("[yellow]⏳ 正在验证 Key…[/]")
|
|
55
|
+
import asyncio
|
|
56
|
+
asyncio.create_task(self._do_verify(key, status))
|
|
57
|
+
elif bid == "btn-apikey-save":
|
|
58
|
+
if self._verified and self._key:
|
|
59
|
+
self.dismiss(self._key)
|
|
60
|
+
else:
|
|
61
|
+
self.query_one("#apikey-status", Static).update(
|
|
62
|
+
"[red]请先点击\"验证 Key\"通过验证。[/]"
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
self.dismiss(None)
|
|
66
|
+
|
|
67
|
+
async def _do_verify(self, key: str, status: Static) -> None:
|
|
68
|
+
try:
|
|
69
|
+
from openai import AsyncOpenAI
|
|
70
|
+
client = AsyncOpenAI(api_key=key, base_url="https://api.deepseek.com", timeout=10)
|
|
71
|
+
resp = await client.models.list()
|
|
72
|
+
await client.close()
|
|
73
|
+
ok = len(resp.data) > 0
|
|
74
|
+
except Exception:
|
|
75
|
+
ok = False
|
|
76
|
+
if ok:
|
|
77
|
+
self._verified = True
|
|
78
|
+
self._key = key
|
|
79
|
+
status.update("[green]✅ Key 验证通过!点击\"保存并开始\"即可。[/]")
|
|
80
|
+
self.query_one("#btn-apikey-save", Button).disabled = False
|
|
81
|
+
else:
|
|
82
|
+
self._verified = False
|
|
83
|
+
status.update("[red]❌ Key 验证失败!请检查 Key 和网络。[/]")
|
|
84
|
+
self.query_one("#btn-apikey-save", Button).disabled = True
|
|
@@ -1,134 +1,139 @@
|
|
|
1
|
-
"""消息组件:用户消息、助手回合(思考面板 + Markdown 正文)、工具卡片、系统提示。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from rich.markup import escape
|
|
6
|
-
from textual.app import ComposeResult
|
|
7
|
-
from textual.containers import Vertical
|
|
8
|
-
from textual.widgets import Collapsible, Static
|
|
9
|
-
from rich.markdown import Markdown as RichMarkdown
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
self.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if self.
|
|
104
|
-
self.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
1
|
+
"""消息组件:用户消息、助手回合(思考面板 + Markdown 正文)、工具卡片、系统提示。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.markup import escape
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.widgets import Collapsible, Static
|
|
9
|
+
from rich.markdown import Markdown as RichMarkdown
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _esc(text: str) -> str:
|
|
13
|
+
"""Rich markup safe escape: [ -> \\[ , ] -> \\]."""
|
|
14
|
+
return escape(text).replace("]", r"\]")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SystemNote(Static):
|
|
18
|
+
"""系统 / 错误提示行。"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, text: str, *, error: bool = False) -> None:
|
|
21
|
+
from nexforge.icons import icon
|
|
22
|
+
|
|
23
|
+
sym = icon("err") if error else icon("running")
|
|
24
|
+
color = "red" if error else "#22D3EE"
|
|
25
|
+
super().__init__(f"[{color}]{sym}[/] {_esc(text)}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UserMessage(Static):
|
|
29
|
+
def __init__(self, text: str) -> None:
|
|
30
|
+
from nexforge.icons import icon
|
|
31
|
+
|
|
32
|
+
super().__init__(f"[b #6D5EF5]{icon('user')}[/] {_esc(text)}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolCard(Static):
|
|
36
|
+
"""一次工具调用:执行中 → 完成(✔/✖)。"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, preview: str, icon_name: str = "running") -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
self._preview = preview
|
|
41
|
+
self._icon = icon_name
|
|
42
|
+
|
|
43
|
+
def on_mount(self) -> None:
|
|
44
|
+
from nexforge.icons import icon
|
|
45
|
+
|
|
46
|
+
ic = icon(self._icon)
|
|
47
|
+
self.update(f"[#22D3EE]{ic}[/] {_esc(self._preview)} [dim]执行中…[/]")
|
|
48
|
+
|
|
49
|
+
def set_result(self, ok: bool, summary: str) -> None:
|
|
50
|
+
from nexforge.icons import icon
|
|
51
|
+
|
|
52
|
+
mark = icon("ok") if ok else icon("err")
|
|
53
|
+
color = "green" if ok else "red"
|
|
54
|
+
self.update(
|
|
55
|
+
f"[{color}]{mark}[/] [#22D3EE]{_esc(self._preview)}[/] "
|
|
56
|
+
f"[dim]{_esc(summary)}[/]"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AssistantTurn(Vertical):
|
|
61
|
+
"""一轮助手输出:可折叠思考面板 + Markdown 正文。"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, initial_content: str = "") -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self._reasoning = ""
|
|
66
|
+
self._content = initial_content
|
|
67
|
+
self._collapsed_once = False
|
|
68
|
+
self._body: Static | None = None # 思考面板引用缓存
|
|
69
|
+
self._txt: Static | None = None # 正文引用缓存
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
import re
|
|
73
|
+
from nexforge.icons import icon
|
|
74
|
+
from rich.markup import escape as rich_escape
|
|
75
|
+
|
|
76
|
+
with Collapsible(title=f"{icon('thinking')} 思考", collapsed=False, id="think"):
|
|
77
|
+
yield Static("", id="reasoning-body")
|
|
78
|
+
# 流式时用 Static 快速渲染(避免每 token 全量 Markdown 解析变慢)
|
|
79
|
+
init = re.sub(r"\n{3,}", "\n\n", self._content).lstrip("\n")
|
|
80
|
+
yield Static(rich_escape(init), id="content-txt")
|
|
81
|
+
|
|
82
|
+
def on_mount(self) -> None:
|
|
83
|
+
# 未产生思考内容前隐藏思考面板
|
|
84
|
+
self.query_one("#think", Collapsible).display = False
|
|
85
|
+
|
|
86
|
+
def add_reasoning(self, text: str) -> None:
|
|
87
|
+
self._reasoning += text
|
|
88
|
+
panel = self.query_one("#think", Collapsible)
|
|
89
|
+
if not panel.display:
|
|
90
|
+
panel.display = True
|
|
91
|
+
if self._body is None:
|
|
92
|
+
self._body = self.query_one("#reasoning-body", Static)
|
|
93
|
+
self._body.update(self._reasoning)
|
|
94
|
+
# 思考面板的 Contents 有 overflow-y:auto,每次更新后滚到底
|
|
95
|
+
try:
|
|
96
|
+
contents = panel.query_one("Contents")
|
|
97
|
+
contents.scroll_end(animate=False)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def add_content(self, text: str) -> None:
|
|
102
|
+
# 正文开始 → 折叠思考面板
|
|
103
|
+
if not self._collapsed_once and self._reasoning:
|
|
104
|
+
self.query_one("#think", Collapsible).collapsed = True
|
|
105
|
+
self._collapsed_once = True
|
|
106
|
+
self._content += text
|
|
107
|
+
from rich.markup import escape as rich_escape
|
|
108
|
+
if self._txt is None:
|
|
109
|
+
self._txt = self.query_one("#content-txt", Static)
|
|
110
|
+
self._txt.update(rich_escape(self._content))
|
|
111
|
+
|
|
112
|
+
def finalize_content(self) -> None:
|
|
113
|
+
"""回合结束:用 Rich Markdown 渲染到 Static(无 Textual Markdown bug)。"""
|
|
114
|
+
import re
|
|
115
|
+
try:
|
|
116
|
+
self.query_one("#content-txt", Static).remove()
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
try:
|
|
120
|
+
self.query_one("#think", Collapsible).remove()
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
normalized = re.sub(r"\n{3,}", "\n\n", self._content).strip("\n")
|
|
124
|
+
rich_md = RichMarkdown(normalized)
|
|
125
|
+
self.mount(Static(rich_md, id="content-rt"))
|
|
126
|
+
self._scroll_to_end()
|
|
127
|
+
|
|
128
|
+
def _scroll_to_end(self) -> None:
|
|
129
|
+
"""精确找到 #chat-log 并滚到底。"""
|
|
130
|
+
try:
|
|
131
|
+
from textual.containers import VerticalScroll
|
|
132
|
+
log = self.app.query_one("#chat-log", VerticalScroll)
|
|
133
|
+
log.scroll_end(animate=False)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def is_empty(self) -> bool:
|
|
139
|
+
return not self._reasoning and not self._content
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
"""API Key 输入面板 —— 首次使用或 /key 无参时弹出。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from textual.app import ComposeResult
|
|
6
|
-
from textual.containers import Vertical
|
|
7
|
-
from textual.screen import ModalScreen
|
|
8
|
-
from textual.widgets import Button, Input, Label
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ApiKeyModal(ModalScreen[str | None]):
|
|
12
|
-
DEFAULT_CSS = """
|
|
13
|
-
ApiKeyModal {
|
|
14
|
-
align: center middle;
|
|
15
|
-
}
|
|
16
|
-
ApiKeyModal > Vertical {
|
|
17
|
-
width: 55;
|
|
18
|
-
background: #1a1a24;
|
|
19
|
-
border: round #6D5EF5;
|
|
20
|
-
padding: 1 2;
|
|
21
|
-
}
|
|
22
|
-
#apikey-title {
|
|
23
|
-
color: #22D3EE;
|
|
24
|
-
text-style: bold;
|
|
25
|
-
margin-bottom: 1;
|
|
26
|
-
}
|
|
27
|
-
#apikey-body {
|
|
28
|
-
height: auto;
|
|
29
|
-
margin-bottom: 1;
|
|
30
|
-
}
|
|
31
|
-
#apikey-input {
|
|
32
|
-
margin-bottom: 1;
|
|
33
|
-
}
|
|
34
|
-
#apikey-buttons {
|
|
35
|
-
height: auto;
|
|
36
|
-
align: center bottom;
|
|
37
|
-
}
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
def compose(self) -> ComposeResult:
|
|
41
|
-
with Vertical():
|
|
42
|
-
yield Label("配置 DeepSeek API Key", id="apikey-title")
|
|
43
|
-
yield Label(
|
|
44
|
-
"请输入你的 DeepSeek API Key(以 sk- 开头)。\n"
|
|
45
|
-
"Key 将安全保存在本地凭据文件中,不会上传。\n\n"
|
|
46
|
-
"[dim]获取地址: https://platform.deepseek.com/api_keys[/]",
|
|
47
|
-
id="apikey-body",
|
|
48
|
-
)
|
|
49
|
-
yield Input(placeholder="sk-…", id="apikey-input", password=True)
|
|
50
|
-
with Vertical(id="apikey-buttons"):
|
|
51
|
-
yield Button("保存并开始", variant="primary", id="btn-apikey-save")
|
|
52
|
-
yield Button("跳过(稍后配置)", id="btn-apikey-skip")
|
|
53
|
-
|
|
54
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
55
|
-
if event.button.id == "btn-apikey-save":
|
|
56
|
-
key = self.query_one("#apikey-input", Input).value.strip()
|
|
57
|
-
if key.startswith("sk-"):
|
|
58
|
-
self.dismiss(key)
|
|
59
|
-
else:
|
|
60
|
-
self.query_one("#apikey-body", Label).update(
|
|
61
|
-
"[red]Key 必须以 sk- 开头,请检查后重试。[/]\n\n"
|
|
62
|
-
"[dim]获取地址: https://platform.deepseek.com/api_keys[/]"
|
|
63
|
-
)
|
|
64
|
-
else:
|
|
65
|
-
self.dismiss(None)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//344/274/232/350/257/235/347/256/241/347/220/206.md"
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//345/267/245/345/205/267/345/217/202/350/200/203.md"
RENAMED
|
File without changes
|
|
File without changes
|
{nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//346/235/203/351/231/220/347/263/273/347/273/237.md"
RENAMED
|
File without changes
|
{nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//350/256/241/345/210/222/346/250/241/345/274/217.md"
RENAMED
|
File without changes
|
{nexforge_cli-0.2.1 → nexforge_cli-0.2.3}/doc//351/253/230/347/272/247/345/212/237/350/203/275.md"
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|