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.
Files changed (74) hide show
  1. nexforge/__init__.py +7 -0
  2. nexforge/__main__.py +6 -0
  3. nexforge/app.py +38 -0
  4. nexforge/assets/icons/agent.svg +13 -0
  5. nexforge/assets/icons/cost.svg +5 -0
  6. nexforge/assets/icons/edit.svg +4 -0
  7. nexforge/assets/icons/err.svg +5 -0
  8. nexforge/assets/icons/file.svg +5 -0
  9. nexforge/assets/icons/model.svg +7 -0
  10. nexforge/assets/icons/ok.svg +4 -0
  11. nexforge/assets/icons/read.svg +8 -0
  12. nexforge/assets/icons/running.svg +5 -0
  13. nexforge/assets/icons/search.svg +5 -0
  14. nexforge/assets/icons/shell.svg +5 -0
  15. nexforge/assets/icons/thinking.svg +5 -0
  16. nexforge/assets/icons/web.svg +6 -0
  17. nexforge/assets/icons/write.svg +4 -0
  18. nexforge/cli.py +159 -0
  19. nexforge/config.py +192 -0
  20. nexforge/core/__init__.py +1 -0
  21. nexforge/core/agent.py +244 -0
  22. nexforge/core/jobs.py +97 -0
  23. nexforge/core/permissions.py +73 -0
  24. nexforge/core/router.py +62 -0
  25. nexforge/core/session.py +55 -0
  26. nexforge/core/types.py +103 -0
  27. nexforge/icons.py +173 -0
  28. nexforge/mcp/__init__.py +168 -0
  29. nexforge/provider/__init__.py +1 -0
  30. nexforge/provider/balance.py +37 -0
  31. nexforge/provider/deepseek.py +204 -0
  32. nexforge/skills/loader.py +102 -0
  33. nexforge/store/__init__.py +1 -0
  34. nexforge/store/plan_store.py +112 -0
  35. nexforge/store/session_store.py +206 -0
  36. nexforge/theme.tcss +128 -0
  37. nexforge/tools/__init__.py +6 -0
  38. nexforge/tools/_paths.py +22 -0
  39. nexforge/tools/background.py +111 -0
  40. nexforge/tools/base.py +56 -0
  41. nexforge/tools/edit_file.py +73 -0
  42. nexforge/tools/fs_ops.py +125 -0
  43. nexforge/tools/git.py +89 -0
  44. nexforge/tools/list_dir.py +49 -0
  45. nexforge/tools/multi_edit.py +84 -0
  46. nexforge/tools/read_file.py +51 -0
  47. nexforge/tools/registry.py +94 -0
  48. nexforge/tools/search_content.py +90 -0
  49. nexforge/tools/search_files.py +48 -0
  50. nexforge/tools/shell.py +60 -0
  51. nexforge/tools/web.py +40 -0
  52. nexforge/tools/write_file.py +40 -0
  53. nexforge/ui/__init__.py +1 -0
  54. nexforge/ui/screens/__init__.py +1 -0
  55. nexforge/ui/screens/chat.py +825 -0
  56. nexforge/ui/widgets/__init__.py +1 -0
  57. nexforge/ui/widgets/apikey_modal.py +65 -0
  58. nexforge/ui/widgets/banner.py +18 -0
  59. nexforge/ui/widgets/confirm_modal.py +58 -0
  60. nexforge/ui/widgets/diff_modal.py +67 -0
  61. nexforge/ui/widgets/messages.py +132 -0
  62. nexforge/ui/widgets/mode_warning.py +57 -0
  63. nexforge/ui/widgets/multiline_modal.py +59 -0
  64. nexforge/ui/widgets/multiline_prompt.py +25 -0
  65. nexforge/ui/widgets/plan_sidebar.py +89 -0
  66. nexforge/ui/widgets/sidebar.py +99 -0
  67. nexforge/ui/widgets/statusbar.py +70 -0
  68. nexforge/web/__init__.py +0 -0
  69. nexforge/web/server.py +177 -0
  70. nexforge/web/static/index.html +123 -0
  71. nexforge_cli-0.1.0.dist-info/METADATA +168 -0
  72. nexforge_cli-0.1.0.dist-info/RECORD +74 -0
  73. nexforge_cli-0.1.0.dist-info/WHEEL +4 -0
  74. 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)