nexforge-cli 0.1.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.
- nexforge/__init__.py +7 -0
- nexforge/__main__.py +6 -0
- nexforge/app.py +38 -0
- nexforge/assets/icons/agent.svg +13 -0
- nexforge/assets/icons/cost.svg +5 -0
- nexforge/assets/icons/edit.svg +4 -0
- nexforge/assets/icons/err.svg +5 -0
- nexforge/assets/icons/file.svg +5 -0
- nexforge/assets/icons/model.svg +7 -0
- nexforge/assets/icons/ok.svg +4 -0
- nexforge/assets/icons/read.svg +8 -0
- nexforge/assets/icons/running.svg +5 -0
- nexforge/assets/icons/search.svg +5 -0
- nexforge/assets/icons/shell.svg +5 -0
- nexforge/assets/icons/thinking.svg +5 -0
- nexforge/assets/icons/web.svg +6 -0
- nexforge/assets/icons/write.svg +4 -0
- nexforge/cli.py +159 -0
- nexforge/config.py +192 -0
- nexforge/core/__init__.py +1 -0
- nexforge/core/agent.py +244 -0
- nexforge/core/jobs.py +97 -0
- nexforge/core/permissions.py +73 -0
- nexforge/core/router.py +62 -0
- nexforge/core/session.py +55 -0
- nexforge/core/types.py +103 -0
- nexforge/icons.py +173 -0
- nexforge/mcp/__init__.py +168 -0
- nexforge/provider/__init__.py +1 -0
- nexforge/provider/balance.py +37 -0
- nexforge/provider/deepseek.py +204 -0
- nexforge/skills/loader.py +102 -0
- nexforge/store/__init__.py +1 -0
- nexforge/store/plan_store.py +112 -0
- nexforge/store/session_store.py +206 -0
- nexforge/theme.tcss +128 -0
- nexforge/tools/__init__.py +6 -0
- nexforge/tools/_paths.py +22 -0
- nexforge/tools/background.py +111 -0
- nexforge/tools/base.py +56 -0
- nexforge/tools/edit_file.py +73 -0
- nexforge/tools/fs_ops.py +125 -0
- nexforge/tools/git.py +89 -0
- nexforge/tools/list_dir.py +49 -0
- nexforge/tools/multi_edit.py +84 -0
- nexforge/tools/read_file.py +51 -0
- nexforge/tools/registry.py +94 -0
- nexforge/tools/search_content.py +90 -0
- nexforge/tools/search_files.py +48 -0
- nexforge/tools/shell.py +60 -0
- nexforge/tools/web.py +40 -0
- nexforge/tools/write_file.py +40 -0
- nexforge/ui/__init__.py +1 -0
- nexforge/ui/screens/__init__.py +1 -0
- nexforge/ui/screens/chat.py +825 -0
- nexforge/ui/widgets/__init__.py +1 -0
- nexforge/ui/widgets/apikey_modal.py +65 -0
- nexforge/ui/widgets/banner.py +18 -0
- nexforge/ui/widgets/confirm_modal.py +58 -0
- nexforge/ui/widgets/diff_modal.py +67 -0
- nexforge/ui/widgets/messages.py +132 -0
- nexforge/ui/widgets/mode_warning.py +57 -0
- nexforge/ui/widgets/multiline_modal.py +59 -0
- nexforge/ui/widgets/multiline_prompt.py +25 -0
- nexforge/ui/widgets/plan_sidebar.py +89 -0
- nexforge/ui/widgets/sidebar.py +99 -0
- nexforge/ui/widgets/statusbar.py +70 -0
- nexforge/web/__init__.py +0 -0
- nexforge/web/server.py +177 -0
- nexforge/web/static/index.html +123 -0
- nexforge_cli-0.1.0.dist-info/METADATA +168 -0
- nexforge_cli-0.1.0.dist-info/RECORD +74 -0
- nexforge_cli-0.1.0.dist-info/WHEEL +4 -0
- nexforge_cli-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
"""ChatScreen —— NexForgeCLI 主交互界面。
|
|
2
|
+
|
|
3
|
+
流程:用户输入 → 挂载 UserMessage → 后台 worker 跑 AgentLoop →
|
|
4
|
+
按事件流实时更新思考面板 / 正文 / 工具卡片 / 状态栏。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from textual import work
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
14
|
+
from textual.screen import Screen
|
|
15
|
+
from textual.widgets import Button, Label, Static
|
|
16
|
+
from nexforge.ui.widgets.multiline_prompt import MultilinePrompt
|
|
17
|
+
import asyncio
|
|
18
|
+
|
|
19
|
+
from nexforge.config import NexForgeConfig
|
|
20
|
+
from nexforge.core.agent import AgentEventKind, AgentLoop
|
|
21
|
+
from nexforge.core.session import Session
|
|
22
|
+
from nexforge.provider.deepseek import DeepSeekProvider, ProviderError
|
|
23
|
+
from nexforge.ui.widgets.banner import Banner
|
|
24
|
+
from nexforge.ui.widgets.apikey_modal import ApiKeyModal
|
|
25
|
+
from nexforge.ui.widgets.confirm_modal import ConfirmModal
|
|
26
|
+
from nexforge.ui.widgets.diff_modal import DiffApprovalModal
|
|
27
|
+
from nexforge.ui.widgets.mode_warning import ModeWarningModal
|
|
28
|
+
from nexforge.ui.widgets.multiline_modal import MultilineInputModal
|
|
29
|
+
from nexforge.ui.widgets.messages import (
|
|
30
|
+
AssistantTurn,
|
|
31
|
+
SystemNote,
|
|
32
|
+
ToolCard,
|
|
33
|
+
UserMessage,
|
|
34
|
+
)
|
|
35
|
+
from nexforge.ui.widgets.statusbar import StatusBar
|
|
36
|
+
from nexforge.ui.widgets.sidebar import Sidebar
|
|
37
|
+
from nexforge.ui.widgets.plan_sidebar import PlanSidebar
|
|
38
|
+
from nexforge.skills.loader import Skill, discover_skills, skill_index_str
|
|
39
|
+
from nexforge.store.session_store import _next_id, _today_dir, list_sessions, save_session
|
|
40
|
+
import nexforge.store.plan_store as plan_store
|
|
41
|
+
|
|
42
|
+
HELP = """\
|
|
43
|
+
/help 显示本帮助
|
|
44
|
+
/key sk-... 保存 API Key(只写一次,后续自动生效)
|
|
45
|
+
/model flash|pro|auto 切换模型档位
|
|
46
|
+
/think on|off 开关思考模式
|
|
47
|
+
/mode review|auto 审批模式(review 每次审批, auto 直接执行)
|
|
48
|
+
/plan 切换计划模式(只读讨论,不写文件)
|
|
49
|
+
/json 切换 JSON 输出模式
|
|
50
|
+
/balance 查询 DeepSeek 账户余额
|
|
51
|
+
/perm <safe|supervised|auto> 切换权限级别
|
|
52
|
+
safe 🛡 审核模式:全部写/删/Shell 需审批
|
|
53
|
+
supervised ⚡ 半自动:写文件自动,删/Shell需审批(默认)
|
|
54
|
+
auto 🔥 全自动:全部直接执行
|
|
55
|
+
/skill <name> 执行指定技能(先 /skills 列清单)
|
|
56
|
+
/skills 列出可用的 SKILL.md 技能
|
|
57
|
+
/save [name] 保存当前对话并命名
|
|
58
|
+
/rename <name> 重命名当前会话
|
|
59
|
+
/sessions 列出历史会话
|
|
60
|
+
/cost 显示累计费用
|
|
61
|
+
/clear 清空当前会话
|
|
62
|
+
/quit 退出
|
|
63
|
+
/web start|stop Web 管理面板
|
|
64
|
+
快捷键:Esc 退出(确认) · Ctrl+L 清屏 · F1 帮助"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ChatScreen(Screen):
|
|
68
|
+
BINDINGS = [
|
|
69
|
+
("escape", "quit_with_confirm", "退出"),
|
|
70
|
+
("ctrl+l", "clear_log", "清屏"),
|
|
71
|
+
("pageup", "scroll_up", "上滚"),
|
|
72
|
+
("pagedown", "scroll_down", "下滚"),
|
|
73
|
+
("up", "line_up", "上一行"),
|
|
74
|
+
("down", "line_down", "下一行"),
|
|
75
|
+
("end", "scroll_bottom", "回底"),
|
|
76
|
+
("ctrl+s", "toggle_sidebar", "会话"),
|
|
77
|
+
("ctrl+b", "toggle_plan", "计划"),
|
|
78
|
+
("f1", "show_help", "帮助"),
|
|
79
|
+
("f2", "multiline_input", "多行"),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self, config: NexForgeConfig, resume_session_id: str | None = None
|
|
84
|
+
) -> None:
|
|
85
|
+
super().__init__()
|
|
86
|
+
self.config = config
|
|
87
|
+
self.session = Session()
|
|
88
|
+
self.provider: DeepSeekProvider | None = None
|
|
89
|
+
self.agent: AgentLoop | None = None
|
|
90
|
+
self._pending_card: ToolCard | None = None
|
|
91
|
+
self._current_turn: AssistantTurn | None = None
|
|
92
|
+
self._skills: list[Skill] = []
|
|
93
|
+
self._sidebar: Sidebar | None = None
|
|
94
|
+
self._plan_sidebar: PlanSidebar | None = None
|
|
95
|
+
self._balance: str = ""
|
|
96
|
+
self._session_id: str = ""
|
|
97
|
+
self._session_name: str = ""
|
|
98
|
+
self._follow: bool = True # 流式时自动跟底,用户上滚后暂停
|
|
99
|
+
self._scroll_pending: bool = False # 滚动节流:同一刷新周期只排一次
|
|
100
|
+
self._agent_running: bool = False
|
|
101
|
+
|
|
102
|
+
def compose(self) -> ComposeResult:
|
|
103
|
+
yield Banner(id="banner")
|
|
104
|
+
with Horizontal():
|
|
105
|
+
with Vertical(id="main-area"):
|
|
106
|
+
yield VerticalScroll(id="chat-log")
|
|
107
|
+
yield StatusBar(id="status")
|
|
108
|
+
yield MultilinePrompt(id="prompt")
|
|
109
|
+
yield Label("Enter 发送 · F1 帮助 · F2 多行 · Esc 停止/退出 · /help 命令", id="prompt-hint")
|
|
110
|
+
self._sidebar = Sidebar(id="sidebar")
|
|
111
|
+
yield self._sidebar
|
|
112
|
+
self._plan_sidebar = PlanSidebar(id="plan-sidebar")
|
|
113
|
+
yield self._plan_sidebar
|
|
114
|
+
self._sidebar.display = False # 初始隐藏
|
|
115
|
+
self._plan_sidebar.display = False
|
|
116
|
+
|
|
117
|
+
def on_mount(self) -> None:
|
|
118
|
+
self.query_one("#prompt", MultilinePrompt).focus()
|
|
119
|
+
# 加载技能
|
|
120
|
+
self._skills = discover_skills(Path.cwd())
|
|
121
|
+
if not self.config.resolve_api_key():
|
|
122
|
+
# 弹出 API Key 输入面板
|
|
123
|
+
async def _ask_key():
|
|
124
|
+
key = await self.app.push_screen_wait(ApiKeyModal())
|
|
125
|
+
if key:
|
|
126
|
+
from nexforge.config import save_api_key
|
|
127
|
+
save_api_key(key)
|
|
128
|
+
self.config.provider.api_key = key
|
|
129
|
+
self._init_provider()
|
|
130
|
+
import asyncio
|
|
131
|
+
asyncio.create_task(_ask_key())
|
|
132
|
+
else:
|
|
133
|
+
self._init_provider()
|
|
134
|
+
self._refresh_status()
|
|
135
|
+
if self.config.ui.mode == "auto":
|
|
136
|
+
self._note(
|
|
137
|
+
"⚠ 全自动模式(所有写/删/命令不经审批)。用 /mode review 切换。",
|
|
138
|
+
error=True,
|
|
139
|
+
)
|
|
140
|
+
self._auto_resume()
|
|
141
|
+
# 自动查询余额
|
|
142
|
+
self._auto_fetch_balance()
|
|
143
|
+
# 启动 splash(1.5s 后自动消失)
|
|
144
|
+
self._auto_fetch_balance()
|
|
145
|
+
|
|
146
|
+
# ---- 输入处理 ---------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async def on_multiline_prompt_prompt_submitted(self, event: MultilinePrompt.PromptSubmitted) -> None:
|
|
149
|
+
text = event.text.strip()
|
|
150
|
+
if not text:
|
|
151
|
+
return
|
|
152
|
+
if text.startswith("/"):
|
|
153
|
+
await self._handle_slash(text)
|
|
154
|
+
return
|
|
155
|
+
if self.agent is None:
|
|
156
|
+
self._note("未初始化(缺少 API Key),无法对话。", error=True)
|
|
157
|
+
return
|
|
158
|
+
self._set_follow(True) # 每次新对话重置跟底
|
|
159
|
+
self._agent_running = True
|
|
160
|
+
self._refresh_status()
|
|
161
|
+
prompt = self.query_one("#prompt", MultilinePrompt)
|
|
162
|
+
prompt.read_only = True
|
|
163
|
+
await self._mount(UserMessage(text))
|
|
164
|
+
self._run_agent(text)
|
|
165
|
+
|
|
166
|
+
async def _handle_slash(self, text: str) -> None:
|
|
167
|
+
parts = text[1:].split()
|
|
168
|
+
cmd = parts[0].lower() if parts else ""
|
|
169
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
170
|
+
|
|
171
|
+
if cmd == "help":
|
|
172
|
+
self._note(HELP)
|
|
173
|
+
elif cmd == "key":
|
|
174
|
+
if not arg.startswith("sk-"):
|
|
175
|
+
self._note("格式: /key sk-你的完整密钥", error=True)
|
|
176
|
+
return
|
|
177
|
+
from nexforge.config import save_api_key
|
|
178
|
+
|
|
179
|
+
save_api_key(arg)
|
|
180
|
+
self.config.provider.api_key = arg
|
|
181
|
+
try:
|
|
182
|
+
self.provider = DeepSeekProvider(self.config)
|
|
183
|
+
self.agent = AgentLoop(
|
|
184
|
+
self.config, self.provider, self.session, approve=self._ask_approve
|
|
185
|
+
)
|
|
186
|
+
self._note("API Key 已保存并生效!")
|
|
187
|
+
except ProviderError as exc:
|
|
188
|
+
self._note(str(exc), error=True)
|
|
189
|
+
return
|
|
190
|
+
elif cmd == "quit" or cmd == "exit":
|
|
191
|
+
self.app.exit()
|
|
192
|
+
elif cmd == "clear":
|
|
193
|
+
self.session = Session()
|
|
194
|
+
if self.provider is not None:
|
|
195
|
+
self.agent = AgentLoop(
|
|
196
|
+
self.config, self.provider, self.session, approve=self._ask_approve
|
|
197
|
+
)
|
|
198
|
+
await self.query_one("#chat-log", VerticalScroll).remove_children()
|
|
199
|
+
self._note("已清空会话。")
|
|
200
|
+
elif cmd == "model" and arg in ("flash", "pro", "auto"):
|
|
201
|
+
self.config.models.default_tier = arg # type: ignore[assignment]
|
|
202
|
+
self._note(f"模型档位 -> {arg}")
|
|
203
|
+
elif cmd == "think" and arg in ("on", "off"):
|
|
204
|
+
self.config.models.thinking = arg == "on"
|
|
205
|
+
self._note(f"思考模式 -> {arg}")
|
|
206
|
+
elif cmd == "mode" and arg == "auto":
|
|
207
|
+
# 推警告弹窗,回调中处理确认/取消
|
|
208
|
+
def _on_auto_confirm(result: bool) -> None:
|
|
209
|
+
if result:
|
|
210
|
+
self.config.ui.mode = "auto" # type: ignore[assignment]
|
|
211
|
+
self._note("已开启全自动模式(⚠ 所有写/删/命令操作不经审批)")
|
|
212
|
+
else:
|
|
213
|
+
self._note("已取消")
|
|
214
|
+
self.app.push_screen(ModeWarningModal(), callback=_on_auto_confirm)
|
|
215
|
+
elif cmd == "plan":
|
|
216
|
+
self.config.ui.plan_mode = not self.config.ui.plan_mode
|
|
217
|
+
state = "已开启" if self.config.ui.plan_mode else "已关闭"
|
|
218
|
+
self._note(f"计划模式 {state}", scroll=True)
|
|
219
|
+
if self.config.ui.plan_mode:
|
|
220
|
+
self.session.messages.insert(1, {"role": "system", "content": "[计划模式]你处于规划阶段,只能读取和分析,不能修改任何文件或执行命令。"})
|
|
221
|
+
return
|
|
222
|
+
elif cmd == "json":
|
|
223
|
+
self.config.ui.json_mode = not self.config.ui.json_mode
|
|
224
|
+
state = "开启" if self.config.ui.json_mode else "关闭"
|
|
225
|
+
self._note(f"JSON 模式已{state}", scroll=True)
|
|
226
|
+
return
|
|
227
|
+
elif cmd == "perm" and arg in ("safe", "supervised", "auto"):
|
|
228
|
+
self.config.permissions.profile = arg
|
|
229
|
+
self._note(f"权限级别 → {arg}", scroll=True)
|
|
230
|
+
self._refresh_status()
|
|
231
|
+
return
|
|
232
|
+
state = "已开启" if self.config.ui.plan_mode else "已关闭"
|
|
233
|
+
self._note(f"计划模式 {state}。{'只读讨论,不写文件。' if self.config.ui.plan_mode else '恢复开发模式。'}", scroll=True)
|
|
234
|
+
if self.config.ui.plan_mode:
|
|
235
|
+
# 注入系统提示
|
|
236
|
+
self.session.messages.insert(
|
|
237
|
+
1,
|
|
238
|
+
{"role": "system", "content": "[计划模式:你处于规划阶段,只能读取和分析,不能修改任何文件或执行命令。请与用户讨论优化计划,不要调用写/删/Shell工具。]"},
|
|
239
|
+
)
|
|
240
|
+
return
|
|
241
|
+
elif cmd == "mode" and arg == "review":
|
|
242
|
+
self.config.ui.mode = "review" # type: ignore[assignment]
|
|
243
|
+
self._note("审批模式 -> review(每次写/删/命令需确认)")
|
|
244
|
+
elif cmd == "skill" and arg:
|
|
245
|
+
sk = next((s for s in self._skills if s.name == arg), None)
|
|
246
|
+
if sk is None:
|
|
247
|
+
self._note(f"未知技能:{arg}(用 /skills 列清单)", error=True, scroll=True)
|
|
248
|
+
return
|
|
249
|
+
# 注入技能正文为一次性 system 消息
|
|
250
|
+
self.session.messages.insert(1, {"role": "system", "content": sk.body})
|
|
251
|
+
self._note(f"技能已激活:{sk.name} — {sk.description or '无描述'}", scroll=True)
|
|
252
|
+
return
|
|
253
|
+
elif cmd == "skills":
|
|
254
|
+
if not self._skills:
|
|
255
|
+
self._note("无可用技能。在 ~/.nexforge/skills/ 或 .nexforge/skills/ 放 SKILL.md。")
|
|
256
|
+
return
|
|
257
|
+
lines = ["\n".join(
|
|
258
|
+
f"/skill {s.name} — {s.description}" for s in self._skills
|
|
259
|
+
)]
|
|
260
|
+
self._note(lines[0])
|
|
261
|
+
return
|
|
262
|
+
elif cmd == "save":
|
|
263
|
+
self._save_as(arg or self._session_name or "manual")
|
|
264
|
+
return
|
|
265
|
+
elif cmd == "rename" and arg:
|
|
266
|
+
self._session_name = arg
|
|
267
|
+
if self._session_id:
|
|
268
|
+
save_session(self._session_id, arg, self.session.messages)
|
|
269
|
+
if self._sidebar:
|
|
270
|
+
self.call_later(self._sidebar.refresh_list)
|
|
271
|
+
self._note(f"已重命名为:{arg}", scroll=True)
|
|
272
|
+
return
|
|
273
|
+
elif cmd == "web" and arg == "start":
|
|
274
|
+
from nexforge.web.server import start_web
|
|
275
|
+
msg = start_web(self)
|
|
276
|
+
self._note(msg, scroll=True)
|
|
277
|
+
return
|
|
278
|
+
elif cmd == "web" and arg == "stop":
|
|
279
|
+
from nexforge.web.server import stop_web
|
|
280
|
+
msg = stop_web()
|
|
281
|
+
self._note(msg, scroll=True)
|
|
282
|
+
return
|
|
283
|
+
if self._session_id:
|
|
284
|
+
save_session(self._session_id, arg, self.session.messages)
|
|
285
|
+
if self._sidebar:
|
|
286
|
+
self.call_later(self._sidebar.refresh_list)
|
|
287
|
+
self._note(f"已重命名为:{arg}", scroll=True)
|
|
288
|
+
return
|
|
289
|
+
elif cmd == "sessions":
|
|
290
|
+
infos = list_sessions()
|
|
291
|
+
if not infos:
|
|
292
|
+
self._note("无历史会话。")
|
|
293
|
+
return
|
|
294
|
+
lines = [
|
|
295
|
+
f"{s.created} {s.id} ({s.message_count} 条消息)"
|
|
296
|
+
for s in infos[:15]
|
|
297
|
+
]
|
|
298
|
+
self._note("\n".join(lines))
|
|
299
|
+
return
|
|
300
|
+
elif cmd == "cost":
|
|
301
|
+
u = self.session.total_usage
|
|
302
|
+
cost = u.cost_usd(self.config.price(self.config.models.default_tier))
|
|
303
|
+
self._note(
|
|
304
|
+
f"累计:输入 {u.prompt_tokens} · 输出 {u.completion_tokens} · ~${cost:.4f}"
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
self._note(f"未知命令:{text}(/help 查看)", error=True)
|
|
308
|
+
self._refresh_status()
|
|
309
|
+
# 延迟滚动:等 SystemNote 被 call_later 挂载完成后再滚底
|
|
310
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
311
|
+
self.set_timer(0.15, lambda: log.scroll_end(animate=False))
|
|
312
|
+
|
|
313
|
+
# ---- Agent 事件流 -----------------------------------------------------
|
|
314
|
+
|
|
315
|
+
@work(exclusive=True, group="agent")
|
|
316
|
+
async def _run_agent(self, text: str) -> None:
|
|
317
|
+
assert self.agent is not None
|
|
318
|
+
prompt = self.query_one("#prompt", MultilinePrompt)
|
|
319
|
+
try:
|
|
320
|
+
async for ev in self.agent.run_turn(text):
|
|
321
|
+
if ev.kind is AgentEventKind.ROUTE:
|
|
322
|
+
self._current_turn = AssistantTurn()
|
|
323
|
+
await self._mount(self._current_turn)
|
|
324
|
+
if ev.route is not None:
|
|
325
|
+
self._refresh_status(
|
|
326
|
+
tier=ev.route.tier, thinking=ev.route.thinking
|
|
327
|
+
)
|
|
328
|
+
elif ev.kind is AgentEventKind.REASONING:
|
|
329
|
+
if self._current_turn:
|
|
330
|
+
self._current_turn.add_reasoning(ev.text)
|
|
331
|
+
self._scroll()
|
|
332
|
+
elif ev.kind is AgentEventKind.CONTENT:
|
|
333
|
+
if self._current_turn:
|
|
334
|
+
self._current_turn.add_content(ev.text)
|
|
335
|
+
self._scroll()
|
|
336
|
+
elif ev.kind is AgentEventKind.TOOL_START:
|
|
337
|
+
from nexforge.icons import tool_icon
|
|
338
|
+
|
|
339
|
+
card = ToolCard(ev.tool_preview, icon_name=ev.tool_name)
|
|
340
|
+
self._pending_card = card
|
|
341
|
+
await self._mount(card)
|
|
342
|
+
elif ev.kind is AgentEventKind.TOOL_END:
|
|
343
|
+
if self._pending_card:
|
|
344
|
+
self._pending_card.set_result(ev.tool_ok, ev.tool_summary)
|
|
345
|
+
self._pending_card = None
|
|
346
|
+
self._scroll()
|
|
347
|
+
elif ev.kind is AgentEventKind.TURN_DONE:
|
|
348
|
+
# 回合结束:Static 切 Markdown,然后保存+跟底
|
|
349
|
+
if self._current_turn:
|
|
350
|
+
self._current_turn.finalize_content()
|
|
351
|
+
# plan 模式:自动保存计划文件+刷新面板
|
|
352
|
+
if self.config.ui.plan_mode and ev.message and ev.message.content:
|
|
353
|
+
self._save_plan_from_response(ev.message.content)
|
|
354
|
+
self._auto_save()
|
|
355
|
+
self._refresh_status()
|
|
356
|
+
# 最后跟底:等当前刷新完成+延迟 0.05s,确保最后一段 Markdown 渲染完
|
|
357
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
358
|
+
self.call_after_refresh(lambda: log.scroll_end(animate=False))
|
|
359
|
+
self.set_timer(0.05, lambda: log.scroll_end(animate=False))
|
|
360
|
+
elif ev.kind is AgentEventKind.ERROR:
|
|
361
|
+
await self._mount(SystemNote(ev.text, error=True))
|
|
362
|
+
finally:
|
|
363
|
+
self._agent_running = False
|
|
364
|
+
self._refresh_status()
|
|
365
|
+
prompt.read_only = False
|
|
366
|
+
prompt.focus()
|
|
367
|
+
|
|
368
|
+
# ---- 辅助 -------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
async def _mount(self, widget) -> None:
|
|
371
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
372
|
+
at_bottom = log.scroll_y >= log.max_scroll_y - 3
|
|
373
|
+
await log.mount(widget)
|
|
374
|
+
if at_bottom:
|
|
375
|
+
self.call_after_refresh(lambda: log.scroll_end(animate=False))
|
|
376
|
+
# 屏幕上下文上限:超过 150 个 widget 删最旧的
|
|
377
|
+
self._trim_chat_log(log)
|
|
378
|
+
|
|
379
|
+
def _scroll(self) -> None:
|
|
380
|
+
"""基于位置的跟底:内容变化前若已贴底则跟随;
|
|
381
|
+
用户滚上去(含鼠标拖动)自然不跟,不会与用户打架。"""
|
|
382
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
383
|
+
if log.scroll_y >= log.max_scroll_y - 3:
|
|
384
|
+
if self._scroll_pending:
|
|
385
|
+
return
|
|
386
|
+
self._scroll_pending = True
|
|
387
|
+
|
|
388
|
+
def _do() -> None:
|
|
389
|
+
self._scroll_pending = False
|
|
390
|
+
log.scroll_end(animate=False)
|
|
391
|
+
|
|
392
|
+
self.call_after_refresh(_do)
|
|
393
|
+
|
|
394
|
+
def _set_follow(self, on: bool) -> None:
|
|
395
|
+
"""on=True 时强制滚到底(新回合/回底键);on=False 不做事。
|
|
396
|
+
不再用 anchor(),避免与鼠标拖动拉锄导致卡死。"""
|
|
397
|
+
self._follow = on
|
|
398
|
+
if on:
|
|
399
|
+
try:
|
|
400
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
401
|
+
self.call_after_refresh(lambda: log.scroll_end(animate=False))
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
def _note(self, text: str, *, error: bool = False, scroll: bool = False) -> None:
|
|
406
|
+
if scroll:
|
|
407
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
408
|
+
self.call_later(lambda: self._mount_then_scroll(SystemNote(text, error=error), log))
|
|
409
|
+
else:
|
|
410
|
+
self.call_later(self._mount, SystemNote(text, error=error))
|
|
411
|
+
|
|
412
|
+
async def _mount_then_scroll(self, widget, log) -> None:
|
|
413
|
+
await self._mount(widget)
|
|
414
|
+
log.scroll_end(animate=False)
|
|
415
|
+
|
|
416
|
+
def _refresh_status(
|
|
417
|
+
self, *, tier: str | None = None, thinking: bool | None = None
|
|
418
|
+
) -> None:
|
|
419
|
+
u = self.session.total_usage
|
|
420
|
+
cost = u.cost_usd(self.config.price(self.config.models.default_tier))
|
|
421
|
+
self.query_one("#status", StatusBar).render_stats(
|
|
422
|
+
tier=tier or self.config.models.default_tier,
|
|
423
|
+
thinking=self.config.models.thinking if thinking is None else thinking,
|
|
424
|
+
ctx_tokens=self.session.approx_context_tokens(),
|
|
425
|
+
cost=cost,
|
|
426
|
+
plan_mode=self.config.ui.plan_mode,
|
|
427
|
+
perm=self.config.permissions.profile,
|
|
428
|
+
balance=getattr(self, "_balance", ""),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
def action_clear_log(self) -> None:
|
|
432
|
+
self.run_worker(self.query_one("#chat-log", VerticalScroll).remove_children())
|
|
433
|
+
|
|
434
|
+
def action_scroll_up(self) -> None:
|
|
435
|
+
self.query_one("#chat-log", VerticalScroll).scroll_page_up()
|
|
436
|
+
|
|
437
|
+
def action_scroll_down(self) -> None:
|
|
438
|
+
self.query_one("#chat-log", VerticalScroll).scroll_page_down()
|
|
439
|
+
|
|
440
|
+
def action_line_up(self) -> None:
|
|
441
|
+
self.query_one("#chat-log", VerticalScroll).scroll_up()
|
|
442
|
+
|
|
443
|
+
def action_line_down(self) -> None:
|
|
444
|
+
self.query_one("#chat-log", VerticalScroll).scroll_down()
|
|
445
|
+
|
|
446
|
+
def action_toggle_sidebar(self) -> None:
|
|
447
|
+
if self._sidebar:
|
|
448
|
+
self._sidebar.display = not self._sidebar.display
|
|
449
|
+
self._scroll_to_bottom()
|
|
450
|
+
|
|
451
|
+
def action_toggle_plan(self) -> None:
|
|
452
|
+
if self._plan_sidebar:
|
|
453
|
+
self._plan_sidebar.display = not self._plan_sidebar.display
|
|
454
|
+
self._scroll_to_bottom()
|
|
455
|
+
|
|
456
|
+
def action_show_help(self) -> None:
|
|
457
|
+
self._note(HELP, scroll=True)
|
|
458
|
+
|
|
459
|
+
def action_multiline_input(self) -> None:
|
|
460
|
+
def _on_submit(text: str | None) -> None:
|
|
461
|
+
if text:
|
|
462
|
+
self._set_follow(True)
|
|
463
|
+
self._agent_running = True
|
|
464
|
+
self._refresh_status()
|
|
465
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
466
|
+
log.mount(UserMessage(text))
|
|
467
|
+
self.query_one("#prompt", MultilinePrompt).disabled = True
|
|
468
|
+
self._run_agent(text)
|
|
469
|
+
self.app.push_screen(MultilineInputModal(), callback=_on_submit)
|
|
470
|
+
|
|
471
|
+
def _scroll_to_bottom(self) -> None:
|
|
472
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
473
|
+
self.call_after_refresh(lambda: log.scroll_end(animate=False))
|
|
474
|
+
|
|
475
|
+
def _init_provider(self) -> None:
|
|
476
|
+
try:
|
|
477
|
+
self.provider = DeepSeekProvider(self.config)
|
|
478
|
+
self.agent = AgentLoop(
|
|
479
|
+
self.config, self.provider, self.session, approve=self._ask_approve
|
|
480
|
+
)
|
|
481
|
+
except ProviderError as exc:
|
|
482
|
+
self._note(str(exc), error=True)
|
|
483
|
+
|
|
484
|
+
def _auto_fetch_balance(self) -> None:
|
|
485
|
+
"""启动时自动查询余额(不阻塞)。"""
|
|
486
|
+
key = self.config.resolve_api_key()
|
|
487
|
+
if not key:
|
|
488
|
+
return
|
|
489
|
+
async def _query():
|
|
490
|
+
from nexforge.provider.balance import fetch_balance
|
|
491
|
+
result = await fetch_balance(key)
|
|
492
|
+
self._balance = result
|
|
493
|
+
self._refresh_status()
|
|
494
|
+
import asyncio
|
|
495
|
+
asyncio.create_task(_query())
|
|
496
|
+
|
|
497
|
+
def _trim_chat_log(self, log: VerticalScroll) -> None:
|
|
498
|
+
"""屏幕上下文上限 150 个 widget,超过则删最旧的(本地文件有记录)。"""
|
|
499
|
+
MAX_WIDGETS = 150
|
|
500
|
+
children = list(log.children)
|
|
501
|
+
if len(children) > MAX_WIDGETS:
|
|
502
|
+
excess = len(children) - MAX_WIDGETS + 20 # 多删一些避免频繁触发
|
|
503
|
+
for old in children[:excess]:
|
|
504
|
+
try:
|
|
505
|
+
old.remove()
|
|
506
|
+
except Exception:
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
# ---- 侧边栏按钮处理 ---------------------------------------------------
|
|
510
|
+
|
|
511
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
512
|
+
bid = event.button.id or ""
|
|
513
|
+
if bid == "btn-new":
|
|
514
|
+
self._do_new_session()
|
|
515
|
+
elif bid == "btn-plan-clear":
|
|
516
|
+
if self._plan_sidebar:
|
|
517
|
+
self._plan_sidebar.clear()
|
|
518
|
+
elif bid == "btn-plan-new":
|
|
519
|
+
self.config.ui.plan_mode = True
|
|
520
|
+
self._note("计划模式已开启。请描述需求,AI 会生成计划。", scroll=True)
|
|
521
|
+
elif bid == "btn-plan-toggle":
|
|
522
|
+
self.config.ui.plan_mode = not self.config.ui.plan_mode
|
|
523
|
+
state = "已开启" if self.config.ui.plan_mode else "已关闭"
|
|
524
|
+
self._note(f"计划模式 {state}", scroll=True)
|
|
525
|
+
self._update_plan_toggle_button()
|
|
526
|
+
elif bid == "btn-plan-del":
|
|
527
|
+
self._delete_plan_with_confirm()
|
|
528
|
+
elif bid and bid.startswith("plan-"):
|
|
529
|
+
self._load_plan_to_main(bid)
|
|
530
|
+
elif bid == "btn-del":
|
|
531
|
+
self._do_delete_session()
|
|
532
|
+
elif bid and bid.startswith("sess-"):
|
|
533
|
+
self._do_switch_session(bid)
|
|
534
|
+
|
|
535
|
+
# ---- 计划文件操作 ---------------------------------------------------
|
|
536
|
+
|
|
537
|
+
def _save_plan_from_response(self, content: str) -> None:
|
|
538
|
+
first_line = content.split("\n")[0].strip()
|
|
539
|
+
title = first_line.lstrip("#").strip() if first_line.startswith("#") else "新计划"
|
|
540
|
+
plan_store.save_plan(title, content)
|
|
541
|
+
if self._plan_sidebar:
|
|
542
|
+
self.call_after_refresh(self._plan_sidebar.refresh_list)
|
|
543
|
+
|
|
544
|
+
def _load_plan_to_main(self, bid: str) -> None:
|
|
545
|
+
if self._plan_sidebar is None:
|
|
546
|
+
return
|
|
547
|
+
info = self._plan_sidebar.plan_map.get(bid)
|
|
548
|
+
if info is None:
|
|
549
|
+
return
|
|
550
|
+
md_text = plan_store.load_plan(info.id)
|
|
551
|
+
if md_text is None:
|
|
552
|
+
self._note("无法加载计划文件(可能已被删除)。", error=True, scroll=True)
|
|
553
|
+
self.call_after_refresh(self._plan_sidebar.refresh_list)
|
|
554
|
+
return
|
|
555
|
+
# 标题行
|
|
556
|
+
self._note(f"📋 {info.title} ({info.done}/{info.total} 已完成)", scroll=True)
|
|
557
|
+
# 正文用 Rich Markdown 渲染(无截断 bug)
|
|
558
|
+
from rich.markdown import Markdown as RichMarkdown
|
|
559
|
+
# 先移除旧计划(避免 DuplicateIds)
|
|
560
|
+
try:
|
|
561
|
+
self.query_one("#plan-rt", Static).remove()
|
|
562
|
+
except Exception:
|
|
563
|
+
pass
|
|
564
|
+
rich_md = RichMarkdown(md_text)
|
|
565
|
+
md_widget = Static(rich_md, id="plan-rt")
|
|
566
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
567
|
+
self.call_later(lambda: log.mount(md_widget))
|
|
568
|
+
self.call_later(lambda: log.scroll_end(animate=False))
|
|
569
|
+
# 激活计划 + 绿点
|
|
570
|
+
self._plan_sidebar.active_id = info.id
|
|
571
|
+
self.call_after_refresh(self._plan_sidebar.refresh_list)
|
|
572
|
+
# 注入计划上下文
|
|
573
|
+
self.session.messages.insert(1, {"role": "system", "content": f"[当前计划]{info.title}\n{md_text}\n请围绕以上计划工作,完成一项用[x]标记。"})
|
|
574
|
+
|
|
575
|
+
def _update_plan_toggle_button(self) -> None:
|
|
576
|
+
try:
|
|
577
|
+
btn = self.query_one("#btn-plan-toggle")
|
|
578
|
+
except Exception:
|
|
579
|
+
return
|
|
580
|
+
if self.config.ui.plan_mode:
|
|
581
|
+
btn.label = "关闭计划模式"
|
|
582
|
+
btn.variant = "error"
|
|
583
|
+
else:
|
|
584
|
+
btn.label = "开启计划模式"
|
|
585
|
+
btn.variant = "primary"
|
|
586
|
+
|
|
587
|
+
def _delete_plan_with_confirm(self) -> None:
|
|
588
|
+
if self._plan_sidebar is None or not self._plan_sidebar.active_id:
|
|
589
|
+
self._note("没有选中的计划。", error=True, scroll=True)
|
|
590
|
+
return
|
|
591
|
+
plan_id = self._plan_sidebar.active_id
|
|
592
|
+
|
|
593
|
+
def _on_confirm(result: bool) -> None:
|
|
594
|
+
if result:
|
|
595
|
+
plan_store.delete_plan(plan_id)
|
|
596
|
+
self._plan_sidebar.active_id = ""
|
|
597
|
+
self._plan_sidebar.refresh_list()
|
|
598
|
+
self._note("计划已删除。", scroll=True)
|
|
599
|
+
|
|
600
|
+
self.app.push_screen(
|
|
601
|
+
ConfirmModal("删除计划", f"确定删除「{plan_id}」?\n不可恢复。"),
|
|
602
|
+
callback=_on_confirm,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def _do_new_session(self) -> None:
|
|
606
|
+
if self._session_id:
|
|
607
|
+
self._auto_save()
|
|
608
|
+
self.session = Session()
|
|
609
|
+
self._session_id = ""
|
|
610
|
+
self._session_name = ""
|
|
611
|
+
self._follow = True
|
|
612
|
+
if self.provider is not None:
|
|
613
|
+
self.agent = AgentLoop(
|
|
614
|
+
self.config, self.provider, self.session, approve=self._ask_approve
|
|
615
|
+
)
|
|
616
|
+
self._session_id = _next_id(_today_dir())
|
|
617
|
+
self._session_name = "新会话"
|
|
618
|
+
save_session(self._session_id, self._session_name, self.session.messages)
|
|
619
|
+
self.call_later(lambda: self.query_one("#chat-log", VerticalScroll).remove_children())
|
|
620
|
+
self.call_later(self._sidebar.refresh_list)
|
|
621
|
+
self._note("已新建会话,之前的内容已自动保存。")
|
|
622
|
+
|
|
623
|
+
def _do_delete_session(self) -> None:
|
|
624
|
+
if self._session_id:
|
|
625
|
+
self._auto_save()
|
|
626
|
+
from nexforge.store.session_store import delete_session
|
|
627
|
+
deleted_id = self._session_id
|
|
628
|
+
delete_session(self._session_id)
|
|
629
|
+
self._session_id = ""
|
|
630
|
+
self._session_name = ""
|
|
631
|
+
self._sidebar.refresh_list()
|
|
632
|
+
self._note(f"已删除会话:{deleted_id}")
|
|
633
|
+
else:
|
|
634
|
+
self._note("当前会话尚未保存,无需删除。", error=True)
|
|
635
|
+
|
|
636
|
+
def _do_switch_session(self, bid: str) -> None:
|
|
637
|
+
if self._sidebar is None:
|
|
638
|
+
return
|
|
639
|
+
info = self._sidebar.session_map.get(bid)
|
|
640
|
+
if info is None:
|
|
641
|
+
return
|
|
642
|
+
from nexforge.store.session_store import load_session
|
|
643
|
+
msgs = load_session(info.path)
|
|
644
|
+
if msgs is None:
|
|
645
|
+
self._note(f"无法加载会话:{info.id}", error=True)
|
|
646
|
+
return
|
|
647
|
+
if self._session_id:
|
|
648
|
+
self._auto_save()
|
|
649
|
+
self.session = Session()
|
|
650
|
+
self.session.messages = msgs
|
|
651
|
+
self._session_id = info.id
|
|
652
|
+
self._session_name = info.name
|
|
653
|
+
self._follow = True
|
|
654
|
+
if self.provider is not None:
|
|
655
|
+
self.agent = AgentLoop(
|
|
656
|
+
self.config, self.provider, self.session, approve=self._ask_approve
|
|
657
|
+
)
|
|
658
|
+
# 清屏 + 渲染历史
|
|
659
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
660
|
+
log.remove_children()
|
|
661
|
+
for msg in msgs:
|
|
662
|
+
role = msg.get("role", "")
|
|
663
|
+
content = msg.get("content", "")
|
|
664
|
+
if role == "user" and content:
|
|
665
|
+
log.mount(UserMessage(content))
|
|
666
|
+
elif role == "assistant" and content:
|
|
667
|
+
log.mount(AssistantTurn(initial_content=content))
|
|
668
|
+
for w in log.query(AssistantTurn):
|
|
669
|
+
w.finalize_content()
|
|
670
|
+
self._sidebar.refresh_list()
|
|
671
|
+
self._note(f"已切换到会话:{info.name}")
|
|
672
|
+
|
|
673
|
+
def action_scroll_bottom(self) -> None:
|
|
674
|
+
|
|
675
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
676
|
+
log.scroll_end(animate=False)
|
|
677
|
+
|
|
678
|
+
def _auto_save(self) -> None:
|
|
679
|
+
# 用首句更新标题(文件名固定为时间戳,标题可变)
|
|
680
|
+
first_user = next(
|
|
681
|
+
(m["content"][:40] for m in self.session.messages if m.get("role") == "user"),
|
|
682
|
+
"",
|
|
683
|
+
)
|
|
684
|
+
if first_user:
|
|
685
|
+
self._session_name = first_user
|
|
686
|
+
if not self._session_id:
|
|
687
|
+
self._session_id = _next_id(_today_dir())
|
|
688
|
+
self._session_name = self._session_name or "聊天"
|
|
689
|
+
save_session(self._session_id, self._session_name or "聊天", self.session.messages)
|
|
690
|
+
if self._sidebar is not None:
|
|
691
|
+
self.call_later(self._sidebar.refresh_list)
|
|
692
|
+
|
|
693
|
+
def action_quit(self) -> None:
|
|
694
|
+
self.app.exit()
|
|
695
|
+
|
|
696
|
+
def action_quit_with_confirm(self) -> None:
|
|
697
|
+
# AI 工作中 → 停止;空闲 → 退出确认
|
|
698
|
+
if self._agent_running:
|
|
699
|
+
self.workers.cancel_group(self, "agent")
|
|
700
|
+
self._note("⏹ 已停止当前 AI 任务。", scroll=True)
|
|
701
|
+
return
|
|
702
|
+
def _on_confirm(result: bool) -> None:
|
|
703
|
+
if result:
|
|
704
|
+
self.app.exit()
|
|
705
|
+
self.app.push_screen(
|
|
706
|
+
ConfirmModal("退出 NexForgeCLI", "确定要退出吗?\n当前会话已自动保存。", confirm_label="确认退出"),
|
|
707
|
+
callback=_on_confirm,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
def _auto_resume(self) -> None:
|
|
711
|
+
"""启动时自动恢复当前项目最近一次会话。"""
|
|
712
|
+
from nexforge.store.session_store import load_session
|
|
713
|
+
infos = list_sessions()
|
|
714
|
+
if not infos:
|
|
715
|
+
return
|
|
716
|
+
try:
|
|
717
|
+
infos.sort(key=lambda s: s.path.stat().st_mtime, reverse=True)
|
|
718
|
+
except OSError:
|
|
719
|
+
pass
|
|
720
|
+
latest = infos[0]
|
|
721
|
+
msgs = load_session(latest.path)
|
|
722
|
+
if msgs is None or len(msgs) <= 1:
|
|
723
|
+
self._session_id = latest.id
|
|
724
|
+
self._session_name = latest.name
|
|
725
|
+
return
|
|
726
|
+
self.session.messages = msgs
|
|
727
|
+
self._session_id = latest.id
|
|
728
|
+
self._session_name = latest.name
|
|
729
|
+
# 渲染历史
|
|
730
|
+
log = self.query_one("#chat-log", VerticalScroll)
|
|
731
|
+
for msg in msgs:
|
|
732
|
+
role = msg.get("role", "")
|
|
733
|
+
content = msg.get("content", "")
|
|
734
|
+
if role == "user" and content:
|
|
735
|
+
log.mount(UserMessage(content))
|
|
736
|
+
elif role == "assistant" and content:
|
|
737
|
+
log.mount(AssistantTurn(initial_content=content))
|
|
738
|
+
# 恢复后渲染 Rich Markdown
|
|
739
|
+
for w in log.query(AssistantTurn):
|
|
740
|
+
w.finalize_content()
|
|
741
|
+
self._set_follow(False)
|
|
742
|
+
# 刷绿点
|
|
743
|
+
if self._sidebar:
|
|
744
|
+
self._sidebar.refresh_list()
|
|
745
|
+
self._note(f"已恢复会话:{latest.name}({latest.created})")
|
|
746
|
+
|
|
747
|
+
# ---- /save 命令回调 ---------------------------------------------------
|
|
748
|
+
def _save_as(self, name: str) -> None:
|
|
749
|
+
self._session_name = name
|
|
750
|
+
if not self._session_id:
|
|
751
|
+
self._session_id = _next_id(_today_dir())
|
|
752
|
+
save_session(self._session_id, self._session_name, self.session.messages)
|
|
753
|
+
if self._sidebar is not None:
|
|
754
|
+
self.call_later(self._sidebar.refresh_list)
|
|
755
|
+
self._note(f"已保存会话:{name}")
|
|
756
|
+
|
|
757
|
+
async def _ask_approve(self, tool, args: dict) -> bool:
|
|
758
|
+
"""审批回调:review 模式下弹 DiffModal 等 y/n。"""
|
|
759
|
+
if self.config.ui.mode == "auto":
|
|
760
|
+
return True
|
|
761
|
+
preview = tool.preview(args)
|
|
762
|
+
diff = self._make_diff(tool, args)
|
|
763
|
+
return await self.app.push_screen_wait(
|
|
764
|
+
DiffApprovalModal(tool.name, preview, diff)
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
def _make_diff(self, tool, args: dict) -> str:
|
|
768
|
+
"""为写文件工具生成统一 diff 预览。"""
|
|
769
|
+
import difflib
|
|
770
|
+
from pathlib import Path
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
if tool.name == "write_file":
|
|
774
|
+
path = args.get("path", "")
|
|
775
|
+
new = args.get("content", "")
|
|
776
|
+
old = (
|
|
777
|
+
Path(path).read_text(encoding="utf-8")
|
|
778
|
+
if Path(path).exists()
|
|
779
|
+
else ""
|
|
780
|
+
)
|
|
781
|
+
return self._unified(old, new, path)
|
|
782
|
+
if tool.name == "edit_file":
|
|
783
|
+
path = args.get("path", "")
|
|
784
|
+
if Path(path).exists():
|
|
785
|
+
old = Path(path).read_text(encoding="utf-8")
|
|
786
|
+
new = old.replace(
|
|
787
|
+
args.get("search", ""), args.get("replace", ""), 1
|
|
788
|
+
)
|
|
789
|
+
return self._unified(old, new, path)
|
|
790
|
+
if tool.name == "run_shell":
|
|
791
|
+
return f"[#22D3EE]$ {args.get('command', '')}[/]"
|
|
792
|
+
if tool.name == "multi_edit":
|
|
793
|
+
edits = args.get("edits", [])
|
|
794
|
+
parts = [
|
|
795
|
+
f"[dim]{e.get('path', '')}[/]"
|
|
796
|
+
for e in (edits if isinstance(edits, list) else [])
|
|
797
|
+
]
|
|
798
|
+
return "\n".join(parts) or "(无 diff)"
|
|
799
|
+
except Exception:
|
|
800
|
+
pass
|
|
801
|
+
return ""
|
|
802
|
+
|
|
803
|
+
@staticmethod
|
|
804
|
+
def _unified(old: str, new: str, path: str) -> str:
|
|
805
|
+
import difflib
|
|
806
|
+
|
|
807
|
+
lines = list(
|
|
808
|
+
difflib.unified_diff(
|
|
809
|
+
old.splitlines(),
|
|
810
|
+
new.splitlines(),
|
|
811
|
+
fromfile=path,
|
|
812
|
+
tofile=path,
|
|
813
|
+
lineterm="",
|
|
814
|
+
n=2,
|
|
815
|
+
)
|
|
816
|
+
)[:40]
|
|
817
|
+
out = []
|
|
818
|
+
for ln in lines:
|
|
819
|
+
if ln.startswith("+") and not ln.startswith("+++"):
|
|
820
|
+
out.append(f"[green]{ln}[/]")
|
|
821
|
+
elif ln.startswith("-") and not ln.startswith("---"):
|
|
822
|
+
out.append(f"[red]{ln}[/]")
|
|
823
|
+
else:
|
|
824
|
+
out.append(f"[dim]{ln}[/]")
|
|
825
|
+
return "\n".join(out)
|