nexforge-cli 0.2.2__tar.gz → 0.2.4__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.4}/PKG-INFO +1 -1
  2. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/pyproject.toml +1 -1
  3. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/screens/chat.py +20 -3
  4. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/messages.py +145 -134
  5. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/.gitignore +0 -0
  6. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/= +0 -0
  7. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/INSTALL.md +0 -0
  8. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/README.md +0 -0
  9. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/agent.svg +0 -0
  10. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/cost.svg +0 -0
  11. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/edit.svg +0 -0
  12. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/err.svg +0 -0
  13. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/file.svg +0 -0
  14. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/model.svg +0 -0
  15. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/ok.svg +0 -0
  16. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/read.svg +0 -0
  17. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/running.svg +0 -0
  18. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/search.svg +0 -0
  19. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/shell.svg +0 -0
  20. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/thinking.svg +0 -0
  21. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/web.svg +0 -0
  22. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/assets/icons/write.svg +0 -0
  23. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/config.example.toml +0 -0
  24. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/devlog/2026-07-07.md +0 -0
  25. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/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.4}/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.4}/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.4}/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.4}/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.4}/doc//346/200/273/350/247/210.md" +0 -0
  31. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/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.4}/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.4}/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.4}/scripts/publish.sh +0 -0
  35. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/scripts/smoke_provider.py +0 -0
  36. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/scripts/smoke_tui.py +0 -0
  37. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/__init__.py +0 -0
  38. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/__main__.py +0 -0
  39. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/app.py +0 -0
  40. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/cli.py +0 -0
  41. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/config.py +0 -0
  42. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/core/__init__.py +0 -0
  43. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/core/agent.py +0 -0
  44. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/core/jobs.py +0 -0
  45. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/core/permissions.py +0 -0
  46. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/core/router.py +0 -0
  47. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/core/session.py +0 -0
  48. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/core/types.py +0 -0
  49. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/icons.py +0 -0
  50. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/mcp/__init__.py +0 -0
  51. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/provider/__init__.py +0 -0
  52. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/provider/balance.py +0 -0
  53. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/provider/deepseek.py +0 -0
  54. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/skills/loader.py +0 -0
  55. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/store/__init__.py +0 -0
  56. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/store/plan_store.py +0 -0
  57. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/store/session_store.py +0 -0
  58. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/theme.tcss +0 -0
  59. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/__init__.py +0 -0
  60. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/_paths.py +0 -0
  61. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/background.py +0 -0
  62. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/base.py +0 -0
  63. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/edit_file.py +0 -0
  64. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/fs_ops.py +0 -0
  65. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/git.py +0 -0
  66. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/list_dir.py +0 -0
  67. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/multi_edit.py +0 -0
  68. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/read_file.py +0 -0
  69. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/registry.py +0 -0
  70. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/search_content.py +0 -0
  71. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/search_files.py +0 -0
  72. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/shell.py +0 -0
  73. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/web.py +0 -0
  74. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/tools/write_file.py +0 -0
  75. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/__init__.py +0 -0
  76. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/screens/__init__.py +0 -0
  77. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/__init__.py +0 -0
  78. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/apikey_modal.py +0 -0
  79. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/banner.py +0 -0
  80. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/confirm_modal.py +0 -0
  81. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/diff_modal.py +0 -0
  82. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/mode_warning.py +0 -0
  83. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/multiline_modal.py +0 -0
  84. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/multiline_prompt.py +0 -0
  85. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/plan_sidebar.py +0 -0
  86. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/sidebar.py +0 -0
  87. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/ui/widgets/statusbar.py +0 -0
  88. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/web/__init__.py +0 -0
  89. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/web/server.py +0 -0
  90. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/src/nexforge/web/static/index.html +0 -0
  91. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/tests/test_approval.py +0 -0
  92. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/tests/test_config.py +0 -0
  93. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/tests/test_icons.py +0 -0
  94. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/tests/test_m2_tools.py +0 -0
  95. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/tests/test_sidebar.py +0 -0
  96. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/tests/test_sidebar_actions.py +0 -0
  97. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/tests/test_tools.py +0 -0
  98. {nexforge_cli-0.2.2 → nexforge_cli-0.2.4}/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.4
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.4"
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,145 @@
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 textual.app import ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.widgets import Collapsible, Static
8
+ from rich.markdown import Markdown as RichMarkdown
9
+ from rich.text import Text
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
+ t = Text()
21
+ t.append(f"{sym} ", style=color)
22
+ t.append(text)
23
+ super().__init__(t)
24
+
25
+
26
+ class UserMessage(Static):
27
+ def __init__(self, text: str) -> None:
28
+ from nexforge.icons import icon
29
+
30
+ t = Text()
31
+ t.append(f"{icon('user')} ", style="bold #6D5EF5")
32
+ t.append(text)
33
+ super().__init__(t)
34
+
35
+
36
+ class ToolCard(Static):
37
+ """一次工具调用:执行中 → 完成(✔/✖)。"""
38
+
39
+ def __init__(self, preview: str, icon_name: str = "running") -> None:
40
+ super().__init__()
41
+ self._preview = preview
42
+ self._icon = icon_name
43
+
44
+ def on_mount(self) -> None:
45
+ from nexforge.icons import icon
46
+
47
+ ic = icon(self._icon)
48
+ t = Text()
49
+ t.append(f"{ic} ", style="#22D3EE")
50
+ t.append(self._preview)
51
+ t.append(" 执行…", style="dim")
52
+ self.update(t)
53
+
54
+ def set_result(self, ok: bool, summary: str) -> None:
55
+ from nexforge.icons import icon
56
+
57
+ mark = icon("ok") if ok else icon("err")
58
+ color = "green" if ok else "red"
59
+ t = Text()
60
+ t.append(f"{mark} ", style=color)
61
+ t.append(f"{self._preview} ", style="#22D3EE")
62
+ t.append(summary, style="dim")
63
+ self.update(t)
64
+
65
+
66
+ class AssistantTurn(Vertical):
67
+ """一轮助手输出:可折叠思考面板 + Markdown 正文。"""
68
+
69
+ def __init__(self, initial_content: str = "") -> None:
70
+ super().__init__()
71
+ self._reasoning = ""
72
+ self._content = initial_content
73
+ self._collapsed_once = False
74
+ self._body: Static | None = None # 思考面板引用缓存
75
+ self._txt: Static | None = None # 正文引用缓存
76
+
77
+ def compose(self) -> ComposeResult:
78
+ import re
79
+ from nexforge.icons import icon
80
+ from rich.markup import escape as rich_escape
81
+
82
+ with Collapsible(title=f"{icon('thinking')} 思考", collapsed=False, id="think"):
83
+ yield Static("", id="reasoning-body")
84
+ # 流式时用 Static 快速渲染(避免每 token 全量 Markdown 解析变慢)
85
+ init = re.sub(r"\n{3,}", "\n\n", self._content).lstrip("\n")
86
+ yield Static(rich_escape(init), id="content-txt")
87
+
88
+ def on_mount(self) -> None:
89
+ # 未产生思考内容前隐藏思考面板
90
+ self.query_one("#think", Collapsible).display = False
91
+
92
+ def add_reasoning(self, text: str) -> None:
93
+ self._reasoning += text
94
+ panel = self.query_one("#think", Collapsible)
95
+ if not panel.display:
96
+ panel.display = True
97
+ if self._body is None:
98
+ self._body = self.query_one("#reasoning-body", Static)
99
+ self._body.update(self._reasoning)
100
+ # 思考面板的 Contents 有 overflow-y:auto,每次更新后滚到底
101
+ try:
102
+ contents = panel.query_one("Contents")
103
+ contents.scroll_end(animate=False)
104
+ except Exception:
105
+ pass
106
+
107
+ def add_content(self, text: str) -> None:
108
+ # 正文开始 折叠思考面板
109
+ if not self._collapsed_once and self._reasoning:
110
+ self.query_one("#think", Collapsible).collapsed = True
111
+ self._collapsed_once = True
112
+ self._content += text
113
+ from rich.markup import escape as rich_escape
114
+ if self._txt is None:
115
+ self._txt = self.query_one("#content-txt", Static)
116
+ self._txt.update(rich_escape(self._content))
117
+
118
+ def finalize_content(self) -> None:
119
+ """回合结束:用 Rich Markdown 渲染到 Static(无 Textual Markdown bug)。"""
120
+ import re
121
+ try:
122
+ self.query_one("#content-txt", Static).remove()
123
+ except Exception:
124
+ pass
125
+ try:
126
+ self.query_one("#think", Collapsible).remove()
127
+ except Exception:
128
+ pass
129
+ normalized = re.sub(r"\n{3,}", "\n\n", self._content).strip("\n")
130
+ rich_md = RichMarkdown(normalized)
131
+ self.mount(Static(rich_md, id="content-rt"))
132
+ self._scroll_to_end()
133
+
134
+ def _scroll_to_end(self) -> None:
135
+ """精确找到 #chat-log 并滚到底。"""
136
+ try:
137
+ from textual.containers import VerticalScroll
138
+ log = self.app.query_one("#chat-log", VerticalScroll)
139
+ log.scroll_end(animate=False)
140
+ except Exception:
141
+ pass
142
+
143
+ @property
144
+ def is_empty(self) -> bool:
145
+ return not self._reasoning and not self._content
File without changes
File without changes
File without changes
File without changes