@aiti-ai/aiti-cmd 0.0.0-stage → 0.1.10
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.
- package/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/audit.ts +255 -0
- package/check-client.mjs +273 -0
- package/client/src/index.js +2216 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/ext-icons-deploy.mjs +24 -0
- package/icon.svg +1 -0
- package/icons/terminal.svg +1 -0
- package/index.ts +544 -0
- package/package.json +34 -4
- package/pi-commands.ts +286 -0
- package/recognize.ts +508 -0
- package/reconcile-deps.mjs +41 -0
- package/reload-instances.mjs +60 -0
- package/runner.ts +594 -0
- package/skills/command-create/SKILL.md +47 -0
- package/skills/command-fill/SKILL.md +46 -0
- package/skills/command-fix/SKILL.md +49 -0
- package/skills/command-run/SKILL.md +41 -0
- package/skills/index.json +6 -0
- package/store.ts +228 -0
- package/tools/build-client.mjs +52 -0
- package/tools/deploy.mjs +120 -0
- package/tools/exec-integration.test.mjs +222 -0
- package/tools/recognize.test.mjs +163 -0
|
@@ -0,0 +1,2216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-cmd client bundle — prebuilt ESM(与 aiti-devtools 同模式:host 传 h/useState/useEffect/apiUrl)
|
|
3
|
+
*
|
|
4
|
+
* UI:
|
|
5
|
+
* - toolbar:⚡ 命令按钮 → 打开主面板
|
|
6
|
+
* - dialogs:命令面板(命令/终端双页签 + xterm.js 真终端)+ 命令详情(消息识别点开)
|
|
7
|
+
* - 全局桥:window.__aitiCmd.openCommand(cmd, cwd) — pi-web core 消息点击识别(onCommand)调用
|
|
8
|
+
*/
|
|
9
|
+
import { Terminal } from "@xterm/xterm";
|
|
10
|
+
import { FitAddon } from "@xterm/addon-fit";
|
|
11
|
+
import xtermCss from "@xterm/xterm/css/xterm.css";
|
|
12
|
+
import { uiConfirm, uiAlert, uiPrompt } from "../../../_shared/ui-dialogs";
|
|
13
|
+
|
|
14
|
+
// 注入 xterm.css(构建时以 text 内联)
|
|
15
|
+
if (typeof document !== "undefined" && !document.getElementById("aiti-cmd-xterm-css")) {
|
|
16
|
+
const style = document.createElement("style");
|
|
17
|
+
style.id = "aiti-cmd-xterm-css";
|
|
18
|
+
style.textContent = xtermCss;
|
|
19
|
+
document.head.appendChild(style);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------- 模块级状态(跨组件共享;同 aiti-client.mjs 的 listener 模式) ----------
|
|
23
|
+
// view:总览页当前详情视图 {kind:"pi"|"pty", id, title}(点列表行进入,← 返回)
|
|
24
|
+
const state = { panelOpen: false, view: null, detail: null, detailBusy: false };
|
|
25
|
+
const stateListeners = new Set();
|
|
26
|
+
function notify() { for (const l of stateListeners) l(); }
|
|
27
|
+
function setState(patch) { Object.assign(state, patch); notify(); }
|
|
28
|
+
function openView(kind, id, title) { setState({ view: { kind, id, title } }); }
|
|
29
|
+
function closeView() { setState({ view: null }); }
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
const btnStyle = {
|
|
33
|
+
borderWidth: 1, borderStyle: "solid", borderColor: "var(--border)", background: "var(--bg-hover)",
|
|
34
|
+
borderRadius: 6, padding: "5px 12px", cursor: "pointer",
|
|
35
|
+
fontSize: 13, color: "var(--text)", fontFamily: "inherit",
|
|
36
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 4,
|
|
37
|
+
whiteSpace: "nowrap", flexShrink: 0,
|
|
38
|
+
};
|
|
39
|
+
const rowStyle = { display: "flex", alignItems: "center", gap: 8, marginBottom: 8, flexWrap: "wrap" };
|
|
40
|
+
const inputStyle = {
|
|
41
|
+
flex: 1, borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", borderRadius: 6,
|
|
42
|
+
padding: "5px 10px", fontSize: 13, background: "var(--bg)", color: "var(--text)",
|
|
43
|
+
fontFamily: "inherit",
|
|
44
|
+
};
|
|
45
|
+
const smallStyle = { fontSize: 13, color: "var(--text-muted)", margin: 0, wordBreak: "break-all", lineHeight: 1.5 };
|
|
46
|
+
// topbar 工具栏按钮:与系统内建按钮一致(AppShell topbarRowBtnStyle:flex 居中 + fixed 36×36(=系统 TOP_BAR_ICON_BUTTON_SIZE) + hover 变色)
|
|
47
|
+
const topbarBtnStyle = {
|
|
48
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
49
|
+
width: 36, height: 36, padding: 0,
|
|
50
|
+
background: "none", borderTop: "none", borderRight: "1px solid var(--border)",
|
|
51
|
+
color: "var(--text-muted)", cursor: "pointer", flexShrink: 0, fontSize: 16, lineHeight: 1,
|
|
52
|
+
transition: "color 0.12s",
|
|
53
|
+
};
|
|
54
|
+
const SOURCE_LABEL = { manual: "手动", message: "消息识别", shell: "终端", favorite: "收藏", "asr-selfheal": "ASR 自愈", schedule: "定时", suggest: "联想", "ai-helper": "AI", codebuddy: "CodeBuddy", service: "服务", unknown: "未知" };
|
|
55
|
+
|
|
56
|
+
// 沙箱模式(默认 none 无沙箱):label 用于编辑弹窗 select;icon 用于列表/会话行标记;hint 用于弹窗提示
|
|
57
|
+
const SANDBOX = {
|
|
58
|
+
none: { label: "无沙箱(完全信任)· 默认", icon: "", short: "无沙箱", hint: "不隔离文件系统/网络,仅限可信命令", warn: true },
|
|
59
|
+
"bwrap-ro": { label: "系统只读(工作目录可写)", icon: " 🛡", short: "只读", hint: "文件系统只读,改动不落盘;工作目录真实可写", warn: false },
|
|
60
|
+
"bwrap-offline": { label: "只读 + 断网", icon: " 🛡", short: "只读+断网", hint: "文件系统只读且无网络——最高隔离,适合不信任命令", warn: false },
|
|
61
|
+
"bwrap-tmp": { label: "临时可写盘(退出即丢)", icon: " ♻", short: "临时盘", hint: "一次性环境:系统只读,/tmp、$HOME 可写但退出即丢", warn: false },
|
|
62
|
+
};
|
|
63
|
+
const SANDBOX_ORDER = ["none", "bwrap-ro", "bwrap-offline", "bwrap-tmp"];
|
|
64
|
+
|
|
65
|
+
// 终端配色(slate 色板 × 深背景):默认主题的纯白前景 + 黑底在亮色 UI 里割裂,
|
|
66
|
+
// 且 brightBlack(#666) 在黑底上几乎看不清;统一用高对比 slate 色板,背景与容器无缝
|
|
67
|
+
const TERM_THEME = {
|
|
68
|
+
background: "#0f172a",
|
|
69
|
+
foreground: "#e2e8f0",
|
|
70
|
+
cursor: "#93c5fd",
|
|
71
|
+
cursorAccent: "#0f172a",
|
|
72
|
+
selectionBackground: "#334155",
|
|
73
|
+
black: "#0f172a", brightBlack: "#7c8ba1",
|
|
74
|
+
red: "#f87171", brightRed: "#fca5a5",
|
|
75
|
+
green: "#4ade80", brightGreen: "#86efac",
|
|
76
|
+
yellow: "#facc15", brightYellow: "#fde047",
|
|
77
|
+
blue: "#60a5fa", brightBlue: "#93c5fd",
|
|
78
|
+
magenta: "#c084fc", brightMagenta: "#e9d5ff",
|
|
79
|
+
cyan: "#22d3ee", brightCyan: "#67e8f9",
|
|
80
|
+
white: "#cbd5e1", brightWhite: "#f8fafc",
|
|
81
|
+
};
|
|
82
|
+
// 字体:不用页面 --font-mono(链到 next/font 的 Noto Sans Mono,异步加载——xterm 先用
|
|
83
|
+
// fallback 测量字符宽度,字体 swap 后列宽错位);系统等宽栈加载即时、度量稳定
|
|
84
|
+
const TERM_FONT = "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, 'DejaVu Sans Mono', 'Liberation Mono', monospace";
|
|
85
|
+
|
|
86
|
+
// ---------- 终端增强:窗口大小 / 全屏 / 虚拟键盘 / 快捷键 ----------
|
|
87
|
+
// 窗口大小预设:fit=自适应容器(默认,桌面端即常规终端观感);其余为固定 列×行(同常规终端窗口,小屏用滚动看全貌)
|
|
88
|
+
const TERM_SIZES = [
|
|
89
|
+
{ id: "fit", label: "自适应", cols: null, rows: null },
|
|
90
|
+
{ id: "80x24", label: "80×24", cols: 80, rows: 24 },
|
|
91
|
+
{ id: "100x30", label: "100×30", cols: 100, rows: 30 },
|
|
92
|
+
{ id: "120x40", label: "120×40", cols: 120, rows: 40 },
|
|
93
|
+
{ id: "160x50", label: "160×50", cols: 160, rows: 50 },
|
|
94
|
+
];
|
|
95
|
+
// 常用快捷键参考(帮助弹层)
|
|
96
|
+
const TERM_SHORTCUTS = [
|
|
97
|
+
["Ctrl+C", "中断当前命令(SIGINT)"],
|
|
98
|
+
["Ctrl+D", "退出 shell / EOF"],
|
|
99
|
+
["Ctrl+Z", "挂起当前任务(后台)"],
|
|
100
|
+
["Ctrl+L", "清屏"],
|
|
101
|
+
["Ctrl+A / Ctrl+E", "光标到行首 / 行尾"],
|
|
102
|
+
["Ctrl+U / Ctrl+K", "删除到行首 / 删除到行尾"],
|
|
103
|
+
["Ctrl+W", "删除前一个单词"],
|
|
104
|
+
["Ctrl+R", "反向搜索命令历史"],
|
|
105
|
+
["Ctrl+← / Ctrl+→", "按词移动光标(readline)"],
|
|
106
|
+
["Ctrl+↑ / Ctrl+↓", "上/下翻(终端)"],
|
|
107
|
+
["Tab", "命令 / 路径补全"],
|
|
108
|
+
["↑ / ↓", "上一条 / 下一条命令历史"],
|
|
109
|
+
["← / →", "光标左 / 右移"],
|
|
110
|
+
["Home / End", "行首 / 行尾"],
|
|
111
|
+
["PgUp / PgDn", "上翻 / 下翻一页"],
|
|
112
|
+
["Esc", "取消 / 进入 vi 模式"],
|
|
113
|
+
["右键 / Ctrl+Shift+C / Ctrl+Insert", "复制选中的终端文本(Ctrl+C 是中断,不能用来复制)"],
|
|
114
|
+
["Ctrl+Shift+V / Shift+Insert", "粘贴剪贴板到终端"],
|
|
115
|
+
["Ctrl+Shift+F", "切换终端全屏"],
|
|
116
|
+
["Ctrl+Shift+K", "切换虚拟键盘"],
|
|
117
|
+
["Ctrl+Shift+S", "循环终端窗口大小"],
|
|
118
|
+
["F1–F12", "终端功能键(虚拟键盘「常用/全键」底部一行,mc / htop 等 TUI 用)"],
|
|
119
|
+
];
|
|
120
|
+
// 工具条按钮说明(帮助弹层用:触屏没有 hover,title 看不见,按钮含义在这里集中解释);
|
|
121
|
+
// 滚动按钮标签随 UI 语言:zh → 「↓底」,其他 → 图标「⇊」
|
|
122
|
+
const TOOLBAR_LEGEND = (zh) => [
|
|
123
|
+
[zh ? "↓底" : "⇊", "滚动到底部:跳到最新输出(上翻阅读时不会打扰)"],
|
|
124
|
+
["⌨", "虚拟键盘:方向 / Tab / Esc / Ctrl / 常用符号(Ctrl+Shift+K)"],
|
|
125
|
+
["?", "本帮助面板"],
|
|
126
|
+
["+", "新建:再开一个 shell(目录 = 当前终端 📁 实时路径)"],
|
|
127
|
+
["新会话", "新建 pi-web 会话:打开新 agent 会话并 @ 当前终端实时路径"],
|
|
128
|
+
["⇲", "拉回窗口:终端视图移回 aiti-cmd 弹窗(页签显示占位;仅页签模式有)"],
|
|
129
|
+
["⛶", "全屏 / 退出全屏(Ctrl+Shift+F)"],
|
|
130
|
+
];
|
|
131
|
+
// Shift 锁的上档映射:数字/标点键 → 上档符号(字母键走大写分支;符号键已是上档形态时直接吞锁)
|
|
132
|
+
const SHIFTED = { "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", "8": "*", "9": "(", "0": ")", "-": "_", "=": "+", "`": "~", "[": "{", "]": "}", "\\": "|", ";": ":", "'": "\"", ",": "<", ".": ">", "/": "?" };
|
|
133
|
+
// 功能键 F1-F12 行:发 xterm 标准转义序列(F1-F4 走 SS3 \x1bOP..,F5-F12 走 CSI \x1b[15~..;
|
|
134
|
+
// 与 xterm.js 物理键盘默认输出一致,mc / htop / less 等 TUI 用 F 键操作)
|
|
135
|
+
const KEYPAD_FN_ROW = [
|
|
136
|
+
{ k: "F1", seq: "\x1bOP", c: "功能键 F1" },
|
|
137
|
+
{ k: "F2", seq: "\x1bOQ", c: "功能键 F2" },
|
|
138
|
+
{ k: "F3", seq: "\x1bOR", c: "功能键 F3" },
|
|
139
|
+
{ k: "F4", seq: "\x1bOS", c: "功能键 F4" },
|
|
140
|
+
{ k: "F5", seq: "\x1b[15~", c: "功能键 F5" },
|
|
141
|
+
{ k: "F6", seq: "\x1b[17~", c: "功能键 F6" },
|
|
142
|
+
{ k: "F7", seq: "\x1b[18~", c: "功能键 F7" },
|
|
143
|
+
{ k: "F8", seq: "\x1b[19~", c: "功能键 F8" },
|
|
144
|
+
{ k: "F9", seq: "\x1b[20~", c: "功能键 F9" },
|
|
145
|
+
{ k: "F10", seq: "\x1b[21~", c: "功能键 F10" },
|
|
146
|
+
{ k: "F11", seq: "\x1b[23~", c: "功能键 F11" },
|
|
147
|
+
{ k: "F12", seq: "\x1b[24~", c: "功能键 F12" },
|
|
148
|
+
];
|
|
149
|
+
// 虚拟键盘键位(参考小屏终端:Termux 默认 extra-keys = ESC / - / HOME / ↑↓←→ / END / PGDN / PGUP / TAB / CTRL / ALT,
|
|
150
|
+
// 并补 JuiceSSH / ConnectBot 常见的常用符号与 Ctrl 组合;外加 SHIFT(大写/上档)与 WIN(Meta)锁)
|
|
151
|
+
const KEYPAD_ROWS = [
|
|
152
|
+
[
|
|
153
|
+
{ k: "ESC", seq: "\x1b", c: "取消" },
|
|
154
|
+
{ k: "TAB", seq: "\t", c: "补全" },
|
|
155
|
+
{ k: "SHIFT", mod: "shift" },
|
|
156
|
+
{ k: "CTRL", mod: "ctrl" },
|
|
157
|
+
{ k: "ALT", mod: "alt" },
|
|
158
|
+
{ k: "WIN", mod: "win" },
|
|
159
|
+
{ k: "|", txt: "|" },
|
|
160
|
+
{ k: "~", txt: "~" },
|
|
161
|
+
{ k: "-", txt: "-" },
|
|
162
|
+
{ k: "_", txt: "_" },
|
|
163
|
+
{ k: "HOME", seq: "\x1b[H", c: "行首" },
|
|
164
|
+
{ k: "END", seq: "\x1b[F", c: "行尾" },
|
|
165
|
+
{ k: "PGUP", seq: "\x1b[5~", c: "上翻" },
|
|
166
|
+
{ k: "PGDN", seq: "\x1b[6~", c: "下翻" },
|
|
167
|
+
],
|
|
168
|
+
[
|
|
169
|
+
{ k: "↑", seq: "\x1b[A", c: "上一条历史/上移" },
|
|
170
|
+
{ k: "↓", seq: "\x1b[B", c: "下一条历史/下移" },
|
|
171
|
+
{ k: "←", seq: "\x1b[D", c: "左移" },
|
|
172
|
+
{ k: "→", seq: "\x1b[C", c: "右移" },
|
|
173
|
+
{ k: "⌫", seq: "\x7f", c: "退格" },
|
|
174
|
+
{ k: "↵", seq: "\r", c: "回车" },
|
|
175
|
+
{ k: "C-c", seq: "\x03", c: "中断" },
|
|
176
|
+
{ k: "C-d", seq: "\x04", c: "退出/EOF" },
|
|
177
|
+
{ k: "C-z", seq: "\x1a", c: "挂起" },
|
|
178
|
+
{ k: "C-l", seq: "\x0c", c: "清屏" },
|
|
179
|
+
{ k: "C-a", seq: "\x01", c: "光标到行首" },
|
|
180
|
+
{ k: "C-e", seq: "\x05", c: "光标到行尾" },
|
|
181
|
+
{ k: "C-r", seq: "\x12", c: "反向搜索历史" },
|
|
182
|
+
{ k: "C-u", seq: "\x15", c: "删到行首" },
|
|
183
|
+
{ k: "C-w", seq: "\x17", c: "删词" },
|
|
184
|
+
],
|
|
185
|
+
KEYPAD_FN_ROW,
|
|
186
|
+
];
|
|
187
|
+
// 全键盘(ABC 全键档):完整字母 / 数字 / 符号 / 空格,CTRL/SHIFT/ALT/WIN 锁 + 键 = 组合键
|
|
188
|
+
const KBD_DQUOTE = String.fromCharCode(34); // "
|
|
189
|
+
const KBD_BSLASH = String.fromCharCode(92); // \
|
|
190
|
+
const KEYPAD_FULL_ROWS = [
|
|
191
|
+
[
|
|
192
|
+
{ k: "1", txt: "1" }, { k: "2", txt: "2" }, { k: "3", txt: "3" }, { k: "4", txt: "4" }, { k: "5", txt: "5" },
|
|
193
|
+
{ k: "6", txt: "6" }, { k: "7", txt: "7" }, { k: "8", txt: "8" }, { k: "9", txt: "9" }, { k: "0", txt: "0" },
|
|
194
|
+
{ k: "-", txt: "-" }, { k: "=", txt: "=" },
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
{ k: "Q", txt: "q" }, { k: "W", txt: "w" }, { k: "E", txt: "e" }, { k: "R", txt: "r" }, { k: "T", txt: "t" },
|
|
198
|
+
{ k: "Y", txt: "y" }, { k: "U", txt: "u" }, { k: "I", txt: "i" }, { k: "O", txt: "o" }, { k: "P", txt: "p" },
|
|
199
|
+
],
|
|
200
|
+
[
|
|
201
|
+
{ k: "A", txt: "a" }, { k: "S", txt: "s" }, { k: "D", txt: "d" }, { k: "F", txt: "f" }, { k: "G", txt: "g" },
|
|
202
|
+
{ k: "H", txt: "h" }, { k: "J", txt: "j" }, { k: "K", txt: "k" }, { k: "L", txt: "l" },
|
|
203
|
+
],
|
|
204
|
+
[
|
|
205
|
+
{ k: "Z", txt: "z" }, { k: "X", txt: "x" }, { k: "C", txt: "c" }, { k: "V", txt: "v" }, { k: "B", txt: "b" },
|
|
206
|
+
{ k: "N", txt: "n" }, { k: "M", txt: "m" }, { k: "⌫", seq: "\x7f", c: "退格" },
|
|
207
|
+
],
|
|
208
|
+
[
|
|
209
|
+
{ k: "&", txt: "&" }, { k: ";", txt: ";" }, { k: ":", txt: ":" }, { k: "'", txt: "'" }, { k: KBD_DQUOTE, txt: KBD_DQUOTE },
|
|
210
|
+
{ k: KBD_BSLASH, txt: KBD_BSLASH }, { k: "/", txt: "/" }, { k: "?", txt: "?" }, { k: "!", txt: "!" }, { k: "$", txt: "$" },
|
|
211
|
+
{ k: "#", txt: "#" }, { k: "@", txt: "@" }, { k: "%", txt: "%" }, { k: "^", txt: "^" }, { k: "*", txt: "*" },
|
|
212
|
+
],
|
|
213
|
+
[
|
|
214
|
+
{ k: "(", txt: "(" }, { k: ")", txt: ")" }, { k: "[", txt: "[" }, { k: "]", txt: "]" }, { k: "{", txt: "{" },
|
|
215
|
+
{ k: "}", txt: "}" }, { k: "<", txt: "<" }, { k: ">", txt: ">" }, { k: "=", txt: "=" }, { k: "+", txt: "+" },
|
|
216
|
+
{ k: ".", txt: "." }, { k: ",", txt: "," }, { k: "|", txt: "|" }, { k: "~", txt: "~" },
|
|
217
|
+
],
|
|
218
|
+
[
|
|
219
|
+
{ k: "ESC", seq: "\x1b", c: "取消" }, { k: "TAB", seq: "\t", c: "补全" },
|
|
220
|
+
{ k: "SHIFT", mod: "shift" }, { k: "CTRL", mod: "ctrl" }, { k: "ALT", mod: "alt" }, { k: "WIN", mod: "win" },
|
|
221
|
+
{ k: "␣", txt: " ", c: "空格" }, { k: "↵", seq: "\r", c: "回车" },
|
|
222
|
+
],
|
|
223
|
+
KEYPAD_FN_ROW,
|
|
224
|
+
];
|
|
225
|
+
// 终端偏好持久化(localStorage:窗口大小 / 键盘开关)
|
|
226
|
+
const LS_TERM_SIZE = "aiti-cmd:term-size";
|
|
227
|
+
const LS_TERM_KBD = "aiti-cmd:term-kbd";
|
|
228
|
+
function lsGet(key, def) { try { const v = localStorage.getItem(key); if (v == null) return def; return JSON.parse(v); } catch { return def; } }
|
|
229
|
+
function lsSet(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch {} }
|
|
230
|
+
// UI 语言(与宿主 pi-web useI18n 同源:localStorage pi-locale;切语言时宿主派发 pi-web:locale-changed)
|
|
231
|
+
const LOCALE_CHANGED_EVENT = "pi-web:locale-changed";
|
|
232
|
+
function uiLocale() { try { return localStorage.getItem("pi-locale") || "en"; } catch { return "en"; } }
|
|
233
|
+
|
|
234
|
+
// ---------- 终端链接识别(xterm link provider):URL → 点击确认 → 新窗口打开 ----------
|
|
235
|
+
// http(s):// 开头,排除空白/引号/尖括号/括号/花括号/方括号;匹配后清洗尾部误收标点
|
|
236
|
+
const TERM_URL_RE = /https?:\/\/[^\s"'<>\[\]{}()]+/g;
|
|
237
|
+
function installTermLinkProvider(term, onLink) {
|
|
238
|
+
try {
|
|
239
|
+
return term.registerLinkProvider({
|
|
240
|
+
provideLinks(bufferLineNumber, callback) {
|
|
241
|
+
// xterm 的 linkifier 传 1 基行号(e.y = 1 基可见行 + ydisp)——官方 WebLinksAddon 同约定:
|
|
242
|
+
// getLine 用 0 基(行号-1),range.y 用 1 基(与 _linkAtPosition 的位置空间一致)
|
|
243
|
+
let line = null;
|
|
244
|
+
try { line = term.buffer.active.getLine(bufferLineNumber - 1); } catch (e) {}
|
|
245
|
+
if (!line) { callback(undefined); return; }
|
|
246
|
+
const text = line.translateToString(true);
|
|
247
|
+
const links = [];
|
|
248
|
+
TERM_URL_RE.lastIndex = 0;
|
|
249
|
+
let m;
|
|
250
|
+
while ((m = TERM_URL_RE.exec(text)) !== null) {
|
|
251
|
+
let raw = m[0];
|
|
252
|
+
let start = m.index;
|
|
253
|
+
// 尾部清洗:去掉常见误收标点(.,;:!?)与不成对的右括号
|
|
254
|
+
let tail = raw.replace(/[.,;:!?]+$/, "");
|
|
255
|
+
const cp = tail.lastIndexOf(")");
|
|
256
|
+
const op = tail.lastIndexOf("(");
|
|
257
|
+
if (cp > op) tail = tail.slice(0, cp);
|
|
258
|
+
raw = tail;
|
|
259
|
+
if (raw.length < 9 || !/^https?:\/\//.test(raw)) continue; // 太短/被清洗成非 URL
|
|
260
|
+
const end = start + raw.length;
|
|
261
|
+
links.push({
|
|
262
|
+
range: { start: { x: start + 1, y: bufferLineNumber }, end: { x: end, y: bufferLineNumber } },
|
|
263
|
+
text: raw,
|
|
264
|
+
decorations: { pointerCursor: true, underline: true },
|
|
265
|
+
activate: (_event, uri) => onLink(uri),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
callback(links);
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
} catch (e) { return null; }
|
|
272
|
+
}
|
|
273
|
+
// 链接确认浮层:点击链接后弹确认,确认后新窗口打开(noopener)——组件定义在 register(ctx) 内(使用 ctx.h)
|
|
274
|
+
|
|
275
|
+
// ---------- 终端复制/粘贴绑定:右键=复制选区(有选区时拦截浏览器菜单);快捷键 Ctrl+Shift+C /
|
|
276
|
+
// Ctrl+Insert 复制、Ctrl+Shift+V / Shift+Insert 粘贴。返回 handleKey 供 attachCustomKeyEventHandler
|
|
277
|
+
// 接线(返回 true = 已消费,调用方应 ev 已被 preventDefault 并 return false 拦住 xterm 默认行为)。
|
|
278
|
+
// 注意 Ctrl+C 保持发 SIGINT 不动——终端里复制只能靠上述途径(浏览器习惯的 Ctrl+C 在终端语义里是中断)。
|
|
279
|
+
function installTermClipboard(term, el, onPaste) {
|
|
280
|
+
const copySel = () => {
|
|
281
|
+
try {
|
|
282
|
+
const s = term.getSelection();
|
|
283
|
+
if (!s) return false;
|
|
284
|
+
navigator.clipboard?.writeText(s)?.catch?.(() => {});
|
|
285
|
+
return true;
|
|
286
|
+
} catch { return false; }
|
|
287
|
+
};
|
|
288
|
+
const onCtx = (e) => { if (copySel()) e.preventDefault(); };
|
|
289
|
+
el.addEventListener("contextmenu", onCtx);
|
|
290
|
+
return {
|
|
291
|
+
copySel,
|
|
292
|
+
handleKey(ev) {
|
|
293
|
+
if (ev.type !== "keydown" || !ev.key) return false;
|
|
294
|
+
const k = ev.key.toLowerCase();
|
|
295
|
+
if (ev.ctrlKey && ev.shiftKey && k === "c") { if (copySel()) { ev.preventDefault(); return true; } return false; }
|
|
296
|
+
if (ev.ctrlKey && !ev.shiftKey && k === "insert") { ev.preventDefault(); return copySel(); }
|
|
297
|
+
if (ev.shiftKey && !ev.ctrlKey && k === "insert") { ev.preventDefault(); onPaste?.(); return true; }
|
|
298
|
+
if (ev.ctrlKey && ev.shiftKey && k === "v") { ev.preventDefault(); onPaste?.(); return true; }
|
|
299
|
+
return false;
|
|
300
|
+
},
|
|
301
|
+
dispose() { el.removeEventListener("contextmenu", onCtx); },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export default function register(ctx) {
|
|
306
|
+
const { h, useState, useEffect, useRef, apiUrl } = ctx;
|
|
307
|
+
|
|
308
|
+
// UI 语言(zh → 中文标签「↓底」,其他语言 → 图标「⇊」;跟随宿主切语言重渲染)
|
|
309
|
+
function useIsZh() {
|
|
310
|
+
const [zh, setZh] = useState(() => uiLocale() === "zh-CN");
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
const onChange = () => setZh(uiLocale() === "zh-CN");
|
|
313
|
+
window.addEventListener(LOCALE_CHANGED_EVENT, onChange);
|
|
314
|
+
return () => window.removeEventListener(LOCALE_CHANGED_EVENT, onChange);
|
|
315
|
+
}, []);
|
|
316
|
+
return zh;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// lucide 图标(与系统内建线条图标同风格)
|
|
320
|
+
const Ic = (size, paths) => {
|
|
321
|
+
const el = h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, paths.map((d, i) => h("path", { key: i, d })));
|
|
322
|
+
if (el._store) el._store.validated = 1;
|
|
323
|
+
return el;
|
|
324
|
+
};
|
|
325
|
+
const ICONS = {
|
|
326
|
+
terminal: ["M4 17l6-6-6-6", "M12 19h8"],
|
|
327
|
+
play: ["m6 3 14 9-14 9V3z"],
|
|
328
|
+
pencil: ["M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z", "m15 5 4 4"],
|
|
329
|
+
trash: ["M3 6h18", "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6", "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"],
|
|
330
|
+
folder: ["M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"],
|
|
331
|
+
x: ["M18 6 6 18", "m6 6 12 12"],
|
|
332
|
+
list: ["M8 6H21", "M8 12H21", "M8 18H21", "M3 6h.01", "M3 12h.01", "M3 18h.01"],
|
|
333
|
+
lock: ["M18 8h1a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4v-8a4 4 0 0 1 4-4h1", "M7 8V6a5 5 0 0 1 10 0v2", "M12 13v4"],
|
|
334
|
+
alert: ["m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z", "M12 9v4", "M12 17h.01"],
|
|
335
|
+
bot: ["M12 8V4H8", "M4 8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z", "M2 14h2", "M20 14h2", "M15 13v2", "M9 13v2"],
|
|
336
|
+
monitor: ["M2 3h20a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z", "M8 21h8", "M12 17v4"],
|
|
337
|
+
};
|
|
338
|
+
const api = (path) => apiUrl("/api/extensions/aiti-cmd" + path);
|
|
339
|
+
|
|
340
|
+
// shell 实时目录查询:GET /sessions/{id}/cwd(服务端读交互 shell 进程实时 pwd)——失败
|
|
341
|
+
// (win32 / 已退出 / 平台不支持)降级会话创建时 cwd。「+ 新建」/「新 pi-web 会话」都用它,
|
|
342
|
+
// 终端里 cd 过后新会话落在当前 pwd,与系统终端行为一致。
|
|
343
|
+
const liveCwdOf = async (sessionId, fallbackCwd) => {
|
|
344
|
+
try {
|
|
345
|
+
const r = await fetch(api(`/sessions/${encodeURIComponent(sessionId)}/cwd`)).then((x) => x.json()).catch(() => null);
|
|
346
|
+
if (r && r.ok && typeof r.cwd === "string" && r.cwd) return r.cwd;
|
|
347
|
+
} catch {}
|
|
348
|
+
return fallbackCwd || undefined;
|
|
349
|
+
};
|
|
350
|
+
// 宿主新 pi-web 会话通道(AppShell 已注册 pi-web:new-session + __piWebPendingComposer 同契约):
|
|
351
|
+
// 写预填(@ 当前路径文本,与宿主 @mention 同格式:目录尾斜杠+空格,含空格路径引号包裹)→
|
|
352
|
+
// 派发事件(detail.cwd = 新会话工作目录)。返回是否派发成功。
|
|
353
|
+
const openNewPiSession = (cwd) => {
|
|
354
|
+
if (!cwd) return false;
|
|
355
|
+
const text = cwd.includes(" ") ? `@"${cwd}"/ ` : `@${cwd}/ `;
|
|
356
|
+
try { window.__piWebPendingComposer = { text, at: Date.now() }; } catch {}
|
|
357
|
+
try { window.dispatchEvent(new CustomEvent("pi-web:new-session", { detail: { cwd } })); } catch { return false; }
|
|
358
|
+
return true;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// ---------- 订阅保活(aiti-event-bus 停推协议)----------
|
|
362
|
+
// 订阅存在期间每 15s POST /alive 报活跃 type;后端 45s 未刷新即停推该 type ——
|
|
363
|
+
// 页面/面板关闭后源头即断,不依赖 SSE 层丢帧。模块级集中一个 timer,
|
|
364
|
+
// 各订阅点 aliveAdd/aliveDel 自己的 type;末位退订自动停表。
|
|
365
|
+
const aliveTypes = new Set();
|
|
366
|
+
let aliveTimer = null;
|
|
367
|
+
const alivePing = () => {
|
|
368
|
+
if (!aliveTypes.size) return;
|
|
369
|
+
try { fetch(api("/alive"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ types: [...aliveTypes] }) }).catch(() => {}); } catch {}
|
|
370
|
+
};
|
|
371
|
+
const aliveAdd = (...types) => {
|
|
372
|
+
let added = false;
|
|
373
|
+
for (const t of types) if (!aliveTypes.has(t)) { aliveTypes.add(t); added = true; }
|
|
374
|
+
if (added) alivePing(); // 新订阅立即报活(不等下个周期,后端尽快从乐观转受管)
|
|
375
|
+
if (!aliveTimer) aliveTimer = setInterval(alivePing, 15000);
|
|
376
|
+
};
|
|
377
|
+
const aliveDel = (...types) => {
|
|
378
|
+
let removed = false;
|
|
379
|
+
for (const t of types) if (aliveTypes.has(t)) { aliveTypes.delete(t); removed = true; }
|
|
380
|
+
if (removed) alivePing(); // 立即上报收缩后的全集(单 tab 时后端 45s 内停推被停 type)
|
|
381
|
+
if (!aliveTypes.size && aliveTimer) { clearInterval(aliveTimer); aliveTimer = null; }
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// 链接确认浮层:点击终端里的 URL → 弹确认 → 确认后新窗口打开(noopener)
|
|
385
|
+
function TermLinkConfirm({ link, onClose }) {
|
|
386
|
+
if (!link) return null;
|
|
387
|
+
const open = () => {
|
|
388
|
+
try { window.open(link.uri, "_blank", "noopener"); } catch (e) {}
|
|
389
|
+
onClose();
|
|
390
|
+
};
|
|
391
|
+
return h("div", {
|
|
392
|
+
style: { position: "fixed", inset: 0, zIndex: 600, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 },
|
|
393
|
+
onClick: onClose,
|
|
394
|
+
},
|
|
395
|
+
h("div", { style: { width: 440, maxWidth: "100%", borderRadius: 12, background: "var(--bg-panel)", border: "1px solid var(--border)", color: "var(--text)", fontFamily: "inherit", boxShadow: "0 20px 60px rgba(0,0,0,0.35)", padding: 14 }, onClick: (e) => e.stopPropagation() },
|
|
396
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 8 } },
|
|
397
|
+
h("span", { style: { fontSize: 15, display: "inline-flex" } }, h("svg", { width: 13, height: 13, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, [h("path", { key: "p1", d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }), h("path", { key: "p2", d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" })])),
|
|
398
|
+
h("b", { style: { fontSize: 14 } }, "打开链接?"),
|
|
399
|
+
),
|
|
400
|
+
h("code", { style: { display: "block", fontSize: 13, color: "var(--accent)", wordBreak: "break-all", whiteSpace: "pre-wrap", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 6, padding: "6px 8px", marginBottom: 4, fontFamily: "var(--font-mono)" } }, link.uri),
|
|
401
|
+
h("div", { style: { fontSize: 13, color: "var(--text-muted)", marginBottom: 10 } }, "在新窗口/新标签页中打开(外部网站)"),
|
|
402
|
+
h("div", { style: { display: "flex", gap: 8, justifyContent: "flex-end" } },
|
|
403
|
+
h("button", { onClick: onClose, style: { ...btnStyle, padding: "5px 16px" } }, "取消"),
|
|
404
|
+
h("button", { onClick: open, style: { ...btnStyle, background: "var(--accent)", color: "#fff", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none", padding: "5px 18px", fontWeight: 600 } }, "打开"),
|
|
405
|
+
),
|
|
406
|
+
),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------- 全局桥(pi-web core 消息点击识别 onCommand 调用) ----------
|
|
411
|
+
if (typeof window !== "undefined" && !window.__aitiCmd) {
|
|
412
|
+
// 打开交互 shell(§3.3 前端「打开终端」下沉后端):POST /shell 由服务端建会话(命令文本不落前端),
|
|
413
|
+
// 前端拿 sid 渲染。→ 弹出面板直达终端视图;返回 Promise<{ok, sessionId?, error?}>
|
|
414
|
+
const createShell = (cwd, name) => {
|
|
415
|
+
return fetch(api("/shell"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ cwd: cwd || undefined, name: name || "终端" }) })
|
|
416
|
+
.then((x) => x.json()).catch(() => ({ ok: false, error: "网络错误" }))
|
|
417
|
+
.then((r) => {
|
|
418
|
+
if (!r || !r.ok || !r.sessionId) return { ok: false, error: (r && r.error) || "创建终端失败" };
|
|
419
|
+
setState({ panelOpen: true, view: { kind: "pty", id: r.sessionId, title: r.name || name || "终端" }, detail: null });
|
|
420
|
+
return { ok: true, sessionId: r.sessionId };
|
|
421
|
+
});
|
|
422
|
+
};
|
|
423
|
+
// 渲染既有会话(sid 已由后端创建——ai-input suggest ⚡ / codebuddy 等消费方交 sid 过来)
|
|
424
|
+
const openSession = (sessionId, title) => {
|
|
425
|
+
if (!sessionId) return;
|
|
426
|
+
setState({ panelOpen: true, view: { kind: "pty", id: String(sessionId), title: title || ("会话 " + String(sessionId)) }, detail: null });
|
|
427
|
+
};
|
|
428
|
+
window.__aitiCmd = {
|
|
429
|
+
openCommand: (cmd, cwd) => { setState({ detail: { cmd: String(cmd || ""), cwd: cwd || null }, detailBusy: false }); },
|
|
430
|
+
// 打开交互 bash 终端(供其他扩展调:aiti-folder/aiti-editor 顶栏终端按钮等)
|
|
431
|
+
openShell: createShell,
|
|
432
|
+
// 渲染既有会话(sid 已由后端创建——ai-input suggest ⚡ / codebuddy 等 services 消费方交 sid 过来)
|
|
433
|
+
openSession,
|
|
434
|
+
};
|
|
435
|
+
// 跨扩展客户端服务出口(DI 规范 §4-A;新消费方一律走 ctx.getExtService,勿直穿 window)
|
|
436
|
+
try { ctx?.registerExtService?.("aiti-cmd.open-session", openSession); } catch { /* 旧 core 无此面 */ }
|
|
437
|
+
try { ctx?.registerExtService?.("aiti-cmd.open-shell", createShell); } catch { /* 旧 core 无此面 */ }
|
|
438
|
+
// 独立查看窗(/viewer 里的 aiti-folder/aiti-editor)点「打开终端」无本扩展面板 →
|
|
439
|
+
// postMessage 请主窗口(opener)代开;处理完回执 open-shell-result(reqId 对应)
|
|
440
|
+
window.addEventListener("message", (ev) => {
|
|
441
|
+
if (ev.origin !== location.origin) return;
|
|
442
|
+
const m = ev.data;
|
|
443
|
+
if (!m || m.source !== "aiti-ext" || m.type !== "open-shell") return;
|
|
444
|
+
createShell(m.cwd, m.name).then((result) => {
|
|
445
|
+
try { if (ev.source) ev.source.postMessage({ source: "aiti-cmd", type: "open-shell-result", reqId: m.reqId, ...result }, location.origin); } catch (e) { /* noop */ }
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
// core 主窗事件通道(监听器唯一,§3.3 收拢历史上的两个重复注册):
|
|
449
|
+
// AppShell 侧栏「终端」handleOpenTerminal / TerminalPanelBridge 等派发 aiti-cmd:open-shell { cwd }
|
|
450
|
+
window.addEventListener("aiti-cmd:open-shell", (ev) => {
|
|
451
|
+
const cwd = ev.detail && typeof ev.detail.cwd === "string" ? ev.detail.cwd : undefined;
|
|
452
|
+
if (cwd === undefined) return;
|
|
453
|
+
try { createShell(cwd, ev.detail.name || "终端")?.catch?.(() => {}); } catch (e) { /* noop */ }
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------- 消息点击识别扩展点:注册命令动作 provider 到 msg-popover-action 主题 ----------
|
|
458
|
+
// (aiti-msg-smart 消费该主题:段落点击 → 调各 provider recognize → 渲染动作条。
|
|
459
|
+
// 识别/校验/打开面板全在本扩展;aiti-msg-smart 未加载时本注册无害(主题注册表持久)。)
|
|
460
|
+
if (typeof ctx.registerTopicItem === "function") {
|
|
461
|
+
ctx.registerTopicItem("msg-popover-action", {
|
|
462
|
+
id: "aiti-cmd-command",
|
|
463
|
+
recognize: async (text, { cwd }) => {
|
|
464
|
+
const j = await fetch(api("/commands/check"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, cwd: cwd || undefined }) })
|
|
465
|
+
.then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
466
|
+
if (!j || !Array.isArray(j.commands) || !j.commands.length) return [];
|
|
467
|
+
return j.commands.map((c) => {
|
|
468
|
+
// cd xxx && … → workdir 作为运行 cwd,action 作为命令本体(识别更准确)
|
|
469
|
+
const wd = c.workdir ? `工作目录:${c.workdir}\n` : "";
|
|
470
|
+
return {
|
|
471
|
+
type: "command",
|
|
472
|
+
id: c.cmd,
|
|
473
|
+
match: c.cmd, // 原文精确匹配文本(aiti-msg-smart hover 高亮用;label 可能被截断)
|
|
474
|
+
label: c.cmd.length > 40 ? c.cmd.slice(0, 40) + "…" : c.cmd,
|
|
475
|
+
danger: !!(c.danger && c.danger.length),
|
|
476
|
+
unavailable: c.exists === false,
|
|
477
|
+
title: wd + (c.exists === false
|
|
478
|
+
? "部分命令未安装。点击查看安装建议"
|
|
479
|
+
: (c.danger && c.danger.length ? `危险命令:${c.danger.join("/")}。点击查看/收藏(不会直接执行)` : "点击查看/收藏此命令(不会直接执行)")),
|
|
480
|
+
onClick: () => { setState({ detail: { cmd: c.action || c.cmd, cwd: c.workdir || cwd || null }, detailBusy: false }); },
|
|
481
|
+
};
|
|
482
|
+
});
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ---------- 行内 code 点击识别:注册命令动作 provider 到 inline-code-action 主题 ----------
|
|
487
|
+
// (aiti-msg-smart 消费:行内 <code> 点击 → 弹层含「复制」+ 各 provider 动作)
|
|
488
|
+
ctx.registerTopicItem("inline-code-action", {
|
|
489
|
+
id: "aiti-cmd-inline",
|
|
490
|
+
recognize: async (text, { element }) => {
|
|
491
|
+
if (!text || text.length > 200) return [];
|
|
492
|
+
const j = await fetch(api("/commands/check"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) })
|
|
493
|
+
.then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
494
|
+
if (!j || !Array.isArray(j.commands) || !j.commands.length) return [];
|
|
495
|
+
const c = j.commands[0];
|
|
496
|
+
const wd = c.workdir ? `工作目录:${c.workdir}\n` : "";
|
|
497
|
+
return [{
|
|
498
|
+
type: "command",
|
|
499
|
+
id: "inline:" + c.cmd,
|
|
500
|
+
label: c.cmd.length > 40 ? c.cmd.slice(0, 40) + "…" : c.cmd,
|
|
501
|
+
danger: !!(c.danger && c.danger.length),
|
|
502
|
+
title: wd + (c.exists === false
|
|
503
|
+
? "部分命令未安装。点击查看安装建议"
|
|
504
|
+
: (c.danger && c.danger.length ? `危险命令:${c.danger.join("/")}。点击查看/收藏(不会直接执行)` : "点击查看/收藏此命令(不会直接执行)")),
|
|
505
|
+
onClick: (codeText, codeEl) => { setState({ detail: { cmd: c.action || c.cmd, cwd: c.workdir || null }, detailBusy: false }); },
|
|
506
|
+
}];
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---------- 订阅模块状态 ----------
|
|
512
|
+
function usePanelOpen() {
|
|
513
|
+
const [v, setV] = useState(state.panelOpen);
|
|
514
|
+
useEffect(() => { const l = () => setV(state.panelOpen); stateListeners.add(l); return () => stateListeners.delete(l); }, []);
|
|
515
|
+
return v;
|
|
516
|
+
}
|
|
517
|
+
function useView() {
|
|
518
|
+
const [v, setV] = useState(state.view);
|
|
519
|
+
useEffect(() => { const l = () => setV(state.view); stateListeners.add(l); return () => stateListeners.delete(l); }, []);
|
|
520
|
+
return v;
|
|
521
|
+
}
|
|
522
|
+
function useDetail() {
|
|
523
|
+
const [v, setV] = useState(state.detail);
|
|
524
|
+
useEffect(() => { const l = () => setV(state.detail); stateListeners.add(l); return () => stateListeners.delete(l); }, []);
|
|
525
|
+
return v;
|
|
526
|
+
}
|
|
527
|
+
function useDetailBusy() {
|
|
528
|
+
const [v, setV] = useState(state.detailBusy);
|
|
529
|
+
useEffect(() => { const l = () => setV(state.detailBusy); stateListeners.add(l); return () => stateListeners.delete(l); }, []);
|
|
530
|
+
return v;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------- 真终端组件(xterm.js + SSE 下行 + POST 上行) ----------
|
|
534
|
+
// 窗口大小:fit=自适应容器(默认);其余固定 列×行(同常规终端窗口,小屏滚动看全貌)。
|
|
535
|
+
// 全屏 / 虚拟键盘 / 帮助 / 滚动:见下方工具按钮与键盘区。
|
|
536
|
+
// pullBack:页签模式的「⇲ 拉回窗口」(弹窗模式不传不渲染);onNewShell:「+ 新建」用
|
|
537
|
+
// 当前终端会话实时 cwd 再开 shell(页签模式开新页签;弹窗模式新建会话并切过去);
|
|
538
|
+
// onNewPiSession:「💬 新会话」新建 pi-web 会话并 @ 当前终端实时路径。
|
|
539
|
+
function TerminalView({ sessionId, onExit, height, counts, pullBack, onNewShell, onNewPiSession, sendToTab, killPending }) {
|
|
540
|
+
const isZh = useIsZh();
|
|
541
|
+
const ref = useRef(null);
|
|
542
|
+
const [status, setStatus] = useState("connecting");
|
|
543
|
+
const [meta, setMeta] = useState(null);
|
|
544
|
+
const [exitInfo, setExitInfo] = useState(null);
|
|
545
|
+
const [linkConfirm, setLinkConfirm] = useState(null); // {uri} —— 终端 URL 点击确认
|
|
546
|
+
const termRef = useRef(null);
|
|
547
|
+
const fitRef = useRef(null);
|
|
548
|
+
// 窗口大小 / 全屏 / 虚拟键盘 / 帮助(大小与键盘开关偏好持久化 localStorage)
|
|
549
|
+
const [sizeId, setSizeId] = useState(() => lsGet(LS_TERM_SIZE, "fit"));
|
|
550
|
+
const [fullscreen, setFullscreen] = useState(false);
|
|
551
|
+
const [kbdOpen, setKbdOpen] = useState(() => lsGet(LS_TERM_KBD, false));
|
|
552
|
+
const [helpOpen, setHelpOpen] = useState(false);
|
|
553
|
+
const [ctrlLock, setCtrlLock] = useState(false);
|
|
554
|
+
const [altLock, setAltLock] = useState(false);
|
|
555
|
+
const [shiftLock, setShiftLock] = useState(false);
|
|
556
|
+
const [winLock, setWinLock] = useState(false);
|
|
557
|
+
const [kbdMode, setKbdMode] = useState("common"); // common=常用键 / full=全键盘(字母+数字+符号)
|
|
558
|
+
const sizeRef = useRef(sizeId);
|
|
559
|
+
sizeRef.current = sizeId;
|
|
560
|
+
// 小屏检测:宽度断点 max-width: 640px(小屏/大屏布局只看宽度;输入法弹起等只改高度,不应翻转布局)
|
|
561
|
+
const [isSmall, setIsSmall] = useState(() => typeof window !== "undefined" && (window.matchMedia ? (window.matchMedia("(max-width: 640px)").matches) : false));
|
|
562
|
+
useEffect(() => {
|
|
563
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
564
|
+
const mq = window.matchMedia("(max-width: 640px)");
|
|
565
|
+
const onChange = () => setIsSmall(mq.matches);
|
|
566
|
+
mq.addEventListener("change", onChange);
|
|
567
|
+
return () => mq.removeEventListener("change", onChange);
|
|
568
|
+
}, []);
|
|
569
|
+
|
|
570
|
+
// 虚拟键盘 / 快捷键直接向 PTY 发字节(不经 DOM 键盘;xterm 缓冲区由服务端回显保持同步)
|
|
571
|
+
const sendKey = (data) => {
|
|
572
|
+
const sid = sessionId;
|
|
573
|
+
try { fetch(api(`/sessions/${sid}/input`), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ data }) }).catch(() => {}); } catch {}
|
|
574
|
+
};
|
|
575
|
+
// 固定尺寸超出容器时跟随最新输出(用户上翻阅读时不打扰)
|
|
576
|
+
const maybeAutoScroll = () => {
|
|
577
|
+
const c = ref.current;
|
|
578
|
+
if (!c || sizeRef.current === "fit") return;
|
|
579
|
+
if (c.scrollHeight - c.scrollTop - c.clientHeight < 40) c.scrollTop = c.scrollHeight;
|
|
580
|
+
};
|
|
581
|
+
const scrollBottom = () => {
|
|
582
|
+
try { const c = ref.current; if (c) c.scrollTop = c.scrollHeight; } catch {}
|
|
583
|
+
try { termRef.current?.scrollToBottom(); } catch {}
|
|
584
|
+
};
|
|
585
|
+
const applySize = () => {
|
|
586
|
+
const term = termRef.current;
|
|
587
|
+
if (!term) return;
|
|
588
|
+
const p = TERM_SIZES.find((s) => s.id === sizeRef.current) || TERM_SIZES[0];
|
|
589
|
+
if (!p.cols) { try { fitRef.current?.fit(); } catch {} }
|
|
590
|
+
else { try { term.resize(p.cols, p.rows); } catch {} } // onResize 自动同步到服务端 PTY
|
|
591
|
+
};
|
|
592
|
+
const cycleSize = () => setSizeId((prev) => {
|
|
593
|
+
const ids = TERM_SIZES.map((s) => s.id);
|
|
594
|
+
const next = ids[(ids.indexOf(prev) + 1) % ids.length];
|
|
595
|
+
lsSet(LS_TERM_SIZE, next);
|
|
596
|
+
return next;
|
|
597
|
+
});
|
|
598
|
+
const toggleKbd = () => setKbdOpen((x) => { lsSet(LS_TERM_KBD, !x); return !x; });
|
|
599
|
+
|
|
600
|
+
useEffect(() => {
|
|
601
|
+
if (!ref.current) return;
|
|
602
|
+
const term = new Terminal({ fontFamily: TERM_FONT, fontSize: 13, scrollback: 1000, cursorBlink: true, convertEol: false, rendererType: "dom", theme: TERM_THEME });
|
|
603
|
+
// rendererType:"dom":xterm 6 默认在支持 WebGL 的环境(如 Edge)走 WebGL renderer——
|
|
604
|
+
// 部分 GPU/驱动下 WebGL 渲染异常导致输入/输出实时不可见(关闭重开重放才显示);
|
|
605
|
+
// DOM renderer 最稳定,无 GPU 依赖。
|
|
606
|
+
const fit = new FitAddon();
|
|
607
|
+
term.loadAddon(fit);
|
|
608
|
+
term.open(ref.current);
|
|
609
|
+
termRef.current = term;
|
|
610
|
+
fitRef.current = fit;
|
|
611
|
+
try { applySize(); } catch {}
|
|
612
|
+
term.focus();
|
|
613
|
+
// 链接识别:URL 可点击 → 确认浮层 → 新窗口打开
|
|
614
|
+
const linkDisp = installTermLinkProvider(term, (uri) => setLinkConfirm({ uri }));
|
|
615
|
+
const sid = sessionId;
|
|
616
|
+
let disposed = false; // effect 卸载标记:总线 ready/快照异步回来后不再碰已释放的 term
|
|
617
|
+
const send = (path, body) => {
|
|
618
|
+
try { fetch(api(path), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).catch(() => {}); } catch {}
|
|
619
|
+
};
|
|
620
|
+
term.onData((data) => send(`/sessions/${sid}/input`, { data }));
|
|
621
|
+
term.onResize(({ cols, rows }) => send(`/sessions/${sid}/resize`, { cols, rows }));
|
|
622
|
+
// 复制/粘贴:右键=复制选区、Ctrl+Shift+C / Ctrl+Insert 复制、Ctrl+Shift+V / Shift+Insert 粘贴
|
|
623
|
+
const pasteClip = () => { try { navigator.clipboard?.readText?.().then((t) => { if (t) send(`/sessions/${sid}/input`, { data: t }); }).catch(() => {}); } catch {} };
|
|
624
|
+
const clip = installTermClipboard(term, ref.current, pasteClip);
|
|
625
|
+
// 应用内快捷键(终端聚焦时):Ctrl+Shift+F 全屏 / Ctrl+Shift+K 虚拟键盘 / Ctrl+Shift+S 循环尺寸
|
|
626
|
+
// Esc:虚拟键盘打开时关键盘(不抢终端原生 Esc 行为——只在 kbdOpen 状态接才安全)
|
|
627
|
+
term.attachCustomKeyEventHandler((ev) => {
|
|
628
|
+
if (clip.handleKey(ev)) return false; // 复制/粘贴快捷键(已 preventDefault)
|
|
629
|
+
if (ev.type === "keydown" && ev.ctrlKey && ev.shiftKey) {
|
|
630
|
+
const k = (ev.key || "").toLowerCase();
|
|
631
|
+
if (k === "f") { ev.preventDefault(); setFullscreen((x) => !x); return false; }
|
|
632
|
+
if (k === "k") { ev.preventDefault(); toggleKbd(); return false; }
|
|
633
|
+
if (k === "s") { ev.preventDefault(); cycleSize(); return false; }
|
|
634
|
+
}
|
|
635
|
+
if (ev.type === "keydown" && ev.key === "Escape" && kbdOpen) {
|
|
636
|
+
ev.preventDefault(); toggleKbd(); return false;
|
|
637
|
+
}
|
|
638
|
+
return true;
|
|
639
|
+
});
|
|
640
|
+
// ---------- 下行:aiti-event-bus 主题订阅(cmd-<sid>)唯一通道(2026-09-09 拍板,禁止兑底) ----------
|
|
641
|
+
// 实时小帧走总线;初始回放(ring 可达 1MB 大消息)不走总线——订阅生效后 REST 拉
|
|
642
|
+
// /sessions/{id}/buffered 快照,reset 重放 + seq 去重,与实时流无缝衔接;
|
|
643
|
+
// 断线重连(up)后重拉快照补丢失段。topics 缺席 → 无实时流(状态条提示)。
|
|
644
|
+
let busOff = null;
|
|
645
|
+
let seenSeq = -1;
|
|
646
|
+
let exited = false;
|
|
647
|
+
let everDown = false;
|
|
648
|
+
// 结束横幅:醒目底色块行(被终止红底 / 非零退出黄底 / 正常结束绿底)——幂等,重放 reset 后可重写
|
|
649
|
+
const writeExitBanner = (m) => {
|
|
650
|
+
const tail = `退出码 ${m.code ?? "?"}${m.signal ? " · " + m.signal : ""} · 耗时 ${((m.durationMs || 0) / 1000).toFixed(1)}s${m.truncated ? " · 输出已截断" : ""}`;
|
|
651
|
+
if (m.signal) term.write(`\r\n\x1b[41;97m ⏹ 会话已停止(被 ${m.signal} 终止) · ${tail} \x1b[0m\r\n`);
|
|
652
|
+
else if ((m.code ?? 0) !== 0) term.write(`\r\n\x1b[43;30m ⚠ 会话已结束 · ${tail} \x1b[0m\r\n`);
|
|
653
|
+
else term.write(`\r\n\x1b[42;30m ✓ 会话已结束 · ${tail} \x1b[0m\r\n`);
|
|
654
|
+
};
|
|
655
|
+
const hideCursor = () => { try { term.options.cursorBlink = false; term.write("\x1b[?25l"); } catch {} }; // 已结束:隐藏光标+停闪,避免看起来还能输入
|
|
656
|
+
const applyExit = (m, opts) => {
|
|
657
|
+
if (exited) { if (opts?.rewrite) writeExitBanner(m); return; } // 已处理过:仅重放补横幅(reset 清掉了上一份)
|
|
658
|
+
exited = true;
|
|
659
|
+
setStatus("exit"); setExitInfo({ code: m.code, signal: m.signal, durationMs: m.durationMs, truncated: m.truncated });
|
|
660
|
+
hideCursor();
|
|
661
|
+
writeExitBanner(m);
|
|
662
|
+
busOff?.(); busOff = null; aliveDel("cmd-" + sid); // 已结束:退订实时流+停保活(死会话无帧可推)
|
|
663
|
+
if (onExit) onExit(m);
|
|
664
|
+
};
|
|
665
|
+
const applyFrame = (m) => {
|
|
666
|
+
if (m.evt === "started") { setStatus("running"); setMeta(m); }
|
|
667
|
+
else if (m.evt === "queued") { setStatus("queued"); setMeta(m); } // 排队中(超并发上限 FIFO):等待自动启动
|
|
668
|
+
else if (m.evt === "pid") { setMeta((prev) => ({ ...(prev || {}), pid: m.pid })); }
|
|
669
|
+
else if (m.evt === "data") {
|
|
670
|
+
if (exited) return; // 已结束不再吃数据帧:防状态回退/光标复活
|
|
671
|
+
if (typeof m.seq === "number") { if (m.seq <= seenSeq) return; seenSeq = m.seq; }
|
|
672
|
+
term.write(m.data); try { term.scrollToBottom(); } catch {} maybeAutoScroll(); setStatus("running");
|
|
673
|
+
}
|
|
674
|
+
else if (m.evt === "exit") applyExit(m);
|
|
675
|
+
};
|
|
676
|
+
const applySnapshot = (d) => {
|
|
677
|
+
if (!d || !d.ok) return;
|
|
678
|
+
// reset 清掉可能已先到的实时帧(订阅生效→快照响应之间的重叠段),
|
|
679
|
+
// 全量重写 ring 后以快照 seq 为去重基线——无缝无重复
|
|
680
|
+
if (d.status === "queued") setStatus("queued"); else if (!exited) setStatus(d.status === "exit" ? "exit" : "running");
|
|
681
|
+
setMeta({ sessionId: sid, pid: d.pid, cmd: d.cmd, cwd: d.cwd });
|
|
682
|
+
try { term.reset(); } catch {}
|
|
683
|
+
for (const c of d.chunks || []) { term.write(c.data); if (typeof c.seq === "number" && c.seq > seenSeq) seenSeq = c.seq; }
|
|
684
|
+
if (typeof d.seq === "number" && d.seq > seenSeq) seenSeq = d.seq;
|
|
685
|
+
if (d.status === "exit") applyExit({ code: d.exitCode, signal: d.signal, durationMs: d.durationMs, truncated: d.truncated }, { rewrite: true });
|
|
686
|
+
else { try { term.scrollToBottom(); } catch {} }
|
|
687
|
+
};
|
|
688
|
+
// 订阅实时流(活会话专用):订阅生效(ready)后才拉全量快照——订阅生效点早于快照内容
|
|
689
|
+
// 截至点,重叠段由 seq 去重(applyFrame 丢弃 seq<=seenSeq)→ 零间隙;全量快照恰好一次
|
|
690
|
+
const setupLive = () => {
|
|
691
|
+
if (exited || disposed) return;
|
|
692
|
+
const topicsSub = ctx?.getExtService?.("aiti-event-bus.subscribe-topics");
|
|
693
|
+
if (!topicsSub) return;
|
|
694
|
+
const pullSnap = () => { fetch(api(`/sessions/${sid}/buffered`)).then((r) => r.json()).then((d) => { if (!disposed) applySnapshot(d); }).catch(() => {}); };
|
|
695
|
+
const sub = topicsSub(applyFrame, ["cmd-" + sid], (s) => {
|
|
696
|
+
if (s === "reconnecting") { everDown = true; setStatus("reconnecting"); }
|
|
697
|
+
else if (s === "down") { everDown = true; }
|
|
698
|
+
else if (s === "up") {
|
|
699
|
+
setStatus((prev) => (prev === "reconnecting" ? "running" : prev));
|
|
700
|
+
if (everDown && !exited) {
|
|
701
|
+
everDown = false;
|
|
702
|
+
pullSnap(); // 重连补丢失段(全量重放 + seq 去重,幂等)
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
busOff = () => { try { sub.off(); } catch {} };
|
|
707
|
+
aliveAdd("cmd-" + sid); // 订阅保活:报活期后端才继续推该终端(45s 无心跳停推)
|
|
708
|
+
sub.ready.then(pullSnap).catch(() => {
|
|
709
|
+
// 总线不可达:退而求其次拉一次快照(无实时流,内容仍可见;状态条已由 onStatus 提示)
|
|
710
|
+
pullSnap();
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
// 初始化:轻量状态查询先行(GET /sessions/{id} 无 chunks,毫秒级)——
|
|
714
|
+
// exit 定案:只拉一次全量快照,不订阅不保活(死会话零 SSE 开销);
|
|
715
|
+
// running/queued:订阅先行零间隙,ready 后拉一次全量快照;
|
|
716
|
+
// 查询失败(网络闪断)保守走活会话路径——宁可多订不可瞎等
|
|
717
|
+
fetch(api(`/sessions/${sid}`)).then((r) => r.json()).then((info) => {
|
|
718
|
+
if (disposed) return;
|
|
719
|
+
if (info && info.ok) {
|
|
720
|
+
if (info.status === "exit") {
|
|
721
|
+
setMeta({ sessionId: sid, cmd: info.cmd, cwd: info.cwd });
|
|
722
|
+
setStatus("exit");
|
|
723
|
+
fetch(api(`/sessions/${sid}/buffered`)).then((r) => r.json()).then((d) => { if (!disposed) applySnapshot(d); }).catch(() => {});
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
setMeta((prev) => ({ ...(prev || {}), sessionId: sid, cmd: info.cmd, cwd: info.cwd }));
|
|
727
|
+
}
|
|
728
|
+
setupLive();
|
|
729
|
+
}).catch(() => setupLive());
|
|
730
|
+
// 自适应模式下容器变化才 refit;固定尺寸模式不 refit(保持标准列×行,由外层滚动)
|
|
731
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => { try { if (sizeRef.current === "fit") fit.fit(); } catch {} }) : null;
|
|
732
|
+
if (ro) ro.observe(ref.current);
|
|
733
|
+
// xterm 6 无原生触摸滚动,在终端容器层补偿:touch → term.scrollLines + 简单惯性
|
|
734
|
+
const touchState = { active: false, lastY: 0, lastTime: 0, velocity: 0, inertia: null };
|
|
735
|
+
const endTouch = () => {
|
|
736
|
+
touchState.active = false;
|
|
737
|
+
document.removeEventListener("touchmove", onTouchMove, { passive: false });
|
|
738
|
+
document.removeEventListener("touchend", onTouchEnd);
|
|
739
|
+
document.removeEventListener("touchcancel", onTouchEnd);
|
|
740
|
+
};
|
|
741
|
+
const onTouchStart = (e) => {
|
|
742
|
+
if (e.touches.length !== 1) { touchState.active = false; return; }
|
|
743
|
+
touchState.active = true;
|
|
744
|
+
touchState.lastY = e.touches[0].clientY;
|
|
745
|
+
touchState.lastTime = Date.now();
|
|
746
|
+
touchState.velocity = 0;
|
|
747
|
+
if (touchState.inertia) { cancelAnimationFrame(touchState.inertia); touchState.inertia = null; }
|
|
748
|
+
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
749
|
+
document.addEventListener("touchend", onTouchEnd);
|
|
750
|
+
document.addEventListener("touchcancel", onTouchEnd);
|
|
751
|
+
};
|
|
752
|
+
const onTouchMove = (e) => {
|
|
753
|
+
if (!touchState.active || e.touches.length !== 1) { endTouch(); return; }
|
|
754
|
+
const currentY = e.touches[0].clientY;
|
|
755
|
+
const currentTime = Date.now();
|
|
756
|
+
const deltaY = currentY - touchState.lastY;
|
|
757
|
+
const dt = currentTime - touchState.lastTime;
|
|
758
|
+
if (dt > 0) touchState.velocity = deltaY / dt;
|
|
759
|
+
const t = termRef.current;
|
|
760
|
+
if (t) {
|
|
761
|
+
const rowHeight = t._core?._renderService?.dimensions?.css?.cell?.height || (ref.current?.clientHeight / (t.rows || 1));
|
|
762
|
+
const lines = Math.round(deltaY / rowHeight);
|
|
763
|
+
if (lines) try { t.scrollLines(lines); } catch {}
|
|
764
|
+
}
|
|
765
|
+
touchState.lastY = currentY;
|
|
766
|
+
touchState.lastTime = currentTime;
|
|
767
|
+
e.preventDefault();
|
|
768
|
+
};
|
|
769
|
+
const onTouchEnd = () => {
|
|
770
|
+
const wasActive = touchState.active;
|
|
771
|
+
endTouch();
|
|
772
|
+
if (!wasActive) return;
|
|
773
|
+
const v = touchState.velocity;
|
|
774
|
+
if (Math.abs(v) > 0.15) {
|
|
775
|
+
let velocity = v;
|
|
776
|
+
const step = () => {
|
|
777
|
+
velocity *= 0.92;
|
|
778
|
+
if (Math.abs(velocity) < 0.05) { touchState.inertia = null; return; }
|
|
779
|
+
const t = termRef.current;
|
|
780
|
+
if (t) {
|
|
781
|
+
const rowHeight = t._core?._renderService?.dimensions?.css?.cell?.height || (ref.current?.clientHeight / (t.rows || 1));
|
|
782
|
+
const lines = Math.round(velocity * 16 / rowHeight);
|
|
783
|
+
if (lines) try { t.scrollLines(lines); } catch {}
|
|
784
|
+
}
|
|
785
|
+
touchState.inertia = requestAnimationFrame(step);
|
|
786
|
+
};
|
|
787
|
+
touchState.inertia = requestAnimationFrame(step);
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
if (ref.current) ref.current.addEventListener("touchstart", onTouchStart, { passive: false });
|
|
791
|
+
return () => {
|
|
792
|
+
disposed = true;
|
|
793
|
+
busOff?.();
|
|
794
|
+
aliveDel("cmd-" + sid);
|
|
795
|
+
linkDisp?.dispose?.();
|
|
796
|
+
clip.dispose();
|
|
797
|
+
ro?.disconnect();
|
|
798
|
+
term.dispose();
|
|
799
|
+
termRef.current = null;
|
|
800
|
+
endTouch();
|
|
801
|
+
if (touchState.inertia) cancelAnimationFrame(touchState.inertia);
|
|
802
|
+
ref.current?.removeEventListener("touchstart", onTouchStart, { passive: false });
|
|
803
|
+
};
|
|
804
|
+
}, [sessionId]);
|
|
805
|
+
// 窗口大小切换:fit → 重新适配容器;固定尺寸 → term.resize(onResize 自动同步到服务端 PTY)
|
|
806
|
+
useEffect(applySize, [sizeId]);
|
|
807
|
+
const miniBtn = { ...btnStyle, padding: "2px 8px", fontSize: 13 };
|
|
808
|
+
const selStyle = { ...inputStyle, flex: "none", width: "auto", fontSize: 13, padding: "1px 8px" };
|
|
809
|
+
// 虚拟键盘按键渲染(常用键 / 全键盘共用):Ctrl/Alt 锁 + 字母键发组合,Ctrl 锁 + 方向键发按词移动
|
|
810
|
+
const renderKey = (k, i) => {
|
|
811
|
+
const active = (k.mod === "ctrl" && ctrlLock) || (k.mod === "alt" && altLock) || (k.mod === "shift" && shiftLock) || (k.mod === "win" && winLock);
|
|
812
|
+
const press = () => {
|
|
813
|
+
if (k.mod === "ctrl") { setCtrlLock((x) => { if (!x) { setAltLock(false); setWinLock(false); } return !x; }); return; }
|
|
814
|
+
if (k.mod === "alt") { setAltLock((x) => { if (!x) { setCtrlLock(false); setWinLock(false); } return !x; }); return; }
|
|
815
|
+
if (k.mod === "shift") { setShiftLock((x) => !x); return; }
|
|
816
|
+
if (k.mod === "win") { setWinLock((x) => { if (!x) setAltLock(false); return !x; }); return; }
|
|
817
|
+
let seq = k.seq;
|
|
818
|
+
// 修改键优先级:Ctrl(字节组合)> SHIFT(字母大写/数字上档)> ALT/WIN(ESC 前缀 Meta)
|
|
819
|
+
if (k.txt) {
|
|
820
|
+
if (ctrlLock && /^[a-zA-Z]$/.test(k.txt)) { seq = String.fromCharCode(k.txt.toLowerCase().charCodeAt(0) - 96); setCtrlLock(false); setShiftLock(false); }
|
|
821
|
+
else if (shiftLock && /^[a-z]$/.test(k.txt)) { seq = k.txt.toUpperCase(); setShiftLock(false); }
|
|
822
|
+
else if (shiftLock && SHIFTED[k.txt]) { seq = SHIFTED[k.txt]; setShiftLock(false); }
|
|
823
|
+
else if (altLock) { seq = "\x1b" + k.txt; setAltLock(false); }
|
|
824
|
+
else if (winLock) { seq = "\x1b" + k.txt; setWinLock(false); }
|
|
825
|
+
else seq = k.txt;
|
|
826
|
+
if (shiftLock && seq === k.txt) setShiftLock(false); // 未映射符号/空格:吞锁防粘连
|
|
827
|
+
}
|
|
828
|
+
// 方向键 × 修改键 → xterm 标准组合序列(mod:2=Shift 3=Alt 5=Ctrl 6=Shift+Ctrl;
|
|
829
|
+
// 以往 ALT+方向键只发裸箭头——程序无法区分是否带 Alt,这里补上)
|
|
830
|
+
if (seq && /^\x1b\[[ABCD]$/.test(seq)) {
|
|
831
|
+
const mod = (ctrlLock && shiftLock) ? 6 : ctrlLock ? 5 : shiftLock ? 2 : (altLock || winLock) ? 3 : 0;
|
|
832
|
+
if (mod) { seq = "\x1b[1;" + mod + seq.slice(-1); setCtrlLock(false); setShiftLock(false); setAltLock(false); setWinLock(false); }
|
|
833
|
+
}
|
|
834
|
+
// F1-F12 × 修改键 → xterm 带修饰序列(F1-F4 SS3 \x1bOP → CSI \x1b[1;2P;F5-F12 \x1b[15~ → \x1b[15;5~);
|
|
835
|
+
// 无修饰时保持裸序列(SS3 / CSI),与 xterm.js 物理键盘输出一致;
|
|
836
|
+
// 数字限定 F 键(15/17/18/19/20/21/23/24)——不误伤 PGUP/PGDN(\x1b[5~/\x1b[6~)等 CSI ~ 键
|
|
837
|
+
if (seq && /^\x1b(O[PQRS]|\[(?:15|17|18|19|20|21|23|24)~)$/.test(seq)) {
|
|
838
|
+
const mod = (ctrlLock && shiftLock) ? 6 : ctrlLock ? 5 : shiftLock ? 2 : (altLock || winLock) ? 3 : 0;
|
|
839
|
+
if (mod) {
|
|
840
|
+
seq = seq[1] === "O" ? "\x1b[1;" + mod + seq[2] : seq.slice(0, -1) + ";" + mod + "~";
|
|
841
|
+
setCtrlLock(false); setShiftLock(false); setAltLock(false); setWinLock(false);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
sendKey(seq);
|
|
845
|
+
};
|
|
846
|
+
return h("button", {
|
|
847
|
+
key: i,
|
|
848
|
+
onPointerDown: (e) => { e.preventDefault(); press(); }, // pointerdown+preventDefault:不抢终端焦点,点键不发移动端输入法
|
|
849
|
+
title: k.mod ? (k.mod === "ctrl" ? "Ctrl 锁:点一次锁定,再按字母键发 Ctrl+字母、按方向键发 Ctrl+方向(按词移动)" : k.mod === "alt" ? "Alt 锁:点一次锁定,再按字母/符号键发 Alt+键(ESC 前缀)、按方向键发 Alt+方向" : k.mod === "shift" ? "SHIFT 锁:点一次锁定,再按字母=大写(如 grep -E)、数字=上档符号、方向键=Shift+方向(选择)" : k.mod === "win" ? "WIN/Meta 锁:点一次锁定,再按键=ESC 前缀(终端 Meta 键,同 ALT 语义;部分程序区分绑定)" : "") : (k.c || ""),
|
|
850
|
+
style: { minWidth: 34, height: 30, borderRadius: 6, border: "1px solid " + (active ? "var(--accent)" : "var(--border)"), background: active ? "var(--accent)" : "var(--bg-hover)", color: active ? "#fff" : "var(--text)", fontSize: 12, fontWeight: 600, cursor: "pointer", padding: "0 6px", fontFamily: "inherit", flexShrink: 0 },
|
|
851
|
+
}, k.k);
|
|
852
|
+
};
|
|
853
|
+
// 虚拟键盘:两档切换(⌨ 常用 = 方向/编辑/Ctrl 组合;ABC 全键 = 完整字母数字符号,Ctrl 锁直接可用)
|
|
854
|
+
const kbdRows = kbdMode === "full" ? KEYPAD_FULL_ROWS : KEYPAD_ROWS;
|
|
855
|
+
const keypad = h("div", { style: { flexShrink: 0, display: "flex", flexDirection: "column", gap: 4, background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 8, padding: 6, maxHeight: "48%", overflowY: "auto" } },
|
|
856
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 4, flexShrink: 0 } },
|
|
857
|
+
h("button", { onClick: () => setKbdMode("common"), title: "常用键:方向 / 编辑 / Ctrl 组合 / 常用符号", style: { ...miniBtn, fontSize: 12, padding: "1px 8px", background: kbdMode === "common" ? "var(--accent)" : "transparent", color: kbdMode === "common" ? "#fff" : "var(--text-muted)", border: "1px solid " + (kbdMode === "common" ? "var(--accent)" : "var(--border)") } }, "常用"),
|
|
858
|
+
h("button", { onClick: () => setKbdMode("full"), title: "全键盘:字母 / 数字 / 符号 / 空格 —— Ctrl 锁 + 字母键 = Ctrl+字母", style: { ...miniBtn, fontSize: 12, padding: "1px 8px", background: kbdMode === "full" ? "var(--accent)" : "transparent", color: kbdMode === "full" ? "#fff" : "var(--text-muted)", border: "1px solid " + (kbdMode === "full" ? "var(--accent)" : "var(--border)") } }, "ABC 全键"),
|
|
859
|
+
h("span", { style: { ...smallStyle, marginLeft: "auto", fontSize: 12, whiteSpace: "nowrap" } }, kbdMode === "full" ? "CTRL/SHIFT/ALT/WIN 锁 + 键 = 组合键" : "参考 Termux extra-keys"),
|
|
860
|
+
// 关闭按钮:常显(即使开键盘就忘的人也一眼能看到),快捷键 Esc + 顶栏 ⌨ 同步切换;
|
|
861
|
+
// aria-label 用「收起」而非「关闭」:ai-helper 弹窗 🤖 扫描器按 aria-label*=「关闭」找弹窗关闭按钮,
|
|
862
|
+
// 这里若叫「关闭…」会被当成弹窗关闭按钮误挂 🤖(它只是键盘面板的开关)
|
|
863
|
+
h("button", { onClick: toggleKbd, title: "关闭虚拟键盘 (Esc)", "aria-label": "收起虚拟键盘", style: { ...miniBtn, fontSize: 13, padding: "0 8px", height: 22, color: "var(--text-muted)", display: "inline-flex", alignItems: "center", gap: 3 } }, "×", h("span", { style: { fontSize: 12 } }, "关闭")),
|
|
864
|
+
),
|
|
865
|
+
kbdRows.map((row, ri) => h("div", { key: ri, style: { display: "flex", flexWrap: "wrap", gap: 3 } },
|
|
866
|
+
row.map((k, i) => renderKey(k, i)),
|
|
867
|
+
)),
|
|
868
|
+
);
|
|
869
|
+
// 常用快捷键帮助(参考小屏终端:Termux / JuiceSSH / ConnectBot 常见键位)+ 工具条按钮说明
|
|
870
|
+
// (触屏没有 hover,title 看不见 —— 按钮含义集中在这里查,不给顶栏按钮加会变字的标签)
|
|
871
|
+
const help = h("div", { style: { flexShrink: 0, background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 8, padding: "8px 10px", maxHeight: "45%", overflowY: "auto", fontSize: 13 } },
|
|
872
|
+
h("div", { style: { fontWeight: 700, marginBottom: 6 } }, "常用快捷键"),
|
|
873
|
+
h("table", { style: { borderCollapse: "collapse", width: "100%" } },
|
|
874
|
+
h("tbody", {},
|
|
875
|
+
TERM_SHORTCUTS.map((pair) =>
|
|
876
|
+
h("tr", { key: pair[0] },
|
|
877
|
+
h("td", { style: { padding: "2px 10px 2px 0", whiteSpace: "nowrap", color: "var(--accent)", fontWeight: 600 } }, pair[0]),
|
|
878
|
+
h("td", { style: { padding: "2px 0", color: "var(--text-muted)" } }, pair[1]),
|
|
879
|
+
)),
|
|
880
|
+
),
|
|
881
|
+
),
|
|
882
|
+
h("div", { style: { marginTop: 6, color: "var(--text-muted)", fontSize: 12, lineHeight: 1.5 } }, "虚拟键盘有「⌨ 常用 / ABC 全键」两档:常用 = 方向/编辑/Ctrl 组合;全键 = 完整字母数字符号键盘(含空格,可输入命令全文)。CTRL/SHIFT/ALT/WIN 四把锁,点一次锁定、再按任意键自动解锁(与物理键盘同感):CTRL 锁 + 字母 = Ctrl+字母(如 a→Ctrl+A 行首)、+ 方向键 = 按词移动(readline);SHIFT 锁 + 字母 = 大写(grep -E 必需)、+ 数字 = 上档符号、+ 方向键 = Shift+方向(选择);ALT/WIN 锁 + 字母/符号 = ESC 前缀(Meta 键),+ 方向键 = Alt+方向(xterm \x1b[1;3x 序列,程序可区分是否带 Alt)。注意:终端转义协议不区分左右 Shift/Alt(只有 GUI 程序能区分),所以只有一把 SHIFT/ALT 锁。终端聚焦时 Ctrl+Shift+F 全屏、Ctrl+Shift+K 键盘、Ctrl+Shift+S 循环窗口大小。"),
|
|
883
|
+
h("div", { style: { fontWeight: 700, margin: "8px 0 4px", borderTop: "1px solid var(--border)", paddingTop: 8 } }, "工具条按钮"),
|
|
884
|
+
h("table", { style: { borderCollapse: "collapse", width: "100%" } },
|
|
885
|
+
h("tbody", {},
|
|
886
|
+
TOOLBAR_LEGEND(isZh).map((pair) =>
|
|
887
|
+
h("tr", { key: pair[0] },
|
|
888
|
+
h("td", { style: { padding: "2px 10px 2px 0", whiteSpace: "nowrap", color: "var(--accent)", fontWeight: 600, textAlign: "center", width: 56 } }, pair[0]),
|
|
889
|
+
h("td", { style: { padding: "2px 0", color: "var(--text-muted)" } }, pair[1]),
|
|
890
|
+
)),
|
|
891
|
+
),
|
|
892
|
+
),
|
|
893
|
+
);
|
|
894
|
+
const isFit = sizeId === "fit";
|
|
895
|
+
return h("div", { style: fullscreen
|
|
896
|
+
? { position: "fixed", inset: 0, zIndex: 500, background: "var(--bg-panel)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 4, boxSizing: "border-box" }
|
|
897
|
+
: { display: "flex", flexDirection: "column", gap: 4, flex: 1, minHeight: 0 } },
|
|
898
|
+
h("div", { style: { ...smallStyle, display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" } },
|
|
899
|
+
// 顶部状态:exit 用醒目底色 badge(被终止红 / 非零黄 / 正常绿),其余纯色文字
|
|
900
|
+
status === "exit" ? h("span", { style: { display: "inline-flex", alignItems: "center", flexShrink: 0, padding: "1px 8px", borderRadius: 6, fontWeight: 700, fontSize: 11, lineHeight: "16px", color: exitInfo?.signal ? "#fff" : exitInfo && (exitInfo.code ?? 0) !== 0 ? "var(--warn, #facc15)" : "var(--ok, #4ade80)", background: exitInfo?.signal ? "var(--danger, #ef4444)" : exitInfo && (exitInfo.code ?? 0) !== 0 ? "color-mix(in srgb, var(--warn, #facc15) 22%, transparent)" : "color-mix(in srgb, var(--ok, #4ade80) 18%, transparent)" } },
|
|
901
|
+
isSmall
|
|
902
|
+
? (exitInfo?.signal ? "已停止" : exitInfo && (exitInfo.code ?? 0) !== 0 ? "已结束" : "已结束")
|
|
903
|
+
: (exitInfo?.signal ? "会话已停止(被 " + exitInfo.signal + " 终止)" : exitInfo && (exitInfo.code ?? 0) !== 0 ? "会话已结束(退出码 " + exitInfo.code + ")" : "会话已结束"))
|
|
904
|
+
: h("span", { style: { color: status === "running" ? (killPending ? "var(--warn, #f59e0b)" : "var(--ok, #4ade80)") : "var(--text)" } },
|
|
905
|
+
isSmall
|
|
906
|
+
? (killPending ? "⏳" : status === "running" ? "●" : status === "queued" ? "⏳" : status === "replaying" ? "⟳" : status === "reconnecting" ? "⚠" : "…")
|
|
907
|
+
: (killPending && status === "running" ? "终止中(已发 SIGTERM,最多 3s 后强制 KILL)" : status === "running" ? "● 运行中" : status === "queued" ? "排队中(等待并发空位)" : status === "replaying" ? "⟳ 回放中" : status === "reconnecting" ? "连接中断,重连中…" : "… 连接中")),
|
|
908
|
+
meta && !isSmall && h("span", { title: meta.cwd, style: { fontSize: 11, color: "var(--text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0, flexShrink: 1 } }, "📁 " + (meta.cwd || "")),
|
|
909
|
+
meta && !isSmall && h("code", { style: { ...smallStyle, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flexShrink: 2 } }, "$ " + String(meta.cmd).slice(0, 60)),
|
|
910
|
+
// 窗口大小(自适应 / 固定标准尺寸):小屏也显示(用户要求可见;窄屏下 label 较短占用有限),Ctrl+Shift+S 仍可循环
|
|
911
|
+
h("select", { value: sizeId, onChange: (e) => { const v = e.target.value; setSizeId(v); lsSet(LS_TERM_SIZE, v); }, style: selStyle, title: "终端窗口大小(列×行):自适应容器,或固定标准尺寸——小屏也能看到完整终端,超出可滚动" },
|
|
912
|
+
TERM_SIZES.map((s) => h("option", { key: s.id, value: s.id }, s.label)),
|
|
913
|
+
),
|
|
914
|
+
h("button", { onClick: scrollBottom, title: "滚动到底部", "aria-label": "滚动到底部", style: miniBtn }, isZh ? "↓底" : "⇊"),
|
|
915
|
+
h("button", { onClick: toggleKbd, title: "虚拟键盘:方向键 / Tab / Esc / Ctrl / 常用符号 (Ctrl+Shift+K)", "aria-label": "虚拟键盘", style: { ...miniBtn, background: kbdOpen ? "var(--accent)" : undefined, color: kbdOpen ? "#fff" : undefined } }, "⌨"),
|
|
916
|
+
h("button", { onClick: () => setHelpOpen((x) => !x), title: "常用快捷键帮助", "aria-label": "常用快捷键帮助", style: { ...miniBtn, background: helpOpen ? "var(--accent)" : undefined, color: helpOpen ? "#fff" : undefined } }, "?"),
|
|
917
|
+
// 页签模式操作区(顺序:+ 新建 · ⇲ 拉回窗口 · ⛶ 最大化);弹窗模式不传不渲染;
|
|
918
|
+
// 弹窗模式传 sendToTab(⇱ 转到页签:终端移到文件区工作区终端页签)
|
|
919
|
+
sendToTab && h("button", { onClick: sendToTab, title: "转到页签:终端移到右侧文件区的工作区终端页签(弹窗内变占位)", "aria-label": "转到工作区页签", style: miniBtn }, isSmall ? "⇱" : "⇱ 转到页签"),
|
|
920
|
+
onNewShell && h("button", { onClick: onNewShell, title: "新建:再开一个 shell(目录 = 当前终端 📁 实时路径)", "aria-label": "新建 shell", style: miniBtn }, isSmall ? "+" : "+ 新建"),
|
|
921
|
+
onNewPiSession && h("button", { onClick: onNewPiSession, title: "新建 pi-web 会话:打开新 agent 会话并 @ 当前终端实时路径", "aria-label": "新建 pi-web 会话", style: miniBtn }, isSmall ? "💬" : "新会话"),
|
|
922
|
+
pullBack && h("button", { onClick: pullBack, title: "拉回窗口:终端视图移回 aiti-cmd 弹窗(页签显示占位)", "aria-label": "拉回窗口", style: miniBtn }, isSmall ? "⇲" : "⇲ 拉回窗口"),
|
|
923
|
+
// 最大化/还原 放最末:与 aiti-float 自动注入的 ⛶/❐ 同位(关闭按钮之前),习惯后置
|
|
924
|
+
h("button", { onClick: () => setFullscreen((x) => !x), title: "全屏 / 退出全屏 (Ctrl+Shift+F)", "aria-label": "全屏", style: { ...miniBtn, background: fullscreen ? "var(--accent)" : undefined, color: fullscreen ? "#fff" : undefined } }, fullscreen ? "⤡" : "⛶"),
|
|
925
|
+
exitInfo && h("span", { style: { fontWeight: 700, color: exitInfo.signal ? "var(--danger, #ef4444)" : (exitInfo.code ?? 0) !== 0 ? "#b45309" : "var(--text-muted)" } }, `退出码 ${exitInfo.code ?? "?"}${exitInfo.signal ? " · " + exitInfo.signal : ""} · ${((exitInfo.durationMs || 0) / 1000).toFixed(1)}s`),
|
|
926
|
+
),
|
|
927
|
+
h("div", { ref, style: { flex: 1, minHeight: 0, background: "#0f172a", borderRadius: 8, padding: 6, overflow: isFit ? "hidden" : "auto", touchAction: "none" } }),
|
|
928
|
+
// 小屏底部状态栏:顶部简化后 cwd/命令全不可见——底部放一份;统计计数(🤖 agent / 🖥 终端)也下沉到这里
|
|
929
|
+
isSmall && meta && h("div", { style: { flexShrink: 0, display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--text-muted)", padding: "4px 6px 0", overflow: "hidden", minHeight: 0, whiteSpace: "nowrap" } },
|
|
930
|
+
counts && h("span", { title: "当前:🤖 agent 在跑 / 🖥 终端在跑", style: { flexShrink: 0 } }, "🤖" + counts.piRunning + "/" + counts.piTotal + " 🖥" + counts.ptyRunning),
|
|
931
|
+
meta.cwd && h("span", { title: "工作目录", style: { overflow: "hidden", textOverflow: "ellipsis", minWidth: 0, flexShrink: 1 } }, "📁 " + meta.cwd),
|
|
932
|
+
h("code", { title: String(meta.cmd), style: { overflow: "hidden", textOverflow: "ellipsis", minWidth: 0, flexShrink: 2, color: "var(--text)" } }, "$ " + String(meta.cmd).slice(0, 60)),
|
|
933
|
+
),
|
|
934
|
+
kbdOpen && keypad,
|
|
935
|
+
helpOpen && help,
|
|
936
|
+
h(TermLinkConfirm, { link: linkConfirm, onClose: () => setLinkConfirm(null) }),
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ---------- agent 运行中命令视图(只读 xterm + SSE diff 协议 data/replace) ----------
|
|
941
|
+
function PiCommandView({ cmdId, counts }) {
|
|
942
|
+
const isZh = useIsZh();
|
|
943
|
+
const ref = useRef(null);
|
|
944
|
+
const [status, setStatus] = useState("connecting");
|
|
945
|
+
const [meta, setMeta] = useState(null);
|
|
946
|
+
const [exitInfo, setExitInfo] = useState(null);
|
|
947
|
+
const [hasSel, setHasSel] = useState(false);
|
|
948
|
+
const [linkConfirm, setLinkConfirm] = useState(null); // {uri} —— 终端 URL 点击确认
|
|
949
|
+
const termRef = useRef(null);
|
|
950
|
+
const [killBusy, setKillBusy] = useState(false); // 终止中(点击即反馈,防重复点)
|
|
951
|
+
const [fs, setFs] = useState(false); // 全屏
|
|
952
|
+
// 小屏检测:宽度断点 max-width: 640px(高度变化如输入法弹起不翻转布局)
|
|
953
|
+
const [isSmall, setIsSmall] = useState(() => typeof window !== "undefined" && (window.matchMedia ? (window.matchMedia("(max-width: 640px)").matches) : false));
|
|
954
|
+
useEffect(() => {
|
|
955
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
956
|
+
const mq = window.matchMedia("(max-width: 640px)");
|
|
957
|
+
const onChange = () => setIsSmall(mq.matches);
|
|
958
|
+
mq.addEventListener("change", onChange);
|
|
959
|
+
return () => mq.removeEventListener("change", onChange);
|
|
960
|
+
}, []);
|
|
961
|
+
useEffect(() => {
|
|
962
|
+
if (!ref.current) return;
|
|
963
|
+
// 只读终端:agent 的 bash 无 stdin,不接 onData;disableStdin 防止抢焦点
|
|
964
|
+
const term = new Terminal({ fontFamily: TERM_FONT, fontSize: 13, scrollback: 2000, convertEol: false, rendererType: "dom", cursorBlink: false, disableStdin: true, theme: TERM_THEME });
|
|
965
|
+
const fit = new FitAddon();
|
|
966
|
+
term.loadAddon(fit);
|
|
967
|
+
term.open(ref.current);
|
|
968
|
+
termRef.current = term;
|
|
969
|
+
try { fit.fit(); } catch {}
|
|
970
|
+
// 链接识别:URL 可点击 → 确认浮层 → 新窗口打开
|
|
971
|
+
const linkDisp = installTermLinkProvider(term, (uri) => setLinkConfirm({ uri }));
|
|
972
|
+
// ---------- 下行:aiti-event-bus 主题订阅(cmd-<cmdId>)唯一通道(2026-09-09 拍板,禁止兑底) ----------
|
|
973
|
+
// 初始全量 text(≤512KB 大消息)不走总线:订阅生效后 REST 拉 /pi-commands/{id}
|
|
974
|
+
// 快照(text=已发布基线 + seq),reset 重写 + seq 去重与实时增量帧无缝衔接。
|
|
975
|
+
let busOff = null;
|
|
976
|
+
let seenSeq = -1;
|
|
977
|
+
let exited = false;
|
|
978
|
+
let everDown = false;
|
|
979
|
+
let disposed = false;
|
|
980
|
+
let currentCmd = "";
|
|
981
|
+
let headerWritten = false;
|
|
982
|
+
// 命令头幂等写入:xterm 的 write 是异步队列、reset 是同步清屏——若 started 和 replace
|
|
983
|
+
// 各写一次,第一次的写入还在队列里,reset 清不掉,会渲染出两遍命令头;改为仅在
|
|
984
|
+
// 真正写输出前懒写一次,replace 重放时重置标志重建。
|
|
985
|
+
const ensureHeader = () => { if (!headerWritten) { headerWritten = true; try { term.write(`\x1b[94m$ ${currentCmd || "(订阅前已开始)"}\x1b[0m\r\n`); } catch {} } };
|
|
986
|
+
// 结束横幅:醒目底色块行(被终止/出错红底 / 正常绿底),替代旧灰色小字;快照重放后也补写
|
|
987
|
+
const writeExitBanner = (m) => {
|
|
988
|
+
const tail = `耗时 ${((m.durationMs || 0) / 1000).toFixed(1)}s${m.truncated ? " · 输出已截断" : ""}`;
|
|
989
|
+
if (m.isError) term.write(`\r\n\x1b[41;97m ⏹ 命令已停止(出错/被终止) · ${tail} \x1b[0m\r\n`);
|
|
990
|
+
else term.write(`\r\n\x1b[42;30m ✓ 命令已完成 · ${tail} \x1b[0m\r\n`);
|
|
991
|
+
};
|
|
992
|
+
const hideCursor = () => { try { term.write("\x1b[?25l"); } catch {} }; // 已结束:隐藏光标(空心框易被当可交互)
|
|
993
|
+
const applyFrame = (m) => {
|
|
994
|
+
if (m.evt === "started") { setStatus(m.status === "running" ? "running" : "exit"); setMeta(m); currentCmd = String(m.cmd || ""); }
|
|
995
|
+
else if (m.evt === "replace") {
|
|
996
|
+
if (typeof m.seq === "number") { if (m.seq <= seenSeq) return; seenSeq = m.seq; }
|
|
997
|
+
try { term.reset(); } catch {} headerWritten = false; ensureHeader(); term.write(m.data || ""); try { term.scrollToBottom(); } catch {}
|
|
998
|
+
}
|
|
999
|
+
else if (m.evt === "data") {
|
|
1000
|
+
if (exited) return; // 已结束不再吃数据帧:防状态回退/横幅后混入旧输出
|
|
1001
|
+
if (typeof m.seq === "number") { if (m.seq <= seenSeq) return; seenSeq = m.seq; }
|
|
1002
|
+
ensureHeader(); term.write(m.data || ""); try { term.scrollToBottom(); } catch {} setStatus("running");
|
|
1003
|
+
}
|
|
1004
|
+
else if (m.evt === "exit") {
|
|
1005
|
+
if (exited) return;
|
|
1006
|
+
exited = true;
|
|
1007
|
+
ensureHeader();
|
|
1008
|
+
setStatus("exit"); setExitInfo(m);
|
|
1009
|
+
hideCursor();
|
|
1010
|
+
writeExitBanner(m);
|
|
1011
|
+
busOff?.(); busOff = null; aliveDel("cmd-" + cmdId); // 已结束:退订实时流+停保活(死会话无帧可推)
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
const applySnapshot = (d) => {
|
|
1015
|
+
if (!d || !d.ok) return;
|
|
1016
|
+
setStatus(d.status === "done" ? "exit" : "running");
|
|
1017
|
+
setMeta({ sessionId: d.sessionId, cmd: d.cmd, cwd: d.cwd }); // 显式字段:不把大 text 存进 state
|
|
1018
|
+
currentCmd = String(d.cmd || "");
|
|
1019
|
+
try { term.reset(); } catch {} headerWritten = false; ensureHeader();
|
|
1020
|
+
if (d.text) term.write(d.text);
|
|
1021
|
+
try { term.scrollToBottom(); } catch {}
|
|
1022
|
+
if (typeof d.seq === "number" && d.seq > seenSeq) seenSeq = d.seq;
|
|
1023
|
+
if (d.status === "done") {
|
|
1024
|
+
hideCursor();
|
|
1025
|
+
writeExitBanner(d); // reset 重放后补横幅(exited 已置位也要补——reset 清掉了上一份)
|
|
1026
|
+
if (!exited) { exited = true; setExitInfo({ isError: d.isError, durationMs: d.durationMs, truncated: d.truncated }); }
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
// 订阅实时流(活命令专用):订阅生效(ready)后才拉全量快照——重叠段由 seq 去重 → 零间隙,
|
|
1030
|
+
// 全量快照恰好一次(同 TerminalView)
|
|
1031
|
+
const setupLive = () => {
|
|
1032
|
+
if (exited || disposed) return;
|
|
1033
|
+
const topicsSub = ctx?.getExtService?.("aiti-event-bus.subscribe-topics");
|
|
1034
|
+
if (!topicsSub) return;
|
|
1035
|
+
const pullSnap = () => { fetch(api(`/pi-commands/${cmdId}`)).then((r) => r.json()).then((d) => { if (!disposed) applySnapshot(d); }).catch(() => {}); };
|
|
1036
|
+
const sub = topicsSub(applyFrame, ["cmd-" + cmdId], (s) => {
|
|
1037
|
+
if (s === "reconnecting") { everDown = true; setStatus("reconnecting"); }
|
|
1038
|
+
else if (s === "up") {
|
|
1039
|
+
setStatus((prev) => (prev === "reconnecting" ? "running" : prev));
|
|
1040
|
+
if (everDown && !exited) {
|
|
1041
|
+
everDown = false;
|
|
1042
|
+
pullSnap(); // 重连补丢失段(全量重放 + seq 去重,幂等)
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
busOff = () => { try { sub.off(); } catch {} };
|
|
1047
|
+
aliveAdd("cmd-" + cmdId); // 订阅保活(停推协议,同 TerminalView)
|
|
1048
|
+
sub.ready.then(pullSnap).catch(() => {
|
|
1049
|
+
// 总线不可达:退而求其次拉一次快照(无实时流,内容仍可见)
|
|
1050
|
+
pullSnap();
|
|
1051
|
+
});
|
|
1052
|
+
};
|
|
1053
|
+
// 初始化:轻量状态查询先行(?meta=1 无 text)——done 定案只拉一次全量,不订阅不保活;
|
|
1054
|
+
// running 订阅先行零间隙;查询失败保守走活命令路径
|
|
1055
|
+
fetch(api(`/pi-commands/${cmdId}?meta=1`)).then((r) => r.json()).then((info) => {
|
|
1056
|
+
if (disposed) return;
|
|
1057
|
+
if (info && info.ok) {
|
|
1058
|
+
if (info.status === "done") {
|
|
1059
|
+
setMeta(info);
|
|
1060
|
+
setStatus("exit");
|
|
1061
|
+
fetch(api(`/pi-commands/${cmdId}`)).then((r) => r.json()).then((d) => { if (!disposed) applySnapshot(d); }).catch(() => {});
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
setMeta((prev) => ({ ...(prev || {}), ...info }));
|
|
1065
|
+
}
|
|
1066
|
+
setupLive();
|
|
1067
|
+
}).catch(() => setupLive());
|
|
1068
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => { try { fit.fit(); } catch {} }) : null;
|
|
1069
|
+
if (ro) ro.observe(ref.current);
|
|
1070
|
+
// 只读视图同样可复制:右键=复制选区、Ctrl+Shift+C / Ctrl+Insert(无粘贴——无 stdin)
|
|
1071
|
+
const clip = installTermClipboard(term, ref.current);
|
|
1072
|
+
term.attachCustomKeyEventHandler((ev) => !clip.handleKey(ev));
|
|
1073
|
+
term.onSelectionChange(() => { try { setHasSel(!!term.getSelection()); } catch {} });
|
|
1074
|
+
return () => { disposed = true; busOff?.(); aliveDel("cmd-" + cmdId); linkDisp?.dispose?.(); clip.dispose(); ro?.disconnect(); term.dispose(); termRef.current = null; };
|
|
1075
|
+
}, [cmdId]);
|
|
1076
|
+
const killPi = () => {
|
|
1077
|
+
if (killBusy) return;
|
|
1078
|
+
setKillBusy(true);
|
|
1079
|
+
// 终端内即时提示(不等总线帧):代理缓冲/总线慢时用户也能立刻看到点击生效
|
|
1080
|
+
try { termRef.current?.write?.("\r\n\x1b[90m[已发送终止信号(SIGTERM),等待进程退出…]\x1b[0m\r\n"); } catch {}
|
|
1081
|
+
fetch(api(`/pi-commands/${cmdId}/kill`), { method: "POST" }).catch(() => {}).finally(() => {
|
|
1082
|
+
setTimeout(() => setKillBusy(false), 1500); // 最短可见防闪烁;退出后按钮随 status 消失
|
|
1083
|
+
});
|
|
1084
|
+
};
|
|
1085
|
+
// 收藏命令:选区优先(可只收藏片段),否则完整命令 → 命令详情面板(编辑/收藏/执行)
|
|
1086
|
+
const saveCmd = () => {
|
|
1087
|
+
try {
|
|
1088
|
+
const sel = termRef.current?.getSelection?.().trim();
|
|
1089
|
+
const cmd = sel || String(meta?.cmd || "").trim();
|
|
1090
|
+
if (!cmd) return;
|
|
1091
|
+
setState({ detail: { cmd, cwd: meta?.cwd || null }, detailBusy: false });
|
|
1092
|
+
} catch {}
|
|
1093
|
+
};
|
|
1094
|
+
// 滚动到底部(长输出跟随最新)
|
|
1095
|
+
const scrollBottom = () => {
|
|
1096
|
+
try { const c = ref.current; if (c) c.scrollTop = c.scrollHeight; } catch {}
|
|
1097
|
+
try { termRef.current?.scrollToBottom(); } catch {}
|
|
1098
|
+
};
|
|
1099
|
+
return h("div", { style: fs ? { position: "fixed", inset: 0, zIndex: 500, background: "var(--bg-panel)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 4, boxSizing: "border-box" } : { display: "flex", flexDirection: "column", gap: 4, flex: 1, minHeight: 0 } },
|
|
1100
|
+
h("div", { style: { ...smallStyle, display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" } },
|
|
1101
|
+
// 顶部状态:exit 用醒目底色 badge(被终止/出错红 / 正常绿 / 已清理灰),其余纯色文字
|
|
1102
|
+
status === "exit" ? h("span", { style: { display: "inline-flex", alignItems: "center", flexShrink: 0, padding: "1px 8px", borderRadius: 6, fontWeight: 700, fontSize: 11, lineHeight: "16px", color: exitInfo?.swept ? "var(--text)" : exitInfo?.isError ? "#fff" : "var(--ok, #4ade80)", background: exitInfo?.swept ? "var(--bg-hover)" : exitInfo?.isError ? "var(--danger, #ef4444)" : "color-mix(in srgb, var(--ok, #4ade80) 18%, transparent)" } },
|
|
1103
|
+
isSmall
|
|
1104
|
+
? (exitInfo?.swept ? "已结束" : exitInfo?.isError ? "已停止" : "完成")
|
|
1105
|
+
: (exitInfo?.swept ? "已结束(服务器已清理记录)" : exitInfo?.isError ? "命令已停止(出错/被终止)" : "命令已完成"))
|
|
1106
|
+
: h("span", { style: { color: status === "running" ? "var(--ok, #4ade80)" : "var(--text)" } },
|
|
1107
|
+
isSmall
|
|
1108
|
+
? (status === "running" ? "●" : status === "reconnecting" ? "⚠" : "…")
|
|
1109
|
+
: (status === "running" ? "● 运行中" : status === "reconnecting" ? "重连中…" : "… 连接中")),
|
|
1110
|
+
// 顶栏 meta 信息小屏隐藏(移到下方状态栏)
|
|
1111
|
+
!isSmall && meta && h("code", { style: smallStyle }, "🤖 " + String(meta.sessionId || "").slice(0, 8) + " $ " + String(meta.cmd || "(订阅前已开始)").slice(0, 70)),
|
|
1112
|
+
h("button", { onClick: saveCmd, disabled: !hasSel && !String(meta?.cmd || "").trim(), title: hasSel ? "收藏选中的文本(可只收藏命令片段)" : "收藏完整命令到常用命令", "aria-label": "收藏命令", style: { ...btnStyle, padding: isSmall ? "2px 6px" : "2px 10px", opacity: hasSel || String(meta?.cmd || "").trim() ? 1 : 0.5, cursor: hasSel || String(meta?.cmd || "").trim() ? "pointer" : "not-allowed" } }, isSmall ? "+" : "+ 收藏"),
|
|
1113
|
+
exitInfo && !isSmall && !exitInfo.swept && h("span", { style: { fontWeight: 700, color: exitInfo.isError ? "var(--danger, #ef4444)" : "var(--text-muted)" } }, (exitInfo.isError ? "出错/被终止" : "完成") + " · " + ((exitInfo.durationMs || 0) / 1000).toFixed(1) + "s"),
|
|
1114
|
+
status !== "exit" && h("button", { onClick: killPi, disabled: killBusy, title: killBusy ? "终止信号已发送,等待进程退出…" : "只终止这条 bash 命令(agent 收到错误后继续思考;同会话并发 bash 会一起终止)", "aria-label": "终止命令", style: { ...btnStyle, color: "var(--danger, #ef4444)", padding: isSmall ? "2px 6px" : "2px 10px", opacity: killBusy ? 0.6 : 1, cursor: killBusy ? "wait" : "pointer" } }, killBusy ? (isSmall ? "⏳" : "终止中…") : (isSmall ? "⏹" : "终止命令")),
|
|
1115
|
+
h("button", { onClick: scrollBottom, title: "滚动到底部", "aria-label": "滚动到底部", style: { ...btnStyle, padding: "2px 8px" } }, isZh ? "↓底" : "⇊"),
|
|
1116
|
+
// 最大化放最末:与 TerminalView 一致习惯
|
|
1117
|
+
h("button", { onClick: () => setFs((x) => !x), title: "全屏 / 退出全屏", "aria-label": "全屏", style: { ...btnStyle, padding: "2px 8px", color: fs ? "var(--accent)" : undefined } }, fs ? "⤡" : "⛶"),
|
|
1118
|
+
),
|
|
1119
|
+
h("div", { ref, style: { flex: 1, minHeight: 0, background: "#0f172a", borderRadius: 8, padding: 6, overflow: "hidden" } }),
|
|
1120
|
+
// 小屏底部状态栏:meta 必含(订阅运行中命令);统计计数下沉到这里
|
|
1121
|
+
isSmall && meta && h("div", { style: { flexShrink: 0, display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--text-muted)", padding: "4px 6px 0", overflow: "hidden", minHeight: 0, whiteSpace: "nowrap" } },
|
|
1122
|
+
counts && h("span", { title: "当前:🤖 agent 在跑 / 🖥 终端在跑", style: { flexShrink: 0 } }, "🤖" + counts.piRunning + "/" + counts.piTotal + " 🖥" + counts.ptyRunning),
|
|
1123
|
+
h("span", { title: "agent 会话", style: { overflow: "hidden", textOverflow: "ellipsis", minWidth: 0, flexShrink: 1 } }, "🤖 " + String(meta.sessionId || "").slice(0, 8)),
|
|
1124
|
+
h("code", { title: String(meta.cmd), style: { overflow: "hidden", textOverflow: "ellipsis", minWidth: 0, flexShrink: 2, color: "var(--text)" } }, "$ " + String(meta.cmd || "(订阅前已开始)").slice(0, 60)),
|
|
1125
|
+
exitInfo && h("span", { style: { color: exitInfo.isError ? "var(--danger, #ef4444)" : "var(--text-muted)", flexShrink: 0 } }, exitInfo.swept ? "已清理" : (exitInfo.isError ? "⚠ " : "✓ ") + ((exitInfo.durationMs || 0) / 1000).toFixed(1) + "s"),
|
|
1126
|
+
),
|
|
1127
|
+
h(TermLinkConfirm, { link: linkConfirm, onClose: () => setLinkConfirm(null) }),
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ---------- 主面板(命令 / 终端 双页签) ----------
|
|
1132
|
+
function CommandPanel() {
|
|
1133
|
+
const open = usePanelOpen();
|
|
1134
|
+
const view = useView();
|
|
1135
|
+
// dock 单实例:终端会话被工作区页签持有(未 detach)时详情区渲染占位卡。
|
|
1136
|
+
// hook 必须在 if (!open) return null 之前无条件调用(Rules of Hooks)。
|
|
1137
|
+
const hostTabId = useSessionHostTab(view && view.kind === "pty" ? view.id : null);
|
|
1138
|
+
const [tab, setTab] = useState("overview");
|
|
1139
|
+
// 小屏(宽度断点 640px):详情工具条按钮图标化(← / + / ⏹);统计下沉到底栏
|
|
1140
|
+
const [isSmall, setIsSmall] = useState(() => typeof window !== "undefined" && (window.matchMedia ? window.matchMedia("(max-width: 640px)").matches : false));
|
|
1141
|
+
useEffect(() => {
|
|
1142
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
1143
|
+
const mq = window.matchMedia("(max-width: 640px)");
|
|
1144
|
+
const onChange = () => setIsSmall(mq.matches);
|
|
1145
|
+
mq.addEventListener("change", onChange);
|
|
1146
|
+
return () => mq.removeEventListener("change", onChange);
|
|
1147
|
+
}, []);
|
|
1148
|
+
const [commands, setCommands] = useState([]);
|
|
1149
|
+
const [sessions, setSessions] = useState([]);
|
|
1150
|
+
const [q, setQ] = useState("");
|
|
1151
|
+
const [err, setErr] = useState(null);
|
|
1152
|
+
const [editing, setEditing] = useState(null); // {name, cmd, cwd, tags, confirm, suggest}
|
|
1153
|
+
const [refreshTick, setRefreshTick] = useState(0);
|
|
1154
|
+
const [records, setRecords] = useState([]);
|
|
1155
|
+
// 来源分布(M4a §4:/history?groupBy=source UI 化)——审计「按来源追责」可视入口
|
|
1156
|
+
const [srcGroups, setSrcGroups] = useState([]);
|
|
1157
|
+
const [srcFilter, setSrcFilter] = useState(null);
|
|
1158
|
+
const [expanded, setExpanded] = useState(null);
|
|
1159
|
+
const [outputs, setOutputs] = useState({});
|
|
1160
|
+
const [groups, setGroups] = useState([]);
|
|
1161
|
+
const [collapsed, setCollapsed] = useState({});
|
|
1162
|
+
const [editingGroup, setEditingGroup] = useState(null); // {id?, name, parentId}
|
|
1163
|
+
const [activeTag, setActiveTag] = useState(null);
|
|
1164
|
+
const [dictOpen, setDictOpen] = useState(false);
|
|
1165
|
+
const [dictData, setDictData] = useState(null); // { groups, user }
|
|
1166
|
+
const [dictText, setDictText] = useState("");
|
|
1167
|
+
const [piCmds, setPiCmds] = useState([]); // agent 运行中命令(pi-commands)
|
|
1168
|
+
// 点击即反馈的忙态:终止中 id 集(点击变 ⏳ + 防重复点);新终端开启中
|
|
1169
|
+
const [killBusy, setKillBusy] = useState(() => new Set());
|
|
1170
|
+
const [piKillBusy, setPiKillBusy] = useState(() => new Set());
|
|
1171
|
+
const [newTermBusy, setNewTermBusy] = useState(false);
|
|
1172
|
+
// 详情态「⋯ 更多终端」下拉开合(点外部自动收起)
|
|
1173
|
+
const [tabMenuOpen, setTabMenuOpen] = useState(false);
|
|
1174
|
+
// Hooks 必须在早退之前全部调用(open=false 时不渲染,但 hook 数量不能变)
|
|
1175
|
+
const refresh = () => {
|
|
1176
|
+
fetch(api("/commands")).then((r) => r.json()).then((d) => { setCommands(d.commands || []); setGroups(d.groups || []); }).catch(() => {});
|
|
1177
|
+
fetch(api("/sessions")).then((r) => r.json()).then((d) => setSessions(d.sessions || [])).catch(() => {});
|
|
1178
|
+
fetch(api("/pi-commands")).then((r) => r.json()).then((d) => setPiCmds(d.commands || [])).catch(() => {});
|
|
1179
|
+
};
|
|
1180
|
+
const refreshHistory = () => {
|
|
1181
|
+
fetch(api("/history?limit=200")).then((r) => r.json()).then((d) => setRecords(d.records || [])).catch(() => {});
|
|
1182
|
+
fetch(api("/history?groupBy=source")).then((r) => r.json()).then((d) => setSrcGroups(d.groups || [])).catch(() => {});
|
|
1183
|
+
};
|
|
1184
|
+
const [verifyResult, setVerifyResult] = useState(null);
|
|
1185
|
+
const loadDict = async () => {
|
|
1186
|
+
const d = await fetch(api("/commands/dict")).then((r) => r.json()).catch(() => null);
|
|
1187
|
+
if (d) { setDictData(d); setDictText((d.user && d.user.extra || []).join("\n")); }
|
|
1188
|
+
};
|
|
1189
|
+
const verifyChain = async () => {
|
|
1190
|
+
const d = await fetch(api("/history/verify")).then((r) => r.json()).catch(() => null);
|
|
1191
|
+
if (!d) { setVerifyResult("校验失败"); return; }
|
|
1192
|
+
setVerifyResult(d.ok ? "链完整 · " + d.checkedHashes + " 条哈希" + (d.legacy ? "(legacy " + d.legacy + ")" : "") : "篡改于 " + (d.brokenAt || "?") + " · " + d.checkedHashes + " 条");
|
|
1193
|
+
};
|
|
1194
|
+
const expand = async (id) => {
|
|
1195
|
+
if (expanded === id) { setExpanded(null); return; }
|
|
1196
|
+
setExpanded(id);
|
|
1197
|
+
if (outputs[id] === undefined) {
|
|
1198
|
+
const d = await fetch(api("/history/" + id)).then((r) => r.json()).catch(() => null);
|
|
1199
|
+
setOutputs((o) => ({ ...o, [id]: (d && d.output) || "(无输出)" }));
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
useEffect(() => { if (open) { refresh(); const iv = setInterval(refresh, 5000); return () => clearInterval(iv); } }, [open, refreshTick]);
|
|
1203
|
+
useEffect(() => { if (open && tab === "history") refreshHistory(); }, [open, tab, refreshTick]);
|
|
1204
|
+
// view 被设置(含外部路径:消息识别面板 doRun / runCommand 等)但当前不在总览页 → 自动切回,
|
|
1205
|
+
// 否则详情视图渲染在总览页里,用户停在历史/命令页签时看不到刚启动的终端
|
|
1206
|
+
useEffect(() => { if (view && tab !== "overview") setTab("overview"); }, [view]);
|
|
1207
|
+
// 列表实时帧(aiti-event-bus:cmd-sessions / cmd-pilist,aiti-cmd 状态变化时推送)——topics 唯一
|
|
1208
|
+
// (2026-09-09 拍板,无兑底轮询;topics 缺席 → 列表靠打开时 refresh() 一次性拉取)。
|
|
1209
|
+
// 另加本地 1s tick(纯重渲染,无网络):运行时长跳动。
|
|
1210
|
+
const [, setListTick] = useState(0);
|
|
1211
|
+
useEffect(() => { if (!open || tab !== "overview") return; const t = setInterval(() => setListTick((x) => x + 1), 1000); return () => clearInterval(t); }, [open, tab]);
|
|
1212
|
+
useEffect(() => {
|
|
1213
|
+
if (!open || tab !== "overview" || view) return;
|
|
1214
|
+
const topicsSub = ctx?.getExtService?.("aiti-event-bus.subscribe-topics");
|
|
1215
|
+
if (!topicsSub) return;
|
|
1216
|
+
const s = topicsSub((f) => {
|
|
1217
|
+
if (f.evt === "sessions") setSessions(f.sessions || []);
|
|
1218
|
+
else if (f.evt === "pilist") setPiCmds(f.commands || []);
|
|
1219
|
+
}, ["cmd-sessions", "cmd-pilist"]);
|
|
1220
|
+
aliveAdd("cmd-sessions", "cmd-pilist"); // 列表帧同样受停推协议管理
|
|
1221
|
+
s.ready.then(() => {
|
|
1222
|
+
// 生效后拉一次初值(订阅前的存量)
|
|
1223
|
+
fetch(api("/pi-commands")).then((r) => r.json()).then((d) => setPiCmds(d.commands || [])).catch(() => {});
|
|
1224
|
+
fetch(api("/sessions")).then((r) => r.json()).then((d) => setSessions(d.sessions || [])).catch(() => {});
|
|
1225
|
+
}).catch(() => {}); // 总线不可用 → 无实时列表(打开时 refresh() 已拉)
|
|
1226
|
+
return () => { try { s.off(); } catch {} aliveDel("cmd-sessions", "cmd-pilist"); };
|
|
1227
|
+
}, [open, tab, view]);
|
|
1228
|
+
// 「⋯ 更多终端」下拉:点面板其他区域自动收起(打开的那次 click 已冒泡完,延后一拍再挂监听)
|
|
1229
|
+
useEffect(() => {
|
|
1230
|
+
if (!tabMenuOpen) return;
|
|
1231
|
+
const close = () => setTabMenuOpen(false);
|
|
1232
|
+
const t = setTimeout(() => document.addEventListener("click", close), 0);
|
|
1233
|
+
return () => { clearTimeout(t); document.removeEventListener("click", close); };
|
|
1234
|
+
}, [tabMenuOpen]);
|
|
1235
|
+
if (!open) return null;
|
|
1236
|
+
|
|
1237
|
+
// 运行命令(危险命令一次性 token 确认流程:服务端强制)
|
|
1238
|
+
const runWithConfirm = async (payload) => {
|
|
1239
|
+
let r = await fetch(api("/run"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }).then((x) => x.json()).catch(() => ({ ok: false, error: "网络错误" }));
|
|
1240
|
+
if (r.needConfirm) {
|
|
1241
|
+
const ok = await uiConfirm({ message: `确认执行危险命令${r.danger && r.danger.length ? "(" + r.danger.join("/") + ")" : ""}?`, detail: `$ ${payload.command}`, danger: true });
|
|
1242
|
+
if (!ok) return null;
|
|
1243
|
+
r = await fetch(api("/run"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...payload, confirmToken: r.confirmToken }) }).then((x) => x.json()).catch(() => ({ ok: false, error: "网络错误" }));
|
|
1244
|
+
}
|
|
1245
|
+
return r;
|
|
1246
|
+
};
|
|
1247
|
+
// 运行一条命令(处理参数/确认)
|
|
1248
|
+
const runCommand = async (item) => {
|
|
1249
|
+
const params = item.params || [];
|
|
1250
|
+
let filled = {};
|
|
1251
|
+
if (params.length) {
|
|
1252
|
+
const vals = {};
|
|
1253
|
+
for (const p of params) { const v = await uiPrompt({ message: `参数 ${p}:` }); if (v == null) return; vals[p] = v; }
|
|
1254
|
+
filled = vals;
|
|
1255
|
+
}
|
|
1256
|
+
let cmd = item.cmd;
|
|
1257
|
+
for (const [k, v] of Object.entries(filled)) cmd = cmd.replaceAll("{" + k + "}", v);
|
|
1258
|
+
const r = await runWithConfirm({ command: cmd, cwd: item.cwd, name: item.name, source: "favorite", commandId: item.id, confirm: !!item.confirm, sandbox: item.sandbox || "none" });
|
|
1259
|
+
if (!r) return;
|
|
1260
|
+
if (!r.ok) { setErr(r.error || "运行失败"); return; }
|
|
1261
|
+
if (r.queued) setErr("已加入队列,等待执行(位置 " + r.position + "/" + r.maxConcurrent + " 并发)");
|
|
1262
|
+
openView("pty", r.sessionId, item.name || item.cmd);
|
|
1263
|
+
setTab("overview");
|
|
1264
|
+
setRefreshTick((t) => t + 1);
|
|
1265
|
+
};
|
|
1266
|
+
// 弹窗终端 ⇱ 转到工作区页签(详情态 TerminalView 工具条按钮):
|
|
1267
|
+
// · 会话已被某页签绑定(含 ⇲ 拉回窗口态)→ 直接切回页签归属(不新建页签);
|
|
1268
|
+
// · 从未绑定 → 走宿主 pi-web.ui 服务 onOpenTerminal(cwd, true) 新建同 cwd 页签
|
|
1269
|
+
// (AppShell 全局注册,任何时刻可建,不依赖已存在终端页签),pendingTakeovers
|
|
1270
|
+
// 由新页签 effect 消费接管(绑既有会话,不另起新 shell),随后弹窗回总览。
|
|
1271
|
+
const sendToTab = (s) => {
|
|
1272
|
+
if (!s) return;
|
|
1273
|
+
for (const [tid, e] of Object.entries(tabAttached)) {
|
|
1274
|
+
if (e && e.sessionId === s.id) {
|
|
1275
|
+
tabDetached[tid] = false;
|
|
1276
|
+
notifyTabAttached();
|
|
1277
|
+
closeView();
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
const cwd = s.cwd || "";
|
|
1282
|
+
if (!cwd) {
|
|
1283
|
+
setErr("转到页签失败:此终端会话没有工作目录,无法定位到项目页签");
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
// 建页签走宿主 pi-web.ui 服务(交互时刻同步读,AppShell 必已挂载);onOpenTerminal
|
|
1287
|
+
// 即 handleOpenTerminal(cwd, forceNew),forceNew=true 与原 openShellTab 语义一致。
|
|
1288
|
+
const openTab = ctx?.getExtService?.("pi-web.ui")?.get?.("onOpenTerminal");
|
|
1289
|
+
if (typeof openTab !== "function") {
|
|
1290
|
+
setErr("转到页签失败:宿主建页签能力缺席(pi-web.ui.onOpenTerminal)");
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
pendingTakeovers.push({ cwd, sessionId: s.id, title: s.name || s.cmd || "bash", ts: Date.now() });
|
|
1294
|
+
try { openTab(cwd, true); } catch (e) {
|
|
1295
|
+
pendingTakeovers.pop();
|
|
1296
|
+
setErr("转到页签失败:" + ((e instanceof Error && e.message) || String(e)));
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
closeView();
|
|
1300
|
+
};
|
|
1301
|
+
const saveEditing = async () => {
|
|
1302
|
+
if (!editing || !editing.cmd.trim()) return;
|
|
1303
|
+
// 规范化 payload:tags 逗号串→数组(store 直接展开,字符串会覆盖 string[] 导致
|
|
1304
|
+
// 标签过滤变成子串匹配);cwd 空串剔除(create 用 ||undefined,update 不清洗会存 "")
|
|
1305
|
+
const payload = { ...editing, tags: (editing.tags || "").split(/[,,]/).map((s) => s.trim()).filter(Boolean) };
|
|
1306
|
+
if (!payload.cwd) delete payload.cwd;
|
|
1307
|
+
if (editing.id) {
|
|
1308
|
+
await fetch(api("/commands/" + editing.id), { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }).catch(() => {});
|
|
1309
|
+
} else {
|
|
1310
|
+
// name 缺省用 cmd(与服务端 store.create 一致,UI 提示「名称(默认=命令)」)
|
|
1311
|
+
await fetch(api("/commands"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...payload, name: editing.name.trim() || editing.cmd, source: "manual" }) }).catch(() => {});
|
|
1312
|
+
}
|
|
1313
|
+
setEditing(null); setRefreshTick((t) => t + 1);
|
|
1314
|
+
};
|
|
1315
|
+
const killSession = (id) => {
|
|
1316
|
+
if (killBusy.has(id)) return; // 终止中:忽略重复点击
|
|
1317
|
+
setKillBusy((prev) => { const n = new Set(prev); n.add(id); return n; });
|
|
1318
|
+
fetch(api(`/sessions/${id}/kill`), { method: "POST" }).catch(() => {}).finally(() => {
|
|
1319
|
+
// 最短可见至服务端 SIGKILL 兕底(3s)之后;会话真正退出后按钮随状态渲染消失
|
|
1320
|
+
setTimeout(() => setKillBusy((prev) => { const n = new Set(prev); n.delete(id); return n; }), 3500);
|
|
1321
|
+
});
|
|
1322
|
+
setRefreshTick((t) => t + 1);
|
|
1323
|
+
};
|
|
1324
|
+
// agent 命令终止(总览行 ⏹;详情视图 PiCommandView 自带本地忙态,不走这里)
|
|
1325
|
+
const killPiCommand = (id) => {
|
|
1326
|
+
if (piKillBusy.has(id)) return;
|
|
1327
|
+
setPiKillBusy((prev) => { const n = new Set(prev); n.add(id); return n; });
|
|
1328
|
+
fetch(api(`/pi-commands/${id}/kill`), { method: "POST" }).catch(() => {}).finally(() => {
|
|
1329
|
+
setTimeout(() => setPiKillBusy((prev) => { const n = new Set(prev); n.delete(id); return n; }), 1500);
|
|
1330
|
+
});
|
|
1331
|
+
};
|
|
1332
|
+
// + 新终端(忙态防连点;§3.3 终端拉起类一律 /shell——后端建会话,命令文本不落前端)
|
|
1333
|
+
const newShell = (cwd) => {
|
|
1334
|
+
if (newTermBusy) return;
|
|
1335
|
+
setNewTermBusy(true);
|
|
1336
|
+
const useCwd = cwd || ctx?.getExtService?.("pi-web.ui")?.get?.("selectedCwd") || undefined;
|
|
1337
|
+
Promise.resolve(
|
|
1338
|
+
fetch(api("/shell"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "终端", cwd, ttlMs: 24 * 3600 * 1000 }) })
|
|
1339
|
+
.then((x) => x.json()).catch(() => ({ ok: false, error: "网络错误" }))
|
|
1340
|
+
).then((r) => {
|
|
1341
|
+
if (!r || !r.ok) setErr((r && r.error) || "创建终端失败");
|
|
1342
|
+
else { openView("pty", r.sessionId, r.name || "终端"); setTab("overview"); setRefreshTick((t) => t + 1); }
|
|
1343
|
+
}).finally(() => setNewTermBusy(false));
|
|
1344
|
+
};
|
|
1345
|
+
// 「+ 新建 / + 新终端」@ 上 shell 实时路径:先查当前终端会话实时 pwd(cd 后取 cd 后的),再建新终端
|
|
1346
|
+
const newShellAt = async (s) => {
|
|
1347
|
+
if (!s) return newShell();
|
|
1348
|
+
if (newTermBusy) return;
|
|
1349
|
+
const live = await liveCwdOf(s.id, s.cwd);
|
|
1350
|
+
newShell(live);
|
|
1351
|
+
};
|
|
1352
|
+
// 「💬 新会话」新建 pi-web 会话 @ 当前终端路径:查实时 pwd → 宿主新会话通道(预填 @路径 + 切换)
|
|
1353
|
+
const newPiSession = async (s) => {
|
|
1354
|
+
if (!s) return;
|
|
1355
|
+
const live = await liveCwdOf(s.id, s.cwd);
|
|
1356
|
+
if (!openNewPiSession(live)) {
|
|
1357
|
+
setErr(live ? "新建 pi-web 会话失败:宿主新会话通道不可用" : "新建 pi-web 会话失败:当前终端会话没有工作目录");
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
closeView(); // 收敛弹窗回总览;宿主已切新会话页
|
|
1361
|
+
};
|
|
1362
|
+
const filtered = commands.filter((c) => (!q || (c.name + " " + c.cmd).toLowerCase().includes(q.toLowerCase())) && (!activeTag || (c.tags || []).includes(activeTag)));
|
|
1363
|
+
// 全部标签(用于过滤 chips)
|
|
1364
|
+
const allTags = Array.from(new Set(commands.flatMap((c) => c.tags || []))).sort();
|
|
1365
|
+
// ---- 树形分组辅助 ----
|
|
1366
|
+
const childrenOf = (pid) => groups.filter((g) => g.parentId === pid).sort((a, b) => a.name.localeCompare(b.name));
|
|
1367
|
+
const commandsOf = (gid) => filtered.filter((c) => c.groupId === gid);
|
|
1368
|
+
const ungrouped = filtered.filter((c) => !c.groupId);
|
|
1369
|
+
const groupPathOf = (id) => {
|
|
1370
|
+
if (!id) return "未分组";
|
|
1371
|
+
const parts = []; let cur = groups.find((g) => g.id === id); let guard = 0;
|
|
1372
|
+
while (cur && guard++ < 20) { parts.unshift(cur.name); cur = cur.parentId ? groups.find((g) => g.id === cur.parentId) : undefined; }
|
|
1373
|
+
return parts.join(" / ") || "未分组";
|
|
1374
|
+
};
|
|
1375
|
+
const saveGroupEditing = async () => {
|
|
1376
|
+
if (!editingGroup || !editingGroup.name.trim()) return;
|
|
1377
|
+
if (editingGroup.id) {
|
|
1378
|
+
await fetch(api("/commands/groups/" + editingGroup.id), { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: editingGroup.name, parentId: editingGroup.parentId }) }).catch(() => {});
|
|
1379
|
+
} else {
|
|
1380
|
+
await fetch(api("/commands/groups"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: editingGroup.name, parentId: editingGroup.parentId }) }).catch(() => {});
|
|
1381
|
+
}
|
|
1382
|
+
setEditingGroup(null); setRefreshTick((t) => t + 1);
|
|
1383
|
+
};
|
|
1384
|
+
const miniBtn = { ...btnStyle, padding: "1px 7px", fontSize: 13 };
|
|
1385
|
+
const miniBtnDanger = { ...miniBtn, color: "var(--danger, #ef4444)" };
|
|
1386
|
+
// 复制命令:写入剪贴板 + 打开「新建命令」弹窗预填内容(可改后另存新命令)
|
|
1387
|
+
const copyAsNew = (c) => {
|
|
1388
|
+
try {
|
|
1389
|
+
const t = c.cmd;
|
|
1390
|
+
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(t).catch(() => {});
|
|
1391
|
+
else {
|
|
1392
|
+
const ta = document.createElement("textarea");
|
|
1393
|
+
ta.value = t; ta.style.position = "fixed"; ta.style.opacity = "0";
|
|
1394
|
+
document.body.appendChild(ta); ta.select();
|
|
1395
|
+
try { document.execCommand("copy"); } catch {}
|
|
1396
|
+
document.body.removeChild(ta);
|
|
1397
|
+
}
|
|
1398
|
+
} catch {}
|
|
1399
|
+
setEditingGroup(null); // 互斥:命令/分组弹窗不同时开
|
|
1400
|
+
setEditing({ name: c.name, cmd: c.cmd, cwd: c.cwd || "", tags: (c.tags || []).join(","), confirm: !!c.confirm, groupId: c.groupId || null, sandbox: c.sandbox || "none", suggest: c.suggest !== false });
|
|
1401
|
+
setTimeout(() => { try { document.getElementById("aiti-cmd-edit-cmd")?.focus(); } catch {} }, 60);
|
|
1402
|
+
};
|
|
1403
|
+
// ⏰ 定时执行:委托 ⏰调度 页预填新建(aiti-ai-schedule 未加载则直接创建待确认计划)
|
|
1404
|
+
const scheduleCommand = async (c) => {
|
|
1405
|
+
// 危险命令(带执行确认标记)排班前二次确认——定时到点会无人值守自动执行
|
|
1406
|
+
if (c.confirm && !(await uiConfirm({ message: `命令「${c.name || c.cmd}」标记为危险命令。\n创建定时计划后到点将不经确认直接执行,确认继续?`, danger: true }))) return;
|
|
1407
|
+
const prefill = {
|
|
1408
|
+
name: (c.name || c.cmd || "定时命令").slice(0, 60),
|
|
1409
|
+
prompt: "执行以下命令并汇报结果" + (c.cwd ? "(工作目录:" + c.cwd + ")" : "") + ":\n$ " + c.cmd + "\n完成后简要汇报输出要点与退出码。" + (c.params && c.params.length ? "\n注意:命令含 {参数} 占位符,请按当前上下文合理填充后再执行。" : ""),
|
|
1410
|
+
cwd: c.cwd || undefined,
|
|
1411
|
+
mode: "session",
|
|
1412
|
+
};
|
|
1413
|
+
try {
|
|
1414
|
+
if (typeof window.__aitiAiScheduleOpen === "function") { window.__aitiAiScheduleOpen(prefill); return; }
|
|
1415
|
+
} catch {}
|
|
1416
|
+
fetch(apiUrl("/api/extensions/aiti-ai-schedule/plans"), {
|
|
1417
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
1418
|
+
body: JSON.stringify({ ...prefill, schedule: "0 9 * * *", enabled: false, autoApprove: false }),
|
|
1419
|
+
}).then((r) => r.json()).then((d) => {
|
|
1420
|
+
if (d && d.ok) void uiAlert({ message: `已创建待确认计划「${prefill.name}」(默认每天9点,到 ⏰调度 页修改并批准)`, kind: "success" });
|
|
1421
|
+
else void uiAlert({ message: "创建失败:" + ((d && d.error) || "未知错误"), kind: "error" });
|
|
1422
|
+
}).catch((e) => void uiAlert({ message: "创建失败:" + String(e), kind: "error" }));
|
|
1423
|
+
};
|
|
1424
|
+
const renderCommandRow = (c, depth) =>
|
|
1425
|
+
h("div", { key: c.id, style: { display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 8, background: "var(--bg-hover)", border: "1px solid var(--border)", marginLeft: depth * 18 } },
|
|
1426
|
+
h("div", { style: { flex: 1, minWidth: 0 } },
|
|
1427
|
+
h("div", { style: { fontSize: 14, fontWeight: 600 } }, c.name + (c.confirm ? " ⚠" : "") + (SANDBOX[c.sandbox || "none"] ? SANDBOX[c.sandbox || "none"].icon : "")),
|
|
1428
|
+
h("code", { style: { fontSize: 13, color: "var(--text-muted)", display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, "$ " + c.cmd + (c.cwd ? " · " + c.cwd : "")),
|
|
1429
|
+
),
|
|
1430
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)" } }, "×" + (c.runCount || 0)),
|
|
1431
|
+
h("button", { onClick: () => runCommand(c), style: { ...btnStyle, background: "var(--accent)", color: "#fff", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none" }, title: "▶ 执行此命令" + (c.params && c.params.length ? "(含参数,执行时逐一填写)" : "") + (c.confirm ? "(危险命令,执行前弹窗确认)" : "") }, Ic(13, ICONS.play), "执行"),
|
|
1432
|
+
h("button", { onClick: () => copyAsNew(c), style: btnStyle, title: "复制命令内容到剪贴板并打开「新建命令」预填(可改后另存)" }, "⧉"),
|
|
1433
|
+
h("button", { onClick: () => scheduleCommand(c), style: btnStyle, title: "定时执行:创建调度计划(预填本条命令)" }, "⏰"),
|
|
1434
|
+
h("button", { onClick: () => setEditing({ id: c.id, name: c.name, cmd: c.cmd, cwd: c.cwd || "", tags: (c.tags || []).join(","), confirm: !!c.confirm, groupId: c.groupId || null, sandbox: c.sandbox || "none", suggest: c.suggest !== false }), style: btnStyle, title: "编辑命令(名称/命令/cwd/标签/分组/沙箱/执行确认/智能提示)" }, Ic(13, ICONS.pencil)),
|
|
1435
|
+
h("button", { onClick: () => { fetch(api("/commands/" + c.id), { method: "DELETE" }).then(() => setRefreshTick((t) => t + 1)); }, style: miniBtnDanger, title: "删除此命令" }, Ic(13, ICONS.trash)),
|
|
1436
|
+
);
|
|
1437
|
+
const renderGroupTree = (parentId, depth) => {
|
|
1438
|
+
const subs = childrenOf(parentId);
|
|
1439
|
+
const rows = [];
|
|
1440
|
+
// 在指定分组下新建命令(groupId 预填,聚焦命令输入框)
|
|
1441
|
+
const newCmdInGroup = (gid) => {
|
|
1442
|
+
setEditingGroup(null); // 互斥:命令/分组弹窗不同时开
|
|
1443
|
+
setEditing({ name: "", cmd: "", cwd: "", tags: "", confirm: false, groupId: gid, sandbox: "none", suggest: true });
|
|
1444
|
+
setTimeout(() => { try { document.getElementById("aiti-cmd-edit-cmd")?.focus(); } catch {} }, 60);
|
|
1445
|
+
};
|
|
1446
|
+
for (const g of subs) {
|
|
1447
|
+
const subCount = childrenOf(g.id).length;
|
|
1448
|
+
const cmdCount = commandsOf(g.id).length;
|
|
1449
|
+
const isCollapsed = !!collapsed[g.id];
|
|
1450
|
+
rows.push(
|
|
1451
|
+
h("div", { key: "g" + g.id, style: { display: "flex", alignItems: "center", gap: 6, padding: "5px 8px", borderRadius: 6, cursor: "pointer", marginLeft: depth * 18, background: "transparent" }, onClick: () => setCollapsed({ ...collapsed, [g.id]: !isCollapsed }) },
|
|
1452
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)" } }, (subCount || cmdCount) ? (isCollapsed ? "▸" : "▾") : "·"),
|
|
1453
|
+
h("span", { style: { fontSize: 14, fontWeight: 600, display: "inline-flex", alignItems: "center", gap: 5 } }, Ic(13, ICONS.folder), g.name),
|
|
1454
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)" } }, cmdCount + (subCount ? "+" + subCount + "子" : "")),
|
|
1455
|
+
h("span", { style: { marginLeft: "auto", display: "flex", gap: 2 }, onClick: (e) => e.stopPropagation() },
|
|
1456
|
+
h("button", { onClick: () => newCmdInGroup(g.id), style: miniBtn, title: "在此分组下新建命令" }, "+命令"),
|
|
1457
|
+
h("button", { onClick: () => { setEditing(null); setEditingGroup({ name: "", parentId: g.id }); }, style: miniBtn, title: "在此分组下新建子分组" }, "+子组"),
|
|
1458
|
+
h("button", { onClick: () => setEditingGroup({ id: g.id, name: g.name, parentId: g.parentId }), style: miniBtn, title: "重命名/移动" }, Ic(12, ICONS.pencil)),
|
|
1459
|
+
h("button", { onClick: async () => { if (await uiConfirm({ message: `删除分组「${g.name}」及其子分组?组内命令将移入未分组`, danger: true })) { fetch(api("/commands/groups/" + g.id), { method: "DELETE" }).then(() => setRefreshTick((t) => t + 1)); } }, style: miniBtnDanger, title: "删除分组" }, Ic(12, ICONS.trash)),
|
|
1460
|
+
),
|
|
1461
|
+
),
|
|
1462
|
+
);
|
|
1463
|
+
if (!isCollapsed) {
|
|
1464
|
+
rows.push(...renderGroupTree(g.id, depth + 1));
|
|
1465
|
+
for (const c of commandsOf(g.id)) rows.push(renderCommandRow(c, depth + 1));
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return rows;
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
// tab 页签按钮(紧凑单行:高度 ~18px,active 下划线)
|
|
1472
|
+
const tabBtn = (active) => ({
|
|
1473
|
+
...btnStyle,
|
|
1474
|
+
background: "transparent", borderTop: "none", borderRight: "none", borderLeft: "none",
|
|
1475
|
+
borderBottom: "2px solid " + (active ? "var(--accent)" : "transparent"),
|
|
1476
|
+
color: active ? "var(--text)" : "var(--text-muted)",
|
|
1477
|
+
fontWeight: active ? 600 : 400,
|
|
1478
|
+
borderRadius: "5px 5px 0 0", padding: "1px 10px", fontSize: 12, lineHeight: "16px",
|
|
1479
|
+
height: 20, marginBottom: -1, display: "inline-flex", alignItems: "center", gap: 4,
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
return h("div", {
|
|
1483
|
+
style: { position: "fixed", inset: 0, zIndex: 320, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 },
|
|
1484
|
+
onClick: () => setState({ panelOpen: false }),
|
|
1485
|
+
},
|
|
1486
|
+
h("div", {
|
|
1487
|
+
"data-ext-float": "1", "data-ext-float-w": 860, "data-ext-float-h": 620, "data-ext-float-full": "1",
|
|
1488
|
+
style: { width: 860, maxWidth: "96vw", height: "82vh", display: "flex", flexDirection: "column", borderRadius: 12, background: "var(--bg-panel)", border: "1px solid var(--border)", color: "var(--text)", fontFamily: "inherit", boxShadow: "0 20px 60px rgba(0,0,0,0.35)", overflow: "hidden" },
|
|
1489
|
+
onClick: (e) => e.stopPropagation(),
|
|
1490
|
+
},
|
|
1491
|
+
// 标题栏(窗口工具区:单行紧凑——icon + 标题 + 副标题同行省略号 + 最大化的⛶/🤖/关闭;tab 在内容区顶部)
|
|
1492
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 6, padding: "2px 8px", borderBottom: "1px solid var(--border)", flexShrink: 0, minHeight: 26, lineHeight: 1 } },
|
|
1493
|
+
h("span", { style: { width: 18, height: 18, borderRadius: 5, background: "var(--bg-hover)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 12, flexShrink: 0 } }, Ic(12, ICONS.terminal)),
|
|
1494
|
+
h("span", { style: { fontSize: 13, fontWeight: 700, whiteSpace: "nowrap", flexShrink: 0 } }, "aiti-cmd"),
|
|
1495
|
+
h("span", { style: { fontSize: 12, color: "var(--text-muted)", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, "运行总览 · 常用命令 · 执行审计"),
|
|
1496
|
+
// aria-label="关闭":ai-helper 弹窗 🤖 注入的定位标记(全局 UI 契约,同 model-wizard/configs 等扩展)
|
|
1497
|
+
h("button", { onClick: () => setState({ panelOpen: false }), title: "关闭面板", "aria-label": "关闭", style: { ...btnStyle, padding: "1px 6px", fontSize: 12, background: "transparent", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none", color: "var(--text-muted)", flexShrink: 0, display: "inline-flex", lineHeight: 1 } }, Ic(12, ICONS.x)),
|
|
1498
|
+
),
|
|
1499
|
+
// 内容区(顶部 tab 页签带:active 下划线 + 主色,系统 TabBar 风格;单行紧凑高度)
|
|
1500
|
+
h("div", { style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", padding: "6px 10px 10px", overflow: "hidden", gap: 6 } },
|
|
1501
|
+
h("div", { style: { display: "flex", gap: 2, flexShrink: 0, borderBottom: "1px solid var(--border)", lineHeight: 1 } },
|
|
1502
|
+
h("button", { onClick: () => { closeView(); setTab("overview"); refresh(); }, title: "总览:agent 运行中命令 / 终端会话一览", style: tabBtn(tab === "overview") }, Ic(12, ICONS.list), "总览"),
|
|
1503
|
+
h("button", { onClick: () => setTab("commands"), title: "命令:常用命令列表(执行/复制/编辑/删除)", style: tabBtn(tab === "commands") }, "命令"),
|
|
1504
|
+
h("button", { onClick: () => setTab("history"), title: "历史:执行审计(append-only 留存)", style: tabBtn(tab === "history") }, "历史"),
|
|
1505
|
+
),
|
|
1506
|
+
err && h("p", { style: { ...smallStyle, color: "var(--danger, #ef4444)", marginBottom: 6 } }, String(err)),
|
|
1507
|
+
tab === "commands" && h("div", { style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 6 } },
|
|
1508
|
+
h("div", { style: rowStyle },
|
|
1509
|
+
h("input", { style: { ...inputStyle, maxWidth: 240 }, value: q, placeholder: "搜索命令…", onChange: (e) => setQ(e.target.value) }),
|
|
1510
|
+
h("button", { onClick: () => { setEditingGroup(null); setEditing({ name: "", cmd: "", cwd: "", tags: "", confirm: false, groupId: null, sandbox: "none", suggest: true }); }, style: btnStyle, title: "+ 新建命令(保存后出现在命令列表对应分组)" }, "+ 新建命令"),
|
|
1511
|
+
h("button", { onClick: () => { setEditing(null); setEditingGroup({ name: "", parentId: null }); }, style: btnStyle, title: "+ 新建分组(树形分组整理命令)" }, "+ 新分组"),
|
|
1512
|
+
h("button", { onClick: () => { loadDict(); setDictOpen(!dictOpen); }, style: btnStyle, title: "词库:常用命令名(AI 消息识别用,全文扫描)" }, "词库"),
|
|
1513
|
+
),
|
|
1514
|
+
allTags.length > 0 && h("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 2 } },
|
|
1515
|
+
h("button", { onClick: () => setActiveTag(null), title: "全部:取消标签过滤", style: { ...btnStyle, padding: "1px 8px", fontSize: 13, background: !activeTag ? "var(--accent)" : "transparent", color: !activeTag ? "#fff" : "var(--text)", border: "1px solid " + (!activeTag ? "var(--accent)" : "var(--border)") } }, "全部"),
|
|
1516
|
+
allTags.map((tag) =>
|
|
1517
|
+
h("button", { key: tag, onClick: () => setActiveTag(activeTag === tag ? null : tag), title: "#" + tag + ":按标签过滤(再点取消)", style: { ...btnStyle, padding: "1px 8px", fontSize: 13, background: activeTag === tag ? "var(--accent)" : "transparent", color: activeTag === tag ? "#fff" : "var(--text)", border: "1px solid " + (activeTag === tag ? "var(--accent)" : "var(--border)") } }, "#" + tag)),
|
|
1518
|
+
),
|
|
1519
|
+
dictOpen && h("div", { style: { border: "1px solid var(--border)", borderRadius: 8, padding: 8, marginBottom: 4, background: "var(--bg)" } },
|
|
1520
|
+
h("div", { style: { ...rowStyle, marginBottom: 4 } },
|
|
1521
|
+
h("b", { style: { fontSize: 14 } }, "常用命令词库(消息识别用)"),
|
|
1522
|
+
h("span", { style: { ...smallStyle, flex: 1 } }, "内置 " + (dictData ? dictData.groups.reduce((a, g) => a + g.names.length, 0) : "?") + " 命令名 / 识别时全文扫描"),
|
|
1523
|
+
h("button", { onClick: () => setDictOpen(false), title: "关闭词库", style: { ...btnStyle, padding: "2px 8px", display: "inline-flex" } }, Ic(12, ICONS.x)),
|
|
1524
|
+
),
|
|
1525
|
+
dictData && h("div", { style: { display: "flex", flexWrap: "wrap", gap: "2px 8px", marginBottom: 6, fontSize: 13, color: "var(--text-muted)" } },
|
|
1526
|
+
dictData.groups.map((g) => h("span", { key: g.group, style: { padding: "1px 8px", background: "var(--bg-hover)", borderRadius: 6 } }, g.group + ": " + g.names.slice(0, 12).join(" ") + (g.names.length > 12 ? " …" : ""))),
|
|
1527
|
+
),
|
|
1528
|
+
h("textarea", { style: { ...inputStyle, minHeight: 56, fontFamily: "var(--font-mono)" }, value: dictText, placeholder: "自定义命令词(每行一个,如:mytool / just get / pnpm-dlx)", onChange: (e) => setDictText(e.target.value) }),
|
|
1529
|
+
h("div", { style: { ...rowStyle, marginTop: 4 } },
|
|
1530
|
+
h("button", { onClick: async () => { await fetch(api("/commands/dict"), { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ extra: dictText.split(/\n|,|,/).map((s) => s.trim()).filter(Boolean) }) }); setDictText(""); setRefreshTick((t) => t + 1); loadDict(); }, style: { ...btnStyle, background: "var(--accent)", color: "#fff", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none" }, title: "保存自定义命令词到词库(消息识别立即生效)" }, "保存词库"),
|
|
1531
|
+
h("span", { style: smallStyle }, "新增词后,消息识别会立即用新词扫描"),
|
|
1532
|
+
),
|
|
1533
|
+
),
|
|
1534
|
+
h("div", { style: { flex: 1, overflow: "auto", display: "flex", flexDirection: "column", gap: 2 } },
|
|
1535
|
+
filtered.length === 0 && groups.length === 0 ? h("p", { style: smallStyle }, "(无命令——从 AI 消息识别收藏,或点「+ 新建命令」)")
|
|
1536
|
+
: [
|
|
1537
|
+
// 顶层分组树
|
|
1538
|
+
renderGroupTree(null, 0),
|
|
1539
|
+
// 未分组命令(无分组归属,且不在任何分组内的命令)
|
|
1540
|
+
ungrouped.length > 0 && h("div", { key: "_ungrouped", style: { marginTop: 4 } },
|
|
1541
|
+
h("div", { style: { fontSize: 13, color: "var(--text-muted)", padding: "2px 8px" } }, "未分组"),
|
|
1542
|
+
ungrouped.map((c) => renderCommandRow(c, 0)),
|
|
1543
|
+
),
|
|
1544
|
+
],
|
|
1545
|
+
),
|
|
1546
|
+
),
|
|
1547
|
+
tab === "history" && h("div", { style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 6 } },
|
|
1548
|
+
h("div", { style: rowStyle },
|
|
1549
|
+
h("b", { style: { fontSize: 14 } }, "执行审计(append-only 留存)"),
|
|
1550
|
+
h("span", { style: { ...smallStyle, flex: 1 } }, "命令/参数/输出/退出码/时间/来源——安全审计"),
|
|
1551
|
+
h("button", { onClick: verifyChain, title: "校验审计链完整性(哈希链)", style: { ...btnStyle, padding: "2px 10px", display: "inline-flex", alignItems: "center", gap: 4 } }, verifyResult ? "✓ " + verifyResult : [Ic(12, ICONS.lock), "校验链"]),
|
|
1552
|
+
h("button", { onClick: () => setRefreshTick((t) => t + 1), style: btnStyle, title: "刷新历史记录" }, "↻ 刷新"),
|
|
1553
|
+
),
|
|
1554
|
+
// 来源分布 chips(M4a:groupBy=source 可视化;点击过滤,再点取消)
|
|
1555
|
+
srcGroups.length > 0 && h("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" } },
|
|
1556
|
+
h("span", { style: { ...smallStyle, marginRight: 2 } }, "来源分布"),
|
|
1557
|
+
srcGroups.map((g) => h("button", {
|
|
1558
|
+
key: g.source,
|
|
1559
|
+
onClick: () => setSrcFilter(srcFilter === g.source ? null : g.source),
|
|
1560
|
+
title: SOURCE_LABEL[g.source] || g.source + ":共 " + g.count + " 条,失败 " + g.failCount + "(" + Math.round(g.failRate * 100) + "%),最近 " + new Date(g.lastTs).toLocaleString("zh-CN", { hour12: false }),
|
|
1561
|
+
style: { ...btnStyle, padding: "1px 8px", fontSize: 13, background: srcFilter === g.source ? "var(--accent)" : "transparent", color: srcFilter === g.source ? "#fff" : "var(--text)", border: "1px solid " + (srcFilter === g.source ? "var(--accent)" : "var(--border)") },
|
|
1562
|
+
}, (SOURCE_LABEL[g.source] || g.source) + " " + g.count + (g.failCount ? " ⚠" + g.failCount : ""))),
|
|
1563
|
+
),
|
|
1564
|
+
h("div", { style: { flex: 1, overflow: "auto", display: "flex", flexDirection: "column", gap: 4 } },
|
|
1565
|
+
records.length === 0 ? h("p", { style: smallStyle }, "(暂无执行记录)")
|
|
1566
|
+
: (srcFilter ? records.filter((r) => r.source === srcFilter) : records).map((rec) =>
|
|
1567
|
+
h("div", { key: rec.id, style: { display: "flex", flexDirection: "column", gap: 4, padding: "6px 8px", borderRadius: 8, background: "var(--bg-hover)", border: "1px solid var(--border)", cursor: "pointer" }, onClick: () => expand(rec.id) },
|
|
1568
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
|
|
1569
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)", whiteSpace: "nowrap" } }, new Date(rec.ts).toLocaleString("zh-CN", { hour12: false })),
|
|
1570
|
+
h("code", { style: { flex: 1, fontSize: 13, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, "$ " + rec.cmd),
|
|
1571
|
+
h("span", { style: { fontSize: 13, color: rec.exit === 0 ? "var(--ok, #4ade80)" : "var(--danger, #f87171)" } }, "exit " + (rec.exit ?? "?")),
|
|
1572
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)" } }, ((rec.durationMs || 0) / 1000).toFixed(1) + "s"),
|
|
1573
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)" } }, SOURCE_LABEL[rec.source] || rec.source),
|
|
1574
|
+
rec.danger && rec.danger.length ? h("span", { style: { color: "var(--warn, #f59e0b)", display: "inline-flex" } }, Ic(12, ICONS.alert)) : null,
|
|
1575
|
+
),
|
|
1576
|
+
expanded === rec.id && h("pre", { style: { ...smallStyle, background: "#0f172a", color: "#a5f3fc", padding: 8, borderRadius: 6, maxHeight: 220, overflow: "auto", whiteSpace: "pre-wrap", margin: 0, fontFamily: "var(--font-mono)" } }, outputs[rec.id] !== undefined ? outputs[rec.id] : "加载中…"),
|
|
1577
|
+
)),
|
|
1578
|
+
),
|
|
1579
|
+
),
|
|
1580
|
+
tab === "overview" && (() => {
|
|
1581
|
+
const piRunning = piCmds.filter((c) => c.status === "running").length;
|
|
1582
|
+
const ptyRunning = sessions.filter((s) => s.status === "running").length;
|
|
1583
|
+
const ptyQueued = sessions.filter((s) => s.status === "queued").length;
|
|
1584
|
+
const counts = { piRunning, piTotal: piCmds.length, ptyRunning, ptyQueued };
|
|
1585
|
+
// 详情态:原「运行总览头」与「视图头」双行合一 → 单行工具条。
|
|
1586
|
+
// 统计压成右侧徽标(大屏显示,小屏下沉到终端底栏);「点行看详情」在详情态无意义,删除。
|
|
1587
|
+
// 详情态工具条:← 返回总览 + 终端 tab 条 + 右侧统计/+新终端/⏹终止。
|
|
1588
|
+
// tab 条显示所有还活着(running/queued)的命令/终端,点击直接切换,不用回总览再点;
|
|
1589
|
+
// 顺序固定(数据序),切换只改高亮不挪位;超过 maxVisible 收进「⋯」下拉(点开列出全部);
|
|
1590
|
+
// 当前 tab 落在隐藏区时「⋯」按钮上带出标题提示;已退出的当前视图末尾补位显示。
|
|
1591
|
+
const renderDetailHeader = () => {
|
|
1592
|
+
const maxVisible = isSmall ? 2 : 5;
|
|
1593
|
+
// 详情态当前 pty 会话(为「+ 新终端」@上当前终端路径:新会话 cwd = 当前终端会话 cwd)
|
|
1594
|
+
const curPty = view && view.kind === "pty" ? sessions.find((x) => x.id === view.id) || null : null;
|
|
1595
|
+
const live = [];
|
|
1596
|
+
for (const c of piCmds) if (c.status === "running") live.push({ kind: "pi", id: c.id, title: c.cmd || "(订阅前已开始)", icon: "🤖", state: "running" });
|
|
1597
|
+
for (const s of sessions) if (s.status === "running" || s.status === "queued") live.push({ kind: "pty", id: s.id, title: s.name || s.cmd, icon: "🖥", state: s.status });
|
|
1598
|
+
const isCur = (t) => !!view && t.kind === view.kind && t.id === view.id;
|
|
1599
|
+
// 当前视图已退出(不在活列表)→ 末尾补位,不动活跃 tab 的位置
|
|
1600
|
+
if (view && !live.some(isCur)) live.push({ kind: view.kind, id: view.id, title: view.title || view.id, icon: view.kind === "pi" ? "🤖" : "🖥", state: "dead" });
|
|
1601
|
+
// 固定顺序截断:前 maxVisible 个直接显示,其余进「⋯」下拉
|
|
1602
|
+
const shown = live.slice(0, maxVisible);
|
|
1603
|
+
const hidden = live.slice(maxVisible);
|
|
1604
|
+
const hiddenCur = hidden.find(isCur);
|
|
1605
|
+
// 菜单可视态:hidden 清零(会话退完)时 tabMenuOpen 残留 true 会让下次首次点「⋯」反转失效,派生量统一兜住
|
|
1606
|
+
const menuOpen = tabMenuOpen && hidden.length > 0;
|
|
1607
|
+
const stateDot = (t) => t.state === "running" ? "●" : t.state === "queued" ? "⏳" : "■";
|
|
1608
|
+
const stateText = { running: "运行中", queued: "排队中", dead: "已退出" };
|
|
1609
|
+
// tab 胶囊:active 主色边框高亮;label 截断,完整标题看 hover tooltip / 下拉
|
|
1610
|
+
const liveTabBtn = (t, active) => h("button", {
|
|
1611
|
+
key: t.kind + ":" + t.id,
|
|
1612
|
+
onClick: () => { if (!active) openView(t.kind, t.id, t.title); },
|
|
1613
|
+
title: stateDot(t) + " " + stateText[t.state] + " — " + t.title + (active ? "(当前)" : "(点击切换)"),
|
|
1614
|
+
style: {
|
|
1615
|
+
...btnStyle, display: "inline-flex", alignItems: "center", minWidth: 0, maxWidth: 160,
|
|
1616
|
+
flexShrink: 1, // 空间不足时收缩(label 已 ellipsis),防止长标题 tab 把「⋯」按钮挤出容器被裁
|
|
1617
|
+
padding: isSmall ? "0 5px" : "0 7px", fontSize: 12, height: 20, lineHeight: "18px",
|
|
1618
|
+
background: active ? "var(--bg-hover)" : "transparent",
|
|
1619
|
+
border: "1px solid " + (active ? "var(--accent)" : "var(--border)"),
|
|
1620
|
+
color: active ? "var(--text)" : "var(--text-muted)", fontWeight: active ? 600 : 400,
|
|
1621
|
+
},
|
|
1622
|
+
},
|
|
1623
|
+
h("span", { style: { color: t.state === "running" ? "var(--ok, #4ade80)" : t.state === "queued" ? "var(--warn, #f59e0b)" : "var(--text-muted)", flexShrink: 0, marginRight: 3 } }, stateDot(t)),
|
|
1624
|
+
h("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, (t.icon + " " + t.title).trim()),
|
|
1625
|
+
);
|
|
1626
|
+
return h("div", { style: { ...rowStyle, marginBottom: 2, gap: 6, minHeight: 22, lineHeight: 1 } },
|
|
1627
|
+
h("button", { onClick: closeView, title: "← 返回总览", style: { ...btnStyle, padding: isSmall ? "0 6px" : "0 8px", fontSize: 12, height: 20, lineHeight: "18px" } }, isSmall ? "←" : "← 返回总览"),
|
|
1628
|
+
// tab 条(固定顺序:数据序前 maxVisible 个 + 「⋯」下拉;切换只改高亮不挪位)
|
|
1629
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 4, flex: 1, minWidth: 0, overflow: "hidden" } },
|
|
1630
|
+
shown.map((t) => liveTabBtn(t, isCur(t))),
|
|
1631
|
+
hidden.length > 0 && h("div", { style: { position: "relative", flexShrink: 0 } },
|
|
1632
|
+
h("button", { onClick: (e) => { e.stopPropagation(); setTabMenuOpen(!menuOpen); }, title: hiddenCur ? "当前:" + stateDot(hiddenCur) + " " + stateText[hiddenCur.state] + " — " + hiddenCur.title + "(更多终端/命令,还有 " + hidden.length + " 个未显示)" : "更多终端/命令(还有 " + hidden.length + " 个未显示),点开列表切换", "aria-label": "更多终端", style: { ...btnStyle, display: "inline-flex", alignItems: "center", gap: 4, padding: isSmall ? "0 5px" : "0 7px", fontSize: 12, height: 20, lineHeight: "18px", background: menuOpen ? "var(--bg-hover)" : "transparent", border: "1px solid " + (hiddenCur ? "var(--accent)" : "var(--border)") } },
|
|
1633
|
+
"⋯",
|
|
1634
|
+
hiddenCur && h("span", { style: { maxWidth: 92, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: hiddenCur.state === "dead" ? "var(--text-muted)" : "var(--text)", fontWeight: 600 } }, stateDot(hiddenCur) + " " + hiddenCur.title),
|
|
1635
|
+
),
|
|
1636
|
+
menuOpen && h("div", { onClick: (e) => e.stopPropagation(), style: { position: "absolute", top: 24, left: 0, zIndex: 350, width: 340, maxWidth: "78vw", maxHeight: 280, overflowY: "auto", background: "var(--bg-panel)", border: "1px solid var(--border)", borderRadius: 10, boxShadow: "0 12px 40px rgba(0,0,0,0.3)", padding: 4, display: "flex", flexDirection: "column", gap: 1 } },
|
|
1637
|
+
live.map((t) => h("button", {
|
|
1638
|
+
key: "m" + t.kind + ":" + t.id,
|
|
1639
|
+
onClick: () => { setTabMenuOpen(false); if (!isCur(t)) openView(t.kind, t.id, t.title); },
|
|
1640
|
+
title: stateDot(t) + " " + stateText[t.state] + " — " + t.title,
|
|
1641
|
+
style: { display: "flex", alignItems: "center", gap: 6, width: "100%", textAlign: "left", border: "1px solid transparent", background: isCur(t) ? "var(--bg-hover)" : "transparent", borderRadius: 6, padding: "5px 8px", cursor: "pointer", fontFamily: "inherit", color: "var(--text)" },
|
|
1642
|
+
},
|
|
1643
|
+
h("span", { style: { fontSize: 12, color: t.state === "running" ? "var(--ok, #4ade80)" : t.state === "queued" ? "var(--warn, #f59e0b)" : "var(--text-muted)", flexShrink: 0 } }, stateDot(t)),
|
|
1644
|
+
h("span", { style: { fontSize: 12, color: "var(--text-muted)", flexShrink: 0 } }, t.icon),
|
|
1645
|
+
h("span", { style: { fontSize: 13, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isCur(t) ? 600 : 400 } }, t.title),
|
|
1646
|
+
h("span", { style: { fontSize: 12, color: "var(--text-muted)", flexShrink: 0 } }, stateText[t.state]),
|
|
1647
|
+
)),
|
|
1648
|
+
),
|
|
1649
|
+
),
|
|
1650
|
+
),
|
|
1651
|
+
!isSmall && h("span", { style: { ...smallStyle, fontSize: 12, lineHeight: 1, flexShrink: 0, whiteSpace: "nowrap" } }, "🤖 " + piRunning + "/" + piCmds.length + " · 🖥 " + ptyRunning),
|
|
1652
|
+
h("button", { onClick: curPty ? () => newShellAt(curPty) : () => newShell(), disabled: newTermBusy, title: newTermBusy ? "正在创建终端会话…" : (curPty ? "+ 新终端:再开一个 shell(目录 = 当前终端 📁 实时路径)" : "+ 新终端:开一个交互式 shell(无沙箱)"), style: { ...btnStyle, padding: isSmall ? "0 6px" : "0 8px", fontSize: 12, height: 20, lineHeight: "18px", background: "var(--accent)", color: "#fff", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none", flexShrink: 0, opacity: newTermBusy ? 0.6 : 1, cursor: newTermBusy ? "wait" : "pointer" } }, newTermBusy ? (isSmall ? "⏳" : "开启中…") : (isSmall ? "+" : "+ 新终端")),
|
|
1653
|
+
// 会话已结束则隐藏中止(点了也只能 409);PTY 结束态经 sessions 列表感知
|
|
1654
|
+
view.kind === "pty" && sessions.find((x) => x.id === view.id)?.status === "running" && (() => {
|
|
1655
|
+
const kb = killBusy.has(view.id);
|
|
1656
|
+
return h("button", { onClick: () => killSession(view.id), disabled: kb, title: kb ? "终止信号已发送,等待进程退出…" : "终止命令(杀进程)", style: { ...btnStyle, color: "var(--danger, #ef4444)", padding: isSmall ? "0 6px" : "0 8px", fontSize: 12, height: 20, lineHeight: "18px", opacity: kb ? 0.6 : 1, cursor: kb ? "wait" : "pointer" } }, isSmall ? (kb ? "⏳" : "⏹") : (kb ? "终止中…" : "终止"));
|
|
1657
|
+
})(),
|
|
1658
|
+
);
|
|
1659
|
+
};
|
|
1660
|
+
const header = view ? renderDetailHeader() : h("div", { style: { ...rowStyle, marginBottom: 2, gap: 6, minHeight: 22, lineHeight: 1 } },
|
|
1661
|
+
h("b", { style: { fontSize: 13, whiteSpace: "nowrap", flexShrink: 0, display: "inline-flex", alignItems: "center", gap: 4 } }, Ic(13, ICONS.list), "运行总览"),
|
|
1662
|
+
h("span", { style: { ...smallStyle, fontSize: 12, lineHeight: 1, flex: 1, minWidth: 140 } },
|
|
1663
|
+
"agent " + piRunning + " 条在跑" + (piCmds.length > piRunning ? "(共 " + piCmds.length + ")" : "") + " · 🖥 终端 " + ptyRunning + " 个在跑" + (ptyQueued ? "(" + ptyQueued + " 排队)" : "") + " · 点行看详情"),
|
|
1664
|
+
h("button", { onClick: () => newShell(), disabled: newTermBusy, title: newTermBusy ? "正在创建终端会话…" : "+ 新终端:开一个交互式 shell(无沙箱)", style: { ...btnStyle, fontSize: 12, height: 20, lineHeight: "18px", padding: "0 10px", background: "var(--accent)", color: "#fff", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none", marginLeft: "auto", flexShrink: 0, opacity: newTermBusy ? 0.6 : 1, cursor: newTermBusy ? "wait" : "pointer" } }, newTermBusy ? "开启中…" : "+ 新终端"),
|
|
1665
|
+
);
|
|
1666
|
+
// dock 单实例:该终端会话被工作区页签持有(未 detach)时,弹窗渲染占位卡——
|
|
1667
|
+
// 视图此刻归页签;「拉回窗口」切归属(detached 标记)后这里恢复 TerminalView。
|
|
1668
|
+
const content = view
|
|
1669
|
+
? h("div", { style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column" } },
|
|
1670
|
+
view.kind === "pi" ? h(PiCommandView, { cmdId: view.id, counts })
|
|
1671
|
+
: hostTabId ? h("div", { style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, padding: 24, color: "var(--text-dim)", fontSize: 12 } },
|
|
1672
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 6, color: "var(--text-muted)" } },
|
|
1673
|
+
h("span", { style: { display: "inline-flex", flexShrink: 0 } }, Ic(14, ICONS.monitor)),
|
|
1674
|
+
h("span", null, "此终端已在工作区页签打开(右侧文件区)"),
|
|
1675
|
+
),
|
|
1676
|
+
h("button", { onClick: () => { tabDetached[hostTabId] = true; notifyTabAttached(); }, style: { ...btnStyle, padding: "6px 14px", color: "var(--accent)", fontWeight: 600, fontSize: 12 } }, "拉回窗口"),
|
|
1677
|
+
h("span", { style: { fontSize: 11, color: "var(--text-muted)" } }, "终端同时只能在一处显示;页签内已变为占位"),
|
|
1678
|
+
)
|
|
1679
|
+
: (() => {
|
|
1680
|
+
const s = sessions.find((x) => x.id === view.id);
|
|
1681
|
+
const canSend = s && (s.status === "running" || s.status === "queued");
|
|
1682
|
+
// onNewShell:弹窗模式「+ 新建」@ 当前终端实时路径(实时 pwd,cd 后新会话落在当前目录);
|
|
1683
|
+
// onNewPiSession:「💬 新会话」新建 pi-web 会话并 @ 同一实时路径
|
|
1684
|
+
return h(TerminalView, { sessionId: view.id, height: "100%", counts, onExit: () => refresh(), sendToTab: canSend ? () => sendToTab(s) : undefined, killPending: killBusy.has(view.id),
|
|
1685
|
+
onNewShell: s ? () => newShellAt(s) : undefined,
|
|
1686
|
+
onNewPiSession: s ? () => newPiSession(s) : undefined });
|
|
1687
|
+
})(),
|
|
1688
|
+
)
|
|
1689
|
+
: h("div", { style: { flex: 1, minHeight: 0, overflow: "auto", display: "flex", flexDirection: "column", gap: 8 } },
|
|
1690
|
+
// 🤖 agent 命令(2s 自动刷新;点行进详情,⏹ 只终止命令不 abort turn)
|
|
1691
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: 4 } },
|
|
1692
|
+
h("div", { style: { fontSize: 14, fontWeight: 700, color: "var(--text-muted)", display: "flex", alignItems: "center", gap: 5 } }, Ic(13, ICONS.bot), "agent 命令"),
|
|
1693
|
+
piCmds.length === 0 ? h("p", { style: { ...smallStyle, margin: 0 } }, "(没有在跑的 bash 命令;agent 会话执行命令时自动出现)")
|
|
1694
|
+
: piCmds.map((c) =>
|
|
1695
|
+
h("div", { key: c.id, style: { display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 8, background: "var(--bg-hover)", border: "1px solid var(--border)", cursor: "pointer" }, onClick: () => openView("pi", c.id, c.cmd) },
|
|
1696
|
+
h("span", { style: { fontSize: 13, color: c.status === "running" ? "var(--ok, #4ade80)" : c.isError ? "var(--danger, #f87171)" : "var(--text-muted)" } }, c.status === "running" ? "●" : c.isError ? "⚠" : "■"),
|
|
1697
|
+
h("div", { style: { flex: 1, minWidth: 0 } },
|
|
1698
|
+
h("code", { style: { fontSize: 13, display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, "$ " + (c.cmd || "(订阅前已开始)")),
|
|
1699
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)" } },
|
|
1700
|
+
"🤖 " + String(c.sessionId || "").slice(0, 8) + (c.cwd ? " · " + c.cwd : "") + " · " + (c.status === "running" ? "运行中" : c.isError ? "出错/被终止" : "完成") + " · " + ((c.status === "running" ? Date.now() - c.startedTs : c.durationMs || 0) / 1000).toFixed(0) + "s" + (c.bytes ? " · " + (c.bytes / 1024).toFixed(1) + "KB" : "") + (c.truncated ? " · 截断" : "")),
|
|
1701
|
+
),
|
|
1702
|
+
c.status === "running" && h("button", { onClick: (e) => { e.stopPropagation(); killPiCommand(c.id); }, disabled: piKillBusy.has(c.id), title: piKillBusy.has(c.id) ? "终止信号已发送,等待进程退出…" : "只终止这条命令(不 abort 整个 turn)", style: { ...btnStyle, color: "var(--danger, #ef4444)", padding: "2px 8px", opacity: piKillBusy.has(c.id) ? 0.6 : 1, cursor: piKillBusy.has(c.id) ? "wait" : "pointer" } }, piKillBusy.has(c.id) ? "⏳" : "⏹"),
|
|
1703
|
+
)),
|
|
1704
|
+
),
|
|
1705
|
+
// 🖥 终端会话(点行进终端;⏹ 杀进程)
|
|
1706
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: 4 } },
|
|
1707
|
+
h("div", { style: { fontSize: 14, fontWeight: 700, color: "var(--text-muted)", display: "flex", alignItems: "center", gap: 5 } }, Ic(13, ICONS.monitor), "终端会话"),
|
|
1708
|
+
sessions.length === 0 ? h("p", { style: { ...smallStyle, margin: 0 } }, "(无活跃会话——点「+ 新终端」开一个 shell,或执行一条命令)")
|
|
1709
|
+
: sessions.map((s) =>
|
|
1710
|
+
h("div", { key: s.id, style: { display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 8, background: "var(--bg-hover)", border: "1px solid var(--border)", cursor: "pointer" }, onClick: () => openView("pty", s.id, s.name || s.cmd) },
|
|
1711
|
+
h("span", { style: { fontSize: 13, color: s.status === "running" ? "var(--ok, #4ade80)" : s.status === "queued" ? "var(--warn, #f59e0b)" : "var(--text-muted)" } }, s.status === "running" ? "●" : s.status === "queued" ? "⏳" : "■"),
|
|
1712
|
+
h("div", { style: { flex: 1, minWidth: 0 } },
|
|
1713
|
+
h("code", { style: { fontSize: 13, display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, "$ " + s.cmd + (SANDBOX[s.sandbox || "none"] ? SANDBOX[s.sandbox || "none"].icon : "")),
|
|
1714
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)" } },
|
|
1715
|
+
(sessionHostTabSync(s.id) ? "工作区页签 · " : "") +
|
|
1716
|
+
(s.status === "queued" ? "排队中 (" + s.queuePosition + "/" + s.queueLength + ")" : (s.status === "running" ? "运行中" : "退出 " + (s.exitCode ?? "?")) + " · " + ((s.status === "running" ? Date.now() - s.startTs : s.durationMs || 0) / 1000).toFixed(0) + "s" + (s.sandbox ? " · " + (SANDBOX[s.sandbox] ? SANDBOX[s.sandbox].short : s.sandbox) : " · 无沙箱"))),
|
|
1717
|
+
),
|
|
1718
|
+
(s.status === "running" || s.status === "queued") && h("button", { onClick: (e) => { e.stopPropagation(); killSession(s.id); }, disabled: killBusy.has(s.id), title: killBusy.has(s.id) ? "终止信号已发送,等待进程退出…" : s.status === "queued" ? "取消排队" : "终止命令(杀进程)", style: { ...btnStyle, color: "var(--danger, #ef4444)", padding: "2px 8px", opacity: killBusy.has(s.id) ? 0.6 : 1, cursor: killBusy.has(s.id) ? "wait" : "pointer" } }, killBusy.has(s.id) ? "⏳" : s.status === "queued" ? "取消" : "⏹"),
|
|
1719
|
+
)),
|
|
1720
|
+
),
|
|
1721
|
+
// ⚡ 常用命令:tag 云卡片(点击执行;颜色随标签,尺寸随使用次数微放大)——放最后
|
|
1722
|
+
commands.length > 0 && (() => {
|
|
1723
|
+
const CLOUD_COLORS = ["#6366f1", "#0ea5e9", "var(--ok, #10b981)", "var(--warn, #f59e0b)", "var(--danger, #ef4444)", "#8b5cf6", "#ec4899", "#14b8a6", "var(--warn, #eab308)", "#06b6d4"];
|
|
1724
|
+
const cloudColor = (s) => { let h = 0; const t = String(s || "?"); for (let i = 0; i < t.length; i++) h = (h * 31 + (t.codePointAt(i) || 0)) % 9973; return CLOUD_COLORS[h % CLOUD_COLORS.length]; };
|
|
1725
|
+
const sorted = [...commands].sort((a, b) => (b.runCount || 0) - (a.runCount || 0) || String(a.name || a.cmd).localeCompare(String(b.name || b.cmd)));
|
|
1726
|
+
const shown = sorted.slice(0, 30);
|
|
1727
|
+
return h("div", { style: { display: "flex", flexDirection: "column", gap: 4 } },
|
|
1728
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 6 } },
|
|
1729
|
+
h("span", { style: { fontSize: 14, fontWeight: 700, color: "var(--text-muted)" } }, "常用命令"),
|
|
1730
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)" } }, sorted.length + " 条 · 点击执行"),
|
|
1731
|
+
h("button", { onClick: () => setTab("commands"), title: "到命令页管理(新建/编辑/分组/标签)", style: { ...miniBtn, marginLeft: "auto" } }, "管理 →"),
|
|
1732
|
+
),
|
|
1733
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: 5 } },
|
|
1734
|
+
shown.map((c) => {
|
|
1735
|
+
const tag = (c.tags && c.tags[0]) || "";
|
|
1736
|
+
const base = cloudColor(tag || (c.name || c.cmd));
|
|
1737
|
+
const weight = Math.min(3, c.runCount || 0);
|
|
1738
|
+
return h("button", {
|
|
1739
|
+
key: c.id,
|
|
1740
|
+
onClick: () => runCommand(c),
|
|
1741
|
+
title: "$ " + c.cmd + (c.cwd ? " · " + c.cwd : "") + (c.confirm ? "(危险命令,执行前确认)" : "") + (c.runCount ? " · 已执行 " + c.runCount + " 次" : ""),
|
|
1742
|
+
style: {
|
|
1743
|
+
border: "1px solid " + base + "66", background: base + "1f", color: `color-mix(in srgb, ${base} 68%, var(--text))`,
|
|
1744
|
+
borderRadius: 999, padding: weight ? "3px 11px" : "2px 9px", fontSize: 13 + weight, fontWeight: 500,
|
|
1745
|
+
cursor: "pointer", fontFamily: "inherit",
|
|
1746
|
+
maxWidth: 220, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
1747
|
+
},
|
|
1748
|
+
}, (c.name || c.cmd) + (c.confirm ? " ⚠" : ""));
|
|
1749
|
+
}),
|
|
1750
|
+
sorted.length > shown.length && h("span", { style: { ...smallStyle, alignSelf: "center" } }, "… 还有 " + (sorted.length - shown.length) + " 条"),
|
|
1751
|
+
),
|
|
1752
|
+
);
|
|
1753
|
+
})(),
|
|
1754
|
+
);
|
|
1755
|
+
return h("div", { style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 6 } },
|
|
1756
|
+
header,
|
|
1757
|
+
content,
|
|
1758
|
+
);
|
|
1759
|
+
})(),
|
|
1760
|
+
// 新建/编辑命令:弹窗(与消息识别的命令详情弹窗同模式:居中卡片 + 遮罩)
|
|
1761
|
+
// 布局:CSS Grid 两列表单(命令/沙箱等长字段跨双列)——flex-wrap 列宽
|
|
1762
|
+
// 不可控(曾把 textarea 挤成窄列),控件统一 flex:none+width:100%(inputStyle
|
|
1763
|
+
// 自带 flex:1,放进纵向 flex 容器会把输入框拉成 90px 高条)。
|
|
1764
|
+
editing && (() => {
|
|
1765
|
+
const set = (patch) => setEditing({ ...editing, ...patch });
|
|
1766
|
+
const ctl = (extra) => ({ ...inputStyle, flex: "none", width: "100%", boxSizing: "border-box", ...extra });
|
|
1767
|
+
const field = (label, control, hint, full) =>
|
|
1768
|
+
h("label", { style: { display: "flex", flexDirection: "column", gap: 4, gridColumn: full ? "1 / -1" : undefined, minWidth: 0 } },
|
|
1769
|
+
h("span", { style: { fontSize: 13, fontWeight: 600, color: "var(--text-muted)" } }, label),
|
|
1770
|
+
control,
|
|
1771
|
+
hint && h("span", { style: { fontSize: 13, color: "var(--text-muted)", lineHeight: 1.4 } }, hint),
|
|
1772
|
+
);
|
|
1773
|
+
return h("div", {
|
|
1774
|
+
style: { position: "fixed", inset: 0, zIndex: 340, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 },
|
|
1775
|
+
onClick: () => setEditing(null),
|
|
1776
|
+
},
|
|
1777
|
+
h("div", {
|
|
1778
|
+
tabIndex: -1,
|
|
1779
|
+
"data-ext-float": "1", "data-ext-float-w": 540, "data-ext-float-autoh": "1", "data-ext-float-full": "1",
|
|
1780
|
+
style: { width: 540, maxWidth: "100%", maxHeight: "88vh", overflow: "auto", borderRadius: 12, background: "var(--bg-panel)", border: "1px solid var(--border)", color: "var(--text)", fontFamily: "inherit", boxShadow: "0 20px 60px rgba(0,0,0,0.35)", outline: "none" },
|
|
1781
|
+
onClick: (e) => e.stopPropagation(),
|
|
1782
|
+
onKeyDown: (e) => { if (e.key === "Escape") { e.stopPropagation(); setEditing(null); } else if (e.key === "Enter" && e.target.tagName !== "TEXTAREA") { e.preventDefault(); saveEditing(); } },
|
|
1783
|
+
},
|
|
1784
|
+
// 标题栏
|
|
1785
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "14px 18px", borderBottom: "1px solid var(--border)" } },
|
|
1786
|
+
h("span", { style: { width: 28, height: 28, borderRadius: 8, background: "var(--bg-hover)", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-mono)", fontSize: 15, fontWeight: 700, color: "var(--accent)", flexShrink: 0 } }, "$"),
|
|
1787
|
+
h("div", { style: { flex: 1, minWidth: 0 } },
|
|
1788
|
+
h("div", { style: { fontSize: 15, fontWeight: 700 } }, editing.id ? "编辑命令" : "新建命令"),
|
|
1789
|
+
h("div", { style: { fontSize: 13, color: "var(--text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, editing.groupId ? "📁 " + groupPathOf(editing.groupId) : "未分组"),
|
|
1790
|
+
),
|
|
1791
|
+
h("button", { onClick: () => setEditing(null), title: "关闭 (Esc)", "aria-label": "关闭", style: { background: "transparent", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none", color: "var(--text-muted)", fontSize: 16, cursor: "pointer", padding: "4px 8px", borderRadius: 4 } }, "✕"),
|
|
1792
|
+
),
|
|
1793
|
+
// 表单区:两列 Grid(名称/分组/工作目录 前置;命令全宽居中)
|
|
1794
|
+
h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px 14px", padding: "16px 18px" } },
|
|
1795
|
+
field("名称", h("input", { style: ctl(), value: editing.name, placeholder: "缺省 = 命令文本", onChange: (e) => set({ name: e.target.value }) })),
|
|
1796
|
+
field("分组", h("select", { style: ctl(), value: editing.groupId || "", onChange: (e) => set({ groupId: e.target.value || null }) },
|
|
1797
|
+
h("option", { value: "" }, "(未分组)"),
|
|
1798
|
+
groups.map((g) => h("option", { key: g.id, value: g.id }, groupPathOf(g.id))),
|
|
1799
|
+
)),
|
|
1800
|
+
field("工作目录", h("input", { style: ctl({ fontFamily: "var(--font-mono)" }), value: editing.cwd || "", placeholder: "缺省 = 当前会话目录", onChange: (e) => set({ cwd: e.target.value }) })),
|
|
1801
|
+
field("标签", h("input", { style: ctl(), value: editing.tags || "", placeholder: "逗号分隔,如 build, fe", onChange: (e) => set({ tags: e.target.value }) })),
|
|
1802
|
+
field("命令", h("textarea", { id: "aiti-cmd-edit-cmd", autoFocus: !editing.id, style: ctl({ minHeight: 72, fontFamily: "var(--font-mono)", resize: "vertical" }), value: editing.cmd, placeholder: "pnpm build --filter {pkg}", onChange: (e) => set({ cmd: e.target.value }) }), "支持 {参数} 占位符,执行时填入", true),
|
|
1803
|
+
field("沙箱", h("select", { style: ctl((editing.sandbox || "none") === "none" ? { borderColor: "var(--warn, #f59e0b)", background: "color-mix(in srgb, var(--warn, #f59e0b) 12%, transparent)", color: "var(--warn, #f59e0b)" } : null), value: editing.sandbox || "none", onChange: (e) => set({ sandbox: e.target.value }) },
|
|
1804
|
+
SANDBOX_ORDER.map((v) => h("option", { key: v, value: v }, SANDBOX[v].label)),
|
|
1805
|
+
), (() => { const m = SANDBOX[editing.sandbox || "none"] || SANDBOX.none; return h("span", { style: { fontSize: 13, lineHeight: 1.4, color: m.warn ? "var(--warn, #f59e0b)" : "var(--ok, #10b981)" } }, m.hint); })()),
|
|
1806
|
+
field("执行确认", h("button", {
|
|
1807
|
+
type: "button",
|
|
1808
|
+
onClick: () => set({ confirm: !editing.confirm }),
|
|
1809
|
+
title: "执行前是否弹窗确认(危险命令建议开启)",
|
|
1810
|
+
style: { ...ctl(), display: "flex", alignItems: "center", gap: 8, textAlign: "left", cursor: "pointer", padding: "6px 10px" },
|
|
1811
|
+
},
|
|
1812
|
+
h("span", { style: { position: "relative", width: 32, height: 18, borderRadius: 999, background: editing.confirm ? "var(--warn, #f59e0b)" : "var(--border)", flexShrink: 0, transition: "background .15s" } },
|
|
1813
|
+
h("span", { style: { position: "absolute", top: 2, left: editing.confirm ? 16 : 2, width: 14, height: 14, borderRadius: "50%", background: "#fff", boxShadow: "0 1px 2px rgba(0,0,0,0.25)", transition: "left .15s" } }),
|
|
1814
|
+
),
|
|
1815
|
+
h("span", { style: { fontSize: 13, color: "var(--text)" } }, editing.confirm ? "执行前弹窗确认" : "直接执行"),
|
|
1816
|
+
), null),
|
|
1817
|
+
field("智能提示", h("button", {
|
|
1818
|
+
type: "button",
|
|
1819
|
+
onClick: () => set({ suggest: !editing.suggest }),
|
|
1820
|
+
title: "作为 aiti-ai-input 智能提示(输入联想/补全)的来源;关掉后输入时不再提示此命令",
|
|
1821
|
+
style: { ...ctl(), display: "flex", alignItems: "center", gap: 8, textAlign: "left", cursor: "pointer", padding: "6px 10px" },
|
|
1822
|
+
},
|
|
1823
|
+
h("span", { style: { position: "relative", width: 32, height: 18, borderRadius: 999, background: editing.suggest ? "var(--accent)" : "var(--border)", flexShrink: 0, transition: "background .15s" } },
|
|
1824
|
+
h("span", { style: { position: "absolute", top: 2, left: editing.suggest ? 16 : 2, width: 14, height: 14, borderRadius: "50%", background: "#fff", boxShadow: "0 1px 2px rgba(0,0,0,0.25)", transition: "left .15s" } }),
|
|
1825
|
+
),
|
|
1826
|
+
h("span", { style: { fontSize: 13, color: "var(--text)" } }, editing.suggest ? "参与智能提示" : "不参与智能提示"),
|
|
1827
|
+
), "作为 aiti-ai-input 输入联想/补全的来源(默认开)", true),
|
|
1828
|
+
),
|
|
1829
|
+
// 页脚
|
|
1830
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "12px 18px", borderTop: "1px solid var(--border)" } },
|
|
1831
|
+
h("span", { style: { ...smallStyle, flex: 1 } }, editing.id ? "改动即时生效" : "保存后出现在命令列表对应分组"),
|
|
1832
|
+
h("button", { onClick: () => setEditing(null), style: { ...btnStyle, padding: "6px 14px" } }, "取消"),
|
|
1833
|
+
h("button", { onClick: saveEditing, disabled: !editing.cmd.trim(), style: { ...btnStyle, background: editing.cmd.trim() ? "var(--accent)" : "var(--bg-hover)", color: "#fff", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none", padding: "6px 18px", fontWeight: 600, cursor: editing.cmd.trim() ? "pointer" : "not-allowed", opacity: editing.cmd.trim() ? 1 : 0.6 } }, editing.id ? "保存修改" : "创建"),
|
|
1834
|
+
),
|
|
1835
|
+
),
|
|
1836
|
+
);
|
|
1837
|
+
})(),
|
|
1838
|
+
// 新建/编辑分组:同弹窗模式(小卡片)
|
|
1839
|
+
editingGroup && (() => {
|
|
1840
|
+
const setG = (patch) => setEditingGroup({ ...editingGroup, ...patch });
|
|
1841
|
+
const ctl = (extra) => ({ ...inputStyle, flex: "none", width: "100%", boxSizing: "border-box", ...extra });
|
|
1842
|
+
const field = (label, control) =>
|
|
1843
|
+
h("label", { style: { display: "flex", flexDirection: "column", gap: 4, minWidth: 0 } },
|
|
1844
|
+
h("span", { style: { fontSize: 13, fontWeight: 600, color: "var(--text-muted)" } }, label),
|
|
1845
|
+
control,
|
|
1846
|
+
);
|
|
1847
|
+
return h("div", {
|
|
1848
|
+
style: { position: "fixed", inset: 0, zIndex: 340, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 },
|
|
1849
|
+
onClick: () => setEditingGroup(null),
|
|
1850
|
+
},
|
|
1851
|
+
h("div", {
|
|
1852
|
+
tabIndex: -1,
|
|
1853
|
+
"data-ext-float": "1", "data-ext-float-w": 460, "data-ext-float-autoh": "1", "data-ext-float-full": "1",
|
|
1854
|
+
style: { width: 460, maxWidth: "100%", borderRadius: 12, background: "var(--bg-panel)", border: "1px solid var(--border)", color: "var(--text)", fontFamily: "inherit", boxShadow: "0 20px 60px rgba(0,0,0,0.35)", outline: "none" },
|
|
1855
|
+
onClick: (e) => e.stopPropagation(),
|
|
1856
|
+
onKeyDown: (e) => { if (e.key === "Escape") { e.stopPropagation(); setEditingGroup(null); } else if (e.key === "Enter") { e.preventDefault(); saveGroupEditing(); } },
|
|
1857
|
+
},
|
|
1858
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "14px 18px", borderBottom: "1px solid var(--border)" } },
|
|
1859
|
+
h("span", { style: { width: 28, height: 28, borderRadius: 8, background: "var(--bg-hover)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 15, flexShrink: 0 } }, "📁"),
|
|
1860
|
+
h("div", { style: { flex: 1 } },
|
|
1861
|
+
h("div", { style: { fontSize: 15, fontWeight: 700 } }, editingGroup.id ? "编辑分组" : "新建分组"),
|
|
1862
|
+
h("div", { style: { fontSize: 13, color: "var(--text-muted)" } }, editingGroup.parentId ? "位于:" + groupPathOf(editingGroup.parentId) : "顶层分组"),
|
|
1863
|
+
),
|
|
1864
|
+
h("button", { onClick: () => setEditingGroup(null), title: "关闭 (Esc)", "aria-label": "关闭", style: { background: "transparent", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none", color: "var(--text-muted)", fontSize: 16, cursor: "pointer", padding: "4px 8px", borderRadius: 4 } }, "✕"),
|
|
1865
|
+
),
|
|
1866
|
+
h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px 14px", padding: "16px 18px" } },
|
|
1867
|
+
field("分组名", h("input", { autoFocus: true, style: ctl(), value: editingGroup.name, placeholder: "如:构建 / 部署", onChange: (e) => setG({ name: e.target.value }) })),
|
|
1868
|
+
field("上级分组", h("select", { style: ctl(), value: editingGroup.parentId || "", onChange: (e) => setG({ parentId: e.target.value || null }) },
|
|
1869
|
+
h("option", { value: "" }, "(顶层)"),
|
|
1870
|
+
groups.filter((g) => g.id !== editingGroup.id).map((g) => h("option", { key: g.id, value: g.id }, groupPathOf(g.id))),
|
|
1871
|
+
)),
|
|
1872
|
+
),
|
|
1873
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "12px 18px", borderTop: "1px solid var(--border)" } },
|
|
1874
|
+
h("span", { style: { ...smallStyle, flex: 1 } }, "删除分组时组内命令自动移入未分组"),
|
|
1875
|
+
h("button", { onClick: () => setEditingGroup(null), style: { ...btnStyle, padding: "6px 14px" } }, "取消"),
|
|
1876
|
+
h("button", { onClick: saveGroupEditing, disabled: !editingGroup.name.trim(), style: { ...btnStyle, background: editingGroup.name.trim() ? "var(--accent)" : "var(--bg-hover)", color: "#fff", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none", padding: "6px 18px", fontWeight: 600, cursor: editingGroup.name.trim() ? "pointer" : "not-allowed", opacity: editingGroup.name.trim() ? 1 : 0.6 } }, editingGroup.id ? "保存修改" : "创建"),
|
|
1877
|
+
),
|
|
1878
|
+
),
|
|
1879
|
+
);
|
|
1880
|
+
})(),
|
|
1881
|
+
),
|
|
1882
|
+
),
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// ---------- 命令详情面板(消息识别点开:点开/编辑/收藏/执行) ----------
|
|
1887
|
+
function CommandDetailPanel() {
|
|
1888
|
+
const detail = useDetail();
|
|
1889
|
+
const busy = useDetailBusy();
|
|
1890
|
+
const [name, setName] = useState("");
|
|
1891
|
+
const [cwd, setCwd] = useState("");
|
|
1892
|
+
const [confirm, setConfirm] = useState(false);
|
|
1893
|
+
const [groupId, setGroupId] = useState(null);
|
|
1894
|
+
const [groups, setGroups] = useState([]);
|
|
1895
|
+
const [saved, setSaved] = useState(false);
|
|
1896
|
+
const [err, setErr] = useState(null);
|
|
1897
|
+
const [info, setInfo] = useState(null);
|
|
1898
|
+
const [desc, setDesc] = useState(null);
|
|
1899
|
+
const [current, setCurrent] = useState(null); // 当前显示/管理的命令(点击 related 可切换)
|
|
1900
|
+
useEffect(() => { if (detail) { setName(""); setCwd(detail.cwd || ""); setConfirm(false); setGroupId(null); setSaved(false); setErr(null); setInfo(null); setDesc(null); setCurrent(detail.cmd); fetch(api("/commands")).then((r) => r.json()).then((d) => setGroups(d.groups || [])).catch(() => {}); } }, [detail]);
|
|
1901
|
+
// 随 current 刷新校验 + 描述(点击 related/peers 切换命令;detail 变化强制重跑——同命令重开也刷新)
|
|
1902
|
+
useEffect(() => { if (!current) return;
|
|
1903
|
+
fetch(api("/commands/check"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: current }) }).then((r) => r.json()).then((d) => { const c = d.single || (Array.isArray(d.commands) && d.commands[0]) || null; setInfo(c || null); }).catch(() => {});
|
|
1904
|
+
fetch(api("/commands/describe?text=" + encodeURIComponent(current))).then((r) => r.json()).then((d) => setDesc(d || null)).catch(() => {});
|
|
1905
|
+
}, [current, detail]);
|
|
1906
|
+
if (!detail) return null;
|
|
1907
|
+
// 分组路径(与命令面板一致):如 "构建 / 前端"
|
|
1908
|
+
const groupPathOf = (id) => {
|
|
1909
|
+
if (!id) return "未分组";
|
|
1910
|
+
const parts = []; let cur = groups.find((g) => g.id === id); let guard = 0;
|
|
1911
|
+
while (cur && guard++ < 20) { parts.unshift(cur.name); cur = cur.parentId ? groups.find((g) => g.id === cur.parentId) : undefined; }
|
|
1912
|
+
return parts.join(" / ") || "未分组";
|
|
1913
|
+
};
|
|
1914
|
+
const doSave = async () => {
|
|
1915
|
+
setSaved(false); setErr(null);
|
|
1916
|
+
if (!current || !String(current).trim()) { setErr("命令内容为空,无法收藏"); return; }
|
|
1917
|
+
const r = await fetch(api("/commands"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: name || current, cmd: current, cwd: cwd || undefined, groupId: groupId || null, confirm, sandbox: "none", source: "ai-message" }) }).then((x) => x.json());
|
|
1918
|
+
if (r.ok) setSaved(true); else setErr(r.error || "保存失败");
|
|
1919
|
+
};
|
|
1920
|
+
const doRun = async () => {
|
|
1921
|
+
const cmd = current;
|
|
1922
|
+
let r = await fetch(api("/run"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ command: cmd, cwd: cwd || undefined, source: "message", sandbox: "none" }) }).then((x) => x.json()).catch(() => ({ ok: false, error: "网络错误" }));
|
|
1923
|
+
if (r.needConfirm) {
|
|
1924
|
+
const danger = (r.danger || []).join("/");
|
|
1925
|
+
const ok = await uiConfirm({ message: `确认执行危险命令${danger ? "(" + danger + ")" : ""}?`, detail: `$ ${cmd}`, danger: true });
|
|
1926
|
+
if (!ok) return;
|
|
1927
|
+
r = await fetch(api("/run"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ command: cmd, cwd: cwd || undefined, source: "message", sandbox: "none", confirmToken: r.confirmToken }) }).then((x) => x.json()).catch(() => ({ ok: false, error: "网络错误" }));
|
|
1928
|
+
}
|
|
1929
|
+
if (!r.ok) { setErr(r.error || "运行失败"); return; }
|
|
1930
|
+
setState({ panelOpen: true, view: { kind: "pty", id: r.sessionId, title: cmd }, detail: null });
|
|
1931
|
+
};
|
|
1932
|
+
return h("div", {
|
|
1933
|
+
style: { position: "fixed", inset: 0, zIndex: 330, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 },
|
|
1934
|
+
onClick: () => setState({ detail: null }),
|
|
1935
|
+
},
|
|
1936
|
+
h("div", {
|
|
1937
|
+
"data-ext-float": "1", "data-ext-float-w": 460, "data-ext-float-h": 540, "data-ext-float-full": "1",
|
|
1938
|
+
style: { width: 460, maxWidth: "92vw", maxHeight: "80vh", display: "flex", flexDirection: "column", borderRadius: 12, background: "var(--bg-panel)", border: "1px solid var(--border)", color: "var(--text)", fontFamily: "inherit", boxShadow: "0 20px 60px rgba(0,0,0,0.35)", overflow: "hidden" },
|
|
1939
|
+
onClick: (e) => e.stopPropagation(),
|
|
1940
|
+
},
|
|
1941
|
+
// 标题栏(统一风格)
|
|
1942
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "12px 16px", borderBottom: "1px solid var(--border)", flexShrink: 0 } },
|
|
1943
|
+
h("span", { style: { width: 28, height: 28, borderRadius: 8, background: "var(--bg-hover)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 15, flexShrink: 0 } }, "+"),
|
|
1944
|
+
h("div", { style: { flex: 1, minWidth: 0 } },
|
|
1945
|
+
h("div", { style: { fontSize: 15, fontWeight: 700 } }, "命令管理"),
|
|
1946
|
+
h("div", { style: { fontSize: 13, color: "var(--text-muted)" } }, "查看 / 收藏 / 编辑,不会直接执行"),
|
|
1947
|
+
),
|
|
1948
|
+
h("button", { onClick: () => setState({ detail: null }), title: "关闭", "aria-label": "关闭", style: { ...btnStyle, padding: "2px 10px", background: "transparent", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none", color: "var(--text-muted)" } }, "✕"),
|
|
1949
|
+
),
|
|
1950
|
+
// 内容区
|
|
1951
|
+
h("div", { style: { flex: 1, minHeight: 0, overflow: "auto", padding: 14 } },
|
|
1952
|
+
h("p", { style: { ...smallStyle, marginBottom: 8, color: "var(--text-muted)" } }, "此面板用于 查看 / 收藏 / 编辑 命令,不会直接执行。"),
|
|
1953
|
+
current !== detail.cmd && h("div", { style: { ...rowStyle, marginBottom: 4 } },
|
|
1954
|
+
h("button", { onClick: () => setCurrent(detail.cmd), title: "返回原命令", style: { ...btnStyle, padding: "1px 8px", fontSize: 13 } }, "← 返回: " + detail.cmd.slice(0, 30)),
|
|
1955
|
+
h("span", { style: smallStyle }, "正在查看相关命令"),
|
|
1956
|
+
),
|
|
1957
|
+
h("pre", { style: { background: "#0f172a", color: "#a5f3fc", padding: 10, borderRadius: 8, fontSize: 13, fontFamily: "var(--font-mono)", whiteSpace: "pre-wrap", wordBreak: "break-all", margin: "0 0 8px" } }, current),
|
|
1958
|
+
info && h("div", { style: { ...smallStyle, marginBottom: 8 } },
|
|
1959
|
+
info.exists === false
|
|
1960
|
+
? h("span", { style: { color: "var(--danger, #ef4444)" } }, "command not found:" + info.cmd + " 未安装(PATH/项目 bin 中找不到)")
|
|
1961
|
+
: h("span", {}, "命令可识别" + (info.exists === true ? " · 已安装(PATH/项目 bin)" : "")),
|
|
1962
|
+
(info.params && info.params.length) ? h("span", {}, " · 参数: {" + info.params.join("}, {") + "}") : null,
|
|
1963
|
+
(info.danger && info.danger.length) ? h("span", {}, " · ⚠ 危险: " + info.danger.join("/")) : null,
|
|
1964
|
+
),
|
|
1965
|
+
info && info.exists === false && info.installSuggestion && h("div", { style: { ...smallStyle, marginBottom: 8, background: "color-mix(in srgb, var(--danger, #ef4444) 8%, transparent)", border: "1px solid color-mix(in srgb, var(--danger, #ef4444) 35%, transparent)", borderRadius: 6, padding: "6px 8px", color: "var(--danger, #ef4444)" } },
|
|
1966
|
+
h("span", { style: { fontWeight: 600 } }, "安装建议:"), h("code", { style: { fontSize: 13 } }, info.installSuggestion),
|
|
1967
|
+
h("button", { onClick: () => { try { navigator.clipboard.writeText(info.installSuggestion); } catch {} }, style: { ...btnStyle, padding: "1px 8px", fontSize: 13, marginLeft: 6 } }, "复制"),
|
|
1968
|
+
),
|
|
1969
|
+
desc && desc.found && h("div", { style: { ...smallStyle, marginBottom: 8, background: "var(--bg-hover)", borderRadius: 6, padding: "6px 8px" } },
|
|
1970
|
+
h("span", { style: { fontWeight: 600 } }, "📂 " + desc.name + " · " + desc.group + ""),
|
|
1971
|
+
desc.desc ? h("span", { style: { marginLeft: 6 } }, desc.desc) : null,
|
|
1972
|
+
desc.related && desc.related.length ? h("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginTop: 4 } },
|
|
1973
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)", marginRight: 2 } }, "常用:"),
|
|
1974
|
+
desc.related.map((p) => h("button", { key: p, type: "button", onClick: () => setCurrent(p), title: "点击查看/管理此命令", style: { fontSize: 13, background: "var(--bg)", padding: "2px 8px", borderRadius: 6, border: "1px solid var(--border)", color: "var(--accent)", cursor: "pointer" } }, p)))
|
|
1975
|
+
: null,
|
|
1976
|
+
desc.peers && desc.peers.length ? h("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginTop: 4 } },
|
|
1977
|
+
h("span", { style: { fontSize: 13, color: "var(--text-muted)", marginRight: 2 } }, "同类:"),
|
|
1978
|
+
desc.peers.map((p) => h("button", { key: p, type: "button", onClick: () => setCurrent(p), title: "点击查看/管理此命令", style: { fontSize: 13, background: "var(--bg)", padding: "2px 8px", borderRadius: 6, border: "1px solid var(--border)", color: "var(--accent)", cursor: "pointer" } }, p)))
|
|
1979
|
+
: null,
|
|
1980
|
+
),
|
|
1981
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: 6, marginBottom: 8 } },
|
|
1982
|
+
h("input", { style: inputStyle, value: name, placeholder: "收藏名称(默认=命令文本)", onChange: (e) => setName(e.target.value) }),
|
|
1983
|
+
h("select", { style: inputStyle, value: groupId || "", title: "收藏到哪个分组", onChange: (e) => setGroupId(e.target.value || null) },
|
|
1984
|
+
h("option", { value: "" }, "(未分组)"),
|
|
1985
|
+
groups.map((g) => h("option", { key: g.id, value: g.id }, groupPathOf(g.id))),
|
|
1986
|
+
),
|
|
1987
|
+
h("input", { style: inputStyle, value: cwd, placeholder: "cwd(缺省=当前会话目录)", onChange: (e) => setCwd(e.target.value) }),
|
|
1988
|
+
h("label", { style: { fontSize: 13, display: "inline-flex", alignItems: "center", gap: 4 } }, h("input", { type: "checkbox", checked: confirm, onChange: (e) => setConfirm(e.target.checked) }), "危险命令执行前确认"),
|
|
1989
|
+
),
|
|
1990
|
+
err && h("p", { style: { ...smallStyle, color: "var(--danger, #ef4444)", marginBottom: 6 } }, String(err)),
|
|
1991
|
+
h("div", { style: { display: "flex", gap: 8 } },
|
|
1992
|
+
h("button", { onClick: doSave, style: { ...btnStyle, background: "var(--accent)", color: "#fff", borderTop: "none", borderRight: "none", borderBottom: "none", borderLeft: "none", flex: 1 } }, saved ? "已收藏" : "+ 收藏到常用命令"),
|
|
1993
|
+
h("button", { onClick: doRun, style: { ...btnStyle, flex: 1, color: "var(--accent)" } }, busy ? "…" : "▶ 单独执行"),
|
|
1994
|
+
),
|
|
1995
|
+
h("p", { style: { ...smallStyle, marginTop: 8 } }, "收藏后可在 ⚡ 命令面板随时一键调用(不依赖 AI);执行记录全部写入审计日志。"),
|
|
1996
|
+
),
|
|
1997
|
+
),
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// ---------- 工具栏按钮 ----------
|
|
2002
|
+
// ---------- 工作区终端页签(dock 位):单实例渲染归属状态机 ----------
|
|
2003
|
+
// dock 方案(docs/aiti-web-ext-float-dock-plan.md)的 terminal 专项二态版:
|
|
2004
|
+
// 一个 pty 会话任一时刻只渲染在「工作区终端页签」(右侧文件区 dock 位)或
|
|
2005
|
+
// 「aiti-cmd 弹窗」其一——tabAttached[tabId] 记归属会话,detached[tabId] 标记
|
|
2006
|
+
// 「已拉回窗口」。两处渲染互为占位(结构性防双开)。
|
|
2007
|
+
const tabAttached = {}; // { [tabId]: { sessionId, title, cwd } | undefined }
|
|
2008
|
+
const tabDetached = {}; // { [tabId]: true } —— true = 视图在弹窗(页签显示占位)
|
|
2009
|
+
const tabAttachedListeners = new Set();
|
|
2010
|
+
function notifyTabAttached() { for (const l of tabAttachedListeners) l(); }
|
|
2011
|
+
// tabId→sessionId 绑定持久化(sessionStorage,与宿主 pi-web:terminal-tabs 同生命周期):
|
|
2012
|
+
// 刷新后宿主按原 tabId 恢复页签(restored),绑定记录随之找回同一 shell 会话——
|
|
2013
|
+
// TerminalView 经 SSE /stream 的 buffered 回放续上历史输出,不再重开新 shell。
|
|
2014
|
+
const DOCK_TABS_KEY = "pi-web:aiti-cmd:terminal-dock";
|
|
2015
|
+
const saveTabAttached = () => { try { sessionStorage.setItem(DOCK_TABS_KEY, JSON.stringify(tabAttached)); } catch {} };
|
|
2016
|
+
try {
|
|
2017
|
+
const saved = JSON.parse(sessionStorage.getItem(DOCK_TABS_KEY) || "null");
|
|
2018
|
+
if (saved && typeof saved === "object") for (const [tid, e] of Object.entries(saved)) if (e && e.sessionId) tabAttached[tid] = e;
|
|
2019
|
+
// 孤儿清理:宿主页签记录里已不存在的 tabId(页签被关但清理 effect 未及跑)→ 丢弃绑定
|
|
2020
|
+
const hostTabs = JSON.parse(sessionStorage.getItem("pi-web:terminal-tabs") || "null");
|
|
2021
|
+
const hostIds = new Set(((hostTabs && Array.isArray(hostTabs.tabs)) || []).map((t) => t && t.id).filter(Boolean));
|
|
2022
|
+
for (const tid of Object.keys(tabAttached)) if (!hostIds.has(tid)) delete tabAttached[tid];
|
|
2023
|
+
} catch {}
|
|
2024
|
+
// entry 增删唯一入口(内存 + 持久化 + 通知);detached 标记是纯内存视图归属,不持久化——
|
|
2025
|
+
// 刷新后统一回「页签持有」态(视图回页签,要弹窗再点 ⇲)。
|
|
2026
|
+
function setTabEntry(tabId, entry) { tabAttached[tabId] = entry; delete tabDetached[tabId]; saveTabAttached(); notifyTabAttached(); }
|
|
2027
|
+
function clearTabEntry(tabId) { if (!(tabId in tabAttached) && !(tabId in tabDetached)) return; delete tabAttached[tabId]; delete tabDetached[tabId]; saveTabAttached(); notifyTabAttached(); }
|
|
2028
|
+
// 非 hook 版:会话当前是否被某页签持有(弹窗总览列表标「页签」徽标用)
|
|
2029
|
+
function sessionHostTabSync(sessionId) { for (const [tid, e] of Object.entries(tabAttached)) if (e && e.sessionId === sessionId && tabDetached[tid] !== true) return tid; return null; }
|
|
2030
|
+
// 待接管队列:弹窗「转到页签」push → openShellTab 新建页签 → 新页签 effect 按 cwd 消费,
|
|
2031
|
+
// 绑定既有会话(不另起新 shell)。带时间戳 TTL:宿主拒绝建页签/时序异常时残留自动过期。
|
|
2032
|
+
const pendingTakeovers = []; // { cwd, sessionId, title, ts }
|
|
2033
|
+
const PENDING_TAKEOVER_TTL = 15000;
|
|
2034
|
+
function useTabAttachedState(tabId) {
|
|
2035
|
+
const [v, setV] = useState(() => ({ entry: tabAttached[tabId] || null, detached: tabDetached[tabId] === true }));
|
|
2036
|
+
useEffect(() => {
|
|
2037
|
+
const l = () => setV({ entry: tabAttached[tabId] || null, detached: tabDetached[tabId] === true });
|
|
2038
|
+
tabAttachedListeners.add(l);
|
|
2039
|
+
l();
|
|
2040
|
+
return () => tabAttachedListeners.delete(l);
|
|
2041
|
+
}, [tabId]);
|
|
2042
|
+
return v;
|
|
2043
|
+
}
|
|
2044
|
+
// 弹窗侧:该 pty 会话是否正被某页签持有(未 detach)——是则渲染占位卡
|
|
2045
|
+
function useSessionHostTab(sessionId) {
|
|
2046
|
+
const [hostTabId, setHostTabId] = useState(() => sessionHostTabSync(sessionId));
|
|
2047
|
+
useEffect(() => {
|
|
2048
|
+
const l = () => setHostTabId(sessionHostTabSync(sessionId));
|
|
2049
|
+
l(); // sessionId 变化(进详情)时立即求值——不等下一次归属变更通知
|
|
2050
|
+
tabAttachedListeners.add(l);
|
|
2051
|
+
return () => tabAttachedListeners.delete(l);
|
|
2052
|
+
}, [sessionId]);
|
|
2053
|
+
return hostTabId;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
// 页签专用建 shell(§3.3 同 createShell 走后端 /shell;不开弹窗、不切 view——归属由调用方决定)
|
|
2057
|
+
async function createShellSession(cwd) {
|
|
2058
|
+
const r = await fetch(api("/shell"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ cwd: cwd || undefined, name: "bash" }) }).then((x) => x.json()).catch(() => ({ ok: false, error: "网络错误" }));
|
|
2059
|
+
if (!r || !r.ok || !r.sessionId) throw new Error((r && r.error) || "创建终端失败");
|
|
2060
|
+
return { sessionId: r.sessionId, title: r.name || "bash" };
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// 工作区终端页签宿主组件(terminalDock 插槽 render):真接管——页签内直接渲染终端视图。
|
|
2064
|
+
// openShellTab:宿主回调(TerminalPanelBridge 透传)——「+ 新建」同 cwd 强制新开 shell 页签;缺席(旧宿主)时不渲染入口。
|
|
2065
|
+
function WorkspaceTerminalHost({ cwd, tabId, active, restored, onClosed, openShellTab }) {
|
|
2066
|
+
const attached = useTabAttachedState(tabId);
|
|
2067
|
+
const triedRef = useRef(false);
|
|
2068
|
+
const attachingRef = useRef(false); // 同事件循环双击守卫(state 异步更新,闭包值滞后)
|
|
2069
|
+
const [attaching, setAttaching] = useState(false);
|
|
2070
|
+
const [error, setError] = useState(null);
|
|
2071
|
+
const attach = async () => {
|
|
2072
|
+
if (attachingRef.current) return;
|
|
2073
|
+
attachingRef.current = true;
|
|
2074
|
+
setAttaching(true);
|
|
2075
|
+
setError(null);
|
|
2076
|
+
try {
|
|
2077
|
+
const entry = await createShellSession(cwd);
|
|
2078
|
+
setTabEntry(tabId, entry);
|
|
2079
|
+
} catch (e) {
|
|
2080
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
2081
|
+
} finally {
|
|
2082
|
+
attachingRef.current = false;
|
|
2083
|
+
setAttaching(false);
|
|
2084
|
+
}
|
|
2085
|
+
};
|
|
2086
|
+
// 首次激活自动接管,分三路(详见 effect 内注释):① 弹窗「转到页签」待接管;
|
|
2087
|
+
// ② 持久化恢复绑定的活性验证;③ 无绑定时新建(restored 出按钮不静默起)。
|
|
2088
|
+
useEffect(() => {
|
|
2089
|
+
if (!active || triedRef.current) return;
|
|
2090
|
+
// ① 弹窗「转到页签」的待接管(同 cwd 新建的本页签):绑定既有会话即收,不另起新 shell。
|
|
2091
|
+
// forceNew 建的新 tabId 此刻必然 triedRef=false 且 active(宿主建后即激活),
|
|
2092
|
+
// 同 cwd 旧页签要么已 tried 要么非激活,不会抣。
|
|
2093
|
+
const now = Date.now();
|
|
2094
|
+
for (let i = 0; i < pendingTakeovers.length; i++) {
|
|
2095
|
+
if (now - pendingTakeovers[i].ts >= PENDING_TAKEOVER_TTL) { pendingTakeovers.splice(i, 1); i--; }
|
|
2096
|
+
}
|
|
2097
|
+
const pIdx = pendingTakeovers.findIndex((p) => p.cwd === cwd);
|
|
2098
|
+
if (pIdx >= 0) {
|
|
2099
|
+
triedRef.current = true;
|
|
2100
|
+
const p = pendingTakeovers.splice(pIdx, 1)[0];
|
|
2101
|
+
setTabEntry(tabId, { sessionId: p.sessionId, title: p.title, cwd });
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
// ② 持久化恢复的绑定(刷新后 attached.entry 从 sessionStorage 找回):单查会话活性——
|
|
2105
|
+
// 活着/已退出都不动(TerminalView 渲染后 SSE 重连回放 / exit 事件→onExit 清绑定),
|
|
2106
|
+
// 404(服务端重启/超时被清)才清掉绑定,回落到③的按钮态。不弹新建,不静默重建。
|
|
2107
|
+
if (attached.entry && !attached.detached) {
|
|
2108
|
+
triedRef.current = true;
|
|
2109
|
+
const sid = attached.entry.sessionId;
|
|
2110
|
+
fetch(api("/sessions/" + sid)).then((r) => (r.ok ? r.json() : null)).then((d) => {
|
|
2111
|
+
if (d && d.ok && (d.status === "running" || d.status === "queued" || d.status === "exit")) return;
|
|
2112
|
+
clearTabEntry(tabId); // 会话已不存在:绑定失效,出「接管到页签」(点击新建)
|
|
2113
|
+
}).catch(() => {}); // 网络异常:保留绑定,交由 TerminalView 的 SSE 自动重连
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
// ③ 无绑定:原版语义——新页签自动起 shell;restored 恢复页签不静默起,出「接管到页签」按钮。
|
|
2117
|
+
if (restored || attached.entry || attachingRef.current) return;
|
|
2118
|
+
triedRef.current = true;
|
|
2119
|
+
void attach();
|
|
2120
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2121
|
+
}, [active, restored, attached.entry]);
|
|
2122
|
+
// 页签关闭:宿主移除页签(closing effect 调 onClosed)→ 本组件卸载 → cleanup 释放
|
|
2123
|
+
// 归属记录(shell 会话不死,仍可在弹窗总览里继续)。onClosed 由宿主负责调用时机。
|
|
2124
|
+
useEffect(() => () => clearTabEntry(tabId), [tabId]);
|
|
2125
|
+
|
|
2126
|
+
const detachedEntry = attached.entry && attached.detached;
|
|
2127
|
+
// 拉回窗口:归属切到弹窗并直接打开该会话视图(「detach 之后窗口就可以用了」)
|
|
2128
|
+
const pullBackToPanel = () => {
|
|
2129
|
+
if (!attached.entry) return;
|
|
2130
|
+
tabDetached[tabId] = true;
|
|
2131
|
+
notifyTabAttached();
|
|
2132
|
+
setState({ panelOpen: true, view: { kind: "pty", id: attached.entry.sessionId, title: attached.entry.title }, detail: null });
|
|
2133
|
+
};
|
|
2134
|
+
// 接管到页签:归属切回页签(弹窗侧该会话自动变占位)
|
|
2135
|
+
const takeBackToTab = () => {
|
|
2136
|
+
tabDetached[tabId] = false;
|
|
2137
|
+
notifyTabAttached();
|
|
2138
|
+
};
|
|
2139
|
+
|
|
2140
|
+
// 头行(dock 方案 DockBody chrome="tab" 同构:标题 + ⇲ 拉回;页签 ✕ 归 TabBar,防双 ✕)
|
|
2141
|
+
let body;
|
|
2142
|
+
if (!attached.entry) {
|
|
2143
|
+
body = h("div", { style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, padding: 24, color: "var(--text-dim)", fontSize: 12 } },
|
|
2144
|
+
h("div", { title: cwd, style: { display: "flex", alignItems: "center", gap: 6, maxWidth: "100%", overflow: "hidden", color: "var(--text-muted)" } },
|
|
2145
|
+
h("span", { style: { display: "inline-flex", flexShrink: 0 } }, Ic(14, ICONS.monitor)),
|
|
2146
|
+
h("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, cwd),
|
|
2147
|
+
),
|
|
2148
|
+
attaching ? h("span", null, "正在启动 shell…")
|
|
2149
|
+
: h("button", { onClick: attach, style: { ...btnStyle, padding: "6px 14px", background: "var(--bg-hover)", borderColor: "var(--border)", color: "var(--accent)", fontWeight: 600, fontSize: 12 } },
|
|
2150
|
+
"接管到页签",
|
|
2151
|
+
),
|
|
2152
|
+
error && h("span", { role: "alert", style: { color: "var(--danger, #ef4444)" } }, error),
|
|
2153
|
+
h("span", { style: { fontSize: 11, color: "var(--text-muted)" } }, "shell 由 aiti-cmd 承载 · 24h 会话"),
|
|
2154
|
+
);
|
|
2155
|
+
} else if (detachedEntry) {
|
|
2156
|
+
// 已拉回窗口:页签显示占位(单实例——视图此刻归弹窗)
|
|
2157
|
+
body = h("div", { style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, padding: 24, color: "var(--text-dim)", fontSize: 12 } },
|
|
2158
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 6, color: "var(--text-muted)" } },
|
|
2159
|
+
h("span", { style: { display: "inline-flex", flexShrink: 0 } }, Ic(14, ICONS.monitor)),
|
|
2160
|
+
h("span", null, attached.entry.title + " 已在 aiti-cmd 窗口打开"),
|
|
2161
|
+
),
|
|
2162
|
+
h("button", { onClick: takeBackToTab, style: { ...btnStyle, padding: "6px 14px", color: "var(--accent)", fontWeight: 600, fontSize: 12 } }, "接管到页签"),
|
|
2163
|
+
h("span", { style: { fontSize: 11, color: "var(--text-muted)" } }, "终端同时只能在一处显示;接管后窗口内该终端变为占位"),
|
|
2164
|
+
);
|
|
2165
|
+
} else {
|
|
2166
|
+
// 无独立头行(双标题栏收敛):标题/目录已在页签栏与工具条(📁 cwd),操作收进 TerminalView
|
|
2167
|
+
// 工具条右侧(+ 新建 · ⇲ 拉回窗口 · ⛶ 最大化)。
|
|
2168
|
+
body = h("div", { style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", overflow: "hidden" } },
|
|
2169
|
+
h(TerminalView, {
|
|
2170
|
+
sessionId: attached.entry.sessionId,
|
|
2171
|
+
height: "100%",
|
|
2172
|
+
onExit: () => clearTabEntry(tabId),
|
|
2173
|
+
pullBack: pullBackToPanel,
|
|
2174
|
+
// 「+ 新建」@ 当前终端实时路径(实时 pwd):先查再开新页签
|
|
2175
|
+
onNewShell: typeof openShellTab === "function" && attached.entry ? () => { void liveCwdOf(attached.entry.sessionId, cwd).then((live) => openShellTab(live || cwd)); } : undefined,
|
|
2176
|
+
// 「💬 新会话」新建 pi-web 会话并 @ 同一实时路径(宿主通道 openNewPiSession)
|
|
2177
|
+
onNewPiSession: attached.entry ? () => { void liveCwdOf(attached.entry.sessionId, cwd).then((live) => { openNewPiSession(live || cwd); }); } : undefined,
|
|
2178
|
+
}),
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
return h("div", { style: { height: "100%", display: "flex", flexDirection: "column", overflow: "hidden", background: "#0f172a" } }, body);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
function ToolbarButton() {
|
|
2185
|
+
return h("button", {
|
|
2186
|
+
onClick: () => setState({ panelOpen: true, view: null }), // 重开面板回总览列表(旧详情视图可能已过期——会话死了会无限重连)
|
|
2187
|
+
style: topbarBtnStyle,
|
|
2188
|
+
onMouseEnter: (e) => { e.currentTarget.style.color = "var(--text)"; },
|
|
2189
|
+
onMouseLeave: (e) => { e.currentTarget.style.color = "var(--text-muted)"; },
|
|
2190
|
+
title: "aiti-cmd:运行总览 / 常用命令 / 执行记录",
|
|
2191
|
+
},
|
|
2192
|
+
// lucide terminal:命令终端——与系统内建线条图标同风格
|
|
2193
|
+
h("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
2194
|
+
h("polyline", { points: "4 17 10 11 4 5" }),
|
|
2195
|
+
h("line", { x1: 12, y1: 19, x2: 20, y2: 19 })));
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// 懒加载 v2 M3批②:声明式条目(无 render)——宿主渲染静态按钮,点击经转发器到本 onClick
|
|
2199
|
+
ctx.registerItems("toolbar", [{
|
|
2200
|
+
id: "aiti-cmd-open",
|
|
2201
|
+
meta: {
|
|
2202
|
+
label: "常用命令",
|
|
2203
|
+
title: "aiti-cmd:运行总览 / 常用命令 / 执行记录",
|
|
2204
|
+
icon: { svg: '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' },
|
|
2205
|
+
// 重开面板回总览列表(旧详情视图可能已过期——会话死了会无限重连)
|
|
2206
|
+
onClick: () => setState({ panelOpen: true, view: null }),
|
|
2207
|
+
},
|
|
2208
|
+
}]);
|
|
2209
|
+
ctx.registerItems("dialogs", [
|
|
2210
|
+
{ id: "aiti-cmd-panel", render: CommandPanel },
|
|
2211
|
+
{ id: "aiti-cmd-detail", render: CommandDetailPanel },
|
|
2212
|
+
]);
|
|
2213
|
+
// 工作区终端页签内容接管(terminalDock):真接管——页签内直接渲染终端视图;
|
|
2214
|
+
// tab⇄弹窗单实例二选一(归属状态机见 WorkspaceTerminalHost),双向占位。
|
|
2215
|
+
ctx.registerItems("terminalDock", [{ id: "workspace-terminal", render: WorkspaceTerminalHost }]);
|
|
2216
|
+
}
|