nexforge-cli 0.2.2__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.
Files changed (98) hide show
  1. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/PKG-INFO +1 -1
  2. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/pyproject.toml +1 -1
  3. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/screens/chat.py +20 -3
  4. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/messages.py +139 -134
  5. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/.gitignore +0 -0
  6. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/= +0 -0
  7. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/INSTALL.md +0 -0
  8. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/README.md +0 -0
  9. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/agent.svg +0 -0
  10. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/cost.svg +0 -0
  11. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/edit.svg +0 -0
  12. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/err.svg +0 -0
  13. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/file.svg +0 -0
  14. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/model.svg +0 -0
  15. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/ok.svg +0 -0
  16. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/read.svg +0 -0
  17. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/running.svg +0 -0
  18. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/search.svg +0 -0
  19. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/shell.svg +0 -0
  20. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/thinking.svg +0 -0
  21. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/web.svg +0 -0
  22. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/assets/icons/write.svg +0 -0
  23. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/config.example.toml +0 -0
  24. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/devlog/2026-07-07.md +0 -0
  25. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/doc//344/274/232/350/257/235/347/256/241/347/220/206.md" +0 -0
  26. {nexforge_cli-0.2.2 → 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
  27. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/doc//345/256/211/350/243/205/344/270/216/351/205/215/347/275/256.md" +0 -0
  28. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/doc//345/257/271/346/257/224/345/210/206/346/236/220.md" +0 -0
  29. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/doc//345/267/245/345/205/267/345/217/202/350/200/203.md" +0 -0
  30. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/doc//346/200/273/350/247/210.md" +0 -0
  31. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/doc//346/235/203/351/231/220/347/263/273/347/273/237.md" +0 -0
  32. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/doc//350/256/241/345/210/222/346/250/241/345/274/217.md" +0 -0
  33. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/doc//351/253/230/347/272/247/345/212/237/350/203/275.md" +0 -0
  34. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/scripts/publish.sh +0 -0
  35. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/scripts/smoke_provider.py +0 -0
  36. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/scripts/smoke_tui.py +0 -0
  37. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/__init__.py +0 -0
  38. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/__main__.py +0 -0
  39. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/app.py +0 -0
  40. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/cli.py +0 -0
  41. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/config.py +0 -0
  42. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/core/__init__.py +0 -0
  43. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/core/agent.py +0 -0
  44. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/core/jobs.py +0 -0
  45. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/core/permissions.py +0 -0
  46. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/core/router.py +0 -0
  47. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/core/session.py +0 -0
  48. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/core/types.py +0 -0
  49. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/icons.py +0 -0
  50. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/mcp/__init__.py +0 -0
  51. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/provider/__init__.py +0 -0
  52. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/provider/balance.py +0 -0
  53. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/provider/deepseek.py +0 -0
  54. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/skills/loader.py +0 -0
  55. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/store/__init__.py +0 -0
  56. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/store/plan_store.py +0 -0
  57. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/store/session_store.py +0 -0
  58. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/theme.tcss +0 -0
  59. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/__init__.py +0 -0
  60. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/_paths.py +0 -0
  61. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/background.py +0 -0
  62. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/base.py +0 -0
  63. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/edit_file.py +0 -0
  64. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/fs_ops.py +0 -0
  65. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/git.py +0 -0
  66. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/list_dir.py +0 -0
  67. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/multi_edit.py +0 -0
  68. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/read_file.py +0 -0
  69. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/registry.py +0 -0
  70. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/search_content.py +0 -0
  71. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/search_files.py +0 -0
  72. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/shell.py +0 -0
  73. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/web.py +0 -0
  74. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/tools/write_file.py +0 -0
  75. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/__init__.py +0 -0
  76. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/screens/__init__.py +0 -0
  77. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/__init__.py +0 -0
  78. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/apikey_modal.py +0 -0
  79. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/banner.py +0 -0
  80. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/confirm_modal.py +0 -0
  81. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/diff_modal.py +0 -0
  82. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/mode_warning.py +0 -0
  83. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/multiline_modal.py +0 -0
  84. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/multiline_prompt.py +0 -0
  85. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/plan_sidebar.py +0 -0
  86. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/sidebar.py +0 -0
  87. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/ui/widgets/statusbar.py +0 -0
  88. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/web/__init__.py +0 -0
  89. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/web/server.py +0 -0
  90. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/src/nexforge/web/static/index.html +0 -0
  91. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/tests/test_approval.py +0 -0
  92. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/tests/test_config.py +0 -0
  93. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/tests/test_icons.py +0 -0
  94. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/tests/test_m2_tools.py +0 -0
  95. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/tests/test_sidebar.py +0 -0
  96. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/tests/test_sidebar_actions.py +0 -0
  97. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/tests/test_tools.py +0 -0
  98. {nexforge_cli-0.2.2 → nexforge_cli-0.2.3}/tests/test_tui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nexforge-cli
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: NexForgeCLI — 一个面向 DeepSeek V4 的终端智能体(TUI + CLI 双模式)
5
5
  Project-URL: Homepage, https://gitee.com/Lighthorn/nexforgecli
6
6
  Author: NexForge
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nexforge-cli"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "NexForgeCLI — 一个面向 DeepSeek V4 的终端智能体(TUI + CLI 双模式)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -100,6 +100,7 @@ class ChatScreen(Screen):
100
100
  self._session_name: str = ""
101
101
  self._follow: bool = True # 流式时自动跟底,用户上滚后暂停
102
102
  self._scroll_pending: bool = False # 滚动节流:同一刷新周期只排一次
103
+ self._scroll_safety_active: bool = False # 安全定时器防重
103
104
  self._agent_running: bool = False
104
105
 
105
106
  @property
@@ -320,9 +321,6 @@ class ChatScreen(Screen):
320
321
  else:
321
322
  self._note(f"未知命令:{text}(/help 查看)", error=True)
322
323
  self._refresh_status()
323
- # 延迟滚动:等 SystemNote 被 call_later 挂载完成后再滚底
324
- log = self._log
325
- self.set_timer(0.15, lambda: log.scroll_end(animate=False))
326
324
 
327
325
  # ---- Agent 事件流 -----------------------------------------------------
328
326
 
@@ -402,9 +400,19 @@ class ChatScreen(Screen):
402
400
 
403
401
  def _do() -> None:
404
402
  self._scroll_pending = False
403
+ self._scroll_safety_active = False
405
404
  log.scroll_end(animate=False)
406
405
 
407
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
408
416
 
409
417
  def _set_follow(self, on: bool) -> None:
410
418
  """on=True 时强制滚到底(新回合/回底键);on=False 不做事。
@@ -416,6 +424,15 @@ class ChatScreen(Screen):
416
424
  self.call_after_refresh(lambda: log.scroll_end(animate=False))
417
425
  except Exception:
418
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
419
436
 
420
437
  def _note(self, text: str, *, error: bool = False, scroll: bool = False) -> None:
421
438
  if scroll:
@@ -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
- class SystemNote(Static):
13
- """系统 / 错误提示行。"""
14
-
15
- def __init__(self, text: str, *, error: bool = False) -> None:
16
- from nexforge.icons import icon
17
-
18
- sym = icon("err") if error else icon("running")
19
- color = "red" if error else "#22D3EE"
20
- super().__init__(f"[{color}]{sym}[/] {escape(text).replace("]", r"]")}")
21
-
22
-
23
- class UserMessage(Static):
24
- def __init__(self, text: str) -> None:
25
- from nexforge.icons import icon
26
-
27
- super().__init__(f"[b #6D5EF5]{icon('user')}[/] {escape(text).replace("]", r"]")}")
28
-
29
-
30
- class ToolCard(Static):
31
- """一次工具调用:执行中 → 完成(✔/✖)。"""
32
-
33
- def __init__(self, preview: str, icon_name: str = "running") -> None:
34
- super().__init__()
35
- self._preview = preview
36
- self._icon = icon_name
37
-
38
- def on_mount(self) -> None:
39
- from nexforge.icons import icon
40
-
41
- ic = icon(self._icon)
42
- self.update(f"[#22D3EE]{ic}[/] {escape(self._preview).replace("]", r"]")} [dim]执行中…[/]")
43
-
44
- def set_result(self, ok: bool, summary: str) -> None:
45
- from nexforge.icons import icon
46
-
47
- mark = icon("ok") if ok else icon("err")
48
- color = "green" if ok else "red"
49
- self.update(
50
- f"[{color}]{mark}[/] [#22D3EE]{escape(self._preview).replace("]", r"]")}[/] "
51
- f"[dim]{escape(summary).replace("]", r"]")}[/]"
52
- )
53
-
54
-
55
- class AssistantTurn(Vertical):
56
- """一轮助手输出:可折叠思考面板 + Markdown 正文。"""
57
-
58
- def __init__(self, initial_content: str = "") -> None:
59
- super().__init__()
60
- self._reasoning = ""
61
- self._content = initial_content
62
- self._collapsed_once = False
63
- self._body: Static | None = None # 思考面板引用缓存
64
- self._txt: Static | None = None # 正文引用缓存
65
-
66
- def compose(self) -> ComposeResult:
67
- import re
68
- from nexforge.icons import icon
69
- from rich.markup import escape as rich_escape
70
-
71
- with Collapsible(title=f"{icon('thinking')} 思考", collapsed=False, id="think"):
72
- yield Static("", id="reasoning-body")
73
- # 流式时用 Static 快速渲染(避免每 token 全量 Markdown 解析变慢)
74
- init = re.sub(r"\n{3,}", "\n\n", self._content).lstrip("\n")
75
- yield Static(rich_escape(init), id="content-txt")
76
-
77
- def on_mount(self) -> None:
78
- # 未产生思考内容前隐藏思考面板
79
- self.query_one("#think", Collapsible).display = False
80
-
81
- def add_reasoning(self, text: str) -> None:
82
- self._reasoning += text
83
- panel = self.query_one("#think", Collapsible)
84
- if not panel.display:
85
- panel.display = True
86
- if self._body is None:
87
- self._body = self.query_one("#reasoning-body", Static)
88
- self._body.update(self._reasoning)
89
- # 思考面板的 Contents 有 overflow-y:auto,每次更新后滚到底
90
- try:
91
- contents = panel.query_one("Contents")
92
- contents.scroll_end(animate=False)
93
- except Exception:
94
- pass
95
-
96
- def add_content(self, text: str) -> None:
97
- # 正文开始 → 折叠思考面板
98
- if not self._collapsed_once and self._reasoning:
99
- self.query_one("#think", Collapsible).collapsed = True
100
- self._collapsed_once = True
101
- self._content += text
102
- from rich.markup import escape as rich_escape
103
- if self._txt is None:
104
- self._txt = self.query_one("#content-txt", Static)
105
- self._txt.update(rich_escape(self._content))
106
-
107
- def finalize_content(self) -> None:
108
- """回合结束:用 Rich Markdown 渲染到 Static(无 Textual Markdown bug)。"""
109
- import re
110
- try:
111
- self.query_one("#content-txt", Static).remove()
112
- except Exception:
113
- pass
114
- try:
115
- self.query_one("#think", Collapsible).remove()
116
- except Exception:
117
- pass
118
- normalized = re.sub(r"\n{3,}", "\n\n", self._content).strip("\n")
119
- rich_md = RichMarkdown(normalized)
120
- self.mount(Static(rich_md, id="content-rt"))
121
- self._scroll_to_end()
122
-
123
- def _scroll_to_end(self) -> None:
124
- """精确找到 #chat-log 并滚到底。"""
125
- try:
126
- from textual.containers import VerticalScroll
127
- log = self.app.query_one("#chat-log", VerticalScroll)
128
- log.scroll_end(animate=False)
129
- except Exception:
130
- pass
131
-
132
- @property
133
- def is_empty(self) -> bool:
134
- return not self._reasoning and not self._content
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
File without changes
File without changes
File without changes
File without changes