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 @@
|
|
|
1
|
+
"""Textual 自定义组件。"""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""API Key 输入面板 —— 首次使用或 /key 无参时弹出。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Input, Label
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiKeyModal(ModalScreen[str | None]):
|
|
12
|
+
DEFAULT_CSS = """
|
|
13
|
+
ApiKeyModal {
|
|
14
|
+
align: center middle;
|
|
15
|
+
}
|
|
16
|
+
ApiKeyModal > Vertical {
|
|
17
|
+
width: 55;
|
|
18
|
+
background: #1a1a24;
|
|
19
|
+
border: round #6D5EF5;
|
|
20
|
+
padding: 1 2;
|
|
21
|
+
}
|
|
22
|
+
#apikey-title {
|
|
23
|
+
color: #22D3EE;
|
|
24
|
+
text-style: bold;
|
|
25
|
+
margin-bottom: 1;
|
|
26
|
+
}
|
|
27
|
+
#apikey-body {
|
|
28
|
+
height: auto;
|
|
29
|
+
margin-bottom: 1;
|
|
30
|
+
}
|
|
31
|
+
#apikey-input {
|
|
32
|
+
margin-bottom: 1;
|
|
33
|
+
}
|
|
34
|
+
#apikey-buttons {
|
|
35
|
+
height: auto;
|
|
36
|
+
align: center bottom;
|
|
37
|
+
}
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
with Vertical():
|
|
42
|
+
yield Label("配置 DeepSeek API Key", id="apikey-title")
|
|
43
|
+
yield Label(
|
|
44
|
+
"请输入你的 DeepSeek API Key(以 sk- 开头)。\n"
|
|
45
|
+
"Key 将安全保存在本地凭据文件中,不会上传。\n\n"
|
|
46
|
+
"[dim]获取地址: https://platform.deepseek.com/api_keys[/]",
|
|
47
|
+
id="apikey-body",
|
|
48
|
+
)
|
|
49
|
+
yield Input(placeholder="sk-…", id="apikey-input", password=True)
|
|
50
|
+
with Vertical(id="apikey-buttons"):
|
|
51
|
+
yield Button("保存并开始", variant="primary", id="btn-apikey-save")
|
|
52
|
+
yield Button("跳过(稍后配置)", id="btn-apikey-skip")
|
|
53
|
+
|
|
54
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
55
|
+
if event.button.id == "btn-apikey-save":
|
|
56
|
+
key = self.query_one("#apikey-input", Input).value.strip()
|
|
57
|
+
if key.startswith("sk-"):
|
|
58
|
+
self.dismiss(key)
|
|
59
|
+
else:
|
|
60
|
+
self.query_one("#apikey-body", Label).update(
|
|
61
|
+
"[red]Key 必须以 sk- 开头,请检查后重试。[/]\n\n"
|
|
62
|
+
"[dim]获取地址: https://platform.deepseek.com/api_keys[/]"
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""顶部品牌横幅。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
from nexforge.icons import icon
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Banner(Static):
|
|
12
|
+
def on_mount(self) -> None:
|
|
13
|
+
ic = icon("agent")
|
|
14
|
+
t = Text()
|
|
15
|
+
t.append(f"{ic} NexForge", style="bold #6D5EF5")
|
|
16
|
+
t.append("CLI", style="bold #22D3EE")
|
|
17
|
+
t.append(" DeepSeek V4 终端智能体", style="dim")
|
|
18
|
+
self.update(t)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""通用确认弹窗:删前确认,防误操作。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Label
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfirmModal(ModalScreen[bool]):
|
|
12
|
+
DEFAULT_CSS = """
|
|
13
|
+
ConfirmModal {
|
|
14
|
+
align: center middle;
|
|
15
|
+
}
|
|
16
|
+
ConfirmModal > Vertical {
|
|
17
|
+
width: 50;
|
|
18
|
+
background: #1a1a24;
|
|
19
|
+
border: round #E04040;
|
|
20
|
+
padding: 1 2;
|
|
21
|
+
}
|
|
22
|
+
#confirm-title {
|
|
23
|
+
color: #E04040;
|
|
24
|
+
text-style: bold;
|
|
25
|
+
margin-bottom: 1;
|
|
26
|
+
}
|
|
27
|
+
#confirm-body {
|
|
28
|
+
height: auto;
|
|
29
|
+
margin-bottom: 1;
|
|
30
|
+
}
|
|
31
|
+
#confirm-buttons {
|
|
32
|
+
height: auto;
|
|
33
|
+
align: center bottom;
|
|
34
|
+
}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, title: str, message: str, confirm_label: str = "确认删除") -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
self._title = title
|
|
40
|
+
self._message = message
|
|
41
|
+
self._confirm_label = confirm_label
|
|
42
|
+
|
|
43
|
+
def compose(self) -> ComposeResult:
|
|
44
|
+
with Vertical():
|
|
45
|
+
yield Label(self._title, id="confirm-title")
|
|
46
|
+
yield Label(self._message, id="confirm-body")
|
|
47
|
+
with Vertical(id="confirm-buttons"):
|
|
48
|
+
yield Button(f"{self._confirm_label} (y)", variant="error", id="btn-confirm-yes")
|
|
49
|
+
yield Button("取消 (n)", variant="primary", id="btn-confirm-no")
|
|
50
|
+
|
|
51
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
52
|
+
self.dismiss(event.button.id == "btn-confirm-yes")
|
|
53
|
+
|
|
54
|
+
def on_key(self, event) -> None:
|
|
55
|
+
if event.key in ("y", "Y"):
|
|
56
|
+
self.dismiss(True)
|
|
57
|
+
elif event.key in ("n", "N", "escape"):
|
|
58
|
+
self.dismiss(False)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Diff 审批模态:写文件/执行危险操作前展示变更,用户 y/n 审批。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Label, Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DiffApprovalModal(ModalScreen[bool]):
|
|
12
|
+
"""模态:展示变更预览,返回 True(批准)或 False(拒绝)。"""
|
|
13
|
+
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
DiffApprovalModal {
|
|
16
|
+
align: center middle;
|
|
17
|
+
}
|
|
18
|
+
DiffApprovalModal > Vertical {
|
|
19
|
+
width: 60;
|
|
20
|
+
max-height: 80%;
|
|
21
|
+
background: #1a1a24;
|
|
22
|
+
border: round #6D5EF5;
|
|
23
|
+
padding: 1 2;
|
|
24
|
+
}
|
|
25
|
+
#diff-title {
|
|
26
|
+
color: #22D3EE;
|
|
27
|
+
text-style: bold;
|
|
28
|
+
margin-bottom: 1;
|
|
29
|
+
}
|
|
30
|
+
#diff-body {
|
|
31
|
+
height: auto;
|
|
32
|
+
max-height: 20;
|
|
33
|
+
margin-bottom: 1;
|
|
34
|
+
}
|
|
35
|
+
#diff-buttons {
|
|
36
|
+
height: auto;
|
|
37
|
+
align: center bottom;
|
|
38
|
+
}
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, tool_name: str, preview: str, diff: str = "") -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self._tool = tool_name
|
|
44
|
+
self._preview = preview
|
|
45
|
+
self._diff = diff
|
|
46
|
+
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
with Vertical():
|
|
49
|
+
yield Label(f"审批 {self._tool}", id="diff-title")
|
|
50
|
+
yield Label(f"[dim]{self._preview}[/]", id="diff-preview")
|
|
51
|
+
if self._diff:
|
|
52
|
+
yield Static(self._diff, id="diff-body")
|
|
53
|
+
with Vertical(id="diff-buttons"):
|
|
54
|
+
yield Button("批准 (y)", variant="primary", id="btn-approve")
|
|
55
|
+
yield Button("拒绝 (n)", variant="error", id="btn-deny")
|
|
56
|
+
|
|
57
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
58
|
+
if event.button.id == "btn-approve":
|
|
59
|
+
self.dismiss(True)
|
|
60
|
+
else:
|
|
61
|
+
self.dismiss(False)
|
|
62
|
+
|
|
63
|
+
def on_key(self, event) -> None:
|
|
64
|
+
if event.key in ("y", "Y"):
|
|
65
|
+
self.dismiss(True)
|
|
66
|
+
elif event.key in ("n", "N", "escape"):
|
|
67
|
+
self.dismiss(False)
|
|
@@ -0,0 +1,132 @@
|
|
|
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)}")
|
|
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)}")
|
|
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)} [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)}[/] "
|
|
51
|
+
f"[dim]{escape(summary)}[/]"
|
|
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
|
+
|
|
64
|
+
def compose(self) -> ComposeResult:
|
|
65
|
+
import re
|
|
66
|
+
from nexforge.icons import icon
|
|
67
|
+
from rich.markup import escape as rich_escape
|
|
68
|
+
|
|
69
|
+
with Collapsible(title=f"{icon('thinking')} 思考", collapsed=False, id="think"):
|
|
70
|
+
yield Static("", id="reasoning-body")
|
|
71
|
+
# 流式时用 Static 快速渲染(避免每 token 全量 Markdown 解析变慢)
|
|
72
|
+
init = re.sub(r"\n{3,}", "\n\n", self._content).lstrip("\n")
|
|
73
|
+
yield Static(rich_escape(init), id="content-txt")
|
|
74
|
+
|
|
75
|
+
def on_mount(self) -> None:
|
|
76
|
+
# 未产生思考内容前隐藏思考面板
|
|
77
|
+
self.query_one("#think", Collapsible).display = False
|
|
78
|
+
|
|
79
|
+
def add_reasoning(self, text: str) -> None:
|
|
80
|
+
self._reasoning += text
|
|
81
|
+
panel = self.query_one("#think", Collapsible)
|
|
82
|
+
if not panel.display:
|
|
83
|
+
panel.display = True
|
|
84
|
+
self.query_one("#reasoning-body", Static).update(self._reasoning)
|
|
85
|
+
# 思考面板的 Contents 有 overflow-y:auto,每次更新后滚到底
|
|
86
|
+
try:
|
|
87
|
+
contents = panel.query_one("Contents")
|
|
88
|
+
contents.scroll_end(animate=False)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
def add_content(self, text: str) -> None:
|
|
93
|
+
# 正文开始 → 折叠思考面板
|
|
94
|
+
if not self._collapsed_once and self._reasoning:
|
|
95
|
+
self.query_one("#think", Collapsible).collapsed = True
|
|
96
|
+
self._collapsed_once = True
|
|
97
|
+
self._content += text
|
|
98
|
+
import re
|
|
99
|
+
from rich.markup import escape as rich_escape
|
|
100
|
+
# 折叠 3+ 连续空行为 2,避免句子间出现大空白
|
|
101
|
+
normalized = re.sub(r"\n{3,}", "\n\n", self._content).lstrip("\n")
|
|
102
|
+
# static 用 escaped 文本,避免 [ / # / ` 触发 Rich markup 吞内容
|
|
103
|
+
self.query_one("#content-txt", Static).update(rich_escape(normalized))
|
|
104
|
+
|
|
105
|
+
def finalize_content(self) -> None:
|
|
106
|
+
"""回合结束:用 Rich Markdown 渲染到 Static(无 Textual Markdown bug)。"""
|
|
107
|
+
import re
|
|
108
|
+
try:
|
|
109
|
+
self.query_one("#content-txt", Static).remove()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
try:
|
|
113
|
+
self.query_one("#think", Collapsible).remove()
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
normalized = re.sub(r"\n{3,}", "\n\n", self._content).strip("\n")
|
|
117
|
+
rich_md = RichMarkdown(normalized)
|
|
118
|
+
self.mount(Static(rich_md, id="content-rt"))
|
|
119
|
+
self._scroll_to_end()
|
|
120
|
+
|
|
121
|
+
def _scroll_to_end(self) -> None:
|
|
122
|
+
"""精确找到 #chat-log 并滚到底。"""
|
|
123
|
+
try:
|
|
124
|
+
from textual.containers import VerticalScroll
|
|
125
|
+
log = self.app.query_one("#chat-log", VerticalScroll)
|
|
126
|
+
log.scroll_end(animate=False)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_empty(self) -> bool:
|
|
132
|
+
return not self._reasoning and not self._content
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""警告模态:切换全自动模式前的确认弹窗。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Label
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ModeWarningModal(ModalScreen[bool]):
|
|
12
|
+
DEFAULT_CSS = """
|
|
13
|
+
ModeWarningModal {
|
|
14
|
+
align: center middle;
|
|
15
|
+
}
|
|
16
|
+
ModeWarningModal > Vertical {
|
|
17
|
+
width: 50;
|
|
18
|
+
background: #1a1a24;
|
|
19
|
+
border: round #E04040;
|
|
20
|
+
padding: 1 2;
|
|
21
|
+
}
|
|
22
|
+
#mw-title {
|
|
23
|
+
color: #E04040;
|
|
24
|
+
text-style: bold;
|
|
25
|
+
margin-bottom: 1;
|
|
26
|
+
}
|
|
27
|
+
#mw-body {
|
|
28
|
+
height: auto;
|
|
29
|
+
margin-bottom: 1;
|
|
30
|
+
}
|
|
31
|
+
#mw-buttons {
|
|
32
|
+
height: auto;
|
|
33
|
+
align: center bottom;
|
|
34
|
+
}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
with Vertical():
|
|
39
|
+
yield Label("⚠ 全自动模式", id="mw-title")
|
|
40
|
+
yield Label(
|
|
41
|
+
"全自动模式下,所有写文件/执行命令/删除操作将 [bold #E04040]不经审批自动执行[/]。\n\n"
|
|
42
|
+
"这可能导致不可逆的文件修改或系统命令执行。\n\n"
|
|
43
|
+
"[dim]确认开启? (y/n)[/]",
|
|
44
|
+
id="mw-body",
|
|
45
|
+
)
|
|
46
|
+
with Vertical(id="mw-buttons"):
|
|
47
|
+
yield Button("确认开启 (y)", variant="error", id="btn-auto-yes")
|
|
48
|
+
yield Button("取消 (n)", variant="primary", id="btn-auto-no")
|
|
49
|
+
|
|
50
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
51
|
+
self.dismiss(event.button.id == "btn-auto-yes")
|
|
52
|
+
|
|
53
|
+
def on_key(self, event) -> None:
|
|
54
|
+
if event.key in ("y", "Y"):
|
|
55
|
+
self.dismiss(True)
|
|
56
|
+
elif event.key in ("n", "N", "escape"):
|
|
57
|
+
self.dismiss(False)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""多行输入弹窗:F2 弹出,Enter 换行,Ctrl+Enter 或按钮提交。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal, Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Label, TextArea
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MultilineInputModal(ModalScreen[str | None]):
|
|
12
|
+
|
|
13
|
+
DEFAULT_CSS = """
|
|
14
|
+
MultilineInputModal {
|
|
15
|
+
align: center middle;
|
|
16
|
+
}
|
|
17
|
+
MultilineInputModal > Vertical {
|
|
18
|
+
width: 80;
|
|
19
|
+
height: 26;
|
|
20
|
+
background: #1a1a24;
|
|
21
|
+
border: round #6D5EF5;
|
|
22
|
+
padding: 1 2;
|
|
23
|
+
}
|
|
24
|
+
#mli-title {
|
|
25
|
+
color: #6D5EF5;
|
|
26
|
+
text-style: bold;
|
|
27
|
+
height: auto;
|
|
28
|
+
}
|
|
29
|
+
#mli-textarea {
|
|
30
|
+
height: 1fr;
|
|
31
|
+
margin: 1 0;
|
|
32
|
+
}
|
|
33
|
+
#mli-buttons {
|
|
34
|
+
height: auto;
|
|
35
|
+
align: center top;
|
|
36
|
+
}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, prefilled: str = "") -> None:
|
|
40
|
+
super().__init__()
|
|
41
|
+
self._prefilled = prefilled
|
|
42
|
+
|
|
43
|
+
def compose(self) -> ComposeResult:
|
|
44
|
+
with Vertical():
|
|
45
|
+
yield Label("多行输入 (Enter 换行,点发送提交)", id="mli-title")
|
|
46
|
+
yield TextArea(self._prefilled, id="mli-textarea")
|
|
47
|
+
with Horizontal(id="mli-buttons"):
|
|
48
|
+
yield Button("发送", variant="primary", id="btn-mli-send")
|
|
49
|
+
yield Button("取消", id="btn-mli-cancel")
|
|
50
|
+
|
|
51
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
52
|
+
if event.button.id == "btn-mli-send":
|
|
53
|
+
self.action_send_text()
|
|
54
|
+
elif event.button.id == "btn-mli-cancel":
|
|
55
|
+
self.dismiss(None)
|
|
56
|
+
|
|
57
|
+
def action_send_text(self) -> None:
|
|
58
|
+
text = self.query_one("#mli-textarea", TextArea).text.strip()
|
|
59
|
+
self.dismiss(text if text else None)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""多行输入组件:Enter 提交,自适应 1~5 行,粘贴多行。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.message import Message
|
|
6
|
+
from textual.widgets import TextArea
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MultilinePrompt(TextArea):
|
|
10
|
+
"""Enter 提交,自适应高度。"""
|
|
11
|
+
|
|
12
|
+
class PromptSubmitted(Message):
|
|
13
|
+
def __init__(self, text: str) -> None:
|
|
14
|
+
self.text = text
|
|
15
|
+
super().__init__()
|
|
16
|
+
|
|
17
|
+
async def _on_key(self, event) -> None:
|
|
18
|
+
if event.key == "enter":
|
|
19
|
+
event.stop()
|
|
20
|
+
event.prevent_default()
|
|
21
|
+
text = self.text
|
|
22
|
+
self.clear()
|
|
23
|
+
self.post_message(self.PromptSubmitted(text))
|
|
24
|
+
return
|
|
25
|
+
await super()._on_key(event)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""计划面板 —— 右侧面板,显示计划标题按钮列表 + 进度。
|
|
2
|
+
|
|
3
|
+
Ctrl+B 开关;默认隐藏。点击标题 → 载入计划到主屏。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Vertical, VerticalScroll
|
|
10
|
+
from textual.widgets import Button, Label
|
|
11
|
+
|
|
12
|
+
from nexforge.store.plan_store import PlanInfo, list_plans
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlanSidebar(Vertical):
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
PlanSidebar {
|
|
18
|
+
width: 32;
|
|
19
|
+
height: 100%;
|
|
20
|
+
border-left: outer #22D3EE;
|
|
21
|
+
background: #12121a;
|
|
22
|
+
padding: 1;
|
|
23
|
+
}
|
|
24
|
+
#plan-title {
|
|
25
|
+
color: #22D3EE;
|
|
26
|
+
text-style: bold;
|
|
27
|
+
height: auto;
|
|
28
|
+
margin-bottom: 1;
|
|
29
|
+
}
|
|
30
|
+
#plan-list {
|
|
31
|
+
height: 1fr;
|
|
32
|
+
}
|
|
33
|
+
#plan-buttons {
|
|
34
|
+
height: auto;
|
|
35
|
+
padding-top: 1;
|
|
36
|
+
}
|
|
37
|
+
PlanSidebar Button {
|
|
38
|
+
width: 100%;
|
|
39
|
+
margin-bottom: 1;
|
|
40
|
+
height: auto;
|
|
41
|
+
}
|
|
42
|
+
.plan-btn {
|
|
43
|
+
background: #1a1a24;
|
|
44
|
+
border: none;
|
|
45
|
+
text-align: left;
|
|
46
|
+
}
|
|
47
|
+
.plan-btn:hover {
|
|
48
|
+
background: #6D5EF5 30%;
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
53
|
+
super().__init__(*args, **kwargs)
|
|
54
|
+
self.plan_map: dict[str, PlanInfo] = {}
|
|
55
|
+
self.active_id: str = "" # 当前激活计划
|
|
56
|
+
self._gen = 0
|
|
57
|
+
|
|
58
|
+
def compose(self) -> ComposeResult:
|
|
59
|
+
yield Label("计划列表", id="plan-title")
|
|
60
|
+
yield VerticalScroll(id="plan-list")
|
|
61
|
+
with Vertical(id="plan-buttons"):
|
|
62
|
+
yield Button("+ 新建计划(/plan)", id="btn-plan-new", variant="primary")
|
|
63
|
+
yield Button("关闭计划模式", id="btn-plan-toggle", variant="default")
|
|
64
|
+
yield Button("✖ 删除当前", id="btn-plan-del", variant="error")
|
|
65
|
+
|
|
66
|
+
def on_mount(self) -> None:
|
|
67
|
+
self.refresh_list()
|
|
68
|
+
|
|
69
|
+
def refresh_list(self) -> None:
|
|
70
|
+
cont = self.query_one("#plan-list", VerticalScroll)
|
|
71
|
+
cont.remove_children()
|
|
72
|
+
self.plan_map.clear()
|
|
73
|
+
self._gen += 1
|
|
74
|
+
gen = self._gen
|
|
75
|
+
plans = list_plans()
|
|
76
|
+
if not plans:
|
|
77
|
+
cont.mount(Label("[dim](暂无计划)[/]", id=f"empty-{gen}"))
|
|
78
|
+
return
|
|
79
|
+
for i, p in enumerate(plans[:40]):
|
|
80
|
+
bid = f"plan-{gen}-{i}"
|
|
81
|
+
self.plan_map[bid] = p
|
|
82
|
+
progress = f" [{p.done}/{p.total}]" if p.total > 0 else ""
|
|
83
|
+
active_dot = "[#3CE88A]●[/] " if p.id == self.active_id else " "
|
|
84
|
+
btn = Button(
|
|
85
|
+
f"{active_dot}{p.title[:20]}{progress}",
|
|
86
|
+
id=bid,
|
|
87
|
+
classes="plan-btn",
|
|
88
|
+
)
|
|
89
|
+
cont.mount(btn)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""会话管理侧边栏 —— 右侧面板,列会话/新建/切换/删除。
|
|
2
|
+
|
|
3
|
+
Ctrl+S 开关;默认隐藏。会话行、新建、删除都是 Button,点击事件
|
|
4
|
+
冒泡到 ChatScreen.on_button_pressed 处理。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.containers import Vertical, VerticalScroll
|
|
11
|
+
from textual.widgets import Button, Label
|
|
12
|
+
|
|
13
|
+
from nexforge.store.session_store import SessionInfo, list_sessions
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Sidebar(Vertical):
|
|
17
|
+
DEFAULT_CSS = """
|
|
18
|
+
Sidebar {
|
|
19
|
+
width: 30;
|
|
20
|
+
height: 100%;
|
|
21
|
+
border-left: outer #6D5EF5;
|
|
22
|
+
background: #12121a;
|
|
23
|
+
padding: 1;
|
|
24
|
+
}
|
|
25
|
+
#sb-title {
|
|
26
|
+
color: #22D3EE;
|
|
27
|
+
text-style: bold;
|
|
28
|
+
height: auto;
|
|
29
|
+
margin-bottom: 1;
|
|
30
|
+
}
|
|
31
|
+
#sb-list {
|
|
32
|
+
height: 1fr;
|
|
33
|
+
}
|
|
34
|
+
#sb-buttons {
|
|
35
|
+
height: auto;
|
|
36
|
+
padding-top: 1;
|
|
37
|
+
}
|
|
38
|
+
Sidebar Button {
|
|
39
|
+
width: 100%;
|
|
40
|
+
margin-bottom: 1;
|
|
41
|
+
height: auto;
|
|
42
|
+
}
|
|
43
|
+
.sess-btn {
|
|
44
|
+
background: #1a1a24;
|
|
45
|
+
border: none;
|
|
46
|
+
text-align: left;
|
|
47
|
+
}
|
|
48
|
+
.sess-btn:hover {
|
|
49
|
+
background: #6D5EF5 30%;
|
|
50
|
+
}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
54
|
+
super().__init__(*args, **kwargs)
|
|
55
|
+
# 按钮 id → SessionInfo 映射
|
|
56
|
+
self.session_map: dict[str, SessionInfo] = {}
|
|
57
|
+
# 刷新代数:每次刷新用新的 id 前缀,避免 remove_children 异步未完成时 id 冲突
|
|
58
|
+
self._gen = 0
|
|
59
|
+
self.active_id: str = "" # 当前激活会话(绿点标识)
|
|
60
|
+
|
|
61
|
+
def compose(self) -> ComposeResult:
|
|
62
|
+
yield Label("会话管理", id="sb-title")
|
|
63
|
+
yield VerticalScroll(id="sb-list")
|
|
64
|
+
with Vertical(id="sb-buttons"):
|
|
65
|
+
yield Button("+ 新建会话", id="btn-new", variant="primary")
|
|
66
|
+
yield Button("✖ 删除当前", id="btn-del", variant="error")
|
|
67
|
+
|
|
68
|
+
def on_mount(self) -> None:
|
|
69
|
+
self.refresh_list()
|
|
70
|
+
|
|
71
|
+
def refresh_list(self) -> None:
|
|
72
|
+
"""重建会话列表。用代数前缀保证 id 唯一(remove_children 是异步的)。"""
|
|
73
|
+
# 从当前屏幕同步激活会话 id(绿点标识)
|
|
74
|
+
try:
|
|
75
|
+
self.active_id = getattr(self.screen, "_session_id", "") or ""
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
cont = self.query_one("#sb-list", VerticalScroll)
|
|
79
|
+
cont.remove_children()
|
|
80
|
+
self.session_map.clear()
|
|
81
|
+
self._gen += 1
|
|
82
|
+
gen = self._gen
|
|
83
|
+
|
|
84
|
+
sessions = list_sessions()
|
|
85
|
+
if not sessions:
|
|
86
|
+
cont.mount(Label("[dim](暂无历史会话)[/]", id=f"empty-{gen}"))
|
|
87
|
+
return
|
|
88
|
+
for i, s in enumerate(sessions[:50]):
|
|
89
|
+
bid = f"sess-{gen}-{i}"
|
|
90
|
+
self.session_map[bid] = s
|
|
91
|
+
active = s.id == self.active_id
|
|
92
|
+
dot = "[#3CE88A]●[/] " if active else " "
|
|
93
|
+
btn = Button(
|
|
94
|
+
f"{dot}{s.name[:15]} ({s.message_count})",
|
|
95
|
+
id=bid,
|
|
96
|
+
classes="sess-btn",
|
|
97
|
+
)
|
|
98
|
+
cont.mount(btn)
|
|
99
|
+
cont.refresh()
|