ot-cli 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openterminal/__init__.py +3 -0
- openterminal/agent/__init__.py +39 -0
- openterminal/agent/backend.py +180 -0
- openterminal/agent/build.py +115 -0
- openterminal/agent/middleware.py +111 -0
- openterminal/agent/model.py +41 -0
- openterminal/agent/prompt.py +63 -0
- openterminal/agent/runner.py +340 -0
- openterminal/agent/sysprobe.py +164 -0
- openterminal/agent/token_est.py +49 -0
- openterminal/app.py +52 -0
- openterminal/approval.py +7 -0
- openterminal/backend.py +3 -0
- openterminal/banner.py +7 -0
- openterminal/cli.py +426 -0
- openterminal/cmdset.py +7 -0
- openterminal/config.py +322 -0
- openterminal/connections/__init__.py +20 -0
- openterminal/connections/db.py +240 -0
- openterminal/connections/models.py +289 -0
- openterminal/connections/secrets.py +68 -0
- openterminal/connections_db.py +6 -0
- openterminal/core/__init__.py +41 -0
- openterminal/core/ai_flow.py +781 -0
- openterminal/core/auth.py +499 -0
- openterminal/core/cmdset_runner.py +352 -0
- openterminal/core/exec_events.py +650 -0
- openterminal/core/history.py +79 -0
- openterminal/core/hook_gate.py +415 -0
- openterminal/core/messages.py +193 -0
- openterminal/core/nested_ssh.py +178 -0
- openterminal/core/pipeline.py +1187 -0
- openterminal/core/pump.py +190 -0
- openterminal/core/transcript.py +26 -0
- openterminal/frontend/__init__.py +21 -0
- openterminal/frontend/approval.py +40 -0
- openterminal/frontend/banner.py +40 -0
- openterminal/frontend/headless.py +290 -0
- openterminal/frontend/render.py +41 -0
- openterminal/frontend/taskview.py +184 -0
- openterminal/frontend/term.py +1417 -0
- openterminal/headless.py +7 -0
- openterminal/history_db.py +7 -0
- openterminal/local_pty.py +3 -0
- openterminal/local_win.py +3 -0
- openterminal/policy.py +362 -0
- openterminal/render.py +7 -0
- openterminal/secrets_store.py +5 -0
- openterminal/session/__init__.py +17 -0
- openterminal/session/base.py +328 -0
- openterminal/session/local_posix.py +184 -0
- openterminal/session/local_win.py +173 -0
- openterminal/session/ssh.py +321 -0
- openterminal/shell/__init__.py +17 -0
- openterminal/shell/cmdset.py +49 -0
- openterminal/shell/integration.py +1298 -0
- openterminal/shell_integration.py +7 -0
- openterminal/shell_session.py +8 -0
- openterminal/skills/__init__.py +45 -0
- openterminal/skills/markdown-tables/SKILL.md +247 -0
- openterminal/ssh_pty.py +6 -0
- openterminal/sysprobe.py +7 -0
- openterminal/taskview.py +7 -0
- openterminal/term_frontend.py +7 -0
- openterminal/token_est.py +3 -0
- openterminal/transcript.py +7 -0
- openterminal/web/__init__.py +1 -0
- openterminal/web/files_api.py +789 -0
- openterminal/web/frontend/index.html +37 -0
- openterminal/web/frontend/static/app.js +3632 -0
- openterminal/web/frontend/static/block_stream.js +274 -0
- openterminal/web/frontend/static/block_view.js +383 -0
- openterminal/web/frontend/static/files_panel.js +782 -0
- openterminal/web/frontend/static/md_lite.js +127 -0
- openterminal/web/frontend/static/modal.js +122 -0
- openterminal/web/frontend/static/pad_ledger.js +46 -0
- openterminal/web/frontend/static/style.css +915 -0
- openterminal/web/frontend/static/ui/ui.css +1 -0
- openterminal/web/frontend/static/ui/ui.js +6292 -0
- openterminal/web/frontend/vendor/addon-fit.js +2 -0
- openterminal/web/frontend/vendor/xterm.css +8 -0
- openterminal/web/frontend/vendor/xterm.js +8 -0
- openterminal/web/protocol.py +41 -0
- openterminal/web/server.py +322 -0
- openterminal/web/worker.py +62 -0
- ot_cli-0.2.1.dist-info/METADATA +439 -0
- ot_cli-0.2.1.dist-info/RECORD +91 -0
- ot_cli-0.2.1.dist-info/WHEEL +5 -0
- ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
- ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
- ot_cli-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// 块流:卡片层的排版地基(Phase 1)。
|
|
2
|
+
//
|
|
3
|
+
// 取代「卡片贴在 xterm 网格行上的浮层」:输出是一串有序的块,卡片块与文本块
|
|
4
|
+
// 是平级兄弟节点,位置由块序决定。这样三条硬约束同时消失——
|
|
5
|
+
// ① 卡片要占网格行 → 就要在缓冲里插空行 → 只能 shell 往返(慢、且只能追加流底);
|
|
6
|
+
// ② 占位行数不满就夹紧截断(真机「⚠ 系统警告只剩标题」);
|
|
7
|
+
// ③ 多卡并发 registerMarker 抢同一行 → 后贴纸盖先贴纸(真机「工具卡盖审批卡」)。
|
|
8
|
+
//
|
|
9
|
+
// 三条设计原则(见 docs/block-stream-plan.md):
|
|
10
|
+
// P1 布局不得依赖 shell 集成(sudo su - 后 hook 必死);
|
|
11
|
+
// P2 标记是加速器不是依赖;
|
|
12
|
+
// P3 **锚点是身份不是坐标**——本模块只认 id,从不认行号。
|
|
13
|
+
//
|
|
14
|
+
// 纯函数 + 可直测(node --test),不碰 DOM / xterm / React。
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
const KINDS = ["term", "card", "banner", "transition"];
|
|
18
|
+
|
|
19
|
+
class BlockStream {
|
|
20
|
+
constructor() {
|
|
21
|
+
this._seq = 0;
|
|
22
|
+
this._blocks = []; // 唯一权威:有序块列表
|
|
23
|
+
this._byId = new Map(); // id -> block
|
|
24
|
+
this._subs = new Set();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── id ───────────────────────────────────────────────
|
|
28
|
+
// 全会话唯一、生成后永不改变。卡片与输出都靠它定位,所以并行插入不可能
|
|
29
|
+
// 撞在同一处(今天抢 marker 行就是撞在这上面)。
|
|
30
|
+
nextId(prefix) {
|
|
31
|
+
this._seq += 1;
|
|
32
|
+
return String(prefix || "blk") + "-" + this._seq;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── 增 ───────────────────────────────────────────────
|
|
36
|
+
// 追加到末尾。id 已存在则拒绝:重连补块必须幂等,否则卡片重复(风险清单)。
|
|
37
|
+
append(block) {
|
|
38
|
+
const b = this._norm(block);
|
|
39
|
+
if (this._byId.has(b.id)) return null;
|
|
40
|
+
this._blocks.push(b);
|
|
41
|
+
this._byId.set(b.id, b);
|
|
42
|
+
this._emit({ type: "add", id: b.id, at: this._blocks.length - 1 });
|
|
43
|
+
return b;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 插到某块之后——**并行工具输出归属的关键**:out-2 挂 after:"card-2",
|
|
47
|
+
// card-3 自然下移。列表插入,不是坐标运算。
|
|
48
|
+
//
|
|
49
|
+
// after 不认识时(块已被移除 / 降级态认不出归属)落到末尾:宁可位置粗一点,
|
|
50
|
+
// 也不许插进一段中间(已定决策)。
|
|
51
|
+
insertAfter(afterId, block) {
|
|
52
|
+
const b = this._norm(block);
|
|
53
|
+
if (this._byId.has(b.id)) return null;
|
|
54
|
+
const idx = this._indexOf(afterId);
|
|
55
|
+
const at = idx < 0 ? this._blocks.length : idx + 1;
|
|
56
|
+
this._blocks.splice(at, 0, b);
|
|
57
|
+
this._byId.set(b.id, b);
|
|
58
|
+
this._emit({ type: "add", id: b.id, at });
|
|
59
|
+
return b;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 插到某块之前。卡片要落在「当前活块」之上时用它:活块恒在流末尾,
|
|
63
|
+
// 新卡 insertBefore(live) 即可,不需要知道上一张卡是谁。
|
|
64
|
+
insertBefore(beforeId, block) {
|
|
65
|
+
const b = this._norm(block);
|
|
66
|
+
if (this._byId.has(b.id)) return null;
|
|
67
|
+
const idx = this._indexOf(beforeId);
|
|
68
|
+
const at = idx < 0 ? this._blocks.length : idx;
|
|
69
|
+
this._blocks.splice(at, 0, b);
|
|
70
|
+
this._byId.set(b.id, b);
|
|
71
|
+
this._emit({ type: "add", id: b.id, at });
|
|
72
|
+
return b;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 改 ───────────────────────────────────────────────
|
|
76
|
+
// 原地更新(tool_end 换徽标、分析卡换装总结卡):零布局位移,不该 emit 布局
|
|
77
|
+
// 变化——但要通知渲染器重画内容。
|
|
78
|
+
update(id, patch) {
|
|
79
|
+
const b = this._byId.get(id);
|
|
80
|
+
if (!b || !patch) return null;
|
|
81
|
+
for (const k of Object.keys(patch)) {
|
|
82
|
+
if (k === "id") continue; // 身份不可改
|
|
83
|
+
if (patch[k] !== undefined) b[k] = patch[k];
|
|
84
|
+
}
|
|
85
|
+
this._emit({ type: "update", id });
|
|
86
|
+
return b;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 删 ───────────────────────────────────────────────
|
|
90
|
+
// 中断 / 整卡移除 / 收束扫尾。删了就是整块没了——「完整或消失」的消失侧。
|
|
91
|
+
//
|
|
92
|
+
// 铁律(用户决策):**等用户点按钮的卡永不移除**,除非显式 force(任务中断
|
|
93
|
+
// 整卡移除、/clear)。把它做成结构性闸门而不是口头约定——否则虚拟化/收束
|
|
94
|
+
// 扫尾任一处手一抖就是「按钮没了 = 审批死锁」。keepAlive 由 armed/disarmed
|
|
95
|
+
// 翻转(见 update)。
|
|
96
|
+
remove(id, opts) {
|
|
97
|
+
const idx = this._indexOf(id);
|
|
98
|
+
if (idx < 0) return false;
|
|
99
|
+
if (this._blocks[idx].keepAlive && !(opts && opts.force)) return false;
|
|
100
|
+
this._blocks.splice(idx, 1);
|
|
101
|
+
this._byId.delete(id);
|
|
102
|
+
this._emit({ type: "remove", id, at: idx });
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 批量删(中断时连带该任务的在途块)。force:中断语义,越过 keepAlive
|
|
107
|
+
removeMany(ids, opts) {
|
|
108
|
+
let n = 0;
|
|
109
|
+
for (const id of ids || []) if (this.remove(id, opts)) n++;
|
|
110
|
+
return n;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
clear(opts) {
|
|
114
|
+
if (!this._blocks.length) return;
|
|
115
|
+
// /clear、新会话是显式清场:越过 keepAlive
|
|
116
|
+
this._blocks = [];
|
|
117
|
+
this._byId.clear();
|
|
118
|
+
this._emit({ type: "clear" });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── 查 ───────────────────────────────────────────────
|
|
122
|
+
get(id) { return this._byId.get(id) || null; }
|
|
123
|
+
has(id) { return this._byId.has(id); }
|
|
124
|
+
get length() { return this._blocks.length; }
|
|
125
|
+
|
|
126
|
+
// 顺序快照:渲染器照它画。返回浅拷贝,外部改不到内部账目。
|
|
127
|
+
list() { return this._blocks.slice(); }
|
|
128
|
+
|
|
129
|
+
indexOf(id) { return this._indexOf(id); }
|
|
130
|
+
|
|
131
|
+
// 某块之后的块(含边界检查):任务收束扫尾要按「本任务开了哪些块」清场。
|
|
132
|
+
after(id) {
|
|
133
|
+
const idx = this._indexOf(id);
|
|
134
|
+
return idx < 0 ? [] : this._blocks.slice(idx + 1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 某类块的最后一张:换装(分析卡 → 总结卡)要找当前活卡
|
|
138
|
+
lastOfKind(kind) {
|
|
139
|
+
for (let i = this._blocks.length - 1; i >= 0; i--) {
|
|
140
|
+
if (this._blocks[i].kind === kind) return this._blocks[i];
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 某任务/某代的块:收束扫尾按 gen 圈定,不误伤别的任务的卡
|
|
146
|
+
filter(fn) { return this._blocks.filter(fn); }
|
|
147
|
+
|
|
148
|
+
// ── 订阅 ─────────────────────────────────────────────
|
|
149
|
+
// 变更只广播一次,渲染器批量重排;不要在这里做重活。
|
|
150
|
+
subscribe(fn) {
|
|
151
|
+
if (typeof fn === "function") this._subs.add(fn);
|
|
152
|
+
return () => this._subs.delete(fn);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_emit(ev) {
|
|
156
|
+
for (const fn of this._subs) {
|
|
157
|
+
try { fn(ev, this); } catch (e) { /* 单个订阅者出错不能打死排版 */ }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_indexOf(id) {
|
|
162
|
+
if (!id) return -1;
|
|
163
|
+
for (let i = 0; i < this._blocks.length; i++) {
|
|
164
|
+
if (this._blocks[i].id === id) return i;
|
|
165
|
+
}
|
|
166
|
+
return -1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_norm(block) {
|
|
170
|
+
const b = Object.assign({}, block || {});
|
|
171
|
+
if (!b.id) b.id = this.nextId(b.kind || "blk");
|
|
172
|
+
if (KINDS.indexOf(b.kind) < 0) b.kind = "term";
|
|
173
|
+
if (b.state !== "live" && b.state !== "frozen") {
|
|
174
|
+
// 文本块默认 live(还在收输出);卡片块出生即定型(born-complete)
|
|
175
|
+
b.state = b.kind === "card" ? "frozen" : "live";
|
|
176
|
+
}
|
|
177
|
+
if (b.conf == null) b.conf = 1;
|
|
178
|
+
return b;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── 纯函数:降级判定 ─────────────────────────────────────
|
|
183
|
+
//
|
|
184
|
+
// 位置精度随可用信息降级(L0 标记 → L1 提示符 → L2 静默 → L3 不切),
|
|
185
|
+
// 但「不遮挡 / 不裁切 / 无大空白 / 不消失」在每一级都成立——这是本架构的支点。
|
|
186
|
+
function canCut(ctx) {
|
|
187
|
+
const c = ctx || {};
|
|
188
|
+
if (c.marker) return { ok: true, level: "L0", conf: 1 };
|
|
189
|
+
if (c.promptSeen && c.quietMs >= (c.minQuietMs || 350)) {
|
|
190
|
+
return { ok: true, level: "L1", conf: 0.8 };
|
|
191
|
+
}
|
|
192
|
+
if (c.quietMs >= (c.minQuietMs || 350) && c.cursorAtLineStart) {
|
|
193
|
+
return { ok: true, level: "L2", conf: 0.5 };
|
|
194
|
+
}
|
|
195
|
+
// L3:切不动就不切,当前块继续长,卡片进待安置条
|
|
196
|
+
return { ok: false, level: "L3", conf: 0 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 换壳 / 跳板指令识别:**指令是我方亲手敲进 PTY 的**,所以不依赖任何远端配合,
|
|
200
|
+
// 在 hook 已死的裸壳下同样成立(P1)。
|
|
201
|
+
//
|
|
202
|
+
// `su` 必须带词边界:否则 `sudo apt install nginx` 会被 `su` 前缀误判成换壳。
|
|
203
|
+
// 不认裸 `bash`/`sh`/`zsh`/`exit`:误触发面太大(`sh -c '…'`、脚本里的 exit),
|
|
204
|
+
// 宁可漏判进 L3 降级(卡片完整、位置稍粗),也不许乱开过渡块。
|
|
205
|
+
const SHELL_SWITCH_RE = /^\s*(?:sudo\s+)?(?:\bsu\b(?:\s+-|\s+[a-z_][a-z0-9_-]*)?|\blogin\b|\bssh\s+|\bmosh\s+|\bexec\s+|\bscreen\s+|\btmux\s+)/;
|
|
206
|
+
|
|
207
|
+
function isShellSwitch(line) {
|
|
208
|
+
return SHELL_SWITCH_RE.test(String(line == null ? "" : line));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
// \-\ 纯函数:回显行 \-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\
|
|
213
|
+
//
|
|
214
|
+
// 工具卡下的输出块要带命令回显行(`[root@x /tmp]# ps aux ...`),观感才和
|
|
215
|
+
// 真终端一致。但 hook 画完回显后 Readline 会来一句「光标上移一格 + 擦行」
|
|
216
|
+
// 重绘提示符,随机啃掉一行——回显活不活得到收束是竞态,于是有的块带有的
|
|
217
|
+
// 不带。这里把它变成确定的:**开括号时记住那行真实回显**(彼时它刚画上),
|
|
218
|
+
// 收束时丢了就原样补回去。补的是 shell 自己画的那行(含真提示符),不是
|
|
219
|
+
// 我们瞎编的。
|
|
220
|
+
|
|
221
|
+
// 回显行 = 提示符尾符 + 这条命令,整行才算
|
|
222
|
+
function plainEcho(text) {
|
|
223
|
+
return String(text == null ? "" : text).replace(/\x1b\[[0-9;]*m/g, "");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isCmdEcho(line, cmd) {
|
|
227
|
+
const c = String(cmd == null ? "" : cmd).trim();
|
|
228
|
+
const h = plainEcho(line).trimEnd();
|
|
229
|
+
if (!c || !h.endsWith(c)) return false;
|
|
230
|
+
const before = h.slice(0, h.length - c.length).trimEnd();
|
|
231
|
+
const last = before.slice(-1);
|
|
232
|
+
return last === "$" || last === "#" || last === "%" || last === ">";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 只为已由后端明确标识的命令回显补色,不猜测普通输出中的命令/自然语言。
|
|
236
|
+
function styleCmdEcho(echo, command) {
|
|
237
|
+
const text = String(echo == null ? "" : echo).trimEnd();
|
|
238
|
+
const cmd = String(command == null ? "" : command).trim();
|
|
239
|
+
if (!isCmdEcho(text, cmd) || text !== plainEcho(text)) return text;
|
|
240
|
+
return text.slice(0, -cmd.length) + "\x1b[38;5;51m" + cmd + "\x1b[0m";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 从「缓冲区文本」里认回显行:只看最后一个非空行(回显刚画在那儿,其后
|
|
244
|
+
// 光标已换行)。认不出就返回空串——宁缺勿猜。
|
|
245
|
+
function pickCmdEcho(bufferText, cmd) {
|
|
246
|
+
const lines = String(bufferText == null ? "" : bufferText).split("\n");
|
|
247
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
248
|
+
const s = lines[i].trimEnd();
|
|
249
|
+
if (!plainEcho(s).trim()) continue;
|
|
250
|
+
return isCmdEcho(s, cmd) ? s : "";
|
|
251
|
+
}
|
|
252
|
+
return "";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 收束时保证输出块以回显行开头:已在开头就原样;在中间就把前面的提示符
|
|
256
|
+
// 重绘噪声丢掉;整段被啃掉就补回去。
|
|
257
|
+
function ensureCmdEcho(text, echo) {
|
|
258
|
+
const src = String(text == null ? "" : text);
|
|
259
|
+
const e = String(echo == null ? "" : echo).trimEnd();
|
|
260
|
+
if (!e) return src;
|
|
261
|
+
const lines = src.split("\n");
|
|
262
|
+
const i = lines.findIndex(l => plainEcho(l).trimEnd() === plainEcho(e));
|
|
263
|
+
if (i >= 0) {
|
|
264
|
+
// hook 没有上色时也可用可信回显补色;已带样式的真实终端行不覆盖。
|
|
265
|
+
if (plainEcho(lines[i]) === lines[i] && plainEcho(e) !== e) lines[i] = e;
|
|
266
|
+
return lines.slice(i).join("\n");
|
|
267
|
+
}
|
|
268
|
+
return e + "\n" + src;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
272
|
+
module.exports = { BlockStream, KINDS, canCut, isShellSwitch, SHELL_SWITCH_RE,
|
|
273
|
+
isCmdEcho, pickCmdEcho, ensureCmdEcho, styleCmdEcho };
|
|
274
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// 块流渲染器:把 BlockStream 的块列表画成流式 DOM(Phase 1)。
|
|
2
|
+
//
|
|
3
|
+
// 取代「xterm decoration 浮层」:卡片块是普通 DOM 节点,高度由内容自撑,
|
|
4
|
+
// 后面的块自然下推。于是三条硬约束同时消失——不遮挡(兄弟节点不重叠)、
|
|
5
|
+
// 不裁切(没有 clamp)、无大空白(没有 pad,也就没有「不可回吐的垫行」)。
|
|
6
|
+
//
|
|
7
|
+
// 卡片渲染复用现有 React island:`feed.mount(id, hostEl)` 只要一个 DOM 元素,
|
|
8
|
+
// 本来就与 xterm 解耦——所以 cards.jsx / island.css / style.css 一行不改,
|
|
9
|
+
// 观感原样保留。
|
|
10
|
+
//
|
|
11
|
+
// 文本块这里用 DOM 渲染(ANSI → HTML);接真 xterm 做活动区是 Phase 2 的事。
|
|
12
|
+
// 纯函数(ansiToHtml / stripAnsi / pickRenderOps)可直测,不碰 DOM。
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
// ── ANSI SGR → HTML ──────────────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// 冻结块是「只读的历史输出」,不需要终端的光标/换行语义,只要把颜色和
|
|
18
|
+
// 常见属性画对。控制序列一律丢弃,避免把 \x1b[K 之类画成乱码。
|
|
19
|
+
const ANSI_FG = {
|
|
20
|
+
30: "#6b7280", 31: "#f87171", 32: "#4ade80", 33: "#fbbf24",
|
|
21
|
+
34: "#60a5fa", 35: "#c084fc", 36: "#22d3ee", 37: "#d1d5db",
|
|
22
|
+
90: "#9ca3af", 91: "#fca5a5", 92: "#86efac", 93: "#fde68a",
|
|
23
|
+
94: "#93c5fd", 95: "#d8b4fe", 96: "#67e8f9", 97: "#f3f4f6",
|
|
24
|
+
};
|
|
25
|
+
const ANSI_BG = {
|
|
26
|
+
40: "#374151", 41: "#7f1d1d", 42: "#14532d", 43: "#713f12",
|
|
27
|
+
44: "#1e3a8a", 45: "#581c87", 46: "#155e75", 47: "#4b5563",
|
|
28
|
+
100: "#4b5563", 101: "#991b1b", 102: "#166534", 103: "#854d0e",
|
|
29
|
+
104: "#1e40af", 105: "#6b21a8", 106: "#155e75", 107: "#6b7280",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function escHtml(s) {
|
|
33
|
+
return String(s)
|
|
34
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stripAnsi(s) {
|
|
38
|
+
return String(s == null ? "" : s)
|
|
39
|
+
// eslint-disable-next-line no-control-regex
|
|
40
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
41
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
42
|
+
.replace(/\x1b[@-Z\\-_]/g, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 解析一段文本,产出 [{text, cls, style}] 片段;再拼成 HTML。
|
|
46
|
+
// 不用 innerHTML 拼用户可控内容以外的标签——所有文本过 escHtml。
|
|
47
|
+
function ansiToHtml(src, opts) {
|
|
48
|
+
const o = opts || {};
|
|
49
|
+
const s = String(src == null ? "" : src);
|
|
50
|
+
const out = [];
|
|
51
|
+
let buf = "";
|
|
52
|
+
let cur = null; // {bold, dim, italic, underline, fg, bg}
|
|
53
|
+
const push = () => {
|
|
54
|
+
if (!buf) return;
|
|
55
|
+
const attrs = [];
|
|
56
|
+
if (cur) {
|
|
57
|
+
if (cur.bold) attrs.push("font-weight:600");
|
|
58
|
+
if (cur.italic) attrs.push("font-style:italic");
|
|
59
|
+
const decoration = [cur.underline ? "underline" : "", cur.strike ? "line-through" : ""].filter(Boolean);
|
|
60
|
+
if (decoration.length) attrs.push("text-decoration:" + decoration.join(" "));
|
|
61
|
+
if (cur.inverse) {
|
|
62
|
+
attrs.push("color:" + (cur.bg || "#0d1117"));
|
|
63
|
+
attrs.push("background:" + (cur.fg || "#e6e8ea"));
|
|
64
|
+
} else {
|
|
65
|
+
if (cur.fg) attrs.push("color:" + cur.fg);
|
|
66
|
+
if (cur.bg) attrs.push("background:" + cur.bg);
|
|
67
|
+
}
|
|
68
|
+
if (cur.invisible) attrs.push("visibility:hidden");
|
|
69
|
+
if (cur.dim) attrs.push("opacity:.62");
|
|
70
|
+
}
|
|
71
|
+
out.push(attrs.length
|
|
72
|
+
? '<span style="' + attrs.join(";") + '">' + escHtml(buf) + "</span>"
|
|
73
|
+
: escHtml(buf));
|
|
74
|
+
buf = "";
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < s.length; ) {
|
|
78
|
+
const ch = s[i];
|
|
79
|
+
if (ch !== "\x1b") { buf += ch; i++; continue; }
|
|
80
|
+
// OSC(标题等):整段丢到终止符
|
|
81
|
+
if (s[i + 1] === "]") {
|
|
82
|
+
let j = i + 2;
|
|
83
|
+
while (j < s.length && s[j] !== "\x07") {
|
|
84
|
+
if (s[j] === "\x1b" && s[j + 1] === "\\") { j += 2; break; }
|
|
85
|
+
j++;
|
|
86
|
+
}
|
|
87
|
+
push();
|
|
88
|
+
i = j + 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// CSI:参数字节 0x30-0x3F(0-9:;<=>?),中间字节 0x20-0x2F,终结字节 0x40-0x7E。
|
|
92
|
+
// 终结判错会把后面的正文整段吞进转义序列(真机 `<script>` 被吃掉的同款 bug)。
|
|
93
|
+
if (s[i + 1] === "[") {
|
|
94
|
+
let j = i + 2;
|
|
95
|
+
while (j < s.length && s.charCodeAt(j) >= 0x30 && s.charCodeAt(j) <= 0x3F) j++;
|
|
96
|
+
const paramEnd = j;
|
|
97
|
+
while (j < s.length && s.charCodeAt(j) >= 0x20 && s.charCodeAt(j) <= 0x2F) j++;
|
|
98
|
+
const code = j < s.length ? s.charCodeAt(j) : -1;
|
|
99
|
+
const isFinal = code >= 0x40 && code <= 0x7E;
|
|
100
|
+
const final = isFinal ? s[j] : null;
|
|
101
|
+
const params = s.slice(i + 2, paramEnd);
|
|
102
|
+
// 残缺序列:跳过 ESC 本身,剩下的当正文(不能整段丢掉用户输出)
|
|
103
|
+
i = isFinal ? j + 1 : i + 1;
|
|
104
|
+
if (final !== "m") continue; // 光标/清屏/擦除等一律丢弃
|
|
105
|
+
push();
|
|
106
|
+
cur = applySgr(cur, params, o);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
push();
|
|
110
|
+
i++;
|
|
111
|
+
}
|
|
112
|
+
push();
|
|
113
|
+
return out.join("");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function applySgr(cur, params, opts) {
|
|
117
|
+
const st = Object.assign({}, cur || {});
|
|
118
|
+
const nums = String(params || "0").split(";").map(x => {
|
|
119
|
+
const n = parseInt(x, 10);
|
|
120
|
+
return isNaN(n) ? 0 : n;
|
|
121
|
+
});
|
|
122
|
+
for (let i = 0; i < nums.length; i++) {
|
|
123
|
+
const n = nums[i];
|
|
124
|
+
if (n === 0) { delete st.bold; delete st.dim; delete st.italic;
|
|
125
|
+
delete st.underline; delete st.fg; delete st.bg;
|
|
126
|
+
delete st.inverse; delete st.invisible; delete st.strike; }
|
|
127
|
+
else if (n === 1) st.bold = true;
|
|
128
|
+
else if (n === 2) st.dim = true;
|
|
129
|
+
else if (n === 3) st.italic = true;
|
|
130
|
+
else if (n === 4) st.underline = true;
|
|
131
|
+
else if (n === 7) st.inverse = true;
|
|
132
|
+
else if (n === 8) st.invisible = true;
|
|
133
|
+
else if (n === 9) st.strike = true;
|
|
134
|
+
else if (n === 22) { delete st.bold; delete st.dim; }
|
|
135
|
+
else if (n === 23) delete st.italic;
|
|
136
|
+
else if (n === 24) delete st.underline;
|
|
137
|
+
else if (n === 27) delete st.inverse;
|
|
138
|
+
else if (n === 28) delete st.invisible;
|
|
139
|
+
else if (n === 29) delete st.strike;
|
|
140
|
+
else if (n === 39) delete st.fg;
|
|
141
|
+
else if (n === 49) delete st.bg;
|
|
142
|
+
else if (n === 38 || n === 48) {
|
|
143
|
+
// 38;5;N / 38;2;r;g;b —— 256 色按灰阶与色立方粗略近似即可,
|
|
144
|
+
// 冻结块是只读历史,不必逐格精确
|
|
145
|
+
const mode = nums[i + 1];
|
|
146
|
+
if (mode === 5) {
|
|
147
|
+
const v = nums[i + 2] | 0;
|
|
148
|
+
const col = v < 16 ? ANSI_FG[v < 8 ? v + 30 : v + 82]
|
|
149
|
+
: v < 232 ? cube256(v) : gray256(v);
|
|
150
|
+
if (n === 38) st.fg = col; else st.bg = col;
|
|
151
|
+
i += 2;
|
|
152
|
+
} else if (mode === 2) {
|
|
153
|
+
const col = "rgb(" + (nums[i + 2] | 0) + "," + (nums[i + 3] | 0) +
|
|
154
|
+
"," + (nums[i + 4] | 0) + ")";
|
|
155
|
+
if (n === 38) st.fg = col; else st.bg = col;
|
|
156
|
+
i += 4;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else if (ANSI_FG[n]) st.fg = ANSI_FG[n];
|
|
160
|
+
else if (ANSI_BG[n]) st.bg = ANSI_BG[n];
|
|
161
|
+
}
|
|
162
|
+
return st;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cube256(v) {
|
|
166
|
+
const idx = v - 16;
|
|
167
|
+
const r = Math.floor(idx / 36), g = Math.floor((idx % 36) / 6), b = idx % 6;
|
|
168
|
+
const f = x => (x === 0 ? 0 : 55 + x * 40);
|
|
169
|
+
return "rgb(" + f(r) + "," + f(g) + "," + f(b) + ")";
|
|
170
|
+
}
|
|
171
|
+
function gray256(v) {
|
|
172
|
+
const g = 8 + (v - 232) * 10;
|
|
173
|
+
return "rgb(" + g + "," + g + "," + g + ")";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 从已解析的 xterm 单元格取快照,而不是重放原始 PTY 控制序列。
|
|
177
|
+
// translateToString 只留文字,会把命令青色、自然语言蓝色和输出本身的颜色洗掉。
|
|
178
|
+
function bufferToAnsi(buffer) {
|
|
179
|
+
const lines = [];
|
|
180
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
181
|
+
const line = buffer.getLine(i);
|
|
182
|
+
if (!line) { lines.push(""); continue; }
|
|
183
|
+
if (typeof line.getCell !== "function") {
|
|
184
|
+
lines.push(line.translateToString(true));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const next = buffer.getLine(i + 1);
|
|
188
|
+
let end = line.length;
|
|
189
|
+
// 双宽字符放不进行末最后一列时,xterm 会留下一个空续位再折行。
|
|
190
|
+
// 它不是用户输入的空格(显式空格 getChars() === " "),合并时不能补进去。
|
|
191
|
+
if (next && next.isWrapped && end > 0 && next.getCell(0)?.getWidth() === 2 &&
|
|
192
|
+
line.getCell(end - 1)?.getChars() === "") end--;
|
|
193
|
+
// 软折行中间的空格是内容;只修剪逻辑行末尾的空白。
|
|
194
|
+
if (!next || !next.isWrapped) {
|
|
195
|
+
while (end > 0) {
|
|
196
|
+
const cell = line.getCell(end - 1);
|
|
197
|
+
if (cell && (cell.getChars().trim() || cell.getWidth() === 0)) break;
|
|
198
|
+
end--;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
let text = "", active = "";
|
|
202
|
+
for (let x = 0; x < end; x++) {
|
|
203
|
+
const cell = line.getCell(x);
|
|
204
|
+
if (!cell || cell.getWidth() === 0) continue; // CJK/emoji 的续格不能再输出一遍
|
|
205
|
+
const codes = [];
|
|
206
|
+
for (const [method, code] of [["isBold", 1], ["isDim", 2], ["isItalic", 3],
|
|
207
|
+
["isUnderline", 4], ["isInverse", 7], ["isInvisible", 8], ["isStrikethrough", 9]]) {
|
|
208
|
+
if (typeof cell[method] === "function" && cell[method]()) codes.push(code);
|
|
209
|
+
}
|
|
210
|
+
for (const [prefix, code] of [["Fg", 38], ["Bg", 48]]) {
|
|
211
|
+
const color = cell["get" + prefix + "Color"]();
|
|
212
|
+
if (cell["is" + prefix + "RGB"]()) {
|
|
213
|
+
codes.push(code, 2, (color >> 16) & 255, (color >> 8) & 255, color & 255);
|
|
214
|
+
} else if (cell["is" + prefix + "Palette"]()) {
|
|
215
|
+
codes.push(code, 5, color);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const style = codes.join(";");
|
|
219
|
+
if (style !== active) {
|
|
220
|
+
text += "\x1b[0m" + (style ? "\x1b[" + style + "m" : "");
|
|
221
|
+
active = style;
|
|
222
|
+
}
|
|
223
|
+
text += cell.getChars() || " ";
|
|
224
|
+
}
|
|
225
|
+
if (active) text += "\x1b[0m";
|
|
226
|
+
if (line.isWrapped && lines.length) lines[lines.length - 1] += text;
|
|
227
|
+
else lines.push(text);
|
|
228
|
+
}
|
|
229
|
+
return lines.join("\n").replace(/\s+$/, "");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── DOM 渲染 ────────────────────────────────────────────
|
|
233
|
+
//
|
|
234
|
+
// sync() 让容器的子节点与块列表一致:按 id 复用节点,只在必要时插入/移动/
|
|
235
|
+
// 删除。移动用 insertBefore,不重建——重建会把 React root 拆了重挂(闪烁)。
|
|
236
|
+
class BlockView {
|
|
237
|
+
/**
|
|
238
|
+
* @param {Element} container 滚动容器
|
|
239
|
+
* @param {object} opts { feed, onUserScroll, follow }
|
|
240
|
+
*/
|
|
241
|
+
constructor(container, opts) {
|
|
242
|
+
this.container = container;
|
|
243
|
+
this.feed = (opts && opts.feed) || null;
|
|
244
|
+
this.onUserScroll = (opts && opts.onUserScroll) || null;
|
|
245
|
+
this.follow = !opts || opts.follow !== false;
|
|
246
|
+
this.nodes = new Map(); // blockId -> element
|
|
247
|
+
this._armed = new Set(); // 已 feed.mount 的卡
|
|
248
|
+
this._bindScroll();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_bindScroll() {
|
|
252
|
+
const c = this.container;
|
|
253
|
+
if (!c || typeof c.addEventListener !== "function") return;
|
|
254
|
+
// 已定决策:跟着滚,但用户一往上翻就停住,不跟用户抢滚动条
|
|
255
|
+
c.addEventListener("scroll", () => {
|
|
256
|
+
if (!this.follow) return;
|
|
257
|
+
if (!this._atBottom()) {
|
|
258
|
+
this.follow = false;
|
|
259
|
+
if (this.onUserScroll) this.onUserScroll(false);
|
|
260
|
+
}
|
|
261
|
+
}, { passive: true });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_atBottom() {
|
|
265
|
+
const c = this.container;
|
|
266
|
+
if (!c) return true;
|
|
267
|
+
return c.scrollHeight - c.scrollTop - c.clientHeight < 24;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 用户点「回到底部」
|
|
271
|
+
resumeFollow() {
|
|
272
|
+
this.follow = true;
|
|
273
|
+
this.scrollToBottom();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
scrollToBottom() {
|
|
277
|
+
const c = this.container;
|
|
278
|
+
if (!c) return;
|
|
279
|
+
c.scrollTop = c.scrollHeight;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 与块列表对齐。返回本次是否有节点移动(调用方据此决定要不要重算滚动)。
|
|
283
|
+
sync(blocks, opts) {
|
|
284
|
+
const c = this.container;
|
|
285
|
+
if (!c) return false;
|
|
286
|
+
const want = blocks || [];
|
|
287
|
+
const liveId = opts && opts.liveId;
|
|
288
|
+
let moved = false;
|
|
289
|
+
|
|
290
|
+
for (let i = 0; i < want.length; i++) {
|
|
291
|
+
const b = want[i];
|
|
292
|
+
let el = this.nodes.get(b.id);
|
|
293
|
+
if (!el) {
|
|
294
|
+
el = this._create(b);
|
|
295
|
+
this.nodes.set(b.id, el);
|
|
296
|
+
moved = true;
|
|
297
|
+
}
|
|
298
|
+
this._paint(b, el, b.id === liveId);
|
|
299
|
+
const at = c.children[i];
|
|
300
|
+
if (at !== el) {
|
|
301
|
+
c.insertBefore(el, at || null);
|
|
302
|
+
moved = true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// 多出来的节点删掉(已从块流移除的)
|
|
306
|
+
while (c.children.length > want.length) {
|
|
307
|
+
const dead = c.children[c.children.length - 1];
|
|
308
|
+
this._destroy(dead);
|
|
309
|
+
c.removeChild(dead);
|
|
310
|
+
moved = true;
|
|
311
|
+
}
|
|
312
|
+
if (this.follow) this.scrollToBottom();
|
|
313
|
+
return moved;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
_create(b) {
|
|
317
|
+
const el = this.container.ownerDocument.createElement("div");
|
|
318
|
+
el.className = "ot-blk ot-blk-" + b.kind;
|
|
319
|
+
el.dataset.blockId = b.id;
|
|
320
|
+
el.dataset.kind = b.kind;
|
|
321
|
+
if (b.kind === "card") {
|
|
322
|
+
// 卡片块:给 React island 一个普通宿主,不是 xterm decoration
|
|
323
|
+
const host = this.container.ownerDocument.createElement("div");
|
|
324
|
+
host.className = "ot-card-host ot-flow-host";
|
|
325
|
+
el.appendChild(host);
|
|
326
|
+
el._host = host;
|
|
327
|
+
} else {
|
|
328
|
+
const body = this.container.ownerDocument.createElement("div");
|
|
329
|
+
body.className = "ot-blk-body";
|
|
330
|
+
el.appendChild(body);
|
|
331
|
+
el._body = body;
|
|
332
|
+
}
|
|
333
|
+
return el;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
_paint(b, el, isLive) {
|
|
337
|
+
el.classList.toggle("ot-live", !!isLive);
|
|
338
|
+
if (b.kind === "card") {
|
|
339
|
+
const host = el._host;
|
|
340
|
+
if (host && this.feed && !this._armed.has(b.id)) {
|
|
341
|
+
try { this.feed.mount(b.id, host); } catch (e) { /* island 缺席不挡排版 */ }
|
|
342
|
+
this._armed.add(b.id);
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const body = el._body;
|
|
347
|
+
if (!body) return;
|
|
348
|
+
const text = (b.payload && (b.payload.text != null ? b.payload.text : b.payload.output)) || "";
|
|
349
|
+
// 重画是幂等的:内容没变就不动 DOM(滚动条/选区都不被打扰)
|
|
350
|
+
if (el._text !== text) {
|
|
351
|
+
el._text = text;
|
|
352
|
+
body.innerHTML = ansiToHtml(text);
|
|
353
|
+
}
|
|
354
|
+
// 过渡段用紫色左边线标注,内容照常(不是空框)
|
|
355
|
+
el.classList.toggle("ot-transition", b.kind === "transition");
|
|
356
|
+
el.classList.toggle("ot-banner", b.kind === "banner");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
_destroy(el) {
|
|
360
|
+
const id = el && el.dataset ? el.dataset.blockId : null;
|
|
361
|
+
if (id) {
|
|
362
|
+
if (this.feed && this._armed.has(id)) {
|
|
363
|
+
try { this.feed.unmount(id); } catch (e) {}
|
|
364
|
+
}
|
|
365
|
+
this._armed.delete(id);
|
|
366
|
+
this.nodes.delete(id);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
destroy() {
|
|
371
|
+
const c = this.container;
|
|
372
|
+
if (c) while (c.firstChild) { this._destroy(c.firstChild); c.removeChild(c.firstChild); }
|
|
373
|
+
this.nodes.clear();
|
|
374
|
+
this._armed.clear();
|
|
375
|
+
if (this.feed && typeof this.feed.destroy === "function") {
|
|
376
|
+
try { this.feed.destroy(); } catch (e) {}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
382
|
+
module.exports = { BlockView, ansiToHtml, stripAnsi, escHtml, bufferToAnsi };
|
|
383
|
+
}
|