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,3632 @@
|
|
|
1
|
+
// OpenTerminal Web 前端:左侧服务器列表 + 右侧多 tab 终端。
|
|
2
|
+
// 单管线架构(docs/design-single-pipeline.md):一个全屏连续 xterm 是唯一渲染
|
|
3
|
+
// 管线,PTY 字节直写(SSH/Agent 统一)——Tab 补全、历史、Ctrl+R、vim/top、
|
|
4
|
+
// 嵌套 shell、clear 全部走终端原生;AI 卡片以 React island 分区挂载(阿里
|
|
5
|
+
// workbench 同款):卡片占 pad 打孔预留的空白行,输出永远从卡片下方流走。
|
|
6
|
+
// 分析卡(流式)、总结卡(mdLite 表格)、审批卡(按钮/编辑/快捷键)。
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
// HTML 转义(与 md_lite.js 的 esc 同实现;island 的 md_lite 打包进了 ui.js,
|
|
10
|
+
// index.html 不再全局装载 md_lite.js,这里本地补一份给 modal/tab 等用)
|
|
11
|
+
function esc(s) {
|
|
12
|
+
return String(s == null ? "" : s).replace(/[&<>"']/g, c => (
|
|
13
|
+
{"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// otdbg 调试仪表:localStorage.otdbg 置位才输出(生产零成本)。
|
|
17
|
+
// 用 performance.now() 打时间戳——真机排「卡顿/回显迟到」必须能和 WS 帧
|
|
18
|
+
// 到达、term.write 对齐到同一时钟。
|
|
19
|
+
function otdbg(...a) {
|
|
20
|
+
if (typeof localStorage !== "undefined" && localStorage.getItem("otdbg"))
|
|
21
|
+
console.log("[otdbg]", performance.now().toFixed(1), ...a);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sessions = {}; // tabId -> Session
|
|
25
|
+
let activeTabId = null;
|
|
26
|
+
// mac 用 Cmd+C/Cmd+V 原生路径;Windows/Linux 的裸 Ctrl+C/Ctrl+V 需特殊路由
|
|
27
|
+
const IS_APPLE = /Mac|iPhone|iPad/.test(navigator.userAgent);
|
|
28
|
+
let targetsData = null; // /api/targets 最近一次返回,供搜索重渲染
|
|
29
|
+
let searchQuery = "";
|
|
30
|
+
// --- 装饰卡落网格用的 ANSI 片段 ---
|
|
31
|
+
|
|
32
|
+
// --- token(局域网访问 `ot web --host 0.0.0.0 --token X` 时使用)---
|
|
33
|
+
function getToken() {
|
|
34
|
+
return sessionStorage.getItem("ot-token") || "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setToken(v) {
|
|
38
|
+
if (v) sessionStorage.setItem("ot-token", v);
|
|
39
|
+
else sessionStorage.removeItem("ot-token");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function promptForToken() {
|
|
43
|
+
return new Promise(resolve => {
|
|
44
|
+
modalShell("访问令牌", `<input type="password" id="modal-value" autofocus>`, [
|
|
45
|
+
{label: "确定", primary: true, onClick: () => {
|
|
46
|
+
setToken(document.getElementById("modal-value").value);
|
|
47
|
+
resolve();
|
|
48
|
+
return true; // close
|
|
49
|
+
}},
|
|
50
|
+
]);
|
|
51
|
+
document.getElementById("modal-value").focus();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 带 token 的 fetch:**只有真的是令牌问题**才问 token 再重试。
|
|
56
|
+
// 401 不等于令牌失效——业务错误(如级联密码错误)也可能用它,误弹「访问令牌」
|
|
57
|
+
// 会让用户莫名其妙(真机反馈:点 📁 被要求填访问令牌)。判据:响应体带
|
|
58
|
+
// 业务 code(unauthorized 除外)就不是令牌问题,原样交回调用方。
|
|
59
|
+
function authFetch(url, opts = {}) {
|
|
60
|
+
opts.headers = Object.assign({"Content-Type": "application/json"}, opts.headers || {});
|
|
61
|
+
const t = getToken();
|
|
62
|
+
if (t) opts.headers["x-ot-token"] = t;
|
|
63
|
+
return fetch(url, opts).then(async r => {
|
|
64
|
+
if (r.status === 401 && !opts._retried && (await _isTokenProblem(r))) {
|
|
65
|
+
await promptForToken();
|
|
66
|
+
const o2 = Object.assign({}, opts, {_retried: true});
|
|
67
|
+
const t2 = getToken();
|
|
68
|
+
if (t2) o2.headers["x-ot-token"] = t2;
|
|
69
|
+
return fetch(url, o2);
|
|
70
|
+
}
|
|
71
|
+
return r;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function _isTokenProblem(r) {
|
|
76
|
+
try {
|
|
77
|
+
const j = await r.clone().json();
|
|
78
|
+
return !(j && j.code && j.code !== "unauthorized");
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return true; // 读不出 body:按令牌问题处理(旧行为)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function wsUrl(tabId) {
|
|
85
|
+
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
86
|
+
let url = `${proto}://${location.host}/ws/${tabId}`;
|
|
87
|
+
const t = getToken();
|
|
88
|
+
if (t) url += `?token=${encodeURIComponent(t)}`;
|
|
89
|
+
return url;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 后端代关陈旧密码模态(密码提示符已被终端手输消费):只关带 pw 标记的
|
|
93
|
+
// 密码模态,审批/主机密钥等其他模态不受影响
|
|
94
|
+
function modalDismissPassword() {
|
|
95
|
+
const root = document.getElementById("modal-root");
|
|
96
|
+
if (root && root.dataset.pw) {
|
|
97
|
+
delete root.dataset.pw;
|
|
98
|
+
root.innerHTML = "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function modalConfirm(title, message, onOk, yesLabel = "信任", noLabel = "拒绝") {
|
|
103
|
+
modalShell(title, `<p>${esc(message)}</p>`, [
|
|
104
|
+
{label: yesLabel, primary: true, onClick: () => onOk(true)},
|
|
105
|
+
{label: noLabel, onClick: () => onOk(false)},
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- 单个会话:唯一全屏 xterm(PTY 字节直写)+ 卡片层 React island(纯 overlay)---
|
|
110
|
+
class Session {
|
|
111
|
+
constructor(tabId, name) {
|
|
112
|
+
this.tabId = tabId;
|
|
113
|
+
this.name = name;
|
|
114
|
+
this.baseName = name; // 原始目标名(保留:未来 Tab 标签动态化用)
|
|
115
|
+
this.mode = "agent"; // 'ssh' | 'agent'(仅切换后端 hook 开关,显示不再分叉)
|
|
116
|
+
this._lineBuf = ""; // 半行缓冲(外部分类触发链,见 _feedLineBuf)
|
|
117
|
+
this._lineCur = 0; // 半行镜像光标位(行内编辑同步用)
|
|
118
|
+
this._lineDirty = false; // 缓冲被无法镜像的转义/控制键清过(提交时随 submit 上报)
|
|
119
|
+
this.status = "connecting"; // connecting | connected | closed
|
|
120
|
+
this.statusText = ""; // 连接中文案由 status-connect 徽标(旋转+循环点)承担
|
|
121
|
+
this.ws = null;
|
|
122
|
+
this._clip = ""; // 页内最近复制的文本(http 局域网无剪贴板读权限时的粘贴兜底)
|
|
123
|
+
this._toolMountQ = []; // 待挂工具卡(串行弹出,见 _pumpToolMounts)
|
|
124
|
+
this._toolMounting = false;
|
|
125
|
+
this._feed = null; // React island 句柄 {handle, mount, unmount, destroy}
|
|
126
|
+
this._feedPromise = null; // island 懒加载 Promise(失败置空可重试)
|
|
127
|
+
this._lastSummaryMd = null; // final/ai_card 双发同文的前端去重
|
|
128
|
+
this._padQ = Promise.resolve(); // pad 请求串行队列(应答不串位)
|
|
129
|
+
this._padWait = null; // 在途 pad 请求的应答 resolver
|
|
130
|
+
this._padSlot = null; // 在途 pad 请求归属的卡
|
|
131
|
+
this._slots = new Map(); // cardId -> Slot
|
|
132
|
+
this._cardSeq = 0; // 卡号计数(island 单卡 root 的 key)
|
|
133
|
+
this._cardReady = Promise.resolve(); // 挂载串行链:卡 handle 先于 mount,出生即完整
|
|
134
|
+
this._cardsText = new Map();// cardId -> 纯文本(_fallbackPlain 兜底用)
|
|
135
|
+
this._padFailStreak = 0; // 连续超时计数(≥2 熔断)
|
|
136
|
+
this._padFused = false; // 熔断:本轮禁用补差,只夹紧
|
|
137
|
+
this._healT = null; // idle 自愈 debounce
|
|
138
|
+
this._remeasurePending = false; // 备用缓冲期推迟重测,回主缓冲再补
|
|
139
|
+
this._lastByteAt = 0; // 最近 PTY 字节到达时间(开区前等流沉降)
|
|
140
|
+
this._lastMsgAt = 0; // 最近任意服务端帧(字节/JSON/心跳)到达时间
|
|
141
|
+
this._lastSendAt = 0; // 最近一次上行(按键/JSON)时间——看门狗判无响应用
|
|
142
|
+
this._holdSince = 0; // 扣流持续起点(泄漏 watchdog:超阈强制放行)
|
|
143
|
+
this._holdDepth = 0; // 字节扣流嵌套深度(welcome + card 临界区)
|
|
144
|
+
this._holdBuf = null; // 扣住的 PTY 字节队列
|
|
145
|
+
this._welcomeHold = null; // 连接横幅扣留态(先横幅后提示符,见 _holdWelcome)
|
|
146
|
+
this._welcomeTimer = null; // 扣留到点自放行的定时器(见 _armWelcomeDeadline)
|
|
147
|
+
this._taskActive = false; // 任务进行中(task_start → final/denied/limit/error)
|
|
148
|
+
this._approvalOpen = false; // 有未决策审批(球在用户侧,不显思考徽标)
|
|
149
|
+
this._approvalCmd = null; // 未决审批的命令(回执/回退显示用)
|
|
150
|
+
this._approvalKey = null; // 去重键 (卡 id, 命令串):同串并行命令不互挡
|
|
151
|
+
this._toolCards = new Map(); // ai_tool id(数字 tool_card_seq) → 卡 id(类型统一)
|
|
152
|
+
this._approvalGraceMs = 6000; // 审批卡可点性看门狗:fund 未到限内转模态兜底
|
|
153
|
+
// (挂载链含 pad 串行,慢机 3s 不够——审批先到卡还在挂载=误转模态)
|
|
154
|
+
this._approvalResolved = false; // 本令决策已发:看门狗/模态不得再发第二发
|
|
155
|
+
this._runningTool = ""; // 在跑的工具(徽标说人话:执行中: docker ps)
|
|
156
|
+
this._timer = null; // 任务计时 setInterval id
|
|
157
|
+
this._taskStart = 0; // 最近一次任务开始时间戳
|
|
158
|
+
this._csTimer = 0; // cmdset 完成提示的消隐定时器
|
|
159
|
+
// Agent 空提示符占位提示(幽灵文本)状态
|
|
160
|
+
this._ghostHide = false; // 有输入痕迹(可打印键/历史召回):藏到新提示符安静
|
|
161
|
+
this._ghostQuietUntil = 0; // 回车/控制键后的重显静默窗
|
|
162
|
+
this._ghostDec = null;
|
|
163
|
+
this._ghostMarker = null;
|
|
164
|
+
this._ghostKey = "";
|
|
165
|
+
this.interactive = false; // shell 集成就绪:AI 功能可用;false=纯终端回退(隐藏模型信息)
|
|
166
|
+
this._dead = false; // 会话已关闭:停止一切写终端
|
|
167
|
+
this._buildDom();
|
|
168
|
+
this._buildTerm();
|
|
169
|
+
// 监听可视区而非终端内容:卡片增多只增加滚动长度,不递归撑高终端。
|
|
170
|
+
const stage = this.paneEl.querySelector(".ot-stage");
|
|
171
|
+
if (stage && typeof ResizeObserver !== "undefined") {
|
|
172
|
+
this._layoutObserver = new ResizeObserver(() => {
|
|
173
|
+
cancelAnimationFrame(this._layoutFrame);
|
|
174
|
+
this._layoutFrame = requestAnimationFrame(() => {
|
|
175
|
+
if (!this._dead) this.fit();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
this._layoutObserver.observe(stage);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_buildDom() {
|
|
183
|
+
const tabBtn = document.createElement("button");
|
|
184
|
+
tabBtn.className = "tab";
|
|
185
|
+
tabBtn.id = `tabbtn-${this.tabId}`;
|
|
186
|
+
tabBtn.innerHTML = `<span class="dot tab-dot connecting" title="连接状态"></span><span class="tab-name">${esc(this.name)}</span><span class="tab-x" title="关闭">×</span>`;
|
|
187
|
+
tabBtn.querySelector(".tab-name").onclick = () => activateTab(this.tabId);
|
|
188
|
+
tabBtn.querySelector(".tab-x").onclick = ev => { ev.stopPropagation(); closeTab(this.tabId); };
|
|
189
|
+
document.getElementById("tabs").appendChild(tabBtn);
|
|
190
|
+
this.tabEl = tabBtn;
|
|
191
|
+
|
|
192
|
+
const pane = document.createElement("div");
|
|
193
|
+
pane.className = "pane";
|
|
194
|
+
pane.id = `pane-${this.tabId}`;
|
|
195
|
+
pane.innerHTML = `
|
|
196
|
+
<div class="pane-head">
|
|
197
|
+
<div class="seg" role="tablist">
|
|
198
|
+
<button data-mode="ssh">Shell</button>
|
|
199
|
+
<button data-mode="agent" class="on">Agent <kbd>Ctrl+Shift+I</kbd></button>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="pane-host" id="host-${this.tabId}"></div>
|
|
202
|
+
<button class="files-toggle" id="filesbtn-${this.tabId}" tabindex="-1" title="远程文件:登录该主机的家目录(上传/下载)">📁 文件</button>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="ot-stage" id="stage-${this.tabId}">
|
|
205
|
+
<div class="ot-blocks" id="blocks-${this.tabId}"></div>
|
|
206
|
+
<div class="term" id="term-${this.tabId}"></div>
|
|
207
|
+
</div>
|
|
208
|
+
<div class="statusbar">
|
|
209
|
+
<span class="status-connect" id="conn-${this.tabId}" title="正在建立连接"><i class="cspin"></i><span>连接中</span><span class="cdots"></span></span>
|
|
210
|
+
<span class="status-think" id="think-${this.tabId}" hidden title="任务未结束:AI 正在分析或执行"><i class="tspin"></i><span class="ttext">AI 正在思考</span><span class="tdots"></span></span>
|
|
211
|
+
<span class="status-text" id="status-${this.tabId}"></span>
|
|
212
|
+
<span class="status-meta">
|
|
213
|
+
<span class="status-cmdset" id="cs-${this.tabId}"></span>
|
|
214
|
+
<button class="status-newtask" id="newtask-${this.tabId}" tabindex="-1" title="开启新会话(/clear):清掉本轮 AI 卡片与任务上下文">新会话</button>
|
|
215
|
+
<button class="status-stop" id="stop-${this.tabId}" hidden tabindex="-1" title="停止当前任务(Ctrl+C)">⏹</button>
|
|
216
|
+
<span class="status-tokens" id="tokens-${this.tabId}" title="累计 token 消耗(输入/输出)"></span>
|
|
217
|
+
<select class="status-model" id="model-${this.tabId}" tabindex="-1" title="切换模型"></select>
|
|
218
|
+
<span class="status-time" id="time-${this.tabId}" title="任务耗时"></span>
|
|
219
|
+
<span class="status-size" id="size-${this.tabId}"></span>
|
|
220
|
+
</span>
|
|
221
|
+
</div>`;
|
|
222
|
+
document.getElementById("panes").appendChild(pane);
|
|
223
|
+
this.paneEl = pane;
|
|
224
|
+
|
|
225
|
+
this.filesPanel = new FilePanel(this);
|
|
226
|
+
document.getElementById(`filesbtn-${this.tabId}`).onclick = () => this.filesPanel.toggle();
|
|
227
|
+
|
|
228
|
+
pane.querySelectorAll(".seg button").forEach(btn => {
|
|
229
|
+
btn.onclick = () => this.setMode(btn.dataset.mode);
|
|
230
|
+
});
|
|
231
|
+
// 点 pane 任意处锁定终端光标(像真实终端):xterm 只处理自己的屏幕区域,
|
|
232
|
+
// 点边距/状态栏时焦点会丢到 body,键盘输入全部失效。
|
|
233
|
+
// 可交互元素(按钮/输入框/审批编辑器)不抢——它们有自己的焦点语义。
|
|
234
|
+
// 划选文字松开鼠标同样会触发 click(click 在 mouseup 后):存在未折叠
|
|
235
|
+
// 选区时说明刚完成一次划选,不抢焦点。
|
|
236
|
+
pane.addEventListener("click", ev => {
|
|
237
|
+
if (ev.target.closest("button, input, textarea, select, a, .modal")) return;
|
|
238
|
+
const sel = window.getSelection();
|
|
239
|
+
if (sel && !sel.isCollapsed && String(sel)) return;
|
|
240
|
+
this.focusCursor();
|
|
241
|
+
});
|
|
242
|
+
// 右键菜单:复制选中 / 粘贴到终端(输入类元素用浏览器原生菜单)
|
|
243
|
+
pane.addEventListener("contextmenu", ev => {
|
|
244
|
+
if (ev.target.closest("input, textarea, button, select, a, .modal")) return;
|
|
245
|
+
ev.preventDefault();
|
|
246
|
+
this._showCtxMenu(ev.clientX, ev.clientY);
|
|
247
|
+
});
|
|
248
|
+
pane.querySelector(`#stop-${this.tabId}`).onclick = () => {
|
|
249
|
+
this.sendJson({type: "interrupt"});
|
|
250
|
+
};
|
|
251
|
+
pane.querySelector(`#newtask-${this.tabId}`).onclick = () => {
|
|
252
|
+
// 单管线:等价于用户在提示符敲入 /clear(hook 拦截重置后端对话)
|
|
253
|
+
this._sendInput("/clear\r");
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_buildTerm() {
|
|
258
|
+
// 单管线:唯一渲染实例,SSH 与 Agent 模式共用(命令/AI 输出全在网格里)
|
|
259
|
+
const term = new Terminal({
|
|
260
|
+
cursorBlink: true, fontSize: 13, scrollback: 5000, convertEol: true,
|
|
261
|
+
// registerMarker(卡片层锚定)在 xterm 5+ 为稳定 API;
|
|
262
|
+
// allowProposedApi 保留以防后续接入装饰类扩展
|
|
263
|
+
allowProposedApi: true,
|
|
264
|
+
// xterm 不认 "var(--...)" 字面量,须传解析后的真实色值;
|
|
265
|
+
// 前景对齐 main 分支的正文白 #e6e8ea(提示符/输出不再发灰)
|
|
266
|
+
theme: {background: getComputedStyle(document.documentElement)
|
|
267
|
+
.getPropertyValue("--term-bg").trim() || "#0d1117",
|
|
268
|
+
foreground: getComputedStyle(document.documentElement)
|
|
269
|
+
.getPropertyValue("--text").trim() || "#e6e8ea"},
|
|
270
|
+
});
|
|
271
|
+
this.term = term;
|
|
272
|
+
this.fitAddon = new FitAddon.FitAddon();
|
|
273
|
+
term.loadAddon(this.fitAddon);
|
|
274
|
+
term.open(this.paneEl.querySelector(`#term-${this.tabId}`));
|
|
275
|
+
// 连接中浮层:盖在终端区中央,两阶段文案(连接中 → 初始化 Agent,由
|
|
276
|
+
// worker 的 stage 事件切换),ready/closed/error 即撤(setStatus 统一
|
|
277
|
+
// 切换);须在 term.open 之后插入,xterm 接管容器
|
|
278
|
+
const termEl = this.paneEl.querySelector(`#term-${this.tabId}`);
|
|
279
|
+
termEl.insertAdjacentHTML("beforeend",
|
|
280
|
+
`<div class="connect-overlay" id="connov-${this.tabId}">` +
|
|
281
|
+
`<i class="cspin"></i><span class="ctext">连接中</span>` +
|
|
282
|
+
`<span class="cdots"></span></div>`);
|
|
283
|
+
// 键盘字节直发 PTY(契约:input 保留)。外部分类触发链:agent 模式下
|
|
284
|
+
// 整行带自然语言特征(CJK/? 前缀)时不发回车,改送 submit 让 worker 直接
|
|
285
|
+
// 起任务——触发不再依赖 shell hook(sudo su - 等 login shell 重置后仍可用);
|
|
286
|
+
// 其余按键原样透传,命令执行/英文自然语言仍由 shell hook 分类(行为不变)。
|
|
287
|
+
// 全屏应用(vim/htop 走 alternate buffer)不拦截,Enter 照常送达。
|
|
288
|
+
term.onData(d => {
|
|
289
|
+
// WS 未就绪(连接中/重连间隙)不丢键:缓存待开连后按序回放。旧实现
|
|
290
|
+
// 静默丢弃——刚开页就打字时前几个 IME 提交整段消失(「整行只剩尾巴」
|
|
291
|
+
// 嫌疑之一;回放走 _feedData 同一条 submit 拦截链,语义不旁路)
|
|
292
|
+
if (!this.ws || this.ws.readyState !== 1) {
|
|
293
|
+
this._keyBacklog = this._keyBacklog || [];
|
|
294
|
+
if (this._keyBacklog.length < 64) this._keyBacklog.push(d);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this._feedData(d);
|
|
298
|
+
});
|
|
299
|
+
term.onResize(({cols, rows}) => {
|
|
300
|
+
this.sendJson({type: "resize", cols, rows});
|
|
301
|
+
const el = document.getElementById(`size-${this.tabId}`);
|
|
302
|
+
if (el) el.textContent = `${cols}×${rows}`;
|
|
303
|
+
this._remeasureCards();
|
|
304
|
+
});
|
|
305
|
+
// 终端级渲染钩子:decoration 的 onRender 不保证滚动后每帧路过(真机实测
|
|
306
|
+
// 旧 top 悬 2.5s+、盒体整体下移压回显——19geo7 的 approval cover 类)。
|
|
307
|
+
// term.onRender 每次渲染 pass(含滚动驱动)都触发;top 只随 viewportY/
|
|
308
|
+
// 缓冲行数失效,二者没变直接返回,不逐帧量空白。
|
|
309
|
+
if (term.onRender) {
|
|
310
|
+
let lastVp = -1, lastLen = -1;
|
|
311
|
+
term.onRender(() => {
|
|
312
|
+
if (this._dead || !this._slots) return;
|
|
313
|
+
try {
|
|
314
|
+
const b = this.term.buffer.active;
|
|
315
|
+
if (b.viewportY === lastVp && b.length === lastLen) return;
|
|
316
|
+
lastVp = b.viewportY; lastLen = b.length;
|
|
317
|
+
for (const sl of this._slots.values()) {
|
|
318
|
+
if (sl.marker && sl.host) this._fixHostVisibility(sl, sl.host);
|
|
319
|
+
}
|
|
320
|
+
} catch (e) {}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (this.term.buffer && this.term.buffer.onBufferChange) {
|
|
324
|
+
this.term.buffer.onBufferChange(() => {
|
|
325
|
+
if (this._remeasurePending) this._remeasureCards();
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
// Cmd/Ctrl+Shift+C 有选中时复制(无选中时 Ctrl+C 照常发 ^C 中断)。
|
|
329
|
+
// attachCustomKeyEventHandler 只有一个槽位,多个逻辑必须合并在一个 handler 里
|
|
330
|
+
term.attachCustomKeyEventHandler(ev => {
|
|
331
|
+
// 卡片 island 的 DOM 选区优先原生复制:返回 false 让 xterm 不吞键
|
|
332
|
+
// (否则 Cmd/Ctrl+C 会变成 ^C 发进 PTY,中断正在跑的命令)
|
|
333
|
+
if (ev.type === "keydown" && (ev.metaKey || ev.ctrlKey) &&
|
|
334
|
+
ev.key.toLowerCase() === "c") {
|
|
335
|
+
const sel = window.getSelection ? window.getSelection() : null;
|
|
336
|
+
if (sel && !sel.isCollapsed) return false;
|
|
337
|
+
}
|
|
338
|
+
// 复制:mac=Cmd+C(原样);Win/Linux=Ctrl+C 终端有选区时复制(无选中
|
|
339
|
+
// 不拦,照发 ^C 中断)与 Ctrl+Shift+C。Windows 习惯选区后 Ctrl+C 复制,
|
|
340
|
+
// 旧行为把 Ctrl+C 一律译成 ^C——选区被中断、复制永不发生,粘贴自然
|
|
341
|
+
// 无物可粘(真机「复制后 Ctrl+V 无效」的复制半边根因)
|
|
342
|
+
if (ev.type === "keydown" &&
|
|
343
|
+
(ev.metaKey || (ev.ctrlKey && ev.shiftKey) ||
|
|
344
|
+
(ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && !IS_APPLE)) &&
|
|
345
|
+
(ev.key === "c" || ev.key === "C" || ev.keyCode === 67) &&
|
|
346
|
+
term.hasSelection()) {
|
|
347
|
+
this._copyText(term.getSelection());
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
// Windows/Linux 裸 Ctrl+V 粘贴:xterm 会把 Ctrl+V 译成 \x16(quoted-insert)
|
|
351
|
+
// 发进 PTY 并 cancel 事件,浏览器原生 paste 永不发生(真机 Windows 粘贴
|
|
352
|
+
// 无效根因)。返回 false 让 xterm 跳过该键且不 cancel,默认动作产生的
|
|
353
|
+
// paste 事件由 xterm 挂在 textarea/element 上的监听接管送进 PTY——
|
|
354
|
+
// http 局域网下也走得通,不依赖 clipboard.readText 权限。keyCode 86 兜底
|
|
355
|
+
// 中文 IME 下 key 报 "Process" 的机型。mac 的 Cmd+V 原生路径不受影响。
|
|
356
|
+
if (ev.type === "keydown" && !IS_APPLE && ev.ctrlKey && !ev.shiftKey &&
|
|
357
|
+
!ev.altKey && !ev.metaKey && !ev.isComposing &&
|
|
358
|
+
(ev.key === "v" || ev.key === "V" || ev.keyCode === 86)) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
// 审批快捷键必须在 xterm 的这个唯一钩子里路由:终端持有焦点时
|
|
362
|
+
// xterm 会处理并吞掉 Enter/Backspace(不冒泡到 document 监听);
|
|
363
|
+
// 消费后返回 false 阻止 xterm 把按键继续发给 PTY
|
|
364
|
+
if (ev.type === "keydown" && this.approvalKeys(ev)) return false;
|
|
365
|
+
return true;
|
|
366
|
+
});
|
|
367
|
+
// 占位提示驱动:低频轮询足够(显示条件全是廉价缓冲读)
|
|
368
|
+
this._ghostTimer = setInterval(() => this._updateGhost(), 250);
|
|
369
|
+
// 连接/扣流看门狗:服务端心跳 10s 一帧,输入无响应 8s 即判死重连;
|
|
370
|
+
// 扣流超 10s 未归零=临界区泄漏,强制放行复活回显(真机「卡死无法输入」)
|
|
371
|
+
this._wdTimer = setInterval(() => this._watchdog(), 5000);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
_watchdog() {
|
|
375
|
+
if (this._dead) return;
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
if ((this._holdDepth || 0) > 0 && this._holdSince &&
|
|
378
|
+
now - this._holdSince > 10000) {
|
|
379
|
+
console.warn("[ot] 扣流泄漏强制放行:", (this._holdTags || []).join(","));
|
|
380
|
+
this._holdDepth = 1;
|
|
381
|
+
this._releaseBytes();
|
|
382
|
+
}
|
|
383
|
+
const ws = this.ws;
|
|
384
|
+
if (!ws || ws.readyState !== 1) return;
|
|
385
|
+
// 发过东西却久无回音(回显/事件/心跳全断):传输层可能假活(服务端
|
|
386
|
+
// sender 卡死/sink 僵尸),等 onclose 永不触发——主动close走退避重连
|
|
387
|
+
if (this._lastSendAt > this._lastMsgAt &&
|
|
388
|
+
now - this._lastSendAt > 8000 && now - this._lastMsgAt > 15000) {
|
|
389
|
+
console.warn("[ot] 连接无响应,主动重连");
|
|
390
|
+
this.setStatus("closed", "连接无响应,正在重连");
|
|
391
|
+
this._failPads();
|
|
392
|
+
try { ws.close(); } catch (e) { this.connectWS(); }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- 密码内联捕获(真实终端 ssh 讨密码语义)---
|
|
397
|
+
// 提示符落屏前光标不在行首才补换行(空终端首次讨密码不甩首行空白)
|
|
398
|
+
_pwNewline() {
|
|
399
|
+
try {
|
|
400
|
+
const b = this.term.buffer.active;
|
|
401
|
+
if (b.type === "normal" && b.cursorX > 0) this._writeTerm("\r\n");
|
|
402
|
+
} catch (e) {}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 无回显收集:退格删末字符(无回显、无 ^H,远端 echo 已关),回车提交
|
|
406
|
+
// (空密码照发——与真实终端一致,远端失败后重讨),Ctrl+C 取消整个连接
|
|
407
|
+
// 尝试(password_cancel → 后端中止认证等待)。方向键等转义序列整体跳过
|
|
408
|
+
// (只滤 ESC 本体会把 CSI 的参数字符当口令收进去),其余控制键忽略。
|
|
409
|
+
_pwFeed(d) {
|
|
410
|
+
const cap = this._pwCapture;
|
|
411
|
+
const sc = Array.from(d);
|
|
412
|
+
for (let i = 0; i < sc.length; ) {
|
|
413
|
+
const ch = sc[i];
|
|
414
|
+
if (ch === "\r") {
|
|
415
|
+
const text = cap.buf;
|
|
416
|
+
cap.buf = "";
|
|
417
|
+
cap.submitted = true;
|
|
418
|
+
// 留档待问:ready 后凭它弹「是否记住密码」(错密会在重讨时被覆盖)
|
|
419
|
+
this._pwEntered = text;
|
|
420
|
+
this._writeTerm("\r\n");
|
|
421
|
+
this.sendJson({type: "auth", auth_kind: cap.kind, text, remember: false});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (ch === "\x1b") {
|
|
425
|
+
// CSI:参数字节 0x20–0x3F 后接终字节 0x40–0x7E;SS3:ESC O + 一字节
|
|
426
|
+
if (sc[i + 1] === "[" || sc[i + 1] === "O") {
|
|
427
|
+
i += 2;
|
|
428
|
+
while (i < sc.length && (sc[i] < "@" || sc[i] > "~")) i++;
|
|
429
|
+
i++; // 跳过终字节
|
|
430
|
+
} else {
|
|
431
|
+
i += 2; // 两字节转义(ESC x)
|
|
432
|
+
}
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (ch === "\x7f" || ch === "\x08") {
|
|
436
|
+
if (cap.buf) cap.buf = cap.buf.slice(0, -1);
|
|
437
|
+
i++;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (ch === "\x03") {
|
|
441
|
+
this._writeTerm("^C\r\n");
|
|
442
|
+
this._pwCapture = null;
|
|
443
|
+
this._pwEntered = null;
|
|
444
|
+
this.sendJson({type: "auth", auth_kind: "password_cancel", text: ""});
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (ch >= " ") cap.buf += ch;
|
|
448
|
+
i++;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// onData 主体(WS 就绪后走这里):submit 拦截链 + 按键透传
|
|
453
|
+
_feedData(d) {
|
|
454
|
+
// 密码内联捕获(初始连接讨密码):按键不进 submit 拦截链、不发 PTY
|
|
455
|
+
if (this._pwCapture && !this._pwCapture.submitted) {
|
|
456
|
+
this._pwFeed(d);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (typeof localStorage !== "undefined" && localStorage.getItem("otdbg"))
|
|
460
|
+
console.log("[ot onData]", d.length, JSON.stringify(d.slice(0, 60)));
|
|
461
|
+
// 占位提示追踪:任何非控制字节=有输入痕迹,立即藏——可打印键之外,
|
|
462
|
+
// 中文 IME 上屏是整串 CJK 一次 onData(旧判据只认单字符,打完一整行
|
|
463
|
+
// 灰字占位还贴在输入后面,真机),↑ 历史召回的方向键序列 \x1b[A 同理
|
|
464
|
+
// 漏判;回车/纯控制键(中断/清屏等)后开静默窗,新提示符安静下来重显
|
|
465
|
+
if (this._ghostTrace(d)) { this._ghostHide = true; this._hideGhost(); }
|
|
466
|
+
else { this._ghostHide = false; this._ghostQuietUntil = Date.now() + 800; }
|
|
467
|
+
const enc = new TextEncoder();
|
|
468
|
+
let rest = d, out = "";
|
|
469
|
+
while (rest.length) {
|
|
470
|
+
const i = rest.indexOf("\r");
|
|
471
|
+
const seg = i === -1 ? rest : rest.slice(0, i);
|
|
472
|
+
if (seg) { this._feedLineBuf(seg); out += seg; }
|
|
473
|
+
if (i === -1) break;
|
|
474
|
+
const line = this._lineBuf;
|
|
475
|
+
this._lineBuf = "";
|
|
476
|
+
this._lineCur = 0;
|
|
477
|
+
if (this.mode === "agent" && line && this._looksNL(line) &&
|
|
478
|
+
!this._pwPromptLine() &&
|
|
479
|
+
this.term.buffer.active.type === "normal") {
|
|
480
|
+
if (out) { this.ws.send(enc.encode(out)); out = ""; }
|
|
481
|
+
this.sendJson({type: "submit", text: line, dirty: this._lineDirty});
|
|
482
|
+
this._lastSubmitAt = Date.now(); // 挂载门闩用:见 _mountCard
|
|
483
|
+
} else if (this.mode === "agent" && !line && this._taskActive &&
|
|
484
|
+
this.term.buffer.active.type === "normal") {
|
|
485
|
+
// 任务期空行 Enter 吞掉:此刻光标可能还在流式卡未落地的预留区里,
|
|
486
|
+
// hook 空行重画的提示符行会写进卡区、掐断连续空白账→卡被提示符墙
|
|
487
|
+
// 覆盖(真机图三)。任务结束后恢复原生空行换行
|
|
488
|
+
if (out) { this.ws.send(enc.encode(out)); out = ""; }
|
|
489
|
+
} else {
|
|
490
|
+
out += "\r";
|
|
491
|
+
}
|
|
492
|
+
this._lineDirty = false;
|
|
493
|
+
rest = rest.slice(i + 1);
|
|
494
|
+
}
|
|
495
|
+
if (out) this.ws.send(enc.encode(out));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
connectWS() {
|
|
499
|
+
const ws = new WebSocket(wsUrl(this.tabId));
|
|
500
|
+
ws.binaryType = "arraybuffer";
|
|
501
|
+
this.ws = ws;
|
|
502
|
+
// 上行记账(看门狗判「发过却无回音」):send 有两个直调点(raw 字节与
|
|
503
|
+
// sendJson),统一在包装里记,避免漏记
|
|
504
|
+
const rawSend = ws.send.bind(ws);
|
|
505
|
+
ws.send = d => { this._lastSendAt = Date.now(); return rawSend(d); };
|
|
506
|
+
// 开连(或重连)后按序回放 WS 未就绪期缓存的按键(onData 不丢键)
|
|
507
|
+
ws.onopen = () => {
|
|
508
|
+
this._wsRetries = 0;
|
|
509
|
+
const bl = this._keyBacklog;
|
|
510
|
+
this._keyBacklog = null;
|
|
511
|
+
if (bl) for (const d of bl) this._feedData(d);
|
|
512
|
+
// 退避重连成功的回连:worker 宽限期内 PTY 还活着,不会再发 ready,
|
|
513
|
+
// 自行把状态翻回已连接(若 PTY 已死,首个键走 worker 重连链再纠正)
|
|
514
|
+
if (this._everRetried && !this._dead) this.setStatus("connected", "连接已恢复");
|
|
515
|
+
};
|
|
516
|
+
// PTY 原始字节走二进制帧直写唯一 xterm(契约),JSON 控制事件走 handleMsg
|
|
517
|
+
ws.onmessage = ev => {
|
|
518
|
+
this._lastMsgAt = Date.now(); // 心跳 ping 也走这里:看门狗的存活基线
|
|
519
|
+
if (ev.data instanceof ArrayBuffer) {
|
|
520
|
+
this._lastByteAt = Date.now();
|
|
521
|
+
const u8 = new Uint8Array(ev.data);
|
|
522
|
+
this._ingestBytes(u8);
|
|
523
|
+
} else this.handleMsg(JSON.parse(ev.data));
|
|
524
|
+
};
|
|
525
|
+
ws.onclose = ev => {
|
|
526
|
+
this._pwCapture = null; // 断线终结登录期密码捕获,重连后不残留
|
|
527
|
+
this._pwEntered = null;
|
|
528
|
+
this.setStatus("closed", "连接已关闭");
|
|
529
|
+
this._failPads();
|
|
530
|
+
// 断线自动重连(指数退避);4401/4404(鉴权失败/worker 已回收)重试无意义
|
|
531
|
+
if (!this._dead && ev.code !== 4401 && ev.code !== 4404) {
|
|
532
|
+
this._scheduleWsRetry();
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ws 断线自动重连:0.5s 起指数退避、封顶 10s,**不限次数**。旧实现 8 次
|
|
538
|
+
// (约 50s)后彻底停手,页面就此冻结在「连接已关闭」——终端打不进字、
|
|
539
|
+
// 「AI 正在思考」徽标永远收不到收尾(真机截图二)。服务端 worker 宽限期
|
|
540
|
+
// 5 分钟,网络/服务抖动回来即自动续上;4401/4404 仍在 onclose 判死不重试。
|
|
541
|
+
_scheduleWsRetry() {
|
|
542
|
+
if (this._wsRetryT) return;
|
|
543
|
+
this._everRetried = true;
|
|
544
|
+
const delay = Math.min(10000, 500 * 2 ** (this._wsRetries || 0));
|
|
545
|
+
this._wsRetries = (this._wsRetries || 0) + 1;
|
|
546
|
+
if (this._wsRetries === 1 || this._wsRetries % 8 === 0)
|
|
547
|
+
console.warn(`[ot] 第 ${this._wsRetries} 次重连,${delay}ms 后`);
|
|
548
|
+
this._wsRetryT = setTimeout(() => {
|
|
549
|
+
this._wsRetryT = null;
|
|
550
|
+
if (!this._dead) this.connectWS();
|
|
551
|
+
}, delay);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
sendJson(obj) {
|
|
555
|
+
if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify(obj));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// --- 字节扣流(临界区)---
|
|
559
|
+
// 挂载/补差期间扣住 PTY 二进制帧,防止输出插进 pad 预留区。
|
|
560
|
+
// 嵌套计数:welcome 与 card 临界区可叠加;归零才放行。
|
|
561
|
+
// 用户按键不扣(onData 直发 PTY,提示符在占位区下方)。
|
|
562
|
+
_holdBytes(tag) {
|
|
563
|
+
this._holdDepth = (this._holdDepth || 0) + 1;
|
|
564
|
+
otdbg("hold+", tag, "depth=" + this._holdDepth);
|
|
565
|
+
if (!this._holdBuf) this._holdBuf = [];
|
|
566
|
+
this._holdTags = this._holdTags || [];
|
|
567
|
+
this._holdTags.push(tag || "");
|
|
568
|
+
if (this._holdDepth === 1) this._holdSince = Date.now();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
_releaseBytes() {
|
|
572
|
+
if (!this._holdDepth) return;
|
|
573
|
+
this._holdDepth -= 1;
|
|
574
|
+
otdbg("hold-", "depth=" + this._holdDepth, "held=" + ((this._holdBuf || []).length));
|
|
575
|
+
if (this._holdTags && this._holdTags.length) this._holdTags.pop();
|
|
576
|
+
if (this._holdDepth > 0) return;
|
|
577
|
+
this._holdSince = 0;
|
|
578
|
+
const q = this._holdBuf || [];
|
|
579
|
+
this._holdBuf = [];
|
|
580
|
+
for (const u8 of q) this._writeTerm(u8);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
_holdActive() {
|
|
584
|
+
return (this._holdDepth || 0) > 0;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// 扣流照旧,但把已扣字节灌进 xterm 并等解析完:重数缓冲前必须调它。
|
|
588
|
+
// PTY 字节顺序即真相——pad 的空行必在其后到达的输出之前,提前灌入不会
|
|
589
|
+
// 错位;不灌就永远数不到刚落地的空行。嵌套扣流下(_settleCard/_mountCard
|
|
590
|
+
// 外层扣着,_ensurePad 内层放行只减一层计数)旧实现按旧缓冲判「还缺行」:
|
|
591
|
+
// 结账时垫的 2 行数不到 → 误夹紧钉死,随后下层卡锚定即永久盖帽,分析卡末行
|
|
592
|
+
// 被切(真机「底部切 ---」);同款还让新卡 marker 钉在尚未落地的字节之上。
|
|
593
|
+
async _drainHeld() {
|
|
594
|
+
if (this._dead) return;
|
|
595
|
+
const q = this._holdBuf;
|
|
596
|
+
if (q && q.length) {
|
|
597
|
+
this._holdBuf = [];
|
|
598
|
+
for (const u8 of q) this._writeTerm(u8);
|
|
599
|
+
}
|
|
600
|
+
await this._flushWrites();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
_writeTerm(u8) {
|
|
604
|
+
if (this._dead) return;
|
|
605
|
+
this._lastByteAt = Date.now();
|
|
606
|
+
this.term.write(u8);
|
|
607
|
+
this._scheduleHeal();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// 字节入口:welcome 扣留优先,其次临界区队列,否则直写
|
|
611
|
+
_ingestBytes(u8) {
|
|
612
|
+
if (this._holdWelcome(u8)) return;
|
|
613
|
+
if (this._holdActive()) {
|
|
614
|
+
if (this._holdBuf) this._holdBuf.push(u8);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
this._writeTerm(u8);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// --- 连接欢迎横幅(先「已连接…」横幅,后提示符;真机反馈的顺序颠倒 bug)---
|
|
621
|
+
// PTY 字节在横幅未落屏前一律扣住:旧实现「提示符先画、400ms 后在其下方补
|
|
622
|
+
// 横幅再前端补画一行提示符」,视觉顺序颠倒且叠出多个提示符。扣住期间跟踪
|
|
623
|
+
// 最后一次清屏序列(\x1b[2J/\x1b[3J)的位置——清屏会擦掉先写的横幅,所以
|
|
624
|
+
// 横幅只在「最后清屏之后、提示符之前」落屏,清屏前的字节反正会被擦掉,
|
|
625
|
+
// 重放时直接丢弃。释放时机:扣住流里出现 ready 下发的提示符文本(去 ANSI
|
|
626
|
+
// 后匹配)即写横幅 + 重放清屏后字节;1.5s 仍未匹配(提示符对不上/status
|
|
627
|
+
// 文案未到)按超时释放,横幅照写、字节不丢。
|
|
628
|
+
_holdWelcome(u8) {
|
|
629
|
+
const h = this._welcomeHold;
|
|
630
|
+
if (!h) return false;
|
|
631
|
+
if (h.raw) {
|
|
632
|
+
const merged = new Uint8Array(h.raw.length + u8.length);
|
|
633
|
+
merged.set(h.raw);
|
|
634
|
+
merged.set(u8, h.raw.length);
|
|
635
|
+
h.raw = merged;
|
|
636
|
+
} else {
|
|
637
|
+
h.raw = u8;
|
|
638
|
+
}
|
|
639
|
+
// 清屏序列扫描在原始字节上做(\x1b[2J = 1b 5b 32 4a;\x1b[3J 同型)
|
|
640
|
+
for (let i = 0; i + 3 < h.raw.length; i++) {
|
|
641
|
+
if (h.raw[i] === 0x1b && h.raw[i + 1] === 0x5b &&
|
|
642
|
+
(h.raw[i + 2] === 0x32 || h.raw[i + 2] === 0x33) &&
|
|
643
|
+
h.raw[i + 3] === 0x4a) {
|
|
644
|
+
h.cleared = i + 4;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (this._welcomeText) {
|
|
648
|
+
const tail = h.cleared ? h.raw.subarray(h.cleared) : h.raw;
|
|
649
|
+
const plain = new TextDecoder().decode(tail)
|
|
650
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "");
|
|
651
|
+
if (plain.includes(this._welcomeNeed)) {
|
|
652
|
+
this._releaseWelcome();
|
|
653
|
+
return true; // 缓冲里已含本块 u8,别再写一遍
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (performance.now() - h.at > 1500) {
|
|
657
|
+
this._releaseWelcome();
|
|
658
|
+
return true; // 同上
|
|
659
|
+
}
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// 扣留期到点自放行:_holdWelcome 只在新字节到达时被调用,而提示符之后
|
|
664
|
+
// 本来就没有输出——静默期就永远走不到超时判定,横幅扣着整屏永不落字、
|
|
665
|
+
// 打字也不回显(真机:本地目标终端全空白、「无法输入任何文字」)。与密码
|
|
666
|
+
// 验证窗口的 _pw_verify_expiry 同类:超时不能靠输出驱动。
|
|
667
|
+
_armWelcomeDeadline() {
|
|
668
|
+
if (this._welcomeTimer) clearTimeout(this._welcomeTimer);
|
|
669
|
+
this._welcomeTimer = setTimeout(() => {
|
|
670
|
+
this._welcomeTimer = null;
|
|
671
|
+
if (this._welcomeHold) {
|
|
672
|
+
console.warn("[ot] 欢迎横幅扣留到点,强制放行");
|
|
673
|
+
this._releaseWelcome();
|
|
674
|
+
}
|
|
675
|
+
}, 1600);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
_releaseWelcome() {
|
|
679
|
+
if (this._welcomeTimer) { clearTimeout(this._welcomeTimer); this._welcomeTimer = null; }
|
|
680
|
+
const h = this._welcomeHold;
|
|
681
|
+
this._welcomeHold = null;
|
|
682
|
+
if (this._dead || !h) return;
|
|
683
|
+
// 必须走 _ingestBytes 而不是 _writeTerm:卡片挂载/pad 的扣流闸(_holdBytes)
|
|
684
|
+
// 要罩住横幅与重放字节。旧实现直接 _writeTerm 绕过闸门,字节落进正在预留
|
|
685
|
+
// 的卡片区、把连续空白打断 → blankRun=0 → 卡永久夹紧(真机图一根因)。
|
|
686
|
+
// _ingestBytes 会先过 _holdWelcome(此刻已置空,直接放行),再按扣流计数
|
|
687
|
+
// 入 _holdBuf,顺序天然保持:横幅仍在前、提示符仍在后。
|
|
688
|
+
const enc = new TextEncoder();
|
|
689
|
+
if (this._welcomeText) {
|
|
690
|
+
this._ingestBytes(enc.encode("\x1b[2m" + this._welcomeText + "\x1b[0m\r\n"));
|
|
691
|
+
}
|
|
692
|
+
// 静默期定时器放行时 h.raw 可能还是 null(一块字节都没到)——
|
|
693
|
+
// 这里不判就会在 setTimeout 回调里抛 uncaught
|
|
694
|
+
const tail = !h.raw ? null : (h.cleared ? h.raw.subarray(h.cleared) : h.raw);
|
|
695
|
+
if (tail && tail.length) this._ingestBytes(tail);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// 键盘字节注入(等价于用户在终端里敲入):审批卡「开启新会话」等场景用
|
|
699
|
+
_sendInput(str) {
|
|
700
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
701
|
+
this.ws.send(new TextEncoder().encode(str));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// 半行缓冲:镜像已透传的可打印输入,供 Enter 时判定自然语言行。带光标位
|
|
706
|
+
// 镜像行内编辑:左右方向键/Home/End/Delete/退格按光标同步(真机:敲完引号
|
|
707
|
+
// 左方向键回引号内补内容再右方向键出来,旧实现遇方向键转义序列即判镜像不
|
|
708
|
+
// 可信清空,submit 只剩光标后的尾巴,worker 降级路径把真整行 \x03 丢弃后按
|
|
709
|
+
// 尾巴重发——AI 收到残句)。
|
|
710
|
+
// 无法镜像的序列(历史召回、Tab 补全、Ctrl+U 杀行、带修饰键的方向键等)仍
|
|
711
|
+
// 清空 + _lineDirty:worker 据此不拿镜像文本重发/不等兜底(hook 上报即真相)。
|
|
712
|
+
_feedLineBuf(seg) {
|
|
713
|
+
const sc = Array.from(seg);
|
|
714
|
+
let cp = Array.from(this._lineBuf);
|
|
715
|
+
let cur = Math.min(this._lineCur, cp.length);
|
|
716
|
+
const drop = () => { cp = []; cur = 0; this._lineDirty = true; };
|
|
717
|
+
for (let i = 0; i < sc.length; ) {
|
|
718
|
+
const ch = sc[i];
|
|
719
|
+
if (ch === "\x1b") {
|
|
720
|
+
// 手动扫 CSI/SS3:只认无修饰参数的光标移动/删除,其余转义一律弃镜像
|
|
721
|
+
let j = i + 1, op = null;
|
|
722
|
+
if (sc[j] === "[") {
|
|
723
|
+
j++;
|
|
724
|
+
let params = "";
|
|
725
|
+
while (j < sc.length && /[0-9;?]/.test(sc[j])) params += sc[j++];
|
|
726
|
+
if (j < sc.length && sc[j] >= "@" && sc[j] <= "~")
|
|
727
|
+
op = this._csiOp(params, sc[j++]);
|
|
728
|
+
} else if (sc[j] === "O" && sc[j + 1] >= "A" && sc[j + 1] <= "Z") {
|
|
729
|
+
op = this._csiOp("", sc[++j]); j++;
|
|
730
|
+
}
|
|
731
|
+
if (!op) { drop(); i = j > i + 1 ? j : i + 1; continue; }
|
|
732
|
+
if (op === "left") cur = Math.max(0, cur - 1);
|
|
733
|
+
else if (op === "right") cur = Math.min(cp.length, cur + 1);
|
|
734
|
+
else if (op === "home") cur = 0;
|
|
735
|
+
else if (op === "end") cur = cp.length;
|
|
736
|
+
else if (op === "del" && cur < cp.length) cp.splice(cur, 1);
|
|
737
|
+
i = j;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (ch === "\x7f" || ch === "\x08") { if (cur > 0) { cp.splice(cur - 1, 1); cur--; } }
|
|
741
|
+
else if (ch === "\x01") cur = 0;
|
|
742
|
+
else if (ch === "\x05") cur = cp.length;
|
|
743
|
+
else if (ch >= " ") { cp.splice(cur, 0, ch); cur++; }
|
|
744
|
+
else drop();
|
|
745
|
+
i++;
|
|
746
|
+
}
|
|
747
|
+
this._lineBuf = cp.join("");
|
|
748
|
+
this._lineCur = cur;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// CSI/SS3 终字符 → 光标操作;带修饰参数(Word 跳转等语义随 shell 变)不认
|
|
752
|
+
_csiOp(params, fin) {
|
|
753
|
+
if (fin === "~") {
|
|
754
|
+
if (params === "" || params === "1") return "home";
|
|
755
|
+
if (params === "4") return "end";
|
|
756
|
+
if (params === "3") return "del";
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
if (params !== "") return null;
|
|
760
|
+
if (fin === "D") return "left";
|
|
761
|
+
if (fin === "C") return "right";
|
|
762
|
+
if (fin === "H") return "home";
|
|
763
|
+
if (fin === "F") return "end";
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
_looksNL(line) {
|
|
768
|
+
return line.startsWith("?") ||
|
|
769
|
+
/[一-鿿-ヿ가- -〿-]/.test(line);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// 光标行是否密码提示符(ssh/sudo 讨密码):整行原样透传、幽灵提示隐藏。
|
|
773
|
+
// 前端本地判定 + 后端 _pw_prompt_seen 兜底,两层任一命中都不拦。
|
|
774
|
+
_pwPromptLine() {
|
|
775
|
+
try {
|
|
776
|
+
const buf = this.term.buffer.active;
|
|
777
|
+
if (buf.type !== "normal") return false;
|
|
778
|
+
const line = buf.baseY + buf.cursorY;
|
|
779
|
+
const t = buf.getLine(line).translateToString(true);
|
|
780
|
+
return /passwo?r?d\s*[::]?\s*$/i.test(t) ||
|
|
781
|
+
/密码\s*[::]?\s*$/.test(t) || /口令\s*[::]?\s*$/.test(t);
|
|
782
|
+
} catch (e) { return false; }
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// --- 终端剪贴板:右键菜单(复制/粘贴)+ Cmd/Ctrl+Shift+C ---
|
|
786
|
+
// 写剪贴板优先异步 API(https/localhost),http 局域网下 navigator.clipboard
|
|
787
|
+
// 不暴露 → 降级 document.execCommand("copy"):要求当前 DOM 选区存在且处于
|
|
788
|
+
// 用户手势内——xterm 划选就是真实 DOM 选区,mouseup 手势内调用即可。
|
|
789
|
+
_copyText(text) {
|
|
790
|
+
if (!text) return;
|
|
791
|
+
this._clip = text;
|
|
792
|
+
if (navigator.clipboard) {
|
|
793
|
+
navigator.clipboard.writeText(text).catch(() => this._execCopy());
|
|
794
|
+
} else {
|
|
795
|
+
this._execCopy();
|
|
796
|
+
}
|
|
797
|
+
this._toastCopied();
|
|
798
|
+
// 右键菜单复制后菜单按钮已 remove、焦点落 body——拉回终端让用户直接
|
|
799
|
+
// 继续打字(Cmd/Ctrl+Shift+C 路径焦点本就在终端,此处为无害 no-op)
|
|
800
|
+
this.focusCursor();
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
_execCopy() {
|
|
804
|
+
try { document.execCommand("copy"); } catch (e) {}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// 「已复制」小提示:常显 1s 后淡出(.copy-toast 样式,opacity 过渡)。
|
|
808
|
+
// 复用同一节点并重置计时,连按复制不叠影、不缩短上一条寿命。
|
|
809
|
+
_toastCopied() {
|
|
810
|
+
let el = this.paneEl.querySelector(".copy-toast");
|
|
811
|
+
if (!el) {
|
|
812
|
+
el = document.createElement("div");
|
|
813
|
+
el.className = "copy-toast";
|
|
814
|
+
el.textContent = "已复制";
|
|
815
|
+
this.paneEl.appendChild(el);
|
|
816
|
+
}
|
|
817
|
+
clearTimeout(this._toastTimer);
|
|
818
|
+
el.classList.add("show");
|
|
819
|
+
this._toastTimer = setTimeout(() => el.classList.remove("show"), 1000);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
_pasteText() {
|
|
823
|
+
const done = t => {
|
|
824
|
+
if (t) { try { this.term.paste(t); } catch (e) {} }
|
|
825
|
+
// 粘贴后焦点仍在(已 remove 的)菜单按钮上落回 body,键盘输入失效
|
|
826
|
+
// (真机「粘贴后要再点一下才能打字」)——还焦点给终端,可直接继续输入
|
|
827
|
+
this.focusCursor();
|
|
828
|
+
};
|
|
829
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
830
|
+
navigator.clipboard.readText().then(done, () => done(this._clip));
|
|
831
|
+
} else {
|
|
832
|
+
done(this._clip);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
_showCtxMenu(x, y) {
|
|
837
|
+
const t = this.term;
|
|
838
|
+
const sel = (t.hasSelection && t.hasSelection()) ? t.getSelection() : "";
|
|
839
|
+
const old = document.querySelector(".ctxmenu");
|
|
840
|
+
if (old) old.remove();
|
|
841
|
+
const menu = document.createElement("div");
|
|
842
|
+
menu.className = "ctxmenu";
|
|
843
|
+
menu.style.left = x + "px";
|
|
844
|
+
menu.style.top = y + "px";
|
|
845
|
+
const mk = (label, disabled, fn) => {
|
|
846
|
+
const b = document.createElement("button");
|
|
847
|
+
b.textContent = label;
|
|
848
|
+
b.disabled = !!disabled;
|
|
849
|
+
b.onclick = () => { menu.remove(); fn(); };
|
|
850
|
+
menu.appendChild(b);
|
|
851
|
+
};
|
|
852
|
+
mk("复制", !sel, () => this._copyText(sel));
|
|
853
|
+
mk("粘贴", false, () => this._pasteText());
|
|
854
|
+
document.body.appendChild(menu);
|
|
855
|
+
// 菜单内部点击不冒泡(避免 document 级关闭先于按钮 click 触发)
|
|
856
|
+
menu.addEventListener("mousedown", ev => ev.stopPropagation());
|
|
857
|
+
setTimeout(() => {
|
|
858
|
+
document.addEventListener("mousedown", () => menu.remove(), {once: true});
|
|
859
|
+
}, 0);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// =====================================================================
|
|
863
|
+
// 卡片层:一卡一钉(registerMarker + registerDecoration)
|
|
864
|
+
// 定位/滚动/trim 全由 xterm 管;推开文字靠 shell pad 空行(账目见 pad_ledger)。
|
|
865
|
+
// app.js 职责:
|
|
866
|
+
// 1) 挂载:settle → marker → decoration → island.mount → 量高补 pad → 重贴定高
|
|
867
|
+
// 2) 长高:150ms 批量重贴(marker 不动);流底才补差,否则夹紧
|
|
868
|
+
// 3) 兜底:registerDecoration 失败 → _writePlain 纯文本
|
|
869
|
+
// =====================================================================
|
|
870
|
+
|
|
871
|
+
async _settle() {
|
|
872
|
+
const t0 = Date.now();
|
|
873
|
+
while (this._lastByteAt && Date.now() - this._lastByteAt < 120 &&
|
|
874
|
+
Date.now() - t0 < 1000) {
|
|
875
|
+
await this._sleep(40);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// 等 PTY **入流**沉降(到达静默 >= ms,最多等 maxMs):用于「先扣流、后锚定」。
|
|
880
|
+
// 扣流只挡字节进 xterm,不挡到达——_lastByteAt 照常更新,故这个静默判据可靠。
|
|
881
|
+
async _waitInputQuiet(ms = 350, maxMs = 2500) {
|
|
882
|
+
const t0 = Date.now();
|
|
883
|
+
while (!this._dead && Date.now() - t0 < maxMs) {
|
|
884
|
+
if (Date.now() - (this._lastByteAt || 0) >= ms) return;
|
|
885
|
+
await this._sleep(30);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
_ensureFeed() {
|
|
890
|
+
if (!this._feedPromise) {
|
|
891
|
+
this._feedPromise = import("/static/ui/ui.js").then(mod => {
|
|
892
|
+
if (this._dead) return null;
|
|
893
|
+
// island 自带样式表单独装载(cssCodeSplit:false 产物 ui.css)
|
|
894
|
+
if (!document.getElementById("ot-ui-css")) {
|
|
895
|
+
const link = document.createElement("link");
|
|
896
|
+
link.id = "ot-ui-css";
|
|
897
|
+
link.rel = "stylesheet";
|
|
898
|
+
link.href = "/static/ui/ui.css";
|
|
899
|
+
document.head.appendChild(link);
|
|
900
|
+
}
|
|
901
|
+
this._feed = mod.createFeed({
|
|
902
|
+
onDecision: (decision, stateText, command, cardId) => {
|
|
903
|
+
this.setStatus(this.status, stateText + " " + (command || ""));
|
|
904
|
+
this._approvalOpen = false; // 决策完成:思考徽标接管执行空隙期
|
|
905
|
+
this._approvalCmd = null;
|
|
906
|
+
this._approvalKey = null;
|
|
907
|
+
this._approvalResolved = true; // 卡内快捷键路径也算已决策
|
|
908
|
+
this._refreshThink();
|
|
909
|
+
this._releaseDecision(decision, cardId);
|
|
910
|
+
this.focusCursor(); // 卡内 mousedown 不冒泡:焦点显式还给终端
|
|
911
|
+
},
|
|
912
|
+
onRescue: accept => {
|
|
913
|
+
// 失败救援卡决策回传(true = 交给 AI)
|
|
914
|
+
this.sendJson({type: "rescue", accept});
|
|
915
|
+
this.focusCursor();
|
|
916
|
+
},
|
|
917
|
+
onNewSession: cardId => {
|
|
918
|
+
// 总结卡「开启新会话」:后端只重置模型上下文(不清屏);PTY 发
|
|
919
|
+
// 一个回车等效换行出新鲜提示符——界面上就像按了一下 Enter
|
|
920
|
+
this.sendJson({type: "new_session"});
|
|
921
|
+
this._sendInput("\r");
|
|
922
|
+
this.focusCursor();
|
|
923
|
+
},
|
|
924
|
+
// store 整卡移除(interrupt 收束/sweep/drop):布局槽同步丢弃——
|
|
925
|
+
// island 与 decoration 一起消失,不留无主半截卡
|
|
926
|
+
onDrop: ids => {
|
|
927
|
+
for (const id of ids || []) {
|
|
928
|
+
const s = this._slots && this._slots.get(id);
|
|
929
|
+
if (s) this._dropSlot(s);
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
// 块流布局(Phase 1):卡片块是流式 DOM 节点,不是 xterm decoration。
|
|
934
|
+
// feed.mount(id, hostEl) 只要一个 DOM 元素,本来就与 xterm 解耦——
|
|
935
|
+
// cards.jsx / island.css 一行不改,观感原样。
|
|
936
|
+
try {
|
|
937
|
+
this._blocks = new BlockStream();
|
|
938
|
+
const host = this.paneEl && this.paneEl.querySelector(
|
|
939
|
+
`#blocks-${this.tabId}`);
|
|
940
|
+
if (host && typeof BlockView === "function") {
|
|
941
|
+
this._blockView = new BlockView(host, { feed: this._feed, follow: false });
|
|
942
|
+
}
|
|
943
|
+
} catch (e) {
|
|
944
|
+
console.error("[ot] 块流初始化失败,回退 decoration 路径:", e);
|
|
945
|
+
this._blocks = null;
|
|
946
|
+
this._blockView = null;
|
|
947
|
+
}
|
|
948
|
+
return this._feed;
|
|
949
|
+
}).catch(e => {
|
|
950
|
+
console.error("[ot] 卡片层加载失败:", e);
|
|
951
|
+
this._feedPromise = null; // 允许下次事件重试
|
|
952
|
+
return null;
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
return this._feedPromise;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// 锚定临界区串行化:_cardReady 只串了走链的调用方(task_start/ai_tool),
|
|
959
|
+
// showApproval/_showRescue/_mountSummary 不走它——tool_start 与 approval
|
|
960
|
+
// 同包到达时两个 _mountCard 并发跑到底,registerMarker 都钉在同一光标行
|
|
961
|
+
// =两张 island 叠同行、后贴纸者盖住先者;被盖的是审批卡时按钮点不到,
|
|
962
|
+
// 看门狗又视 mounted+未夹紧为健康不转模态=决策死锁(真机「工具卡盖审批
|
|
963
|
+
// 卡、界面干等审批」)。锁必须落在 _mountCard 内部才罩得住所有调用方;
|
|
964
|
+
// 范围含尾部 _ensurePad——下张卡的 marker 必须钉在本卡预留空行落地、光标
|
|
965
|
+
// 下移之后,否则空行落进新卡区、新卡出生即盖帽夹紧。
|
|
966
|
+
async _mountCard(cardId, kind = "phase") {
|
|
967
|
+
const run = () => this._runMountCard(cardId, kind);
|
|
968
|
+
// 前卡挂载抛错不得堵住后卡锚定(onRejected 同样排队放行)
|
|
969
|
+
this._mountQ = (this._mountQ || Promise.resolve()).then(run, run);
|
|
970
|
+
return this._mountQ;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// 挂载一张卡:marker 钉流底,decoration 贴纸盖在 pad 预留行上。
|
|
974
|
+
// 整段在扣流临界区内完成,输出不可能插进占位区。失败返回 null。
|
|
975
|
+
// 待审批的卡必须始终在视野里:窗口变窄/变矮会把它顶出可视区,而按钮仍然
|
|
976
|
+
// 「在盒内」——用户看不到就等于点不到(真机 800x600 按钮落在折叠线之下)。
|
|
977
|
+
_keepApprovalInView() {
|
|
978
|
+
try {
|
|
979
|
+
for (const slot of (this._slots ? this._slots.values() : [])) {
|
|
980
|
+
if (!slot || !slot.host) continue;
|
|
981
|
+
const el = slot.host.closest ? slot.host.closest(".ot-blk") : null;
|
|
982
|
+
if (!el || typeof el.scrollIntoView !== "function") continue;
|
|
983
|
+
const interactive = slot.host.querySelector(
|
|
984
|
+
".cmdacts button, .apbtns button");
|
|
985
|
+
if (interactive) { el.scrollIntoView({ block: "nearest" }); return; }
|
|
986
|
+
}
|
|
987
|
+
} catch (e) {}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ── 文本分块:冻结当前输出 ─────────────────────────────────────────
|
|
991
|
+
// 卡片要和输出交错,就必须把「已经跑完的输出」从活动终端里搬成一个文本块,
|
|
992
|
+
// 卡片才能落在它下面、新输出再落在卡片下面。搬完 xterm 清屏继续收新输出。
|
|
993
|
+
//
|
|
994
|
+
// 这样得到的顺序正是图二那张效果图:文本 / 卡片 / 文本 / 卡片 / 活动输出。
|
|
995
|
+
// 代价:块与块之间不能跨选(用户已定「不允许跨卡选」);块内选中复制正常。
|
|
996
|
+
_snapshotTermText(styled = false) {
|
|
997
|
+
try {
|
|
998
|
+
const b = this.term.buffer.active;
|
|
999
|
+
if (styled) return bufferToAnsi(b);
|
|
1000
|
+
const out = [];
|
|
1001
|
+
for (let i = 0; i < b.length; i++) {
|
|
1002
|
+
const ln = b.getLine(i);
|
|
1003
|
+
out.push(ln ? ln.translateToString(true) : "");
|
|
1004
|
+
}
|
|
1005
|
+
return out.join("\n").replace(/\s+$/, "");
|
|
1006
|
+
} catch (e) {
|
|
1007
|
+
return "";
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// 冻结:当前活动终端的内容 → 文本块,然后清屏继续。内容为空就不切
|
|
1012
|
+
// (切出空块只会留下大白空)。
|
|
1013
|
+
// afterId:这段输出该挂在谁后面(输出归属)。给了就插到它后面——四张卡并行
|
|
1014
|
+
// 时输出按**抢锁序**回来,不贴回自己那张卡就会整坨落到所有卡之后(真机
|
|
1015
|
+
// 「四张卡只看得见一条输出」);不给则流末尾追加(横幅/用户输入这类「卡片
|
|
1016
|
+
// 之前」的内容)。afterId 认不出时 BlockStream 自己落到末尾,宁粗不乱插。
|
|
1017
|
+
_cutBlock(kind, afterId, echo) {
|
|
1018
|
+
if (!this._blocks || !this._blockView) return null;
|
|
1019
|
+
const text = this._snapshotTermText(true);
|
|
1020
|
+
if (!text || !text.trim()) return null;
|
|
1021
|
+
// 搬走即清屏:先 reset 再落位。反过来(落位失败就 return)会把同一段
|
|
1022
|
+
// 留在活动终端里,下一次切块再冻一遍 = 同一段输出重复两遍。
|
|
1023
|
+
try { this.term.reset(); } catch (e) {}
|
|
1024
|
+
return this._placeText(ensureCmdEcho(text, echo), kind, afterId);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
// 落位一段已冻结的输出。afterId = 它归属的那张卡。
|
|
1029
|
+
_placeText(text, kind, afterId) {
|
|
1030
|
+
// 卡还没落进块流(挂载是异步链,term_close 常抢在它前面):先存着,
|
|
1031
|
+
// 卡一落位就补插到它后面。直接 insertAfter 会因认不出 afterId 退回末尾
|
|
1032
|
+
// 追加——那正是「四条输出整坨落在所有卡之后」的症状本身。
|
|
1033
|
+
if (afterId != null && !this._blocks.has(afterId)) {
|
|
1034
|
+
if (!this._pendingTextByCard) this._pendingTextByCard = new Map();
|
|
1035
|
+
const q = this._pendingTextByCard.get(afterId) || [];
|
|
1036
|
+
q.push({ kind, text });
|
|
1037
|
+
this._pendingTextByCard.set(afterId, q);
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
const blk = this._blocks.insertAfter(afterId, {
|
|
1041
|
+
id: this._blocks.nextId(kind === "transition" ? "trans" : "txt"),
|
|
1042
|
+
kind: kind === "transition" ? "transition" : "term",
|
|
1043
|
+
state: "frozen",
|
|
1044
|
+
payload: { text },
|
|
1045
|
+
});
|
|
1046
|
+
if (!blk) return null;
|
|
1047
|
+
this._blockView.sync(this._blocks.list());
|
|
1048
|
+
return blk;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// ── 输出归属:执行边界括号(term_open / term_close)──────────────────
|
|
1052
|
+
//
|
|
1053
|
+
// 并行 tool_call 一次宣告 N 张卡,而挂载是异步链(_ensureFeed 加载 React
|
|
1054
|
+
// island + scrollIntoView):N 张卡在第一字节输出到达前就全挂完了,于是
|
|
1055
|
+
// 「挂卡时切块」次次切空、一个文本块都生不出来,N 条输出全堆在活动终端,
|
|
1056
|
+
// 直到下一张卡(往往是总结卡)才被整段冻成一坨、落在所有工具卡之后。
|
|
1057
|
+
//
|
|
1058
|
+
// 所以归属不能绑挂载时刻,要绑**执行边界**:后端在 C/D 标记处发
|
|
1059
|
+
// term_open/term_close(谁在跑就是谁的),收束时 insertAfter 它自己的卡。
|
|
1060
|
+
// 顺序与宣告序无关——抢锁序乱了也贴得对。
|
|
1061
|
+
// 归属/切块全部走这条链,且每次快照前等 xterm 把在途 write 解析完:
|
|
1062
|
+
// xterm.write 是异步解析的,而 D 标记与最后一段输出常在同一块字节里到达
|
|
1063
|
+
// ——不同步等就快照,尾巴会漏给下一次切块(真机 nproc 的「6」被后续挂载
|
|
1064
|
+
// 切走、落到流末尾)。链本身保证 open/close/挂载 三者互斥,不互相偷字节。
|
|
1065
|
+
_outOp(fn) {
|
|
1066
|
+
this._outOpQ = (this._outOpQ || Promise.resolve()).then(fn, fn);
|
|
1067
|
+
return this._outOpQ;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
_claimCmdCard(command) {
|
|
1071
|
+
const key = String(command || "");
|
|
1072
|
+
const q = this._cmdCardQ && this._cmdCardQ.get(key);
|
|
1073
|
+
if (!q || !q.length) return null;
|
|
1074
|
+
return q.shift();
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// 归位排队的输出:term_close 抢在挂载前到时先存着,卡落位后补插到它后面
|
|
1078
|
+
_flushPendingFor(cardId) {
|
|
1079
|
+
const pend = this._pendingTextByCard && this._pendingTextByCard.get(cardId);
|
|
1080
|
+
if (!pend || !pend.length) return;
|
|
1081
|
+
this._pendingTextByCard.delete(cardId);
|
|
1082
|
+
for (const t of pend) {
|
|
1083
|
+
this._blocks.insertAfter(cardId, {
|
|
1084
|
+
id: this._blocks.nextId(t.kind === "transition" ? "trans" : "txt"),
|
|
1085
|
+
kind: t.kind === "transition" ? "transition" : "term",
|
|
1086
|
+
state: "frozen",
|
|
1087
|
+
payload: { text: t.text },
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
this._blockView.sync(this._blocks.list());
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
_onTermOpen(ev) {
|
|
1094
|
+
this._outOp(async () => {
|
|
1095
|
+
if (!this._blocks || !this._blockView) return;
|
|
1096
|
+
await this._flushWrites();
|
|
1097
|
+
// 上一个括号没关(D 标记丢了):先把它的输出归位,别混进这条
|
|
1098
|
+
if (this._outOpen) this._closeOutAnchor();
|
|
1099
|
+
this._outOpen = true;
|
|
1100
|
+
// 记下回显行:Readline 的「上移一格+擦行」会随机啃掉它,收束时丢了
|
|
1101
|
+
// 就用这行补回去。优先用后端拼好的(确定),读缓冲只作兜底
|
|
1102
|
+
this._outEcho = styleCmdEcho((ev && ev.echo) ||
|
|
1103
|
+
pickCmdEcho(this._snapshotTermText(), ev && ev.command), ev && ev.command);
|
|
1104
|
+
this._outAnchor = this._claimCmdCard(ev && ev.command);
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// 收束一段在途输出:冻成文本块贴到它自己的卡后面(认不出卡就落末尾)
|
|
1109
|
+
_closeOutAnchor() {
|
|
1110
|
+
this._cutBlock(null, this._outAnchor == null ? undefined : this._outAnchor,
|
|
1111
|
+
this._outEcho);
|
|
1112
|
+
this._outOpen = false;
|
|
1113
|
+
this._outAnchor = null;
|
|
1114
|
+
this._outEcho = null;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
_onTermClose(ev) {
|
|
1118
|
+
this._outOp(async () => {
|
|
1119
|
+
if (!this._blocks || !this._blockView) return;
|
|
1120
|
+
await this._flushWrites();
|
|
1121
|
+
// 没开过括号(hook 降级 / 事件丢失):退回原行为(流末尾追加),不乱贴
|
|
1122
|
+
if (!this._outOpen) { this._cutBlock(); return; }
|
|
1123
|
+
this._closeOutAnchor();
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ── 块流挂载(Phase 1)──────────────────────────────────────────────
|
|
1128
|
+
// 卡片是流式 DOM 块:高度由内容自撑,后面的块自然下推。于是「不遮挡 /
|
|
1129
|
+
// 不裁切 / 无大空白」是结构属性,不需要 pad、不需要夹紧、不需要扣流记账。
|
|
1130
|
+
// 多卡并发也撞不到一起——insertAfter 按 id 插入,不是抢坐标。
|
|
1131
|
+
async _runMountCardBlocks(cardId, kind = "phase") {
|
|
1132
|
+
if (!this._blocks || !this._blockView) return null;
|
|
1133
|
+
const feed = this._feed;
|
|
1134
|
+
if (!feed) return null;
|
|
1135
|
+
// 落块走归属链:与 term_open/term_close 互斥,不互相偷走缓冲里的字节
|
|
1136
|
+
await this._outOp(async () => {
|
|
1137
|
+
if (!this._blocks.get(cardId)) {
|
|
1138
|
+
// 把「卡片之前」的内容冻成文本块,卡片才落在它下面(输出/卡片交错)。
|
|
1139
|
+
// **归属括号开着时不要切**:那是别的命令正在收输出,切走就把它从
|
|
1140
|
+
// 自己的卡下偷走;它的归位由 term_close 落到它自己那张卡后面。
|
|
1141
|
+
if (!this._outOpen) this._cutBlock();
|
|
1142
|
+
this._blocks.append({
|
|
1143
|
+
id: cardId, kind: "card", state: "frozen",
|
|
1144
|
+
payload: { cardType: kind },
|
|
1145
|
+
keepAlive: kind === "approval", // 待审批的卡永不被收束/虚拟化删掉
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
this._flushPendingFor(cardId);
|
|
1149
|
+
this._blockView.sync(this._blocks.list());
|
|
1150
|
+
});
|
|
1151
|
+
// 幂等:同 id 已在块流里就直接取宿主(重连补挂不许重复落块)
|
|
1152
|
+
const blk = this._blocks.get(cardId);
|
|
1153
|
+
const slot = {
|
|
1154
|
+
id: cardId, marker: null, decoration: null, host: null,
|
|
1155
|
+
reserved: 0, need: 0, rows: 0, clamped: false, ro: null,
|
|
1156
|
+
reanchorT: null, mounted: false, pendingMount: false,
|
|
1157
|
+
pinnedRows: null, forceReapply: false,
|
|
1158
|
+
kind, gen: this._taskGen || 0,
|
|
1159
|
+
revealed: true, frozen: true, frozenAt: Date.now(),
|
|
1160
|
+
padOut: 0, padP: null, blockMode: true,
|
|
1161
|
+
};
|
|
1162
|
+
const el = this._blockView.nodes.get(cardId);
|
|
1163
|
+
const host = el && el._host;
|
|
1164
|
+
if (!host) return null;
|
|
1165
|
+
slot.host = host;
|
|
1166
|
+
slot.mounted = true;
|
|
1167
|
+
// 揭示类必须打上:island.css 把 .ablk 的 opacity 钉在 0、动画 paused,
|
|
1168
|
+
// 只有 .ot-reveal/.ot-revealed 才放行。漏了就是「正文有内容但看不见」——
|
|
1169
|
+
// 分析卡/总结卡整段空白(工具卡不走 .ablk 所以看不出问题)。
|
|
1170
|
+
// 只加 ot-reveal,**不要**换成 ot-revealed:ot-revealed 会把动画关掉
|
|
1171
|
+
// (animation:none),流式后到的块就再也不逐块揭示=打字机效果消失。
|
|
1172
|
+
// 块布局下宿主不会被重建,不存在「滚动重播」的问题,无需终态锁存。
|
|
1173
|
+
try { host.classList.add("ot-reveal"); } catch (e) {}
|
|
1174
|
+
if (this._slots) this._slots.set(cardId, slot);
|
|
1175
|
+
// 卡必须真在视野里:单一滚动面下块多了会把新卡顶出可视区,而
|
|
1176
|
+
// getBoundingClientRect 仍给正常坐标 → 真鼠标点不到(真机 C2 hit=null)
|
|
1177
|
+
try {
|
|
1178
|
+
if (el && typeof el.scrollIntoView === "function") {
|
|
1179
|
+
el.scrollIntoView({ block: "nearest" });
|
|
1180
|
+
}
|
|
1181
|
+
} catch (e) {}
|
|
1182
|
+
return slot;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async _runMountCard(cardId, kind = "phase") {
|
|
1186
|
+
// 块流路径:卡片不占网格行,直接挂流式块(不走 marker/decoration/pad)
|
|
1187
|
+
if (this._blocks && this._blockView) {
|
|
1188
|
+
const s = await this._runMountCardBlocks(cardId, kind);
|
|
1189
|
+
if (s) return s;
|
|
1190
|
+
}
|
|
1191
|
+
// 欢迎横幅扣留期不挂卡:横幅与其后的重放字节随后才落屏,会写进 marker
|
|
1192
|
+
// 行、把预留区的连续空白打断(blankRun 遇非空即停)→ bspan 恒 0,卡被
|
|
1193
|
+
// 永久夹紧成只有一条头的细条、并盖住横幅折行(真机图一:横幅右端被截断
|
|
1194
|
+
// + ⚠ 系统警告只剩标题)。等横幅落屏重放完再锚定,marker 才钉在真流底。
|
|
1195
|
+
// 有界等待:横幅超时自放行(_armWelcomeDeadline),不许把挂载卡死。
|
|
1196
|
+
const w0 = Date.now();
|
|
1197
|
+
while (this._welcomeHold && !this._dead && Date.now() - w0 < 2500) {
|
|
1198
|
+
await this._sleep(20);
|
|
1199
|
+
}
|
|
1200
|
+
await this._settle();
|
|
1201
|
+
// 提交在途门闩:task_start 可能先于 \r 的回显/重绘字节到达,此刻 settle 误判
|
|
1202
|
+
// 静默、marker 钉在回显行上——卡盖住输入行(真机「中文提交行消失」根因)。
|
|
1203
|
+
// 先等提交后首波字节落地再走静默判定;超时(hook 死/无字节)放行老路
|
|
1204
|
+
if (this._lastSubmitAt && Date.now() - this._lastSubmitAt < 1500) {
|
|
1205
|
+
const t0 = Date.now();
|
|
1206
|
+
while (this._lastByteAt <= this._lastSubmitAt && Date.now() - t0 < 1200) {
|
|
1207
|
+
await this._sleep(10);
|
|
1208
|
+
}
|
|
1209
|
+
await this._settle();
|
|
1210
|
+
}
|
|
1211
|
+
if (this._dead) return null;
|
|
1212
|
+
const feed = await this._ensureFeed();
|
|
1213
|
+
if (!feed) return null;
|
|
1214
|
+
this._holdBytes("card-mount");
|
|
1215
|
+
try {
|
|
1216
|
+
// 先扣流、再等**入流**沉降,然后才灌缓冲钉 marker——marker 必须钉在真流底。
|
|
1217
|
+
// 旧序是「settle 后才扣流」:连接期的登录横幅/命令输出随后才到、落进 marker
|
|
1218
|
+
// 之下,把预留区的连续空白挤断(真机图一:卡钉在 ssh 回显行上,_echoSkip
|
|
1219
|
+
// 跳到第 9 行而第 10 行就是登录横幅文本 → 区里只剩 1 行空白,need=7 却
|
|
1220
|
+
// 只能垫 1 行,卡被夹紧成只剩卡头的细条)。
|
|
1221
|
+
await this._waitInputQuiet(350, 2500);
|
|
1222
|
+
// 扣流只挡「新到」的字节,队列里已扣的仍在缓冲之外:先灌干净再判盖帽、
|
|
1223
|
+
// 再钉 marker。否则 marker 钉在未落地的字节之上,那些字节随后写进新卡的
|
|
1224
|
+
// 预留区,新卡一出生就被盖帽(blank=0 → 夹紧到 1 行,永不再长)。
|
|
1225
|
+
await this._drainHeld();
|
|
1226
|
+
// 锚定窗口:此刻光标下还没有新 marker,最末卡尚未被盖帽——先把它定格
|
|
1227
|
+
// 后的最终高度垫满(一旦下层锚定,上层长高只能夹紧截断,真机分析卡
|
|
1228
|
+
// 底部切 "---" 即此)。中层卡区已闭合,垫不进去,_settleCard 自会跳过。
|
|
1229
|
+
const up = [...this._slots.values()].filter(o => o.marker)
|
|
1230
|
+
.sort((a, b) => a.marker.line - b.marker.line).pop();
|
|
1231
|
+
if (up && !this._capped(up)) await this._settleCard(up);
|
|
1232
|
+
await this._drainHeld(); // 结账垫的空行必须已落地,marker 才钉在真流底
|
|
1233
|
+
// 空白尾复用:冻结上游卡多垫的空白尾无法回收(空白行不可回吐),留着
|
|
1234
|
+
// 就是卡间永久大缝隙(真机 2026-09-30:三张卡每张之间约 6 行空白)。
|
|
1235
|
+
// 空白 run 连续到光标、pad 只追加流底,故把新卡 marker 钉到空白尾首行:
|
|
1236
|
+
// 预留区天然含复用语段,卡间缝隙归零。只认冻结槽——活卡还可能长高,
|
|
1237
|
+
// 那段空白是它的裕量,不许动。
|
|
1238
|
+
let reuse = 0;
|
|
1239
|
+
if (up && up.frozen && (up.reserved || 0) > (up.rows || 0)) {
|
|
1240
|
+
const buf = this.term.buffer.active;
|
|
1241
|
+
const cursorAbs = buf.baseY + buf.cursorY;
|
|
1242
|
+
const surplus = Math.min(
|
|
1243
|
+
up.reserved - up.rows,
|
|
1244
|
+
cursorAbs - (up.marker ? up.marker.line : cursorAbs));
|
|
1245
|
+
while (reuse < surplus) {
|
|
1246
|
+
const ln = buf.getLine(cursorAbs - 1 - reuse);
|
|
1247
|
+
const txt = ln ? ln.translateToString(true) : "";
|
|
1248
|
+
if (txt && txt.trim()) break;
|
|
1249
|
+
reuse++;
|
|
1250
|
+
}
|
|
1251
|
+
if (reuse > 0) {
|
|
1252
|
+
up.reserved -= reuse;
|
|
1253
|
+
// 让出的空白尾下面是新卡不是输出:冻结卡不再保留 +1 保险行,
|
|
1254
|
+
// 否则下次 settle 会替它在流底再垫一行
|
|
1255
|
+
up.need = Math.min(up.need || 0, up.rows);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
const marker = this.term.registerMarker(reuse ? -reuse : 0);
|
|
1259
|
+
if (!marker) return null;
|
|
1260
|
+
const slot = {
|
|
1261
|
+
id: cardId, marker, decoration: null, host: null,
|
|
1262
|
+
reserved: 0, need: 2, rows: 2, clamped: false, ro: null,
|
|
1263
|
+
reanchorT: null, mounted: false, pendingMount: false,
|
|
1264
|
+
pinnedRows: null, // 夹紧态钉死的行数(null = 内容自撑 auto)
|
|
1265
|
+
forceReapply: false, // resize 等需重贴 decoration 刷宽度
|
|
1266
|
+
// 宁藏不盖(spec §13.1 待办 B):流式 phase 卡 fund 之前(reserved ≥
|
|
1267
|
+
// rows)整卡 visibility:hidden,思考徽标接班——出生 pad 往返窗口里
|
|
1268
|
+
// 不再露出「只有一条头」的细条(真机实测每张新卡 0.3~0.8s)
|
|
1269
|
+
kind, // 'phase' | 'summary' | 'approval',隐藏只认 phase
|
|
1270
|
+
gen: this._taskGen || 0, // 任务代数:收束扫尾只清旧代槽
|
|
1271
|
+
revealed: false, // fund/盖帽/冻结阀放行后锁存为 true,不复隐
|
|
1272
|
+
frozen: false, // 卡已定格(collapse/final/fail/审批):不再参与隐藏
|
|
1273
|
+
frozenAt: 0,
|
|
1274
|
+
padOut: 0, // 已发未落账的 pad 空行(防并发双垫,见 _ensurePad)
|
|
1275
|
+
padP: null, // 本格 pad 发送链尾 promise(gap=0 waiter 等落地)
|
|
1276
|
+
};
|
|
1277
|
+
// 旧钉的迟到 dispose 不得删槽(同 id 顶替/重贴换钉场景)——只认
|
|
1278
|
+
// 「仍是本槽当前钉」的那次 dispose
|
|
1279
|
+
marker.onDispose(() => {
|
|
1280
|
+
if (slot.marker === marker) this._discardSlot(slot);
|
|
1281
|
+
});
|
|
1282
|
+
// 同 id 防御:旧槽还占着 id 时先整体拆掉(装饰/RO/root)。孤儿槽的
|
|
1283
|
+
// decoration 活着就会每帧 onRender → feed.mount(id, el) 与新槽互抢
|
|
1284
|
+
// 宿主——React root 每帧在两个 el 间迁移拆建 = 卡片闪烁
|
|
1285
|
+
// (真机工具卡跨任务同 id 实锤:TaskRunner 的 index 每任务归 1)
|
|
1286
|
+
const stale = this._slots.get(cardId);
|
|
1287
|
+
if (stale) {
|
|
1288
|
+
this._discardSlot(stale);
|
|
1289
|
+
}
|
|
1290
|
+
this._slots.set(cardId, slot);
|
|
1291
|
+
this._applyDecoration(slot, 2);
|
|
1292
|
+
// xterm 按需渲染:task_start 落在「提交→首字节」的空闲间隙时没有缓冲
|
|
1293
|
+
// 变化、渲染循环不跑,decoration 的 onRender 不触发、host 拿不到(真机
|
|
1294
|
+
// 整卡不显示的根因)。_applyDecoration 尾部已强制刷视口一次,onRender
|
|
1295
|
+
// 空闲期即交 host,pad 才能趁光标还在 marker 行时把空行垫在正确位置。
|
|
1296
|
+
const t0 = Date.now();
|
|
1297
|
+
while (!slot.host && Date.now() - t0 < 500) await this._sleep(10);
|
|
1298
|
+
if (slot.host) {
|
|
1299
|
+
feed.mount(cardId, slot.host);
|
|
1300
|
+
slot.mounted = true;
|
|
1301
|
+
this._watchCardResize(slot);
|
|
1302
|
+
await new Promise(r => requestAnimationFrame(() => r()));
|
|
1303
|
+
this._watchCardResize(slot); // React 子节点此时已挂,补观察
|
|
1304
|
+
this._fitNeed(slot);
|
|
1305
|
+
await this._ensurePad(slot);
|
|
1306
|
+
// 预留被随后落下的输出挤断(连接期典型):一发搬到流底再垫一次
|
|
1307
|
+
if ((slot.reserved || 0) < Math.max(1, Math.ceil((slot.rows || 1) / 2))) {
|
|
1308
|
+
if (await this._relocateToBottom(slot)) {
|
|
1309
|
+
this._fitNeed(slot);
|
|
1310
|
+
await this._ensurePad(slot);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
this._reanchor(slot);
|
|
1314
|
+
// 出生即冻结(Phase 1):内容完整挂载,此后高度永不再涨——空白尾
|
|
1315
|
+
// 复用/零空白藏/冻结阀全按此态判
|
|
1316
|
+
slot.frozen = true;
|
|
1317
|
+
slot.frozenAt = Date.now();
|
|
1318
|
+
} else {
|
|
1319
|
+
// 终端此刻空闲(提交→首字节间隙):xterm 按需渲染,无缓冲变化就不跑
|
|
1320
|
+
// 渲染循环,onRender 不触发、host 拿不到。旧实现在这里 discard+return
|
|
1321
|
+
// null → task_start 进不了 store、后续 token 全丢、整卡不显示(真机
|
|
1322
|
+
// 「思考界面没出来」)。改为挂起,等首笔输出引发渲染时 onRender 补挂载。
|
|
1323
|
+
slot.pendingMount = true;
|
|
1324
|
+
}
|
|
1325
|
+
return slot;
|
|
1326
|
+
} finally {
|
|
1327
|
+
this._releaseBytes();
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// 延迟补挂载:onRender 在首笔输出引发的渲染里才拿到 host 时走这里。
|
|
1332
|
+
// 与 _mountCard 快路径同一套收尾(feed.mount + RO + 量高 + 补差 + 重锚)。
|
|
1333
|
+
_finishMount(slot) {
|
|
1334
|
+
if (slot.mounted || !slot.host || this._dead) return;
|
|
1335
|
+
slot.pendingMount = false;
|
|
1336
|
+
slot.mounted = true;
|
|
1337
|
+
if (this._feed) this._feed.mount(slot.id, slot.host);
|
|
1338
|
+
this._watchCardResize(slot);
|
|
1339
|
+
requestAnimationFrame(() => {
|
|
1340
|
+
if (!this._dead && slot.host) this._watchCardResize(slot);
|
|
1341
|
+
});
|
|
1342
|
+
this._fitNeed(slot);
|
|
1343
|
+
this._ensurePad(slot)
|
|
1344
|
+
.then(() => this._drainHeld())
|
|
1345
|
+
.then(() => {
|
|
1346
|
+
this._reanchor(slot);
|
|
1347
|
+
// 出生即冻结(Phase 1):内容完整挂载,此后高度永不再涨
|
|
1348
|
+
slot.frozen = true;
|
|
1349
|
+
slot.frozenAt = Date.now();
|
|
1350
|
+
})
|
|
1351
|
+
.catch(() => {});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// RO 同时观察 host 与卡内 React 子节点:xterm 每帧把 host inline 高度写回
|
|
1355
|
+
// 登记行数、外盒不涨;子节点外盒随内容涨——折叠/展开等 UI 驱动的高度变化
|
|
1356
|
+
// 靠它抓住(流式长高另有事件通路钩子,双保险)。
|
|
1357
|
+
_watchCardResize(slot) {
|
|
1358
|
+
if (slot.blockMode) return;
|
|
1359
|
+
if (!slot.host) return;
|
|
1360
|
+
const child = slot.host.firstElementChild;
|
|
1361
|
+
// 记忆守卫:host/子节点没换就不重建 RO(onRender 每帧渲染都会路过)
|
|
1362
|
+
if (slot.ro && slot._roHost === slot.host && slot._roChild === child) return;
|
|
1363
|
+
if (slot.ro) { try { slot.ro.disconnect(); } catch (e) {} }
|
|
1364
|
+
slot.ro = new ResizeObserver(() => this._scheduleDrift(slot));
|
|
1365
|
+
slot.ro.observe(slot.host);
|
|
1366
|
+
if (child) slot.ro.observe(child);
|
|
1367
|
+
slot._roHost = slot.host;
|
|
1368
|
+
slot._roChild = child || null;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// 贴/重贴 decoration。marker 不动——钉子在缓冲行上,贴纸可换。
|
|
1372
|
+
// 但**元素活着就不换贴纸**:xterm 的新元素要等下次渲染才诞生,而卡高于视口
|
|
1373
|
+
// 时 marker 转眼就在视口上方,_refreshStyle 走藏分支 → 新元素永不诞生、
|
|
1374
|
+
// slot.host 停在被 remove 的旧节点上 = 整卡消失(真机「总结卡位置不对/被遮」
|
|
1375
|
+
// 根因)。高度/宽度都是纯 CSS,改样式即可,无需 dispose+重注册。
|
|
1376
|
+
_applyDecoration(slot, heightRows) {
|
|
1377
|
+
if (this._dead || !slot.marker) return;
|
|
1378
|
+
if (slot.decoration && slot.host && this._hostAlive(slot.host)) {
|
|
1379
|
+
this._styleHost(slot, heightRows);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
// marker 离屏时 vendored xterm **不建元素**(_refreshStyle 的藏分支只对已有
|
|
1383
|
+
// 元素 fire onRender):此刻重贴会 dispose 旧元素而新元素永不诞生 → 卡片
|
|
1384
|
+
// 永久消失(真机「总结卡没了」)。改为挂 forceReapply 推迟,等 marker 滚回
|
|
1385
|
+
// 视口时由 onRender 补重贴;旧贴纸在位,_fixHostVisibility 仍按相交显示。
|
|
1386
|
+
if (slot.decoration && !this._markerOnScreen(slot)) {
|
|
1387
|
+
slot.forceReapply = true;
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (slot.decoration) {
|
|
1391
|
+
try { slot.decoration.dispose(); } catch (e) {}
|
|
1392
|
+
slot.decoration = null;
|
|
1393
|
+
}
|
|
1394
|
+
try {
|
|
1395
|
+
const deco = this.term.registerDecoration({
|
|
1396
|
+
marker: slot.marker,
|
|
1397
|
+
x: 0,
|
|
1398
|
+
width: this.term.cols,
|
|
1399
|
+
height: Math.max(1, heightRows | 0),
|
|
1400
|
+
});
|
|
1401
|
+
if (!deco) { this._fallbackPlain(slot); return; }
|
|
1402
|
+
deco.onRender(el => {
|
|
1403
|
+
slot.host = el;
|
|
1404
|
+
// 卡内 mousedown 不冒泡到 xterm:拖选卡内文字会同时启动终端选区并
|
|
1405
|
+
// 抢焦点(选完字粘贴态错乱)。click 事件独立于冒泡链——按钮照点;
|
|
1406
|
+
// 终端焦点由各决策回调显式 focusCursor 补回
|
|
1407
|
+
if (!el._otMD && typeof el.addEventListener === "function") {
|
|
1408
|
+
el._otMD = true;
|
|
1409
|
+
el.addEventListener("mousedown", ev => ev.stopPropagation());
|
|
1410
|
+
}
|
|
1411
|
+
// 追加而非覆盖:xterm.css 靠 .xterm-decoration 类给装饰元素
|
|
1412
|
+
// position:absolute+z-index(叠在 marker 行上);覆盖成 ot-card-host
|
|
1413
|
+
// 会抹掉该类 → 元素变 static 掉到屏幕块末尾、屏上不可见(真机根因#2)
|
|
1414
|
+
el.classList.add("ot-card-host");
|
|
1415
|
+
// 打字机揭示类随重贴迁移:已 reveal 的卡新 host 补加(不补=动画停在
|
|
1416
|
+
// paused 初始态 opacity:0,卡隐形)。揭示完走终态类(动画不重播)
|
|
1417
|
+
if (slot.revealed) {
|
|
1418
|
+
el.classList.add(slot.revealDone ? "ot-revealed" : "ot-reveal");
|
|
1419
|
+
}
|
|
1420
|
+
// 高度(钉死 px / 内容自撑 auto)、宽度、显隐与 top 统一在这里落:
|
|
1421
|
+
// xterm 只在创建时写一次盒高,且按 marker 行离屏把整元素 display:none
|
|
1422
|
+
// ——高卡顶行先出视口时整卡被误藏(正文还在屏上),故自管。
|
|
1423
|
+
this._styleHost(slot);
|
|
1424
|
+
// 重贴换 host:RO 观察目标跟着迁(记忆守卫防每帧重建)
|
|
1425
|
+
this._watchCardResize(slot);
|
|
1426
|
+
// 重贴会换新 host 元素:island 内部把 root 迁过去(main.jsx mount)
|
|
1427
|
+
if (slot.mounted && this._feed) { this._feed.mount(slot.id, el); return; }
|
|
1428
|
+
// 空闲期 refresh 没赶上、首笔输出才引发渲染:补挂载(pendingMount 路径)
|
|
1429
|
+
if (slot.pendingMount) this._finishMount(slot);
|
|
1430
|
+
});
|
|
1431
|
+
slot.decoration = deco;
|
|
1432
|
+
slot.forceReapply = false; // 重贴已成,推迟原因消费掉(防 onRender 回环)
|
|
1433
|
+
// xterm 按需渲染:重贴后的新元素要等下次渲染 onRender 才交出;空闲期
|
|
1434
|
+
// 重贴(流停后钉死/解钉/resize)会等不到 → host 停在刚 dispose 的旧
|
|
1435
|
+
// 元素上(detached、rect 0、卡消失、量高归 1)。同挂载路径:强制刷视口。
|
|
1436
|
+
try { this.term.refresh(0, this.term.rows - 1); } catch (e) {}
|
|
1437
|
+
} catch (e) {
|
|
1438
|
+
this._fallbackPlain(slot);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// marker 行是否落在视口内——vendored xterm 只为视口内的 marker 建/留元素
|
|
1443
|
+
// (_refreshStyle:t = line - ydisp,t<0 || t>=rows 就 display:none)。
|
|
1444
|
+
_markerOnScreen(slot) {
|
|
1445
|
+
try {
|
|
1446
|
+
const buf = this.term.buffer.active;
|
|
1447
|
+
if (buf !== this.term.buffer.normal) return false;
|
|
1448
|
+
const t = (slot.marker ? slot.marker.line : 0) - buf.viewportY;
|
|
1449
|
+
return t >= 0 && t < this.term.rows;
|
|
1450
|
+
} catch (e) { return true; }
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// 宁藏不盖:流式 phase 卡 fund 之前整卡隐藏(visibility:hidden,不用
|
|
1454
|
+
// display:none——display 下无布局,_fitNeed 的 scrollHeight 恒 0、RO 静默,
|
|
1455
|
+
// need 冻结在出生值,解锁瞬间 auto 高度会先盖住下方文本一轮 150ms 批处理)。
|
|
1456
|
+
// 只认 phase:审批卡藏了就没法点(决策死锁),总结卡出生即完整。
|
|
1457
|
+
// revealed 锁存不复隐:复隐防不了闪烁,后续长高由既有夹紧钉死路径兜住。
|
|
1458
|
+
// 只读便宜字段、每帧经 _fixHostVisibility 调用,不得碰 _capped(遍历缓冲)。
|
|
1459
|
+
_slotHidden(slot) {
|
|
1460
|
+
return !!slot && slot.kind === "phase" && !slot.revealed &&
|
|
1461
|
+
!this._padFused && this._hostAlive(slot.host) &&
|
|
1462
|
+
slot.reserved < slot.rows;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// 隐藏释放沿:fund(reserved 追上 rows)/盖帽/冻结阀。打字机揭示类
|
|
1466
|
+
// (ot-reveal)此刻落上,块动画从 paused 起跑;_styleHost 立即解除
|
|
1467
|
+
// visibility,不等下一帧 onRender。
|
|
1468
|
+
_revealSlot(slot) {
|
|
1469
|
+
if (!slot || slot.revealed) return;
|
|
1470
|
+
slot.revealed = true;
|
|
1471
|
+
if (slot.host && slot.host.classList) slot.host.classList.add("ot-reveal");
|
|
1472
|
+
this._styleHost(slot);
|
|
1473
|
+
// 徽标同步(任务全程常显不变量):揭示不退徽标,只同步三态文案
|
|
1474
|
+
this._refreshThink();
|
|
1475
|
+
// 揭示完成后锁存终态(ot-revealed):动画只跑一次,滚动/重贴不得重播
|
|
1476
|
+
// ——真机「滑动时总结卡刷新加载一次」=打字机动画随 host 迁移重跑
|
|
1477
|
+
setTimeout(() => {
|
|
1478
|
+
slot.revealDone = true;
|
|
1479
|
+
const h = slot.host;
|
|
1480
|
+
if (h && h.classList && typeof h.classList.remove === "function") {
|
|
1481
|
+
h.classList.remove("ot-reveal");
|
|
1482
|
+
h.classList.add("ot-revealed");
|
|
1483
|
+
}
|
|
1484
|
+
}, 1200); // 250ms 动画 + 逐块延迟(块多时约 1s)留足余量
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// 高卡可见性:卡占 [marker+回显偏移, +max(rows,pinnedRows)) 行,与视口
|
|
1488
|
+
// [ydisp, ydisp+term.rows) 相交就显示;top 用顶行算(可为负 = 上部滚出、
|
|
1489
|
+
// 由 .term 的 overflow:hidden 裁掉,等同终端内容滚屏)。alt 屏保持 xterm 的藏。
|
|
1490
|
+
_fixHostVisibility(slot, el) {
|
|
1491
|
+
try {
|
|
1492
|
+
const buf = this.term.buffer.active;
|
|
1493
|
+
if (buf !== this.term.buffer.normal) return;
|
|
1494
|
+
// 宁藏不盖:fund 之前的流式 phase 卡整卡隐身(visibility 与 xterm 自己
|
|
1495
|
+
// 每帧写回的 display 通道正交,互不干扰),思考徽标接班
|
|
1496
|
+
if (this._slotHidden(slot)) {
|
|
1497
|
+
el.style.visibility = "hidden";
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
// 零空白盖帽:区内连一行空白都没有(回显/输出把预留区整段插死)——露出
|
|
1501
|
+
// 必压活文本/被输出切成半行细条,整卡藏(宁藏不盖);pad 落地/空白出现后
|
|
1502
|
+
// top 与 maxHeight 自然放行。钉死态同样按零空白藏:钉高只保证钉那一刻的
|
|
1503
|
+
// run,输出随后把 run 吃掉后钉高就压在活文本上(工具卡「✓ 完成」细条被
|
|
1504
|
+
// 输出切半行的根因);blankSpan ≥ 1 维持现状露出已垫部分。任务定格
|
|
1505
|
+
// (collapse/final/fail,frozen)后不再藏:冻结阀/收束结账是「不留隐形卡」
|
|
1506
|
+
// 的放出档。熔断态不藏(保活优先);审批卡豁免(按钮不可点=决策死锁)。
|
|
1507
|
+
if (slot.marker && slot.kind !== "approval" && !slot.frozen &&
|
|
1508
|
+
!this._padFused && this._blankSpan(slot) === 0) {
|
|
1509
|
+
el.style.visibility = "hidden";
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
el.style.visibility = "visible";
|
|
1513
|
+
// 硬线几何守卫:盒底越过「当前」空白 run 就立刻钉死(实测渲染高,不信任
|
|
1514
|
+
// 账目滞后)——cover 的三个来源(账目 rows 滞后于渲染高、auto 自撑冲过
|
|
1515
|
+
// run、pad 擦自身回显行把下方内容整体上移)全都在这一步掐断,不等
|
|
1516
|
+
// 150ms 批量、也不等 heal 的 250ms 静默门(命令执行期 heal 被挡,真机
|
|
1517
|
+
// cover 持续 6s 全耗在这窗口)。100ms 节流:blankSpan/offsetHeight 逐帧
|
|
1518
|
+
// 量太贵。钉死后由 _reanchor 非 short 分支在垫够时解钉。
|
|
1519
|
+
if (slot.revealed && slot.pinnedRows == null &&
|
|
1520
|
+
Date.now() - (slot._guardAt || 0) > 100) {
|
|
1521
|
+
slot._guardAt = Date.now();
|
|
1522
|
+
// 审批卡底线抬高 safe:钉死也不许低于按钮行(见 _floorRows)
|
|
1523
|
+
const safe = Math.max(this._blankSpan(slot), this._floorRows(slot));
|
|
1524
|
+
const drawn = Math.max(slot.rows || 1,
|
|
1525
|
+
Math.ceil((el.offsetHeight || 0) / this._lineHeight()));
|
|
1526
|
+
if (safe >= 1 && drawn > safe) {
|
|
1527
|
+
this._clampCard(slot);
|
|
1528
|
+
slot.pinnedRows = Math.max(1, safe);
|
|
1529
|
+
slot.reserved = safe;
|
|
1530
|
+
this._styleHost(slot);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
const ydisp = buf.viewportY;
|
|
1534
|
+
// 视觉顶 = marker + 回显偏移:marker 钉在活回显行上时卡体从下一行起,
|
|
1535
|
+
// 不把 zsh 原位重画的提示符/回显行盖掉(锚点对齐空白 run,见 _echoSkip)
|
|
1536
|
+
const top = (slot.marker ? slot.marker.line : 0) + this._echoSkip(slot);
|
|
1537
|
+
const bot = top + Math.max(1, slot.rows || 1, slot.pinnedRows || 1) - 1;
|
|
1538
|
+
const inView = bot >= ydisp && top < ydisp + this.term.rows;
|
|
1539
|
+
const wantDisp = inView ? "block" : "none";
|
|
1540
|
+
if (inView) {
|
|
1541
|
+
el.style.display = "block";
|
|
1542
|
+
el.style.top = (top - ydisp) * this._lineHeight() + "px";
|
|
1543
|
+
} else {
|
|
1544
|
+
el.style.display = "none";
|
|
1545
|
+
}
|
|
1546
|
+
} catch (e) {}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// 元素活着就只改样式(不 dispose+重注册):盒高、宽度、显隐/top 全自管。
|
|
1550
|
+
// heightRows 省略时按当前账目(钉死态 pinnedRows,自撑态 auto)。
|
|
1551
|
+
_styleHost(slot, heightRows) {
|
|
1552
|
+
const el = slot.host;
|
|
1553
|
+
if (!this._hostAlive(el)) { slot.forceReapply = true; return; }
|
|
1554
|
+
// 块流槽:位置与高度由流式布局决定(CSS 高度自撑),不许用 marker/top/
|
|
1555
|
+
// clamp 那套去钉——那正是「卡片占网格行」的几何,会把块拽回浮层。
|
|
1556
|
+
if (slot.blockMode) return;
|
|
1557
|
+
try {
|
|
1558
|
+
el.dataset.mline = slot.marker ? slot.marker.line : -1;
|
|
1559
|
+
el.dataset.bspan = this._blankSpan(slot);
|
|
1560
|
+
el.dataset.rows = slot.rows;
|
|
1561
|
+
el.dataset.pinned = slot.pinnedRows == null ? -1 : slot.pinnedRows;
|
|
1562
|
+
el.dataset.need = slot.need;
|
|
1563
|
+
} catch (e) {}
|
|
1564
|
+
const lh = this._lineHeight();
|
|
1565
|
+
// 钉死高每帧兜底到交互底线:pinnedRows 是 clamp 那一刻记的,按钮在窗口
|
|
1566
|
+
// 变窄/系统缩放后会换行长高、或 clamp 时按钮还没布局完量矮了一行——
|
|
1567
|
+
// 按旧值钉死就把按钮行裁出盒 = 真鼠标点不到 = 决策死锁(红线⑨)。只在
|
|
1568
|
+
// 武装时抬一次底线不够,必须每次 _styleHost 都取当前 _floorRows。
|
|
1569
|
+
const floor = this._floorRows(slot);
|
|
1570
|
+
const rows = Math.max(1, floor, (heightRows != null ? heightRows
|
|
1571
|
+
: (slot.pinnedRows != null ? slot.pinnedRows : slot.rows)) | 0);
|
|
1572
|
+
// 硬线上盖:盒矩形任何一帧都不许越过当前空白 run(auto 自撑冲high、
|
|
1573
|
+
// 钉死高滞后于擦行上移、账目 rows 滞后渲染高——三个 cover 来源全靠这
|
|
1574
|
+
// 一道 maxHeight 在绘制前掐死;onRender 每帧路过,缓冲上移当帧生效)。
|
|
1575
|
+
// 内容超出部分盒内裁掉(宁矮不盖),垫够后 _reanchor 解钉/刷新即长回。
|
|
1576
|
+
// 编辑浮层开启期不盖:下拉框伸出盒外是本意(同 overflow 豁免口径)
|
|
1577
|
+
if (slot.marker && !this._padFused && !(typeof el.querySelector === "function"
|
|
1578
|
+
&& el.querySelector(".edopen"))) {
|
|
1579
|
+
// 审批卡底线:盖帽高度不低过按钮行(空白不够时短暂压提示符行,
|
|
1580
|
+
// 好过按钮被裁=决策死锁);phase/summary 底线 0,硬线不变
|
|
1581
|
+
const safe = Math.max(this._blankSpan(slot), this._floorRows(slot));
|
|
1582
|
+
el.style.maxHeight = Math.round(safe * lh) + "px";
|
|
1583
|
+
el.style.overflow = "hidden";
|
|
1584
|
+
} else if (slot.marker) {
|
|
1585
|
+
el.style.maxHeight = "none";
|
|
1586
|
+
}
|
|
1587
|
+
if (slot.clamped || slot.pinnedRows != null) {
|
|
1588
|
+
// 钉死截断:xterm 创建时写的盒高不随内容变,这里按已垫行数覆写
|
|
1589
|
+
el.style.height = Math.round(rows * lh) + "px";
|
|
1590
|
+
el.style.overflow = "hidden";
|
|
1591
|
+
} else {
|
|
1592
|
+
// 内容自撑:量高(scrollHeight)与流式增长(RO)才有真信号
|
|
1593
|
+
el.style.height = "auto";
|
|
1594
|
+
}
|
|
1595
|
+
const cw = this._cellWidth();
|
|
1596
|
+
if (cw > 0) el.style.width = Math.round(this.term.cols * cw) + "px";
|
|
1597
|
+
this._fixHostVisibility(slot, el);
|
|
1598
|
+
slot.forceReapply = false;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
_hostAlive(el) {
|
|
1602
|
+
try { return !!el && el.isConnected; } catch (e) { return false; }
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// 审批交互底线(行数):夹紧/钉死/盖帽高度都不许低过它——按钮不可点=
|
|
1606
|
+
// 决策死锁(红线⑨)。底线量到「按钮簇底」为准(夹紧只裁底,裁掉的不得
|
|
1607
|
+
// 是按钮簇):
|
|
1608
|
+
// 命令卡(Phase 2 二合一,kind='phase'):按钮簇 .cmdacts 在**卡头右侧**
|
|
1609
|
+
// (还原原版布局),底线=卡头高——夹紧裁底只丢状态行,按钮恒可点。
|
|
1610
|
+
// 救援卡(kind='approval'):按钮在 .aphead(首行),底线是首行高。
|
|
1611
|
+
// 空白 run 不够时宁可短暂压住下方提示符行(pad 落地解钉即让开),功能
|
|
1612
|
+
// 优先于观感硬线;无交互行的卡恒 0(硬线 coveredTextRows 不变)。
|
|
1613
|
+
// 只认**真有按钮**的卡:旧实现把任何 .aphead 都当交互行,AlertCard/
|
|
1614
|
+
// ToolCard 的卡头恰是它们唯一的定高行,于是夹紧恒钉在卡头高、正文永远
|
|
1615
|
+
// 被裁出盒(真机图一「⚠ 系统警告」只剩标题、错误正文整段不可见)。
|
|
1616
|
+
_floorRows(slot) {
|
|
1617
|
+
if (slot.blockMode) return 0;
|
|
1618
|
+
if (!this._hostAlive(slot.host)) return 0;
|
|
1619
|
+
try {
|
|
1620
|
+
const lh = this._lineHeight();
|
|
1621
|
+
const acts = slot.host.querySelector(".cmdacts");
|
|
1622
|
+
if (acts && typeof acts.getBoundingClientRect === "function" &&
|
|
1623
|
+
typeof slot.host.getBoundingClientRect === "function") {
|
|
1624
|
+
const hostTop = slot.host.getBoundingClientRect().top;
|
|
1625
|
+
const bottom = acts.getBoundingClientRect().bottom;
|
|
1626
|
+
const rows = Math.ceil((bottom - hostTop) / lh);
|
|
1627
|
+
return rows > 0 ? rows : 0;
|
|
1628
|
+
}
|
|
1629
|
+
const head = slot.host.querySelector(".aphead");
|
|
1630
|
+
if (!head || typeof head.getBoundingClientRect !== "function") return 0;
|
|
1631
|
+
// 卡头里没有按钮 = 不是交互行(警告/工具卡头只有标签与徽标)
|
|
1632
|
+
if (!head.querySelector("button")) return 0;
|
|
1633
|
+
const h = head.getBoundingClientRect().height;
|
|
1634
|
+
return h > 0 ? Math.ceil(h / lh) : 0;
|
|
1635
|
+
} catch (e) { return 0; }
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// 字宽实测(同 _lineHeight,不拍脑袋):resize 后刷卡宽用
|
|
1639
|
+
_cellWidth() {
|
|
1640
|
+
const rowsEl = this.term.element &&
|
|
1641
|
+
this.term.element.querySelector(".xterm-rows");
|
|
1642
|
+
if (rowsEl) {
|
|
1643
|
+
const w = rowsEl.getBoundingClientRect().width;
|
|
1644
|
+
if (w > 0 && this.term.cols) return w / this.term.cols;
|
|
1645
|
+
}
|
|
1646
|
+
return 0;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// 量高 → need = rows+1(+1 保险,卡与输出间 ≤1 空行)
|
|
1650
|
+
_fitNeed(slot) {
|
|
1651
|
+
if (slot.blockMode) return;
|
|
1652
|
+
if (!this._hostAlive(slot.host)) return; // 元素未诞生/已移除:不量,账目不动
|
|
1653
|
+
// 编辑框开启期不计账:编辑框浮在卡下方行上、纯属临时 UI——否则 pad 会为
|
|
1654
|
+
// 编辑框高度预留空行,取消/拒绝后卡缩回而预留空行无法回收=卡下大白空
|
|
1655
|
+
// (真机图二)。视窗撑高由 EditorCard 自管,与账目解耦
|
|
1656
|
+
if (slot.host.querySelector(".edopen")) return;
|
|
1657
|
+
let h = 0;
|
|
1658
|
+
try { h = slot.host.scrollHeight; } catch (e) {}
|
|
1659
|
+
// 量到 0(元素被藏/display:none 间隙)不动账目:rows 打回 1 会把已垫
|
|
1660
|
+
// 的空白判成「预留过量」,夹紧/截断误触发
|
|
1661
|
+
if (h <= 0) return;
|
|
1662
|
+
const n = rowsForPx(h, this._lineHeight());
|
|
1663
|
+
slot.rows = n;
|
|
1664
|
+
// 出生即定型(Phase 4):内容完整才挂载、挂后高度钉死,不存在「流式长高」
|
|
1665
|
+
// ——need 就是量高 + 1 保险行。旧 +5 长高裕量只服务流式时代(空白行不可
|
|
1666
|
+
// 回吐,落地即卡间大缝隙根因,真机 2026-09-30),已随长高机械一并拆除。
|
|
1667
|
+
slot.need = n + 1;
|
|
1668
|
+
// 编辑框浮层开启期宿主须 overflow visible(浮层伸出盒外),量高/夹紧不得
|
|
1669
|
+
// 把它钉回 hidden——否则下拉编辑框被裁(真机「修改后无法编辑」成因之一)
|
|
1670
|
+
if (!slot.host.querySelector(".edopen")) slot.host.style.overflow = "hidden";
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// 一发搬移:预留区被随后落下的输出挤断时,把**本卡自己**的锚搬到流底。
|
|
1674
|
+
//
|
|
1675
|
+
// 连接期的典型时序:卡在横幅后、命令集执行前挂载,marker 钉在提示符行;
|
|
1676
|
+
// pad 要过 _runner_lock(命令集的注入正持着),往返期间命令回显 + 登录输出
|
|
1677
|
+
// 已经落进 marker 行之下,把预留区的连续空白挤断——_echoSkip 跳到第一处
|
|
1678
|
+
// 空白,区里只剩 1 行,need=7 却只能垫 1 行,卡被夹紧成只剩卡头的细条
|
|
1679
|
+
// (真机图一「⚠ 系统警告只剩标题」)。pad 只能追加流底,垫不进被挤断的区。
|
|
1680
|
+
//
|
|
1681
|
+
// 搬移只动自己的锚:换 marker 后再 dispose 旧钉(onDispose 的
|
|
1682
|
+
// `slot.marker === marker` 守卫认的是当前钉,故不会误触发 _discardSlot),
|
|
1683
|
+
// 不碰别的卡、不偷别人的预留——与已拆除的 _stealBlankBelow 不同。
|
|
1684
|
+
// 只在「预留不足一半」时一发:搬移换贴纸、React root 迁移,不宜反复。
|
|
1685
|
+
async _relocateToBottom(slot) {
|
|
1686
|
+
if (this._dead || !slot.marker || !slot.host) return false;
|
|
1687
|
+
try {
|
|
1688
|
+
const buf = this.term.buffer.active;
|
|
1689
|
+
const cursorAbs = buf.baseY + buf.cursorY;
|
|
1690
|
+
if (cursorAbs <= slot.marker.line) return false; // 没有下方空间
|
|
1691
|
+
// 流底往下要真有成片空白才值得搬(越界行视为空白,故上限放开)
|
|
1692
|
+
const free = blankRun(buf, cursorAbs + 1, cursorAbs + 1 + (slot.rows || 1) + 2);
|
|
1693
|
+
if (free < (slot.rows || 1)) return false;
|
|
1694
|
+
const old = slot.marker;
|
|
1695
|
+
const m = this.term.registerMarker(0);
|
|
1696
|
+
if (!m) return false;
|
|
1697
|
+
slot.marker = m;
|
|
1698
|
+
m.onDispose(() => { if (slot.marker === m) this._discardSlot(slot); });
|
|
1699
|
+
try { old.dispose(); } catch (e) {}
|
|
1700
|
+
if (slot.decoration) { try { slot.decoration.dispose(); } catch (e) {} }
|
|
1701
|
+
slot.decoration = null;
|
|
1702
|
+
slot.host = null;
|
|
1703
|
+
slot.clamped = false;
|
|
1704
|
+
slot.pinnedRows = null;
|
|
1705
|
+
slot.reserved = 0;
|
|
1706
|
+
slot.padOut = 0;
|
|
1707
|
+
this._applyDecoration(slot, 2);
|
|
1708
|
+
const t0 = Date.now();
|
|
1709
|
+
while (!slot.host && Date.now() - t0 < 500) await this._sleep(10);
|
|
1710
|
+
if (slot.host && this._feed && slot.mounted) this._feed.mount(slot.id, slot.host);
|
|
1711
|
+
// 新 host:RO 观察目标要跟着迁,否则后续长高不再感知
|
|
1712
|
+
if (slot.host) this._watchCardResize(slot);
|
|
1713
|
+
return !!slot.host;
|
|
1714
|
+
} catch (e) {
|
|
1715
|
+
return false;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// 重贴/decoration 高度策略:流底卡高度由内容自撑(host inline height=auto),
|
|
1720
|
+
// 长高只补 pad 不重贴(重贴换新元素=React 重挂载,流式期间会闪);仅三种情况
|
|
1721
|
+
// 重贴:解除夹紧、上游夹紧钉高、resize 刷宽度(forceReapply)。
|
|
1722
|
+
_reanchor(slot) {
|
|
1723
|
+
if (slot.blockMode) return;
|
|
1724
|
+
if (this._dead || !slot.marker) return;
|
|
1725
|
+
this._fitNeed(slot);
|
|
1726
|
+
slot.reserved = this._blankSpan(slot); // 落账只认缓冲,每步重数
|
|
1727
|
+
// 缺行就截断(宁矮不盖):垫得进去的由 _ensurePad 补上后自动解除,
|
|
1728
|
+
// 垫不进去的(盖帽:下方已有输出/别的卡)就此钉死到已垫高度。
|
|
1729
|
+
// 钉死高度 = 预留空白 run 的行数(审批卡例外:不低于按钮行 _floorRows,
|
|
1730
|
+
// 宁短暂压提示符行也不许决策死锁):卡体顶行就是 run 首行(视觉 top 含
|
|
1731
|
+
// 回显偏移,见 _fixHostVisibility),run 有多长盒就多高,盒底恰好压在
|
|
1732
|
+
// run 末行上,不会越过它盖住下面的活提示符/输出。
|
|
1733
|
+
const short = slot.reserved < slot.rows;
|
|
1734
|
+
if (short) {
|
|
1735
|
+
// 钉死高度不低于交互底线(_floorRows):审批卡被钉到按钮行之下=
|
|
1736
|
+
// 按钮不可点=决策死锁(红线⑨)。非审批卡 floorRows 恒 0,行为不变。
|
|
1737
|
+
const rows = Math.max(1, slot.reserved, this._floorRows(slot));
|
|
1738
|
+
this._clampCard(slot);
|
|
1739
|
+
if (slot.pinnedRows !== rows) {
|
|
1740
|
+
slot.pinnedRows = rows;
|
|
1741
|
+
this._applyDecoration(slot, rows);
|
|
1742
|
+
}
|
|
1743
|
+
// 已盖帽(下方被输出/别的卡占住):blankSpan ≥ 1 按夹紧路径放出、露出
|
|
1744
|
+
// 已垫部分(宁藏不盖只遮「等 pad 落地」的过渡期);盖帽零垫(一行空白
|
|
1745
|
+
// 都没有)的非审批卡不走「盖帽即放行」——露出必被输出切成半行细条,
|
|
1746
|
+
// 整卡藏到定格/冻结阀放出。权衡:永久盖帽卡徽标陪跑观感差于细条,故
|
|
1747
|
+
// 只藏零空白档,≥1 行档维持现状
|
|
1748
|
+
if (this._capped(slot)) {
|
|
1749
|
+
if (slot.kind !== "approval" && this._blankSpan(slot) === 0) {
|
|
1750
|
+
this._refreshThink(); // 卡不放行:徽标继续接班
|
|
1751
|
+
} else {
|
|
1752
|
+
this._revealSlot(slot);
|
|
1753
|
+
}
|
|
1754
|
+
} else {
|
|
1755
|
+
this._refreshThink(); // 显→隐沿:夹紧隐藏态让思考徽标接班
|
|
1756
|
+
}
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (this._padFused) return; // 熔断后保持钉死截断,不回升
|
|
1760
|
+
// 流底卡长高**不重贴**:重贴=dispose+新元素+React root 迁移重挂载,
|
|
1761
|
+
// 流式期间批量重贴就是一闪一闪。盒高靠 onRender 的 height:auto 自撑,
|
|
1762
|
+
// 长高的记账靠事件钩子+子节点 RO。仅解钉/钉死/resize 才换贴纸。
|
|
1763
|
+
if (slot.clamped || slot.pinnedRows != null || slot.forceReapply) {
|
|
1764
|
+
slot.clamped = false;
|
|
1765
|
+
slot.pinnedRows = null;
|
|
1766
|
+
slot.forceReapply = false;
|
|
1767
|
+
this._applyDecoration(slot, Math.max(1, slot.rows));
|
|
1768
|
+
}
|
|
1769
|
+
this._revealSlot(slot); // reserved ≥ rows:fund 到位,放行(锁存)
|
|
1770
|
+
this._followBottom();
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// 跟随滚动:卡长高/补差不产生新缓冲行,xterm 不会自动滚——视口原贴底时
|
|
1774
|
+
// 跟到底,否则流式内容藏在折下看不见(真机「分析卡触底还在输出、屏幕不滚」)
|
|
1775
|
+
_followBottom() {
|
|
1776
|
+
try {
|
|
1777
|
+
const b = this.term.buffer.active;
|
|
1778
|
+
if (b.viewportY >= b.baseY - 1) this.term.scrollToBottom();
|
|
1779
|
+
} catch (e) {}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// --- Agent 空提示符占位提示(幽灵文本)---
|
|
1783
|
+
// 终端是真 PTY、没有输入框:光标停在提示符行尾且一切安静时,挂一个灰字
|
|
1784
|
+
// decoration 当占位提示;任何输入痕迹立即藏,回车/控制键后静默窗重显。
|
|
1785
|
+
// 显示条件全显示态判定(内容回显进缓冲),shell 改提示符/cd 后依然成立。
|
|
1786
|
+
// 输入痕迹判据:含任何非 C0 控制字节即算(含 Backspace \x7f——编辑中也是
|
|
1787
|
+
// 痕迹)——见 term.onData 内注释
|
|
1788
|
+
_ghostTrace(d) { return /[^\x00-\x1f]/.test(d); }
|
|
1789
|
+
|
|
1790
|
+
_updateGhost() {
|
|
1791
|
+
if (this._dead) return;
|
|
1792
|
+
let show = this.mode === "agent" && this.interactive &&
|
|
1793
|
+
!this._taskActive && !this._approvalOpen && !this._ghostHide;
|
|
1794
|
+
let line = -1, x = 0;
|
|
1795
|
+
if (show) {
|
|
1796
|
+
try {
|
|
1797
|
+
const buf = this.term.buffer.active;
|
|
1798
|
+
if (buf.type !== "normal") show = false;
|
|
1799
|
+
else {
|
|
1800
|
+
line = buf.baseY + buf.cursorY;
|
|
1801
|
+
x = buf.cursorX;
|
|
1802
|
+
const t = buf.getLine(line).translateToString(true);
|
|
1803
|
+
// 光标须在行尾(容尾随空白)、行非空(=提示符行)、PTY 字节静默;
|
|
1804
|
+
// 密码提示符行永远不显(正讨密码,与自然语言无关)
|
|
1805
|
+
show = t.trim() !== "" && x >= t.replace(/\s+$/, "").length &&
|
|
1806
|
+
!this._pwPromptLine() &&
|
|
1807
|
+
Date.now() - (this._lastByteAt || 0) > 400 &&
|
|
1808
|
+
Date.now() >= this._ghostQuietUntil;
|
|
1809
|
+
}
|
|
1810
|
+
} catch (e) { show = false; }
|
|
1811
|
+
}
|
|
1812
|
+
if (!show) return this._hideGhost();
|
|
1813
|
+
const key = line + ":" + x;
|
|
1814
|
+
if (this._ghostDec && this._ghostKey === key) return;
|
|
1815
|
+
this._hideGhost();
|
|
1816
|
+
try {
|
|
1817
|
+
this._ghostMarker = this.term.registerMarker(0); // 钉此刻光标行
|
|
1818
|
+
if (!this._ghostMarker) return;
|
|
1819
|
+
this._ghostDec = this.term.registerDecoration({
|
|
1820
|
+
marker: this._ghostMarker, x,
|
|
1821
|
+
width: Math.max(1, this.term.cols - x), height: 1,
|
|
1822
|
+
});
|
|
1823
|
+
if (!this._ghostDec) return this._hideGhost();
|
|
1824
|
+
this._ghostKey = key;
|
|
1825
|
+
this._ghostDec.onRender(el => {
|
|
1826
|
+
el.classList.add("ot-ghost");
|
|
1827
|
+
el.textContent = "输入自然语言任务,AI 帮你执行;? 前缀强制 AI,! 强制命令";
|
|
1828
|
+
});
|
|
1829
|
+
try { this.term.refresh(0, this.term.rows - 1); } catch (e) {}
|
|
1830
|
+
} catch (e) { this._hideGhost(); }
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
_hideGhost() {
|
|
1834
|
+
if (this._ghostDec) {
|
|
1835
|
+
try { this._ghostDec.dispose(); } catch (e) {}
|
|
1836
|
+
this._ghostDec = null;
|
|
1837
|
+
}
|
|
1838
|
+
if (this._ghostMarker) {
|
|
1839
|
+
try { this._ghostMarker.dispose(); } catch (e) {}
|
|
1840
|
+
this._ghostMarker = null;
|
|
1841
|
+
}
|
|
1842
|
+
this._ghostKey = "";
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// 定格即结账:卡还在流底(垫得进去)时把最终高度一次垫满。等 React 把
|
|
1846
|
+
// 定格内容渲染完再量(两帧),否则量到半截高度,随后下层卡一锚定就永久
|
|
1847
|
+
// 盖帽、只能截断(真机分析卡底部切 "---" 即此)。同卡并发只跑一次。
|
|
1848
|
+
_settleCard(slot) {
|
|
1849
|
+
if (this._dead || !slot || !slot.marker) return Promise.resolve();
|
|
1850
|
+
if (slot.settling) return slot.settling;
|
|
1851
|
+
if (slot.reanchorT) { clearTimeout(slot.reanchorT); slot.reanchorT = null; }
|
|
1852
|
+
slot.settling = (async () => {
|
|
1853
|
+
this._holdBytes("card-settle");
|
|
1854
|
+
// 定格接管结账=漂移批量窗被取消(只清定时器,无长高扣流记账)
|
|
1855
|
+
try {
|
|
1856
|
+
// 空闲期补 host:PTY 静默窗里槽可能还 pendingMount(无 host、React 内容
|
|
1857
|
+
// 没落地、高度停在出生值)——按出生高结账等于没结:等真高度量出来时
|
|
1858
|
+
// 下层卡/命令回显已锚定,pad 只追加在流底、垫不进已闭合的区=卡永久
|
|
1859
|
+
// 截断在出生高、下层卡贴着切边压脸(真机 2026-10-06:工具卡只剩半截
|
|
1860
|
+
// 头、分析卡紧贴其下)。强制刷视口让 onRender 空闲期交 host,再按真高
|
|
1861
|
+
// 结账。离屏 marker xterm 不建元素,不等空(_markerOnScreen 门卫)
|
|
1862
|
+
if (!slot.host && this._markerOnScreen(slot)) {
|
|
1863
|
+
try { this.term.refresh(0, this.term.rows - 1); } catch (e) {}
|
|
1864
|
+
const t0 = Date.now();
|
|
1865
|
+
while (!slot.host && !this._dead && Date.now() - t0 < 300) {
|
|
1866
|
+
await this._sleep(10);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
await new Promise(r => requestAnimationFrame(() => r()));
|
|
1870
|
+
await new Promise(r => requestAnimationFrame(() => r()));
|
|
1871
|
+
if (this._dead || !slot.marker) return;
|
|
1872
|
+
await this._drainHeld();
|
|
1873
|
+
this._fitNeed(slot);
|
|
1874
|
+
slot.reserved = this._blankSpan(slot);
|
|
1875
|
+
await this._ensurePad(slot);
|
|
1876
|
+
await this._drainHeld(); // 结账的空行落地了才算得准,否则白垫一轮
|
|
1877
|
+
this._reanchor(slot);
|
|
1878
|
+
} finally {
|
|
1879
|
+
this._releaseBytes();
|
|
1880
|
+
slot.settling = null;
|
|
1881
|
+
}
|
|
1882
|
+
})();
|
|
1883
|
+
return slot.settling;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// 放行决策前先给审批卡结账:worker 一收到决策就注入命令,输出随即从卡底
|
|
1887
|
+
// 流走——预留区被真实输出断开即永久盖帽,此后卡只能按已垫高度截断(真机
|
|
1888
|
+
// 审批卡 7 行内容只剩 2 行、"是否同意…" 切在半句)。此刻 shell 正阻塞等
|
|
1889
|
+
// 审批、字节静默,是最后一块垫得进去的窗口。垫不上也不许拖住决策:600ms
|
|
1890
|
+
// 兜底放行(审批响应速度优先于占位美观)。
|
|
1891
|
+
_releaseDecision(decision, cardId) {
|
|
1892
|
+
let sent = false;
|
|
1893
|
+
const once = () => {
|
|
1894
|
+
if (sent || this._dead) return;
|
|
1895
|
+
sent = true;
|
|
1896
|
+
this.sendJson({type: "decision", decision});
|
|
1897
|
+
};
|
|
1898
|
+
const slot = cardId == null ? null : this._slots.get(cardId);
|
|
1899
|
+
if (!slot || !slot.marker || this._padFused) { once(); return; }
|
|
1900
|
+
const timer = setTimeout(once, 600);
|
|
1901
|
+
Promise.resolve().then(() => this._settleCard(slot))
|
|
1902
|
+
.catch(() => {})
|
|
1903
|
+
.then(() => { clearTimeout(timer); once(); });
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// 高度漂移处理(Phase 4,长高机械已拆除):卡出生即定型,RO 只报
|
|
1907
|
+
// 「内容高 ≠ 预留」的漂移(典型来源:resize 折行变化、字体加载)。
|
|
1908
|
+
// 处置只有两条路,永不长高:
|
|
1909
|
+
// ① 立即夹紧钉死到已垫高度(防 auto 盒越过预留区盖住下方活文本);
|
|
1910
|
+
// ② 流底卡给一次性重垫机会(pad 只追加流底;_ensurePad 自带 card-pad
|
|
1911
|
+
// 扣流窗,与旧「长高扣流」记账无关),垫上自动解钉、垫不上维持截断。
|
|
1912
|
+
// 上游卡(非流底)夹紧即终态——scrollback 中间物理插不进新行。
|
|
1913
|
+
// 150ms 节流:夹紧自身也触发 RO,防抖会饿死、连发会撕贴纸。
|
|
1914
|
+
_scheduleDrift(slot) {
|
|
1915
|
+
if (this._dead) return;
|
|
1916
|
+
this._fitNeed(slot);
|
|
1917
|
+
// 感知即钉盒(只改样式不动贴纸):漂移窗里 auto 盒会越过空白 run 盖住
|
|
1918
|
+
// 下方活提示符行(硬线 coveredTextRows)
|
|
1919
|
+
if (slot.revealed && slot.rows > slot.reserved && slot.pinnedRows == null) {
|
|
1920
|
+
this._clampCard(slot);
|
|
1921
|
+
// 审批卡钉死高度不低过按钮行(_floorRows):细条态按钮不可点=死锁
|
|
1922
|
+
slot.pinnedRows = Math.max(1, slot.reserved, this._floorRows(slot));
|
|
1923
|
+
this._styleHost(slot);
|
|
1924
|
+
}
|
|
1925
|
+
if (slot.reanchorT) return;
|
|
1926
|
+
slot.reanchorT = setTimeout(async () => {
|
|
1927
|
+
slot.reanchorT = null;
|
|
1928
|
+
if (this._dead || !slot.marker) return;
|
|
1929
|
+
if (this._capped(slot) || this._padFused) { this._reanchor(slot); return; }
|
|
1930
|
+
// 一次性重垫只给流底卡:pad 只追加在流底,上游垫不进(盖帽即终态)
|
|
1931
|
+
const bottom = [...this._slots.values()].filter(o => o.marker)
|
|
1932
|
+
.sort((a, b) => a.marker.line - b.marker.line).pop();
|
|
1933
|
+
if (bottom !== slot) { this._reanchor(slot); return; }
|
|
1934
|
+
this._holdBytes("card-drift");
|
|
1935
|
+
try {
|
|
1936
|
+
await this._ensurePad(slot);
|
|
1937
|
+
await this._drainHeld();
|
|
1938
|
+
this._reanchor(slot);
|
|
1939
|
+
} finally {
|
|
1940
|
+
this._releaseBytes();
|
|
1941
|
+
}
|
|
1942
|
+
}, 150);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// 夹紧:卡 DOM 截断,不给卡内滚动条——宁可矮,不许盖网格
|
|
1946
|
+
_clampCard(slot) {
|
|
1947
|
+
if (slot.blockMode) return;
|
|
1948
|
+
slot.clamped = true;
|
|
1949
|
+
if (slot.host && !slot.host.querySelector(".edopen")) {
|
|
1950
|
+
slot.host.style.overflow = "hidden";
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// --- pad 账本(先数后打 + 落账只认缓冲)---
|
|
1955
|
+
|
|
1956
|
+
// 区界:光标行,但被「下方最近一张卡的 marker」夹紧——每卡账目独立,
|
|
1957
|
+
// 不借邻卡 pad 顶出来的空白。
|
|
1958
|
+
_zoneLimit(slot) {
|
|
1959
|
+
const buf = this.term.buffer.active;
|
|
1960
|
+
let end = buf.baseY + buf.cursorY;
|
|
1961
|
+
for (const o of this._slots.values()) {
|
|
1962
|
+
if (o !== slot && o.marker &&
|
|
1963
|
+
o.marker.line > slot.marker.line && o.marker.line < end) {
|
|
1964
|
+
end = o.marker.line;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
return end;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// 回显偏移:从 marker 扫到**首行空白** = 卡视觉顶/区顶对齐空白 run 首行,
|
|
1971
|
+
// 领头所有非空白行(活提示符/回显及其折行)整体跳过——盒体任何一帧都不压
|
|
1972
|
+
// 活文本行。旧判据只跳 1 行:回显折两行时第二行仍被盒顶盖住(真机 cover)。
|
|
1973
|
+
// 区内全无空白(被回显/输出整段插死)返回 0 + blankSpan=0 → 走隐藏路径
|
|
1974
|
+
// (宁藏不盖);pad 落地擦掉回显行后自然对齐回空白 run 首行。
|
|
1975
|
+
_echoSkip(slot) {
|
|
1976
|
+
if (!slot.marker) return 0;
|
|
1977
|
+
try {
|
|
1978
|
+
const buf = this.term.buffer.active;
|
|
1979
|
+
for (let i = 0; i < 8; i++) {
|
|
1980
|
+
const line = buf.getLine(slot.marker.line + i);
|
|
1981
|
+
const s = line ? line.translateToString(true) : "";
|
|
1982
|
+
if (!s || !s.trim()) return i;
|
|
1983
|
+
}
|
|
1984
|
+
return 0;
|
|
1985
|
+
} catch (e) {
|
|
1986
|
+
return 0;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// 预留区顶行 = marker + 回显偏移:账目与视觉都从卡真正压住的第一行起算,
|
|
1991
|
+
// marker 钉在活回显行上时不把那行非空白算进/盖进预留区(锚点对齐空白 run)。
|
|
1992
|
+
_regionStart(slot) {
|
|
1993
|
+
return slot.marker ? slot.marker.line + this._echoSkip(slot) : 0;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// 预留区 = 从区顶(marker+回显偏移)起的**连续**空白行数,遇真实输出/区界
|
|
1997
|
+
// 即停。唯一权威是缓冲;连续才作数——中间夹了输出,pad 就再也垫不进本区了。
|
|
1998
|
+
// 两种几何下含义一致:都等于「卡视觉顶行之下(含顶行若是空白)的连续空白」,
|
|
1999
|
+
// padGap(need=rows+1, 本值) 在两种几何里都恰好垫到卡底再留一行保险。
|
|
2000
|
+
_blankSpan(slot) {
|
|
2001
|
+
if (!slot.marker) return 0;
|
|
2002
|
+
try {
|
|
2003
|
+
return blankRun(this.term.buffer.active, this._regionStart(slot),
|
|
2004
|
+
this._zoneLimit(slot));
|
|
2005
|
+
} catch (e) {
|
|
2006
|
+
return 0;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// 被盖帽:pad 只能追加在流底,垫不进本卡预留区。情形——下方已有别的卡
|
|
2011
|
+
// (区界被邻卡 marker 夹紧)、预留区下方已有真实输出/回显(连续空白断在
|
|
2012
|
+
// 那儿)、光标跑到 marker 上方(异常)。盖帽卡长高只许夹紧截断,不许再补差。
|
|
2013
|
+
// 容忍一行(仅当光标行非空白):zsh 会把活提示符行原位重画到 pad 空白正
|
|
2014
|
+
// 下方、光标就停在这行非空白上,空白 run 结构上永远数不到光标行——旧判据
|
|
2015
|
+
// 「run 必须触到光标」在 zsh 下恒假,流底卡出生即永久盖帽(真机卡钉死 2 行
|
|
2016
|
+
// 不长高的根因)。放行「区尾 == 光标-1 且光标行非空白」不漏判真实输出:
|
|
2017
|
+
// run 只停在非空白行或区界(=光标行)上,区尾 == 光标-1 时断点就是光标自己
|
|
2018
|
+
// 站的那行(活提示符/半行输出),卡体(区尾之上)压不到它,pad 也只会注到
|
|
2019
|
+
// 光标处、落在这行之下;光标行是空白时仍走严格判据——那种几何下 run 要么
|
|
2020
|
+
// 本来就数到光标(距离 0),要么断在真实输出行(输出占住光标-1,必须盖帽)。
|
|
2021
|
+
_capped(slot) {
|
|
2022
|
+
if (!slot.marker) return true;
|
|
2023
|
+
try {
|
|
2024
|
+
const buf = this.term.buffer.active;
|
|
2025
|
+
const cursorAbs = buf.baseY + buf.cursorY;
|
|
2026
|
+
if (cursorAbs < slot.marker.line) return true;
|
|
2027
|
+
const limit = this._zoneLimit(slot);
|
|
2028
|
+
if (limit < cursorAbs) return true; // 下方有别的卡
|
|
2029
|
+
const end = this._regionStart(slot) + this._blankSpan(slot);
|
|
2030
|
+
if (end >= cursorAbs) return false;
|
|
2031
|
+
if (end < cursorAbs - 1) return true; // 输出断在更上方:盖帽
|
|
2032
|
+
let s = "";
|
|
2033
|
+
try {
|
|
2034
|
+
const cl = buf.getLine(cursorAbs);
|
|
2035
|
+
s = cl ? cl.translateToString(true) : "";
|
|
2036
|
+
} catch (e) { s = ""; }
|
|
2037
|
+
return !(s && s.trim()); // 光标行非空白=活提示符行 → 容忍;空白 → 盖帽
|
|
2038
|
+
} catch (e) {
|
|
2039
|
+
return true;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// (_stealBlankBelow/_unrevealedBelow 移锚回收已随 Phase 4 拆除:盖帽欠账
|
|
2044
|
+
// 回收靠出生事务的「锚定前 settle 上游 + 空白尾复用」,中途不再挪别人的锚)
|
|
2045
|
+
|
|
2046
|
+
// 补差:先数后打,只补真缺口;落账只认缓冲(应答只是唤醒)。
|
|
2047
|
+
// 扣流临界区内完成,输出插不进占位区。
|
|
2048
|
+
// padOut = 已发未落账的空行数:并发/连发的 _ensurePad(挂载 vs 150ms 批量 vs
|
|
2049
|
+
// 自愈)都按「旧缓冲」数缺口,各自发一轮 = 双倍空行落地 = 卡下永久大白空
|
|
2050
|
+
// (真机审批卡下 9 行空白根因:pad 往返 > 150ms 批量窗时两路并发)。在途
|
|
2051
|
+
// 计入后第二路看到缺口已填即 gap=0;整链收尾(所有排队发送 ack 完)落账重置。
|
|
2052
|
+
async _ensurePad(slot) {
|
|
2053
|
+
if (slot.blockMode) return;
|
|
2054
|
+
if (this._dead || !slot.marker || this._padFused) return;
|
|
2055
|
+
// 先灌已扣字节再数:外层临界区(挂载/结账/自愈)扣着时,pad 的空行可能
|
|
2056
|
+
// 已在队列里没落缓冲,按旧缓冲数会多垫一轮(垫过不能回吐 = 卡间空白)
|
|
2057
|
+
await this._drainHeld();
|
|
2058
|
+
let blank = this._blankSpan(slot);
|
|
2059
|
+
slot.reserved = blank;
|
|
2060
|
+
if (this._capped(slot)) {
|
|
2061
|
+
// 垫不进本区(下方已有输出/别的卡):预留够高就原地不补;不够只能
|
|
2062
|
+
// 夹紧截断(宁可矮不许盖)。大头在 _mountCard 锚定新卡前的 settle
|
|
2063
|
+
// 窗口里提前补掉。
|
|
2064
|
+
if (blank < slot.rows) this._clampCard(slot);
|
|
2065
|
+
// 盖帽零垫(一行空白都没有)的非审批卡不走「盖帽即放行」:露出必被
|
|
2066
|
+
// 输出切成半行细条,整卡藏到定格/冻结阀放出。权衡:徽标陪跑观感差于
|
|
2067
|
+
// 细条,故只藏零空白档;blankSpan ≥ 1 照旧放行露出已垫部分
|
|
2068
|
+
if (slot.kind !== "approval" && this._blankSpan(slot) === 0) {
|
|
2069
|
+
this._refreshThink(); // 卡不放行:徽标继续接班(显→隐沿同款)
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
this._revealSlot(slot); // 盖帽即放行,宁藏不盖只遮等 pad 的过渡期
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
const gap = padGap(slot.need, blank + (slot.padOut || 0));
|
|
2076
|
+
if (gap <= 0) {
|
|
2077
|
+
// 在途额已盖住缺口、但发送还在 _padQ 里排队等 ack:结账类调用方
|
|
2078
|
+
// (决策/边界门闩)若看 gap=0 就返回会立刻放行,回显抢在在途空行
|
|
2079
|
+
// 前面落进本区把连续空白打断 = 永久盖帽(真机审批卡 r5/n9 钉死根因)。
|
|
2080
|
+
// 等本格发送链收尾 + 空行灌进缓冲再返回,放行序才等于 PTY 序。
|
|
2081
|
+
if (slot.padOut > 0 && slot.padP) {
|
|
2082
|
+
await slot.padP.catch(() => {});
|
|
2083
|
+
await this._drainHeld();
|
|
2084
|
+
}
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
this._holdBytes("card-pad");
|
|
2088
|
+
try {
|
|
2089
|
+
// padOut **入队当刻**就记账(不是任务跑起来才记):并发/连发的
|
|
2090
|
+
// _ensurePad(挂载 vs 漂移 vs 结账 vs 自愈)各自「drain 后算 gap」,
|
|
2091
|
+
// 若都等进队执行才 +padOut,两路都在 padOut=0 时算出同一缺口、各发
|
|
2092
|
+
// 一轮 = 双倍空行落地 = 卡下永久大白空(评审 MEDIUM;真机审批卡下
|
|
2093
|
+
// 9 行空白同族)。入队即记,后到者 gap 归 0 / 只补差额。
|
|
2094
|
+
slot.padOut = (slot.padOut || 0) + gap;
|
|
2095
|
+
this._padQ = this._padQ.then(async () => {
|
|
2096
|
+
if (this._dead || !slot.marker) {
|
|
2097
|
+
// 发送被取消(会话死/卡散场):在途额就地退回,不然 padOut 永久
|
|
2098
|
+
// 虚高、真缺口再也垫不进
|
|
2099
|
+
slot.padOut = Math.max(0, (slot.padOut || 0) - gap);
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
this._padSlot = slot;
|
|
2103
|
+
slot.ackDeferred = false; // _onPadded 按 ack 事件的 deferred 置位
|
|
2104
|
+
this._padSentAt = Date.now();
|
|
2105
|
+
const blankBefore = this._blankSpan(slot);
|
|
2106
|
+
// mirror:可信的半行镜像交给 worker 收纳/复原——pad 注入不再吞掉
|
|
2107
|
+
// 用户正在敲的输入(dirty 时省略,worker 走旧的丢弃路径)
|
|
2108
|
+
otdbg("pad-send", "gap=" + gap, "need=" + slot.need, "blank=" + blankBefore);
|
|
2109
|
+
this.sendJson({type: "pad", pad: gap,
|
|
2110
|
+
mirror: this._lineDirty ? undefined : this._lineBuf});
|
|
2111
|
+
let timedOut = true;
|
|
2112
|
+
await new Promise(r => {
|
|
2113
|
+
const done = () => { timedOut = false; r(); };
|
|
2114
|
+
this._padWait = done;
|
|
2115
|
+
setTimeout(done, 8000);
|
|
2116
|
+
});
|
|
2117
|
+
if (timedOut) {
|
|
2118
|
+
// 这轮空行不会落地:把在途额退回去,后续才敢再补(熔断另兜底)
|
|
2119
|
+
slot.padOut = Math.max(0, (slot.padOut || 0) - gap);
|
|
2120
|
+
this._padFailStreak++;
|
|
2121
|
+
if (this._padFailStreak >= 2) this._fusePads();
|
|
2122
|
+
} else if (!slot.marker) {
|
|
2123
|
+
// 等待期间卡已散场:无从对账,也不冤枉通道
|
|
2124
|
+
} else if (!slot.ackDeferred && slot.marker) {
|
|
2125
|
+
// ack 不作凭据只唤醒(_onPadded),这里对账:空白一行没长 =
|
|
2126
|
+
// 通道谎报——su - 换壳后嵌套壳标记流还在、hook 函数已空,后端
|
|
2127
|
+
// 把 pad 行敲进裸壳只换来 command not found + 新提示符而 ack 照
|
|
2128
|
+
// 发,超时熔断永远够不着,pad 请求循环把提示符敲成横排墙(真机
|
|
2129
|
+
// 101.33 su - 后截图)。与超时同账:先灌在途字节再数,连败 2 次
|
|
2130
|
+
// 熔断,宁截断不空转。deferred(重注入在途窗口)不计:痊愈后
|
|
2131
|
+
// 下一轮 pad 自会补齐
|
|
2132
|
+
await this._drainHeld();
|
|
2133
|
+
if (this._blankSpan(slot) <= blankBefore) {
|
|
2134
|
+
this._padFailStreak++;
|
|
2135
|
+
if (this._padFailStreak >= 2) this._fusePads();
|
|
2136
|
+
} else {
|
|
2137
|
+
this._padFailStreak = 0;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
this._padSlot = null;
|
|
2141
|
+
}).catch(() => {});
|
|
2142
|
+
slot.padP = this._padQ; // 本格发送链尾:gap=0 waiter 用它等在途额落地
|
|
2143
|
+
await this._padQ;
|
|
2144
|
+
} finally {
|
|
2145
|
+
this._releaseBytes();
|
|
2146
|
+
}
|
|
2147
|
+
// 落账前把 pad 的空行真灌进 xterm 并等解析完:term.write 是异步的,外层
|
|
2148
|
+
// 还扣着流时 _releaseBytes 也不会放行(计数只减一层),立刻重数会按旧缓冲
|
|
2149
|
+
// 判「还缺行」而误夹紧(真机分析卡被截断的来源之一)
|
|
2150
|
+
await this._drainHeld();
|
|
2151
|
+
slot.padOut = 0; // 整链发送都已 ack:在途额消费完,落账只认缓冲
|
|
2152
|
+
blank = this._blankSpan(slot);
|
|
2153
|
+
slot.reserved = blank;
|
|
2154
|
+
if (padGap(slot.need, blank) > 0) this._clampCard(slot);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// 等已提交的 write 被解析完(空串回调排在队尾);回调不来则 120ms 兜底
|
|
2158
|
+
_flushWrites() {
|
|
2159
|
+
return new Promise(r => {
|
|
2160
|
+
let done = false;
|
|
2161
|
+
const fin = () => { if (!done) { done = true; r(); } };
|
|
2162
|
+
try { this.term.write("", fin); } catch (e) { fin(); }
|
|
2163
|
+
setTimeout(fin, 120);
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
_onPadded(ev) {
|
|
2168
|
+
otdbg("pad-ack", ev && ev.deferred ? "deferred" : "ok",
|
|
2169
|
+
"waited=" + (this._padSentAt ? (Date.now() - this._padSentAt) : "?"));
|
|
2170
|
+
// deferred:重注入在途窗口的 ack——空白没长是暂时现象,不计熔断连败
|
|
2171
|
+
if (this._padSlot) this._padSlot.ackDeferred = !!(ev && ev.deferred);
|
|
2172
|
+
if (this._padWait) {
|
|
2173
|
+
const r = this._padWait;
|
|
2174
|
+
this._padWait = null;
|
|
2175
|
+
r();
|
|
2176
|
+
}
|
|
2177
|
+
// 应答不作凭据只唤醒:落账是 _ensurePad/_reanchor 每步重数缓冲空白
|
|
2178
|
+
// pad 擦自身回显行把缓冲整体上移:各卡 es/视觉顶随即过时。若此后输出
|
|
2179
|
+
// 恰停(等审批/模型思考),不再路过 onRender → 旧 top 悬着压回显(真机
|
|
2180
|
+
// 剩的唯一 cover 类)。同步全卡重贴一次 top/显隐,不等下一笔字节。
|
|
2181
|
+
for (const sl of this._slots.values()) {
|
|
2182
|
+
if (sl.marker && sl.host) this._fixHostVisibility(sl, sl.host);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
_fusePads() {
|
|
2187
|
+
this._padFused = true;
|
|
2188
|
+
this.setStatus(this.status, "占位补差已熔断,卡片按已垫高度截断");
|
|
2189
|
+
for (const slot of this._slots.values()) {
|
|
2190
|
+
this._clampCard(slot);
|
|
2191
|
+
slot.padOut = 0; // 熔断后不再补差:在途额清零,落账只认缓冲
|
|
2192
|
+
slot.padP = null;
|
|
2193
|
+
// 钉死到已垫高度:熔断后不再补差,auto 高度会压住后续输出
|
|
2194
|
+
// (钉死高度 = 预留空白 run 行数;卡体顶行即 run 首行,盒底不越过
|
|
2195
|
+
// run 末行,见 _reanchor)
|
|
2196
|
+
if (slot.marker) {
|
|
2197
|
+
slot.reserved = this._blankSpan(slot);
|
|
2198
|
+
const rows = Math.max(1, slot.reserved);
|
|
2199
|
+
slot.pinnedRows = rows;
|
|
2200
|
+
this._applyDecoration(slot, rows);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// ws 断开:放行在途 pad,不卡死队列(§5.9)
|
|
2206
|
+
_failPads() {
|
|
2207
|
+
if (this._padWait) {
|
|
2208
|
+
const r = this._padWait;
|
|
2209
|
+
this._padWait = null;
|
|
2210
|
+
r();
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// decoration 失败兜底:markdown 纯文本写进网格(唯一允许的前端 write 路径,
|
|
2215
|
+
// 写的是内容不是空行占位)
|
|
2216
|
+
_fallbackPlain(slot) {
|
|
2217
|
+
const md = this._cardsText && this._cardsText.get(slot.id);
|
|
2218
|
+
if (md) this._writePlain(md.replace(/[*`#>|]/g, ""));
|
|
2219
|
+
this.setStatus(this.status, "卡片渲染失败,已降级为纯文本");
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
_writePlain(text) {
|
|
2223
|
+
const s = String(text || "").replace(/\r/g, "");
|
|
2224
|
+
this._wp(s.endsWith("\n") ? s : s + "\n");
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
_discardSlot(slot) {
|
|
2228
|
+
if (slot.reanchorT) { clearTimeout(slot.reanchorT); slot.reanchorT = null; }
|
|
2229
|
+
if (slot.ro) { try { slot.ro.disconnect(); } catch (e) {} }
|
|
2230
|
+
if (slot.decoration) { try { slot.decoration.dispose(); } catch (e) {} }
|
|
2231
|
+
if (this._cardsText) this._cardsText.delete(slot.id);
|
|
2232
|
+
// 同 id 被新槽顶替后,旧槽 marker 迟到 dispose 不得删掉新槽(unmount
|
|
2233
|
+
// 的是新槽的 React root = 卡片凭空消失)
|
|
2234
|
+
if (this._slots.get(slot.id) === slot) {
|
|
2235
|
+
if (this._feed) this._feed.unmount(slot.id);
|
|
2236
|
+
this._slots.delete(slot.id);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
// 整卡丢弃(收束扫尾/中止移除):marker 一并 dispose,锚点不留尸体钉。
|
|
2241
|
+
// 先摘身份再 dispose——onDispose 的身份守卫(slot.marker === marker)不再
|
|
2242
|
+
// 触发二次删槽,收尾统一走 _discardSlot
|
|
2243
|
+
_dropSlot(slot) {
|
|
2244
|
+
if (!slot) return;
|
|
2245
|
+
// 块流槽:整块移除(「完整或消失」的消失侧)。force:中断/收束是显式终态,
|
|
2246
|
+
// 越过 keepAlive 闸门(待审批卡只有在这里才允许被清)
|
|
2247
|
+
if (slot.blockMode && this._blocks && this._blockView) {
|
|
2248
|
+
this._blocks.remove(slot.id, {force: true});
|
|
2249
|
+
this._blockView.sync(this._blocks.list());
|
|
2250
|
+
}
|
|
2251
|
+
const m = slot.marker;
|
|
2252
|
+
slot.marker = null;
|
|
2253
|
+
if (m) { try { m.dispose(); } catch (e) {} }
|
|
2254
|
+
this._discardSlot(slot);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
_destroySlots() {
|
|
2258
|
+
if (this._healT) { clearTimeout(this._healT); this._healT = null; }
|
|
2259
|
+
for (const slot of [...this._slots.values()]) this._discardSlot(slot);
|
|
2260
|
+
this._slots.clear();
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// 新会话(/clear):销毁全部装饰钉并清空 island 卡片流
|
|
2264
|
+
_clearCards() {
|
|
2265
|
+
this._destroySlots();
|
|
2266
|
+
if (this._blocks && this._blockView) {
|
|
2267
|
+
this._blocks.clear();
|
|
2268
|
+
this._blockView.sync(this._blocks.list());
|
|
2269
|
+
}
|
|
2270
|
+
if (this._feed) this._feed.handle({kind: "clear"});
|
|
2271
|
+
this._approvalOpen = false;
|
|
2272
|
+
this._approvalKey = null;
|
|
2273
|
+
this._approvalResolved = false;
|
|
2274
|
+
this._toolCards.clear();
|
|
2275
|
+
if (this._cmdCardQ) this._cmdCardQ.clear();
|
|
2276
|
+
// 收束时括号还开着就先归位再清账——顺序反了会把刚存下的归属一并抹掉。
|
|
2277
|
+
// _pendingTextByCard 刻意不清:它的卡可能还挂在队列里没落位。
|
|
2278
|
+
if (this._outOpen) this._closeOutAnchor();
|
|
2279
|
+
this._outEcho = null;
|
|
2280
|
+
this._runningTool = "";
|
|
2281
|
+
this._refreshThink();
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// §5.6 重试门:补差重试以 _lastByteAt 不变为门(PTY 静默才能证明缓冲未被推进)。
|
|
2285
|
+
// 静默 250ms 后按计数缺口重试一次,仍失败进夹紧。Task 7 的 _healAll 落实此门:
|
|
2286
|
+
// if (this._lastByteAt && Date.now() - this._lastByteAt < 250) continue;
|
|
2287
|
+
_remeasureCards() {
|
|
2288
|
+
if (this._dead) return;
|
|
2289
|
+
const buf = this.term.buffer.active;
|
|
2290
|
+
if (buf !== this.term.buffer.normal) {
|
|
2291
|
+
// vim/htop 备用缓冲期间推迟,回到主缓冲再补
|
|
2292
|
+
this._remeasurePending = true;
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
this._remeasurePending = false;
|
|
2296
|
+
// 只有最靠流底的卡能物理补差;上游卡一律夹紧(scrollback 中间插不进新行)
|
|
2297
|
+
const slots = [...this._slots.values()].filter(s => s.marker);
|
|
2298
|
+
slots.sort((a, b) => a.marker.line - b.marker.line);
|
|
2299
|
+
const bottom = slots[slots.length - 1] || null;
|
|
2300
|
+
for (const slot of slots) {
|
|
2301
|
+
slot.forceReapply = true; // 列数/行高变了:重贴 decoration 刷宽度
|
|
2302
|
+
this._scheduleDrift(slot);
|
|
2303
|
+
if (slot !== bottom) this._clampCard(slot);
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// §5.7 idle 自愈:字节静默 250ms 后,对流底卡重数空白区补差
|
|
2308
|
+
_scheduleHeal() {
|
|
2309
|
+
if (this._dead) return;
|
|
2310
|
+
if (this._healT) clearTimeout(this._healT);
|
|
2311
|
+
this._healT = setTimeout(() => {
|
|
2312
|
+
this._healT = null;
|
|
2313
|
+
this._healAll();
|
|
2314
|
+
}, 250);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
_healAll() {
|
|
2318
|
+
if (this._dead || this._padFused) return;
|
|
2319
|
+
if (!this.term || !this.term.buffer) return; // 测试桩/未初始化终端无 buffer
|
|
2320
|
+
const buf = this.term.buffer.active;
|
|
2321
|
+
if (buf !== this.term.buffer.normal) return;
|
|
2322
|
+
for (const slot of [...this._slots.values()]) {
|
|
2323
|
+
if (!slot.marker) continue;
|
|
2324
|
+
// 冻结阀:任务已定格(collapse/final/fail/审批)却一直没 fund 的隐藏卡
|
|
2325
|
+
// (既没盖帽、pad 又迟迟不落地的僵局),2s 后按夹紧路径放出,不留隐形卡
|
|
2326
|
+
if (slot.frozen && !slot.revealed && Date.now() - slot.frozenAt > 2000) {
|
|
2327
|
+
if (slot.reserved < slot.rows) {
|
|
2328
|
+
this._clampCard(slot);
|
|
2329
|
+
// 审批卡放出高度不低过按钮行(_floorRows),细条态按钮不可点=死锁
|
|
2330
|
+
const rows = Math.max(1, slot.reserved, this._floorRows(slot));
|
|
2331
|
+
if (slot.pinnedRows !== rows) {
|
|
2332
|
+
slot.pinnedRows = rows;
|
|
2333
|
+
this._applyDecoration(slot, rows);
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
this._revealSlot(slot);
|
|
2337
|
+
}
|
|
2338
|
+
// §5.6 重试门:盖帽卡(下方已有输出/别的卡)垫不进差,不再发 pad。
|
|
2339
|
+
// 但钉死的高度会过时——下层卡的占位落地后,本卡预留区其实变宽了
|
|
2340
|
+
// (真机分析卡被钉在半截高度、要等内容再次变化才解开)。所以钉死/
|
|
2341
|
+
// 夹紧的盖帽卡仍要重锚一次:reserved ≥ rows 即解钉,卡自己长回来。
|
|
2342
|
+
const capped = this._capped(slot);
|
|
2343
|
+
if (capped && !slot.clamped && slot.pinnedRows == null) continue;
|
|
2344
|
+
// PTY 仍在输出时不重试(缓冲可能被推进)
|
|
2345
|
+
if (this._lastByteAt && Date.now() - this._lastByteAt < 250) continue;
|
|
2346
|
+
this._holdBytes("card-heal");
|
|
2347
|
+
Promise.resolve()
|
|
2348
|
+
// 盖帽卡不再移锚回收(Phase 4):夹紧是终态(宁矮不盖),仍走
|
|
2349
|
+
// _reanchor 只为「预留区事后变宽」的解钉(reserved ≥ rows 即长回)
|
|
2350
|
+
.then(() => (capped ? Promise.resolve() : this._ensurePad(slot)))
|
|
2351
|
+
.then(() => this._drainHeld())
|
|
2352
|
+
.then(() => this._reanchor(slot))
|
|
2353
|
+
.finally(() => this._releaseBytes());
|
|
2354
|
+
}
|
|
2355
|
+
// 欠账自循环:_scheduleHeal 只由字节(_writeTerm)/任务事件唤醒,PTY 静默
|
|
2356
|
+
// 期(等审批、模型思考)没人重试钉死卡——pad 在途时钉到半截、落地后无人
|
|
2357
|
+
// 解钉=永久细条(真机审批卡压 2 行 18s 根因)。仍有「未盖帽且钉死/夹紧/
|
|
2358
|
+
// 未 fund」的卡就 250ms 自排下一次;fund/盖帽/熔断任一达成即停(熔断在
|
|
2359
|
+
// 本函数开头早退)。每轮 _ensurePad 自带在途去重,不会重复发 pad。
|
|
2360
|
+
for (const s of this._slots.values()) {
|
|
2361
|
+
if (!s.marker) continue;
|
|
2362
|
+
const owed = s.pinnedRows != null || s.clamped || s.reserved < s.rows;
|
|
2363
|
+
// 盖帽且仍欠账:垫不进本区,夹紧是终态(宁矮不盖),不空转;
|
|
2364
|
+
// 盖帽但已 fund:_reanchor 非 short 分支解钉即可,不需要 pad,也要唤醒
|
|
2365
|
+
if (owed && (!this._capped(s) || s.reserved >= s.rows)) {
|
|
2366
|
+
this._scheduleHeal();
|
|
2367
|
+
break;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// 异步写:仅用于极少数不走 ConPTY 的系统提示(错误/关闭),卡片禁用
|
|
2373
|
+
_wp(data) {
|
|
2374
|
+
return new Promise(r => {
|
|
2375
|
+
if (this._dead) { r(); return; }
|
|
2376
|
+
this.term.write(data, r);
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
_sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
2381
|
+
|
|
2382
|
+
// 网格行高实测:字号×lineHeight 随 DPI 变化,不能拍脑袋(约束5)
|
|
2383
|
+
_lineHeight() {
|
|
2384
|
+
const rowsEl = this.term.element &&
|
|
2385
|
+
this.term.element.querySelector(".xterm-rows");
|
|
2386
|
+
if (rowsEl) {
|
|
2387
|
+
const h = rowsEl.getBoundingClientRect().height;
|
|
2388
|
+
if (h > 0 && this.term.rows) return h / this.term.rows;
|
|
2389
|
+
}
|
|
2390
|
+
return 15; // 兜底:13px 字号常见行高
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// --- AI 任务生命周期(状态栏/计时器)+ 事件转发 island ---
|
|
2394
|
+
|
|
2395
|
+
async _onTaskStart() {
|
|
2396
|
+
this._taskActive = true;
|
|
2397
|
+
this._taskClosed = false; // 新任务起跑:收束门卫复位(迟到宣告照常挂)
|
|
2398
|
+
this._taskGen = (this._taskGen || 0) + 1; // 任务代数:收束扫尾按代过滤
|
|
2399
|
+
// 同步给 store:store._gen 递增 + 清跨任务残留(_pendingArms/_lastSummaryMd)。
|
|
2400
|
+
// 漏发=store._gen 恒 0 → sweep 代数过滤失效(真机 2026-10-17:新任务
|
|
2401
|
+
// 审批卡被上代收束当未完毕摘掉 → 按钮永不出生转模态)
|
|
2402
|
+
this._ensureFeed().then(f => f && f.handle({kind: "task_start"}))
|
|
2403
|
+
// Enter 后立即弹「AI 思考中」占位卡(id 与后续 ai_result 同一张)
|
|
2404
|
+
.then(f => {
|
|
2405
|
+
if (!f) return null;
|
|
2406
|
+
const cid = ++this._cardSeq;
|
|
2407
|
+
f.handle({kind: "analysis_boot", id: cid});
|
|
2408
|
+
return this._mountCard(cid, "phase");
|
|
2409
|
+
});
|
|
2410
|
+
this._lastSummaryMd = null;
|
|
2411
|
+
// Phase 3 徽标进度:progress 心跳(1s)累计,任务起跑清零
|
|
2412
|
+
this._progress = {chars: 0, tokensIn: 0, tokensOut: 0};
|
|
2413
|
+
this._taskStartedAt = Date.now();
|
|
2414
|
+
this._setStopVisible(true);
|
|
2415
|
+
this._startTimer();
|
|
2416
|
+
// 思考徽标即刻接班(_refreshThink 按 _taskActive 解隐):纯推理空隙期
|
|
2417
|
+
// 卡未挂前不得假死(旧 _openPhaseCard 的解隐职责移到这里)
|
|
2418
|
+
this._refreshThink();
|
|
2419
|
+
// Phase 1「定格一发入魂」:不预开流式分析卡——分析段由 ai_result 成卡
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// 任务收束公共账(final/task_end/task_fail 幂等共用):状态栏/计时器/审批态
|
|
2423
|
+
_taskTeardown() {
|
|
2424
|
+
this._taskActive = false;
|
|
2425
|
+
this._taskClosed = true; // 收束:迟到的工具宣告不再挂卡
|
|
2426
|
+
this._approvalOpen = false; // 任务收束:未决审批窗(若有)一并释放
|
|
2427
|
+
this._approvalCmd = null;
|
|
2428
|
+
this._approvalKey = null;
|
|
2429
|
+
this._approvalResolved = false;
|
|
2430
|
+
this._toolCards.clear();
|
|
2431
|
+
this._runningTool = "";
|
|
2432
|
+
this._endTimer();
|
|
2433
|
+
this._setStopVisible(false);
|
|
2434
|
+
this._refreshThink();
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
// 一次性分析段(Phase 1):内容完整才成卡——handle 先入 store(量高时内容
|
|
2438
|
+
// 已完整),再挂出生即完整的卡,挂载后高度永不再涨。空段不发不成卡。
|
|
2439
|
+
_onAiResult(ev) {
|
|
2440
|
+
const text = (ev && ev.text) || "";
|
|
2441
|
+
const reason = (ev && ev.think) || "";
|
|
2442
|
+
if (!text && !reason) return;
|
|
2443
|
+
const cardId = ++this._cardSeq;
|
|
2444
|
+
this._cardsText.set(cardId, reason ? reason + "\n" + text : text);
|
|
2445
|
+
// 链在在途开卡之后(工具卡/上一段卡可能还在挂载临界区内):挂载全程
|
|
2446
|
+
// 扣流,两段并发会把 marker 钉错
|
|
2447
|
+
this._cardReady = this._cardReady
|
|
2448
|
+
.then(() => this._ensureFeed())
|
|
2449
|
+
.then(f => f && f.handle({
|
|
2450
|
+
kind: "ai_result", id: cardId, text, reason,
|
|
2451
|
+
failed: !!(ev && ev.failed),
|
|
2452
|
+
}))
|
|
2453
|
+
.then(() => this._mountCard(cardId, "phase"))
|
|
2454
|
+
.then(slot => { if (slot) this._followBottom(); });
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
// 红色警告卡:配置错误(协议二选一冲突/都缺)与系统异常(模型/工具超时等)。
|
|
2458
|
+
// kind='phase' 复用宁藏不盖/夹紧兜底;出生即完整(done 恒真)不进收束扫尾。
|
|
2459
|
+
_onAlert(ev) {
|
|
2460
|
+
const text = (ev && ev.text) || "";
|
|
2461
|
+
if (!text) return;
|
|
2462
|
+
// 状态栏兜底:卡被夹紧(预留不足,宁可矮不许盖)时正文可能整段裁出盒,
|
|
2463
|
+
// 但系统警告必须始终读得到——状态栏常驻一行,且不占网格、不与卡争预留。
|
|
2464
|
+
this.setStatus(this.status, text);
|
|
2465
|
+
const cardId = ++this._cardSeq;
|
|
2466
|
+
this._cardsText.set(cardId, text);
|
|
2467
|
+
this._cardReady = this._cardReady
|
|
2468
|
+
.then(() => this._ensureFeed())
|
|
2469
|
+
.then(f => f && f.handle({kind: "alert", id: cardId, text}))
|
|
2470
|
+
.then(() => this._mountCard(cardId, "phase"))
|
|
2471
|
+
.then(slot => { if (slot) this._followBottom(); });
|
|
2472
|
+
}
|
|
2473
|
+
// start 在流底挂 tool 卡(kind 同 phase——宁藏不盖/夹紧兜底全复用),end 经
|
|
2474
|
+
// store 同 id 置状态徽标(卡高不变,零 pad 往返)。工具卡不是流式分析卡,
|
|
2475
|
+
// 之后的模型输出由 _onAiResult 另开新卡。
|
|
2476
|
+
// 工具卡串行挂载队列:并行 tool_call 一次宣告 N 张,逐张落地。
|
|
2477
|
+
// pri=true(要审批)插队——审批卡必须立刻可点,等前一张收束就是决策死锁。
|
|
2478
|
+
_pumpToolMounts(pri) {
|
|
2479
|
+
// 惰性建:测试用 Object.create(原型) 不跑构造器,字段不一定在
|
|
2480
|
+
if (!this._toolMountQ) this._toolMountQ = [];
|
|
2481
|
+
if (this._toolMounting) return;
|
|
2482
|
+
let item = null;
|
|
2483
|
+
if (pri) {
|
|
2484
|
+
const i = this._toolMountQ.findIndex(x => x.pri);
|
|
2485
|
+
if (i >= 0) item = this._toolMountQ.splice(i, 1)[0];
|
|
2486
|
+
}
|
|
2487
|
+
if (!item) item = this._toolMountQ.shift();
|
|
2488
|
+
if (!item) return;
|
|
2489
|
+
this._toolMounting = true;
|
|
2490
|
+
const { cardId, ev } = item;
|
|
2491
|
+
this._cardReady = this._cardReady
|
|
2492
|
+
.then(() => this._ensureFeed())
|
|
2493
|
+
.then(f => f && f.handle({
|
|
2494
|
+
kind: "tool_start", id: cardId, name: ev.name || "", args: ev.args || "",
|
|
2495
|
+
command: ev.command || "", needs_approval: !!ev.needs_approval,
|
|
2496
|
+
reasons: ev.reasons || "", risk: ev.risk || "",
|
|
2497
|
+
}))
|
|
2498
|
+
.then(() => this._mountCard(cardId, "phase"))
|
|
2499
|
+
.then(slot => { if (slot) this._followBottom(); })
|
|
2500
|
+
.catch(() => {})
|
|
2501
|
+
.then(() => {
|
|
2502
|
+
this._toolMounting = false;
|
|
2503
|
+
if (this._toolMountQ.length) this._pumpToolMounts();
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// 审批帧到时该卡还在队列里 → 插队立即挂(按钮出不来就是决策死锁)
|
|
2508
|
+
_flushToolMountFor(cardId) {
|
|
2509
|
+
try {
|
|
2510
|
+
if (!this._toolMountQ) return;
|
|
2511
|
+
const i = this._toolMountQ.findIndex(x => x.cardId === cardId);
|
|
2512
|
+
if (i >= 0) {
|
|
2513
|
+
this._toolMountQ[i].pri = true;
|
|
2514
|
+
this._toolMountQ[i].ev.needs_approval = true;
|
|
2515
|
+
this._pumpToolMounts(true);
|
|
2516
|
+
}
|
|
2517
|
+
} catch (e) {}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
_onAiTool(ev) {
|
|
2521
|
+
// id 统一:ev.id 是后端数字 tool_card_seq,与前端 _cardSeq 是两个序列,
|
|
2522
|
+
// 映射表解耦(审批消息的 card_id 也从这里反查命令卡)。
|
|
2523
|
+
if (ev.phase === "start") {
|
|
2524
|
+
// 收束后迟到的工具宣告不再挂卡(事件队列尾残迹)——半截卡只减不增
|
|
2525
|
+
if (this._taskClosed) return;
|
|
2526
|
+
const cardId = ++this._cardSeq;
|
|
2527
|
+
this._toolCards.set(ev.id, cardId);
|
|
2528
|
+
// 输出归属索引:execute 的 term_open 只带命令串,据此反查它该贴哪张卡。
|
|
2529
|
+
// 同名命令同轮出现多次时按宣告序排队取(抢锁序会变,队列不会乱)。
|
|
2530
|
+
if (ev.name === "execute" && (ev.command || ev.args)) {
|
|
2531
|
+
if (!this._cmdCardQ) this._cmdCardQ = new Map();
|
|
2532
|
+
const k = String(ev.command || ev.args);
|
|
2533
|
+
const q = this._cmdCardQ.get(k) || [];
|
|
2534
|
+
q.push(cardId);
|
|
2535
|
+
this._cmdCardQ.set(k, q);
|
|
2536
|
+
}
|
|
2537
|
+
// 徽标同步说人话:模型在跑哪条命令。旧实现全程「AI 正在思考」,
|
|
2538
|
+
// 长命令/多轮排查时用户看到的就是假死(真机 bug2 的观感根因之一)。
|
|
2539
|
+
this._runningTool = (ev.name === "execute"
|
|
2540
|
+
? String(ev.command || ev.args || "") : String(ev.name || "")).trim();
|
|
2541
|
+
this._refreshThink();
|
|
2542
|
+
// handle 先于 mount(对齐 _onAiResult 的出生事务):量高时内容已完整,
|
|
2543
|
+
// 命令卡出生即定型。旧序 mount→handle 有两个真机后果:①量到空卡再靠
|
|
2544
|
+
// 漂移补垫(夹紧竞态窗);②审批帧可以抢在 tool_start 入店前到 →
|
|
2545
|
+
// command_card_waiting 找不到卡 → 按钮永不出生转模态(冒烟 2026-10-16)
|
|
2546
|
+
// 串行弹出(真机反馈):并行 tool_call 会一次发齐全部 tool_start,旧序
|
|
2547
|
+
// 一次挂满一屏卡。改成入队逐张挂——上一张 tool_end 才弹下一张,观感是
|
|
2548
|
+
// 「一张卡 + 它的输出」依次出现。要审批的卡插队立即挂,否则按钮出不来。
|
|
2549
|
+
if (!this._toolMountQ) this._toolMountQ = [];
|
|
2550
|
+
this._toolMountQ.push({ cardId, ev });
|
|
2551
|
+
this._pumpToolMounts();
|
|
2552
|
+
} else {
|
|
2553
|
+
this._runningTool = "";
|
|
2554
|
+
this._refreshThink();
|
|
2555
|
+
const cardId = this._toolCards.get(ev.id);
|
|
2556
|
+
// 上一张收束了:弹队列里的下一张(串行观感)
|
|
2557
|
+
this._pumpToolMounts();
|
|
2558
|
+
this._cardReady = this._cardReady.then(() => this._ensureFeed())
|
|
2559
|
+
.then(f => f && f.handle({kind: "tool_end", id: cardId, failed: !!ev.failed}));
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// 阶段边界前置结账:worker 在**执行命令前**发 ai_boundary 并等本片 ack
|
|
2564
|
+
// (700ms 兜底)——命令回显在 PTY 里必须落在 pad 空行**之后**,否则回显
|
|
2565
|
+
// 插进未垫够的卡区把连续空白打断 = 永久盖帽截断(真机分析卡末行被切、
|
|
2566
|
+
// 下条回显贴脸根因:旧序 ai_collapse 在 exec_start 才发,回显早已进区)。
|
|
2567
|
+
// 结账范围是**全部存活卡**而不只流底任务卡:待审批/已审批卡的在途 pad
|
|
2568
|
+
// 同样排在 _padQ 里,只结任务卡会漏掉它们 → 回显先落、审批卡永久钉死
|
|
2569
|
+
// (真机 r5/n9 审批卡)。此刻流底卡内容已终(模型消息以 tool_call 收尾,
|
|
2570
|
+
// token 批 flush 完即最终高),结账垫满后回显自然落在卡区下方。
|
|
2571
|
+
// 只结账不定格——定格仍沿 ai_collapse。
|
|
2572
|
+
// 出生垫行排水(Phase 4):先等在途挂载链/挂载串行队列落定——命令回显
|
|
2573
|
+
// 必须落在出生 pad 空行之后,否则插进预留区=永久盖帽。结账范围排水后
|
|
2574
|
+
// 重扫(含刚出生的卡)。
|
|
2575
|
+
_onBoundary() {
|
|
2576
|
+
const ack = () => {
|
|
2577
|
+
try { this.sendJson({type: "boundary_settled"}); } catch (e) {}
|
|
2578
|
+
};
|
|
2579
|
+
if (this._padFused) { ack(); return; }
|
|
2580
|
+
this._ensureFeed()
|
|
2581
|
+
.then(() => this._cardReady || Promise.resolve())
|
|
2582
|
+
.then(() => this._mountQ || Promise.resolve())
|
|
2583
|
+
.then(() => {
|
|
2584
|
+
const slots = [];
|
|
2585
|
+
for (const s of this._slots.values()) if (s.marker) slots.push(s);
|
|
2586
|
+
if (!slots.length) return null;
|
|
2587
|
+
return Promise.all(slots.map(s => this._settleCard(s)));
|
|
2588
|
+
})
|
|
2589
|
+
.catch(() => {})
|
|
2590
|
+
.then(ack);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// ai_collapse 已随流式契约退役(Phase 1):事件仍留在线上(CLI 依赖),
|
|
2594
|
+
// Web 侧 dispatch 两处 case 直接 no-op,无折叠/裁尾/定格语义。
|
|
2595
|
+
|
|
2596
|
+
_onAiCard(markdown) {
|
|
2597
|
+
// ai_card 与 final 同文连发时本调用会去重早退(_mountSummary/_lastSummaryMd)
|
|
2598
|
+
// ——扫尾同样等挂载完再查完整性
|
|
2599
|
+
this._endSweepSoon(this._mountSummary(markdown || ""));
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
_onFinal(text) {
|
|
2603
|
+
this._taskTeardown();
|
|
2604
|
+
// 总结卡一次性挂载(出生即完整,无换装/裁尾)
|
|
2605
|
+
const mp = this._mountSummary(text || "");
|
|
2606
|
+
this._scheduleHeal();
|
|
2607
|
+
// 收束扫尾等总结卡挂完:完整卡显示、垫不满的保留夹紧(不丢弃不留洞)
|
|
2608
|
+
this._endSweepSoon(mp);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// task_end(worker 任务流末端必发):final 已收束时本调用幂等复用 teardown,
|
|
2612
|
+
// 否则(异常路径漏 final)兜底收束 + 扫尾
|
|
2613
|
+
_onTaskEnd() {
|
|
2614
|
+
this._taskTeardown();
|
|
2615
|
+
this._scheduleHeal();
|
|
2616
|
+
this._endSweepSoon();
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
// progress 心跳(后端 1s 节流):徽标进度计数刷新——顺带推动「思考 Ns」
|
|
2620
|
+
// 秒数走字(_refreshThink 自身无定时器,靠心跳/事件唤醒)
|
|
2621
|
+
_onProgress(ev) {
|
|
2622
|
+
if (!ev) return;
|
|
2623
|
+
this._progress = {
|
|
2624
|
+
chars: ev.chars || 0,
|
|
2625
|
+
tokensIn: ev.tokens_in || 0,
|
|
2626
|
+
tokensOut: ev.tokens_out || 0,
|
|
2627
|
+
};
|
|
2628
|
+
this._refreshThink();
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// 总结挂载:一次性挂出生即完整的总结卡(Phase 1 无换装/裁尾)。同文去重
|
|
2632
|
+
// (ai_card 与 final 双发同一份 markdown);handle 先于 mount——量高时内容
|
|
2633
|
+
// 已完整。
|
|
2634
|
+
async _mountSummary(md) {
|
|
2635
|
+
if (!md || md === this._lastSummaryMd) return;
|
|
2636
|
+
this._lastSummaryMd = md;
|
|
2637
|
+
const cardId = ++this._cardSeq;
|
|
2638
|
+
this._cardsText.set(cardId, md);
|
|
2639
|
+
await this._ensureFeed()
|
|
2640
|
+
.then(f => f && f.handle({kind: "ai_card", md, id: cardId}));
|
|
2641
|
+
await this._mountCard(cardId, "summary");
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
// 任务中止(denied/limit/error/interrupt):island 定格失败态,文案只进
|
|
2645
|
+
// 状态栏(终端网格只留原生字节,不写系统提示——单管线约束)。
|
|
2646
|
+
// interrupt(Ctrl+C/⏹)例外:未完毕的内容卡整卡移除——store
|
|
2647
|
+
// task_fail 撤内容、onDrop 丢槽(中断零残留语义),活流卡不做失败定格。
|
|
2648
|
+
// 任何收束都过 _endSweepSoon:垫不满的卡保留夹紧(见 _endSweep 注释)。
|
|
2649
|
+
_onTaskFail(kind, text) {
|
|
2650
|
+
this._taskTeardown();
|
|
2651
|
+
this._ensureFeed().then(f => f && f.handle({kind: "task_fail", reason: kind}));
|
|
2652
|
+
this.setStatus(kind === "denied" ? "error" : this.status,
|
|
2653
|
+
text || (kind === "denied" ? "✗ 已拒绝" : "任务已中止"));
|
|
2654
|
+
this._scheduleHeal();
|
|
2655
|
+
this._endSweepSoon();
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// 收束扫尾编排:串行防重入(fail 紧跟 final 两连发不交错),等在途挂载链、
|
|
2659
|
+
// 挂载串行队列与 extra(总结挂载)全部落定再查完整性
|
|
2660
|
+
_endSweepSoon(extra) {
|
|
2661
|
+
const gen = this._taskGen || 0; // 收束当刻代数:只清本代及以前的卡/槽
|
|
2662
|
+
const run = async () => {
|
|
2663
|
+
try {
|
|
2664
|
+
await Promise.all([
|
|
2665
|
+
this._cardReady || Promise.resolve(),
|
|
2666
|
+
this._mountQ || Promise.resolve(),
|
|
2667
|
+
extra || Promise.resolve(),
|
|
2668
|
+
]);
|
|
2669
|
+
await this._endSweep(gen);
|
|
2670
|
+
} catch (e) {}
|
|
2671
|
+
};
|
|
2672
|
+
this._sweepQ = (this._sweepQ || Promise.resolve()).then(run, run);
|
|
2673
|
+
return this._sweepQ;
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
// 收束不变量(Phase 4,改「整卡丢弃」为「保留夹紧」):
|
|
2677
|
+
// ① store 扫迟到补挂的未完毕内容卡(sweep——中断/异常残留,内容级);
|
|
2678
|
+
// ② 逐槽结账一次给「垫得进去」的卡补齐机会;
|
|
2679
|
+
// ③ 仍不完整的卡**保留夹紧**(钉死到已垫高度 + 揭示放出)——出生即完整的
|
|
2680
|
+
// 卡天然过检;整卡丢弃拆了贴纸但 pad 空行回收不了=原位留洞(真机图二)。
|
|
2681
|
+
// 宁矮不盖:夹紧态盒底不越过预留区。
|
|
2682
|
+
// 仅「从未挂载成功」的槽(pendingMount:没有 host,也没写过 pad 行)走
|
|
2683
|
+
// _fallbackPlain 纯文本降级后拆除——它没占预留行,拆了也不留洞,内容不丢。
|
|
2684
|
+
// 审批/救援卡豁免:按钮行底线夹紧是功能(可点性优先)。
|
|
2685
|
+
async _endSweep(gen) {
|
|
2686
|
+
if (this._dead) return;
|
|
2687
|
+
const sweepGen = gen != null ? gen : (this._taskGen || 0);
|
|
2688
|
+
const feed = await this._ensureFeed();
|
|
2689
|
+
if (feed) feed.handle({kind: "sweep", gen: sweepGen});
|
|
2690
|
+
// React 把定格内容渲一帧再量高(_settleCard 自带 host 补挂),否则量到半截
|
|
2691
|
+
await new Promise(r => requestAnimationFrame(() => r()));
|
|
2692
|
+
const settle = [...this._slots.values()]
|
|
2693
|
+
.filter(s => s.marker && s.kind !== "approval");
|
|
2694
|
+
await Promise.all(settle.map(s => this._settleCard(s).catch(() => {})));
|
|
2695
|
+
await new Promise(r => requestAnimationFrame(() => r()));
|
|
2696
|
+
const drop = [];
|
|
2697
|
+
for (const slot of [...this._slots.values()]) {
|
|
2698
|
+
if (!slot.marker || slot.kind === "approval") continue;
|
|
2699
|
+
if ((slot.gen || 0) > sweepGen) continue; // 新任务在挂槽:不归本次收束管
|
|
2700
|
+
if (!slot.mounted || slot.pendingMount) {
|
|
2701
|
+
// 挂载失败降级:内容以纯文本写进网格,不留隐形卡、不丢内容
|
|
2702
|
+
this._fallbackPlain(slot);
|
|
2703
|
+
drop.push(slot.id);
|
|
2704
|
+
this._dropSlot(slot);
|
|
2705
|
+
continue;
|
|
2706
|
+
}
|
|
2707
|
+
const full = slot.pinnedRows == null && !slot.clamped &&
|
|
2708
|
+
(slot.reserved || 0) >= (slot.rows || 1);
|
|
2709
|
+
if (!full) {
|
|
2710
|
+
// 保留夹紧:钉死到已垫高度(审批卡不低于按钮行),不丢弃
|
|
2711
|
+
this._clampCard(slot);
|
|
2712
|
+
const rows = Math.max(1, slot.reserved || 1, this._floorRows(slot));
|
|
2713
|
+
if (slot.pinnedRows !== rows) {
|
|
2714
|
+
slot.pinnedRows = rows;
|
|
2715
|
+
this._applyDecoration(slot, rows);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
if (!slot.revealed) this._revealSlot(slot);
|
|
2719
|
+
}
|
|
2720
|
+
if (drop.length && feed) feed.handle({kind: "drop", ids: drop});
|
|
2721
|
+
this._scheduleHeal();
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
// 失败救援卡(reactive):用户命令非零退出,worker 发 rescue 事件挂卡,
|
|
2725
|
+
// 一键交给 AI 判定原意/修复。worker 侧已保证 AI 任务不在跑(_ai_task 为空
|
|
2726
|
+
// 才发),这里无需定格活卡。挂载 kind 沿用 "approval":救援卡同款
|
|
2727
|
+
// 「藏了就点不了」+ 夹紧保按钮行(_slotHidden/_floorRows 都按该 kind 认)
|
|
2728
|
+
_showRescue(ev) {
|
|
2729
|
+
this._ensureFeed().then(async feed => {
|
|
2730
|
+
if (!feed) return;
|
|
2731
|
+
const cardId = ++this._cardSeq;
|
|
2732
|
+
feed.handle({kind: "rescue", line: ev.line || "", ec: ev.ec,
|
|
2733
|
+
output: ev.output || "", id: cardId});
|
|
2734
|
+
await this._mountCard(cardId, "approval");
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
// 审批走 store 更新:命令卡(tool_start 已挂载)就地武装按钮,不再单独
|
|
2739
|
+
// 挂审批卡/垫行(二合一:一卡全程)。island 加载失败兜底 modal 弹窗收
|
|
2740
|
+
// 决策,不让审批死锁。
|
|
2741
|
+
showApproval(command, reasons, host, risk, id) {
|
|
2742
|
+
// 补发去重(WS 重连/刷新重挂时 attach 会再投一次未决审批):去重键
|
|
2743
|
+
// (卡 id, 命令串)——同串并行命令是两张卡两条审批,旧全局串去重会把
|
|
2744
|
+
// 第二条当重发挡掉(第二张卡永远等不到按钮=死锁)。
|
|
2745
|
+
const key = (id == null ? "" : id) + "\u0000" + (command || "");
|
|
2746
|
+
if (this._approvalOpen && this._approvalKey === key) return;
|
|
2747
|
+
// 未决态必须同步记账,不能等卡片层落定:_ensureFeed() 一卡(脚本加载
|
|
2748
|
+
// 慢、promise 不落),徽标就停在「AI 正在思考」、审批 UI 一张不出,而
|
|
2749
|
+
// core 侧在干等决策——真机看就是任务卡死(bug2 截图)。球此刻已在用户侧。
|
|
2750
|
+
this._approvalOpen = true;
|
|
2751
|
+
this._approvalCmd = command;
|
|
2752
|
+
this._approvalKey = key;
|
|
2753
|
+
this._approvalResolved = false; // 新一令:上一令的已决策态不带到本令
|
|
2754
|
+
this._refreshThink();
|
|
2755
|
+
this._ensureFeed().then(async feed => {
|
|
2756
|
+
// 慢到的卡片层:决策已下 / 命令已换就别再武装旧卡
|
|
2757
|
+
if (this._dead || this._approvalKey !== key) return;
|
|
2758
|
+
// 命令卡 id 映射缺失(claim_seq 未认领 / 卡已被中断丢)→ 模态兜底
|
|
2759
|
+
const cardId = (id != null) ? this._toolCards.get(id) : null;
|
|
2760
|
+
if (!feed || cardId == null) {
|
|
2761
|
+
return this._approvalModal(command, reasons, key);
|
|
2762
|
+
}
|
|
2763
|
+
// 该卡可能还在串行队列里没落地:插队立即挂,否则按钮出不来=决策死锁
|
|
2764
|
+
this._flushToolMountFor(cardId);
|
|
2765
|
+
// 就地武装命令卡按钮(不 mount 不垫行:命令卡出生即完整已结账)。
|
|
2766
|
+
// store 返回 false=卡已不在/非命令卡/无操作行(needs_approval 假的
|
|
2767
|
+
// 卡永不长操作行,谓词不可能中途反转)→ 模态兜底不长高。
|
|
2768
|
+
const armed = feed.handle({kind: "command_card_waiting", id: cardId,
|
|
2769
|
+
command, reasons, risk});
|
|
2770
|
+
if (!armed) {
|
|
2771
|
+
return this._approvalModal(command, reasons, key);
|
|
2772
|
+
}
|
|
2773
|
+
// 武装后重钉:按钮行刚出生(React 渲染后 floorRows 才有值),卡可能
|
|
2774
|
+
// 已按底线=0 钉死 1 行 → 按钮被裁出盒=真机「执行按钮点不动」根因。
|
|
2775
|
+
// 重算底线把钉死抬到按钮行底(宁短暂压提示符,不许按钮不可点=死锁)
|
|
2776
|
+
await this._sleep(30);
|
|
2777
|
+
const armedSlot = this._slots.get(cardId);
|
|
2778
|
+
if (armedSlot && armedSlot.marker && this._hostAlive(armedSlot.host)) {
|
|
2779
|
+
const floor = this._floorRows(armedSlot);
|
|
2780
|
+
if (floor > 0 && (armedSlot.pinnedRows == null || armedSlot.pinnedRows < floor)) {
|
|
2781
|
+
if (armedSlot.pinnedRows == null) this._clampCard(armedSlot);
|
|
2782
|
+
armedSlot.pinnedRows = Math.max(1, floor);
|
|
2783
|
+
}
|
|
2784
|
+
this._styleHost(armedSlot);
|
|
2785
|
+
}
|
|
2786
|
+
// 可点性看门狗(持续型):直接查 DOM——「执行」按钮真实存在且落在
|
|
2787
|
+
// 卡盒可视区内才算用户点得到。旧版只采样一次(mounted 且未钉死)就
|
|
2788
|
+
// 返回:resize 折行/晚到 pad 事后把按钮行夹出屏时无人兜底(评审 HIGH)。
|
|
2789
|
+
// 现在审批存续期间持续巡检:先给 grace 等出生落定,此后任一时刻失
|
|
2790
|
+
// 可点性就 disarm 转模态——审批永远有可点的入口(红线⑨)。
|
|
2791
|
+
// 判定不看 pinnedRows:钉死但按钮仍在盒内(_floorRows 保底)是可点的。
|
|
2792
|
+
let t0 = Date.now(); // let:挂载未落定期重置(不烧宽限)
|
|
2793
|
+
const tStart = Date.now(); // 绝对兜底:挂载永不到也得转模态
|
|
2794
|
+
let slot = null;
|
|
2795
|
+
let everClickable = false;
|
|
2796
|
+
let misses = 0;
|
|
2797
|
+
const buttonClickable = () => {
|
|
2798
|
+
if (!slot || !slot.mounted || !this._hostAlive(slot.host)) return false;
|
|
2799
|
+
try {
|
|
2800
|
+
const b = slot.host.querySelector(".cmdacts button.primary");
|
|
2801
|
+
if (!b || typeof b.getBoundingClientRect !== "function") return false;
|
|
2802
|
+
const box = slot.host.getBoundingClientRect();
|
|
2803
|
+
const r = b.getBoundingClientRect();
|
|
2804
|
+
return r.height > 0 && r.top >= box.top - 1 && r.bottom <= box.bottom + 1;
|
|
2805
|
+
} catch (e) { return false; }
|
|
2806
|
+
};
|
|
2807
|
+
// 失可点性 → disarm 转模态。grace 期内用户可能已按 Ctrl+Enter 放行:
|
|
2808
|
+
// 再转模态就是给已决策的命令再开一扇「批准」,点下去第二发决策会落到
|
|
2809
|
+
// core 下一次 ask_approval 头上=幽灵批准(真机 B1)。守卫三件套:
|
|
2810
|
+
// _approvalResolved / key 校验 / 已有模态不叠。
|
|
2811
|
+
const toModal = () => {
|
|
2812
|
+
if (this._dead || this._approvalResolved ||
|
|
2813
|
+
this._approvalKey !== key) return;
|
|
2814
|
+
try {
|
|
2815
|
+
if (document.querySelector &&
|
|
2816
|
+
document.querySelector("#modal-root .modal")) return;
|
|
2817
|
+
} catch (e) {}
|
|
2818
|
+
// disarm 不删卡:工具记录留屏,快捷键路径从此无 armed 卡可双决策
|
|
2819
|
+
feed.handle({kind: "approval_void", id: cardId});
|
|
2820
|
+
this._approvalModal(command, reasons, key);
|
|
2821
|
+
};
|
|
2822
|
+
while (!this._dead && !this._approvalResolved &&
|
|
2823
|
+
this._approvalKey === key) {
|
|
2824
|
+
slot = this._slots.get(cardId) || slot;
|
|
2825
|
+
if (buttonClickable()) {
|
|
2826
|
+
everClickable = true;
|
|
2827
|
+
misses = 0;
|
|
2828
|
+
} else if (everClickable) {
|
|
2829
|
+
// 连续失点才算真失点(React 重渲染/编辑框开合的单帧空隙不得误报)
|
|
2830
|
+
misses++;
|
|
2831
|
+
if (misses < 5) { await this._sleep(200); continue; }
|
|
2832
|
+
toModal();
|
|
2833
|
+
return;
|
|
2834
|
+
} else if (!slot || slot.pendingMount) {
|
|
2835
|
+
// 挂载未落定(槽未注册/xterm 空闲不出 host):宽限等挂载,不烧钟;
|
|
2836
|
+
// 15s 绝对兜底——挂载永不到也得给用户可点入口
|
|
2837
|
+
t0 = Date.now();
|
|
2838
|
+
if (Date.now() - tStart > 15000) { toModal(); return; }
|
|
2839
|
+
} else if (Date.now() - t0 >= this._approvalGraceMs) {
|
|
2840
|
+
toModal();
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
await this._sleep(200);
|
|
2844
|
+
}
|
|
2845
|
+
}).catch(e => {
|
|
2846
|
+
// 卡片层中途抛错也不能让审批静默死掉——core 在等决策,任务会假死。
|
|
2847
|
+
// 转模态兜底,决策永远有可点入口;已有模态就不叠第二张。
|
|
2848
|
+
console.error("[ot] 审批武装失败,转模态兜底:", e);
|
|
2849
|
+
if (this._dead || this._approvalResolved ||
|
|
2850
|
+
this._approvalKey !== key) return;
|
|
2851
|
+
try {
|
|
2852
|
+
if (document.querySelector &&
|
|
2853
|
+
document.querySelector("#modal-root .modal")) return;
|
|
2854
|
+
} catch (e) {}
|
|
2855
|
+
this._approvalModal(command, reasons, key);
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
// 审批模态兜底(feed 缺失 / 武装失败 / 看门狗超时共用):决策直发 WS、不经
|
|
2860
|
+
// store——命令卡已 disarm 或根本没有 armed 卡,快捷键路径无从双决策。
|
|
2861
|
+
// 按钮带 key:模态是异步落体,点击时卡号/命令可能早已换代(同串并行命令
|
|
2862
|
+
// 的第二条),不带 key 校验就会把新令的决策发给旧令(幽灵决策)。
|
|
2863
|
+
_approvalModal(command, reasons, key) {
|
|
2864
|
+
// modal.js 缺席(加载失败/测试环境)时不得抛穿——看门狗循环的 toModal
|
|
2865
|
+
// 会把异常吃成 unhandledRejection,测试互相污染
|
|
2866
|
+
if (typeof modalShell !== "function") {
|
|
2867
|
+
console.warn("[ot] 模态组件缺失(modal.js 未载),审批模态兜底不可用");
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
modalShell("审批:" + (command || ""),
|
|
2871
|
+
`<p>${esc(reasons || "")}</p>`,
|
|
2872
|
+
[{label: "批准", primary: true,
|
|
2873
|
+
onClick: () => this._sendDecision({type: "approve"}, command, key)},
|
|
2874
|
+
{label: "拒绝",
|
|
2875
|
+
onClick: () => this._sendDecision(
|
|
2876
|
+
{type: "reject", message: "用户拒绝了该命令"}, command, key)}]);
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
// 决策单发出口:卡内 Ctrl+Enter(feed onDecision)与模态按钮都会到,
|
|
2880
|
+
// 只放一发。第二发决策会落到 core 下一次 ask_decision 头上,等于替用户
|
|
2881
|
+
// 批准一条根本没看过的命令(真机 B1 的幽灵模态双决策)。
|
|
2882
|
+
_sendDecision(decision, command, key) {
|
|
2883
|
+
if (this._dead || this._approvalResolved) return false;
|
|
2884
|
+
// 键对不上=本令已决策或已被下一令顶掉(命令串可能与并行同串命令相同,
|
|
2885
|
+
// 只有 (卡 id,命令串) 整键才认得准)
|
|
2886
|
+
if (key != null && this._approvalKey !== key) return false;
|
|
2887
|
+
if (key == null && command != null && this._approvalCmd !== command) return false;
|
|
2888
|
+
this._approvalResolved = true;
|
|
2889
|
+
this._approvalOpen = false;
|
|
2890
|
+
this._approvalCmd = null;
|
|
2891
|
+
this._approvalKey = null;
|
|
2892
|
+
this._refreshThink();
|
|
2893
|
+
this.sendJson({type: "decision", decision});
|
|
2894
|
+
return true;
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
// 审批快捷键路由(终端保持焦点):island 的 approval_key 返回是否消费,
|
|
2898
|
+
// 消费才 preventDefault 并标记已处理(attachCustomKeyEventHandler 与
|
|
2899
|
+
// document 冒泡两条路径都可能到达,防 e/E 双翻折编辑框)。
|
|
2900
|
+
// 返回是否消费,供 xterm 钩子决定要不要把按键截下。
|
|
2901
|
+
approvalKeys(ev) {
|
|
2902
|
+
if (!ev.ctrlKey || !this._feed || ev._otApprovalDone) return false;
|
|
2903
|
+
let key = null;
|
|
2904
|
+
if (ev.key === "Enter") key = "Enter";
|
|
2905
|
+
else if (ev.key === "e" || ev.key === "E") key = "e";
|
|
2906
|
+
else if (ev.key === "Backspace") key = "Backspace";
|
|
2907
|
+
if (!key) return false;
|
|
2908
|
+
if (this._feed.handle({kind: "approval_key", key})) {
|
|
2909
|
+
ev.preventDefault();
|
|
2910
|
+
ev._otApprovalDone = true;
|
|
2911
|
+
return true;
|
|
2912
|
+
}
|
|
2913
|
+
return false;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
focusCursor() {
|
|
2917
|
+
try { this.term.focus(); } catch (e) {}
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
fit() {
|
|
2921
|
+
if (!this.paneEl.classList.contains("active")) return;
|
|
2922
|
+
try {
|
|
2923
|
+
const stage = this.paneEl.querySelector(".ot-stage");
|
|
2924
|
+
const termEl = this.paneEl.querySelector(".term");
|
|
2925
|
+
// 明确给活动终端一屏高度,不能由 xterm 当前 24 行内容反推容器高度。
|
|
2926
|
+
// 向下取整避免小数像素造成额外滚动条;历史卡片不参与高度计算。
|
|
2927
|
+
const height = stage && Math.floor(stage.getBoundingClientRect().height);
|
|
2928
|
+
if (termEl && height > 0 && termEl.style.height !== height + "px") {
|
|
2929
|
+
termEl.style.height = height + "px";
|
|
2930
|
+
}
|
|
2931
|
+
if (this.filesPanel && this.filesPanel._built && !this.filesPanel.el.hidden) {
|
|
2932
|
+
this.filesPanel._place();
|
|
2933
|
+
}
|
|
2934
|
+
this.fitAddon.fit();
|
|
2935
|
+
} catch (e) { /* 隐藏/零尺寸时忽略 */ }
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
activate() {
|
|
2939
|
+
this.fit();
|
|
2940
|
+
this.focusCursor();
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// seg 仅作后端 hook 开关(__ot_off),显示不再分叉
|
|
2944
|
+
setMode(mode) {
|
|
2945
|
+
if (this.mode === mode) return;
|
|
2946
|
+
this.mode = mode;
|
|
2947
|
+
this._lineBuf = "";
|
|
2948
|
+
this._lineCur = 0;
|
|
2949
|
+
if (this.interactive) this.sendJson({type: "mode", text: mode});
|
|
2950
|
+
this.paneEl.querySelectorAll(".seg button").forEach(b =>
|
|
2951
|
+
b.classList.toggle("on", b.dataset.mode === mode));
|
|
2952
|
+
this.focusCursor();
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
setStatus(state, text) {
|
|
2956
|
+
this.status = state;
|
|
2957
|
+
if (text != null) this.statusText = text;
|
|
2958
|
+
const el = document.getElementById(`status-${this.tabId}`);
|
|
2959
|
+
if (el) el.textContent = this.statusText;
|
|
2960
|
+
// 连接中徽标+终端区浮层:ready/closed/error 一到即撤(浮层在 term.open
|
|
2961
|
+
// 后插入,初始 connecting 应显示)
|
|
2962
|
+
const conn = document.getElementById(`conn-${this.tabId}`);
|
|
2963
|
+
if (conn) conn.hidden = state !== "connecting";
|
|
2964
|
+
const ov = document.getElementById(`connov-${this.tabId}`);
|
|
2965
|
+
if (ov) ov.hidden = state !== "connecting";
|
|
2966
|
+
// tab 标签页上的状态点(每 tab 独立):绿=已连接、红=断开、黄闪=连接中
|
|
2967
|
+
const td = this.tabEl && this.tabEl.querySelector(".tab-dot");
|
|
2968
|
+
if (td) td.className = "dot tab-dot " + state;
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// 浮层两阶段文案:stage 事件来自 worker(open_session 完成 → agent 初始化)
|
|
2972
|
+
_setConnectStage(stage) {
|
|
2973
|
+
const ov = document.getElementById(`connov-${this.tabId}`);
|
|
2974
|
+
if (!ov) return;
|
|
2975
|
+
const t = ov.querySelector(".ctext");
|
|
2976
|
+
if (t) t.textContent = stage === "agent_init" ? "初始化 Agent" : "连接中";
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
_fmtDuration(ms) {
|
|
2980
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
2981
|
+
if (s < 60) return `${s}s`;
|
|
2982
|
+
return `${Math.floor(s / 60)}m${s % 60}s`;
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
_startTimer() {
|
|
2986
|
+
this._stopTimer();
|
|
2987
|
+
this._taskStart = Date.now();
|
|
2988
|
+
const el = document.getElementById(`time-${this.tabId}`);
|
|
2989
|
+
if (el) el.textContent = "⏱ " + this._fmtDuration(0);
|
|
2990
|
+
this._timer = setInterval(() => {
|
|
2991
|
+
const e = document.getElementById(`time-${this.tabId}`);
|
|
2992
|
+
if (e) e.textContent = "⏱ " + this._fmtDuration(Date.now() - this._taskStart);
|
|
2993
|
+
}, 1000);
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
_stopTimer() {
|
|
2997
|
+
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
_endTimer() {
|
|
3001
|
+
// 任务结束:停止计时,定格显示本次任务耗时
|
|
3002
|
+
const el = document.getElementById(`time-${this.tabId}`);
|
|
3003
|
+
if (el && this._taskStart) {
|
|
3004
|
+
el.textContent = "⏱ " + this._fmtDuration(Date.now() - this._taskStart);
|
|
3005
|
+
}
|
|
3006
|
+
this._stopTimer();
|
|
3007
|
+
this._taskStart = 0;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
_renderModelSelect(models, current) {
|
|
3011
|
+
const sel = document.getElementById(`model-${this.tabId}`);
|
|
3012
|
+
if (!sel) return;
|
|
3013
|
+
sel.innerHTML = "";
|
|
3014
|
+
(models || []).forEach(m => {
|
|
3015
|
+
const o = document.createElement("option");
|
|
3016
|
+
o.value = m;
|
|
3017
|
+
o.textContent = m;
|
|
3018
|
+
sel.appendChild(o);
|
|
3019
|
+
});
|
|
3020
|
+
sel.value = current || "";
|
|
3021
|
+
sel.onchange = () => {
|
|
3022
|
+
this.sendJson({type: "change_model", model: sel.value});
|
|
3023
|
+
};
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
_renderTokens(inTok, outTok, estimated) {
|
|
3027
|
+
const el = document.getElementById(`tokens-${this.tabId}`);
|
|
3028
|
+
if (!el) return;
|
|
3029
|
+
// 网关不回传真实 usage 时为 tiktoken 估算口径,加 ≈ 标注
|
|
3030
|
+
const a = estimated ? "≈" : "";
|
|
3031
|
+
el.textContent = `in ${a}${inTok || 0} · out ${a}${outTok || 0}`;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
// 连接后命令集进度:running 显示 i/total;paused 等用户在终端输入;
|
|
3035
|
+
// done 定格片刻后消隐
|
|
3036
|
+
_renderCmdset(msg) {
|
|
3037
|
+
const el = document.getElementById(`cs-${this.tabId}`);
|
|
3038
|
+
if (!el) return;
|
|
3039
|
+
clearTimeout(this._csTimer);
|
|
3040
|
+
if (msg.state === "paused") {
|
|
3041
|
+
el.innerHTML = '命令集暂停:请在终端输入密码 ' +
|
|
3042
|
+
'<button class="status-cmdset-resume" tabindex="-1">继续</button>';
|
|
3043
|
+
el.querySelector(".status-cmdset-resume").onclick = () =>
|
|
3044
|
+
this.sendJson({type: "cmdset_resume"});
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
if (msg.state === "done") {
|
|
3048
|
+
el.textContent = "命令集执行完毕";
|
|
3049
|
+
this._csTimer = setTimeout(() => { el.textContent = ""; }, 4000);
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
// 状态栏说清在跑哪条:只报 1/3 计数时用户看不出第 2、3 条到底执行了
|
|
3053
|
+
// 没有(真机反馈「只显示了第 1 条命令的执行记录」)。应答行不带内容
|
|
3054
|
+
// (core 对 `>` 行传空串),防密码泄进状态栏。
|
|
3055
|
+
const cmd = msg.cmd ? String(msg.cmd) : "";
|
|
3056
|
+
const short = cmd.length > 40 ? cmd.slice(0, 40) + "…" : cmd;
|
|
3057
|
+
el.textContent = `命令集 ${msg.index || ""}/${msg.total || ""}` +
|
|
3058
|
+
(short ? ":" + short : "");
|
|
3059
|
+
el.title = cmd ? "正在执行:" + cmd : "";
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
_setStopVisible(visible) {
|
|
3063
|
+
const btn = document.getElementById(`stop-${this.tabId}`);
|
|
3064
|
+
if (btn) btn.hidden = !visible;
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
// 「AI正在思考」任务条:任务未结束(task_start → final/task_fail)全程
|
|
3068
|
+
// 常显在状态栏左侧——思考、流式卡、命令执行、审批等待全程都有活动指示,
|
|
3069
|
+
// 右侧 ⏹ 随时可中断(用户诉求:只要对话没完结就一直显示)
|
|
3070
|
+
_refreshThink() {
|
|
3071
|
+
const el = document.getElementById(`think-${this.tabId}`);
|
|
3072
|
+
if (el) {
|
|
3073
|
+
el.hidden = !this._taskActive;
|
|
3074
|
+
// 等审批期间球在用户侧:徽标文案说实话(旧实现照转「AI 正在思考」,
|
|
3075
|
+
// 真机假死 25min 无人知晓在等决策)
|
|
3076
|
+
const tt = el.querySelector ? el.querySelector(".ttext") : null;
|
|
3077
|
+
// 徽标三态说实话:等审批 > 在跑命令 > 在思考。长命令/多轮排查时
|
|
3078
|
+
// 不能一路「AI 正在思考」——用户看到的就是假死(bug2 观感根因)。
|
|
3079
|
+
const elapsed = this._taskStartedAt
|
|
3080
|
+
? Math.max(0, Math.round((Date.now() - this._taskStartedAt) / 1000)) : 0;
|
|
3081
|
+
let text = elapsed ? `AI 正在思考 ${elapsed}s` : "AI 正在思考";
|
|
3082
|
+
let title = "任务未结束:AI 正在分析或执行";
|
|
3083
|
+
if (this._runningTool) {
|
|
3084
|
+
const t = this._runningTool.length > 36
|
|
3085
|
+
? this._runningTool.slice(0, 36) + "…" : this._runningTool;
|
|
3086
|
+
text = "执行中: " + t;
|
|
3087
|
+
title = "正在执行: " + this._runningTool;
|
|
3088
|
+
}
|
|
3089
|
+
if (this._approvalOpen) {
|
|
3090
|
+
text = "等待审批";
|
|
3091
|
+
title = "命令等待你的决定(Ctrl+Enter 执行 / Ctrl+Backspace 拒绝)";
|
|
3092
|
+
}
|
|
3093
|
+
// Phase 3 徽标增强:三态文案拼进度计数(progress 心跳 1s 刷)——
|
|
3094
|
+
// 「思考中 12s · 已生成 N 字 · in X / out Y」。tokens 为 runner 估算口径,
|
|
3095
|
+
// usage 事件到后由 _renderTokens 修正在状态栏右侧(此处保持心跳值)
|
|
3096
|
+
const p = this._progress;
|
|
3097
|
+
if (p && (p.chars || p.tokensIn || p.tokensOut)) {
|
|
3098
|
+
text += ` · 已生成 ${p.chars || 0} 字 · in ${p.tokensIn || 0} / out ${p.tokensOut || 0}`;
|
|
3099
|
+
}
|
|
3100
|
+
if (tt) tt.textContent = text;
|
|
3101
|
+
el.title = title;
|
|
3102
|
+
}
|
|
3103
|
+
const bar = this.paneEl && this.paneEl.querySelector(".statusbar");
|
|
3104
|
+
if (bar) bar.classList.toggle("task-on", this._taskActive);
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// --- WS JSON 事件 ---
|
|
3108
|
+
// 契约:ready/status/closed/usage/approval/ask_password/ask_password_dismiss/ask_host_key/
|
|
3109
|
+
// interrupt/mode/auth/change_model/task_start/final/denied/limit/error/
|
|
3110
|
+
// stage + Phase 1:ai_result/task_end/ai_card/ai_collapse(no-op)/ping。
|
|
3111
|
+
handleMsg(msg) {
|
|
3112
|
+
switch (msg.type) {
|
|
3113
|
+
case "ping":
|
|
3114
|
+
break; // 心跳:存活基线已在 onmessage 记 _lastMsgAt,这里无需动作
|
|
3115
|
+
case "ready": {
|
|
3116
|
+
this._pwCapture = null; // 登录成功:退出密码内联捕获
|
|
3117
|
+
this.setStatus("connected",
|
|
3118
|
+
`已连接 ${msg.host}${msg.distro ? "(" + msg.distro + ")" : ""}`);
|
|
3119
|
+
this.interactive = !!msg.interactive;
|
|
3120
|
+
// 欢迎横幅:紧随 ready 的第一条 status 是横幅文案;落点锚在首个
|
|
3121
|
+
// 提示符文本上(OSC 133 标记被后端消费,不会到达前端)
|
|
3122
|
+
this._awaitWelcome = msg.interactive ? true : false;
|
|
3123
|
+
this._welcomeNeed = (msg.prompt || "").trim();
|
|
3124
|
+
// 尺寸同步:onResize 只在尺寸变化时触发,xterm 创建时即为此尺寸则
|
|
3125
|
+
// 永远不会发 resize、远端 PTY 停在默认 40×120——ready 后显式同步一次
|
|
3126
|
+
this.sendJson({type: "resize", cols: this.term.cols, rows: this.term.rows});
|
|
3127
|
+
const hostEl = document.getElementById(`host-${this.tabId}`);
|
|
3128
|
+
if (hostEl) hostEl.textContent = msg.host || "";
|
|
3129
|
+
this._renderModelSelect(msg.models, msg.model);
|
|
3130
|
+
// 本机终端没有远程文件:知道会话类型后收掉 📁 入口(否则是个死按钮)
|
|
3131
|
+
if (this.filesPanel) this.filesPanel.probeSupport();
|
|
3132
|
+
// 集成失败回退 = 纯终端(无 AI),不显示模型信息
|
|
3133
|
+
const modelSel = document.getElementById(`model-${this.tabId}`);
|
|
3134
|
+
if (modelSel) modelSel.hidden = !this.interactive;
|
|
3135
|
+
if (this.interactive && this.mode !== "agent") this.setMode("agent");
|
|
3136
|
+
this.fit();
|
|
3137
|
+
this.focusCursor();
|
|
3138
|
+
// 本次是手输密码登录成功 → 询问是否记住(错密不会走到 ready,
|
|
3139
|
+
// 凭据库只进验证过的密码;记住后下次连接自动填充、免提示直进)
|
|
3140
|
+
if (this._pwEntered) {
|
|
3141
|
+
this._pwEntered = null;
|
|
3142
|
+
modalConfirm("记住密码",
|
|
3143
|
+
"已成功登录。记住该主机的密码?下次连接将自动登录" +
|
|
3144
|
+
"(写入系统凭据库,不落数据库)",
|
|
3145
|
+
ok => {
|
|
3146
|
+
if (ok) this.sendJson({type: "auth",
|
|
3147
|
+
auth_kind: "password_remember",
|
|
3148
|
+
text: ""});
|
|
3149
|
+
}, "记住", "不记住");
|
|
3150
|
+
}
|
|
3151
|
+
break;
|
|
3152
|
+
}
|
|
3153
|
+
case "status":
|
|
3154
|
+
if (this._awaitWelcome) { // ready 后第一条 status = 横幅文案
|
|
3155
|
+
this._awaitWelcome = false;
|
|
3156
|
+
this._welcomeText = msg.text || "";
|
|
3157
|
+
// 进入扣留态:PTY 字节扣到提示符落屏,先写横幅再重放(P0-3:
|
|
3158
|
+
// 此前 _welcomeHold 从未被赋值,横幅永不落屏、扣留机制全死)
|
|
3159
|
+
this._welcomeHold = {raw: null, cleared: 0, at: performance.now()};
|
|
3160
|
+
this._armWelcomeDeadline();
|
|
3161
|
+
}
|
|
3162
|
+
this.setStatus(this.status, msg.text);
|
|
3163
|
+
break;
|
|
3164
|
+
case "stage": // 连接两阶段:连接中 →(建连完成)→ 初始化 Agent
|
|
3165
|
+
this._setConnectStage(msg.text);
|
|
3166
|
+
break;
|
|
3167
|
+
case "cmdset":
|
|
3168
|
+
this._renderCmdset(msg);
|
|
3169
|
+
break;
|
|
3170
|
+
case "usage":
|
|
3171
|
+
this._renderTokens(msg.tokens_in, msg.tokens_out, msg.estimated);
|
|
3172
|
+
break;
|
|
3173
|
+
case "approval":
|
|
3174
|
+
this.showApproval(msg.command, msg.reasons, msg.host, msg.risk, msg.id);
|
|
3175
|
+
break;
|
|
3176
|
+
case "ask_password":
|
|
3177
|
+
if (!msg.auth_kind || msg.auth_kind === "password") {
|
|
3178
|
+
// 初始连接讨密码:与真实终端一致——提示符直接落进终端,无回显
|
|
3179
|
+
// 收集输入(不弹模态);错密后端重讨时补 OpenSSH 的 denied 行。
|
|
3180
|
+
// 取消=Ctrl+C(发 password_cancel,中止整个连接尝试)
|
|
3181
|
+
if (this._pwCapture && this._pwCapture.submitted)
|
|
3182
|
+
this._writeTerm("\r\nPermission denied, please try again.");
|
|
3183
|
+
this._pwNewline();
|
|
3184
|
+
this._writeTerm(msg.label || "密码: ");
|
|
3185
|
+
// 撤连接浮层让提示符可见(ready/closed 自会撤,无需恢复)
|
|
3186
|
+
const ov = document.getElementById(`connov-${this.tabId}`);
|
|
3187
|
+
if (ov) ov.hidden = true;
|
|
3188
|
+
this._pwCapture = {kind: "password", buf: "", submitted: false};
|
|
3189
|
+
break;
|
|
3190
|
+
}
|
|
3191
|
+
modalAsk("密码", msg.label, (value, remember) => {
|
|
3192
|
+
this.sendJson({type: "auth", auth_kind: msg.auth_kind || "password",
|
|
3193
|
+
text: value, remember});
|
|
3194
|
+
}, {remember: msg.auth_kind !== "cmdset",
|
|
3195
|
+
rememberLabel: "记住密码(写入系统凭据库,不落数据库)",
|
|
3196
|
+
// 嵌套密码模态取消/点遮罩:改终端手输,通知 core 立即退出验证
|
|
3197
|
+
// 窗口(否则手输的密码被 hold 到超时才放行)
|
|
3198
|
+
onCancel: msg.auth_kind === "nested_password" ? () => {
|
|
3199
|
+
this.sendJson({type: "auth", auth_kind: "nested_password_cancel",
|
|
3200
|
+
text: ""});
|
|
3201
|
+
} : null});
|
|
3202
|
+
break;
|
|
3203
|
+
case "ask_password_dismiss":
|
|
3204
|
+
// 密码提示符已被终端手输消费:后端防抖代关陈旧模态
|
|
3205
|
+
modalDismissPassword();
|
|
3206
|
+
break;
|
|
3207
|
+
case "ask_remember":
|
|
3208
|
+
// 手输密码验证成功后的「记住」询问(嵌套 ssh/sudo,P1-3/P1-4):
|
|
3209
|
+
// 同意 → remember_yes 按后端留档键写 keyring;拒绝 → remember_no
|
|
3210
|
+
// 只清档。文案对齐 ready 后的「记住密码」确认框
|
|
3211
|
+
modalConfirm("记住密码",
|
|
3212
|
+
msg.text || "已成功登录。记住该密码?下次自动填充" +
|
|
3213
|
+
"(写入系统凭据库,不落数据库)",
|
|
3214
|
+
ok => {
|
|
3215
|
+
this.sendJson({type: "auth",
|
|
3216
|
+
auth_kind: ok ? "remember_yes" : "remember_no",
|
|
3217
|
+
text: ""});
|
|
3218
|
+
}, "记住", "不记住");
|
|
3219
|
+
break;
|
|
3220
|
+
case "ask_host_key":
|
|
3221
|
+
modalConfirm("主机密钥", msg.message, ok => {
|
|
3222
|
+
this.sendJson({type: "auth", auth_kind: "host_key", text: String(ok)});
|
|
3223
|
+
});
|
|
3224
|
+
break;
|
|
3225
|
+
case "interrupt":
|
|
3226
|
+
this._onTaskFail("interrupt", msg.text || "已中断");
|
|
3227
|
+
break;
|
|
3228
|
+
case "mode":
|
|
3229
|
+
// 后端 hook 开关确认
|
|
3230
|
+
if (msg.text === "ssh" || msg.text === "agent") {
|
|
3231
|
+
this.mode = msg.text;
|
|
3232
|
+
this.paneEl.querySelectorAll(".seg button").forEach(b =>
|
|
3233
|
+
b.classList.toggle("on", b.dataset.mode === this.mode));
|
|
3234
|
+
}
|
|
3235
|
+
break;
|
|
3236
|
+
case "auth":
|
|
3237
|
+
break; // 应答确认,无 UI
|
|
3238
|
+
case "closed":
|
|
3239
|
+
this._pwCapture = null; // 连接终结:退出密码内联捕获
|
|
3240
|
+
this._pwEntered = null; // 失败的连接不弹「记住密码」
|
|
3241
|
+
this.setStatus("closed", msg.text || "连接已关闭");
|
|
3242
|
+
break;
|
|
3243
|
+
case "reconnected":
|
|
3244
|
+
this.setStatus("connected", msg.text || "已重新连接");
|
|
3245
|
+
break;
|
|
3246
|
+
// --- AI 事件:新契约顶层直接下发;兼容旧 {type:"event"} 包装 ---
|
|
3247
|
+
case "event":
|
|
3248
|
+
this.handleEvent(msg.event);
|
|
3249
|
+
break;
|
|
3250
|
+
case "task_start":
|
|
3251
|
+
this._onTaskStart();
|
|
3252
|
+
break;
|
|
3253
|
+
case "ai_result":
|
|
3254
|
+
this._onAiResult(msg);
|
|
3255
|
+
break;
|
|
3256
|
+
case "task_end":
|
|
3257
|
+
this._onTaskEnd();
|
|
3258
|
+
break;
|
|
3259
|
+
case "progress":
|
|
3260
|
+
this._onProgress(msg);
|
|
3261
|
+
break;
|
|
3262
|
+
case "ai_collapse":
|
|
3263
|
+
break; // 流式契约退役:事件留线(CLI 依赖),Web 侧 no-op
|
|
3264
|
+
case "ai_card":
|
|
3265
|
+
this._onAiCard(msg.markdown);
|
|
3266
|
+
break;
|
|
3267
|
+
case "final":
|
|
3268
|
+
this._onFinal(msg.text);
|
|
3269
|
+
break;
|
|
3270
|
+
case "denied":
|
|
3271
|
+
case "limit":
|
|
3272
|
+
case "error":
|
|
3273
|
+
this._onTaskFail(msg.type, msg.text);
|
|
3274
|
+
break;
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
// 旧事件包装({type:"event", event:{kind:...}})按 kind 路由到同一批处理器
|
|
3279
|
+
handleEvent(ev) {
|
|
3280
|
+
if (!ev) return;
|
|
3281
|
+
switch (ev.kind) {
|
|
3282
|
+
case "task_start": this._onTaskStart(); break;
|
|
3283
|
+
case "ai_result": this._onAiResult(ev); break;
|
|
3284
|
+
case "task_end": this._onTaskEnd(); break;
|
|
3285
|
+
case "progress": this._onProgress(ev); break;
|
|
3286
|
+
case "ai_boundary": this._onBoundary(); break;
|
|
3287
|
+
case "ai_collapse": break; // 流式契约退役:事件留线(CLI 依赖),Web 侧 no-op
|
|
3288
|
+
case "ai_tool": this._onAiTool(ev); break;
|
|
3289
|
+
case "term_open": this._onTermOpen(ev); break;
|
|
3290
|
+
case "term_close": this._onTermClose(ev); break;
|
|
3291
|
+
case "ai_card": this._onAiCard(ev.markdown); break;
|
|
3292
|
+
case "final": this._onFinal(ev.text); break;
|
|
3293
|
+
case "interrupt": this._onTaskFail("interrupt", ev.text || "已中断"); break;
|
|
3294
|
+
case "rescue": this._showRescue(ev); break;
|
|
3295
|
+
case "denied":
|
|
3296
|
+
case "limit":
|
|
3297
|
+
case "error":
|
|
3298
|
+
// 系统异常(模型/工具超时、崩溃等)入红色警告卡展示(真机约定:
|
|
3299
|
+
// 报错信息都进红卡)。denied 是策略拒绝、limit 是预算停——业务
|
|
3300
|
+
// 语义,不入红卡
|
|
3301
|
+
if (ev.kind === "error") this._onAlert({text: ev.text || "系统异常"});
|
|
3302
|
+
this._onTaskFail(ev.kind, ev.text);
|
|
3303
|
+
break;
|
|
3304
|
+
case "session_cleared":
|
|
3305
|
+
this._clearCards();
|
|
3306
|
+
break;
|
|
3307
|
+
case "padded":
|
|
3308
|
+
this._onPadded(ev);
|
|
3309
|
+
break;
|
|
3310
|
+
case "alert":
|
|
3311
|
+
this._onAlert(ev);
|
|
3312
|
+
break;
|
|
3313
|
+
default:
|
|
3314
|
+
break; // 未知事件静默忽略(此前 JSON.stringify 进终端是调试残留)
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
close() {
|
|
3319
|
+
this._dead = true;
|
|
3320
|
+
if (this._layoutObserver) this._layoutObserver.disconnect();
|
|
3321
|
+
if (this._layoutFrame != null) cancelAnimationFrame(this._layoutFrame);
|
|
3322
|
+
if (this._healT) { clearTimeout(this._healT); this._healT = null; }
|
|
3323
|
+
if (this._wsRetryT) { clearTimeout(this._wsRetryT); this._wsRetryT = null; }
|
|
3324
|
+
if (this._ghostTimer) { clearInterval(this._ghostTimer); this._ghostTimer = null; }
|
|
3325
|
+
this._hideGhost();
|
|
3326
|
+
this._destroySlots();
|
|
3327
|
+
if (this._feed) { try { this._feed.destroy(); } catch (e) {} this._feed = null; }
|
|
3328
|
+
authFetch(`/api/tabs/${this.tabId}/close`, {method: "POST"}).catch(() => {});
|
|
3329
|
+
if (this.ws) { try { this.ws.onclose = null; this.ws.close(); } catch (e) {} }
|
|
3330
|
+
this._stopTimer();
|
|
3331
|
+
try { this.term.dispose(); } catch (e) {}
|
|
3332
|
+
this.tabEl.remove();
|
|
3333
|
+
this.paneEl.remove();
|
|
3334
|
+
delete sessions[this.tabId];
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
// --- tab / pane 管理 ---
|
|
3339
|
+
function openTab(targetName) {
|
|
3340
|
+
authFetch("/api/tabs", {
|
|
3341
|
+
method: "POST",
|
|
3342
|
+
body: JSON.stringify({target: targetName}),
|
|
3343
|
+
})
|
|
3344
|
+
.then(r => r.json())
|
|
3345
|
+
.then(({tab_id}) => {
|
|
3346
|
+
const s = new Session(tab_id, targetName);
|
|
3347
|
+
sessions[tab_id] = s;
|
|
3348
|
+
activateTab(tab_id);
|
|
3349
|
+
s.connectWS();
|
|
3350
|
+
})
|
|
3351
|
+
.catch(err => console.error("openTab failed", err));
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
function activateTab(tabId) {
|
|
3355
|
+
activeTabId = tabId;
|
|
3356
|
+
document.querySelectorAll(".tab").forEach(b =>
|
|
3357
|
+
b.classList.toggle("active", b.id === `tabbtn-${tabId}`));
|
|
3358
|
+
document.querySelectorAll(".pane").forEach(p =>
|
|
3359
|
+
p.classList.toggle("active", p.id === `pane-${tabId}`));
|
|
3360
|
+
const s = sessions[tabId];
|
|
3361
|
+
if (s) s.activate();
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
function closeTab(tabId) {
|
|
3365
|
+
const s = sessions[tabId];
|
|
3366
|
+
if (!s) return;
|
|
3367
|
+
const wasActive = activeTabId === tabId;
|
|
3368
|
+
s.close();
|
|
3369
|
+
if (wasActive) {
|
|
3370
|
+
const rest = Object.keys(sessions);
|
|
3371
|
+
activeTabId = rest.length ? rest[rest.length - 1] : null;
|
|
3372
|
+
if (activeTabId) activateTab(activeTabId);
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
// --- 侧栏 ---
|
|
3377
|
+
function loadTargets() {
|
|
3378
|
+
authFetch("/api/targets")
|
|
3379
|
+
.then(r => r.json())
|
|
3380
|
+
.then(data => { targetsData = data; renderTargets(); })
|
|
3381
|
+
.catch(() => {});
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
function matchesQuery(t) {
|
|
3385
|
+
if (!searchQuery) return true;
|
|
3386
|
+
const q = searchQuery.toLowerCase();
|
|
3387
|
+
return [t.name, t.host, t.user].filter(Boolean)
|
|
3388
|
+
.some(v => String(v).toLowerCase().includes(q));
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
function renderTargets() {
|
|
3392
|
+
const nav = document.getElementById("targets");
|
|
3393
|
+
nav.innerHTML = "";
|
|
3394
|
+
const data = targetsData;
|
|
3395
|
+
if (!data) return;
|
|
3396
|
+
|
|
3397
|
+
if (matchesQuery({name: "local"})) nav.appendChild(localEntry());
|
|
3398
|
+
|
|
3399
|
+
const direct = (data.direct || []).filter(matchesQuery);
|
|
3400
|
+
if (direct.length) {
|
|
3401
|
+
nav.appendChild(sep("直接连接"));
|
|
3402
|
+
direct.forEach(t => nav.appendChild(entry(t)));
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
function sep(text) {
|
|
3407
|
+
const d = document.createElement("div");
|
|
3408
|
+
d.className = "sep";
|
|
3409
|
+
d.innerHTML = `<span class="sep-caret">▾</span>${esc(text)}`;
|
|
3410
|
+
return d;
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
// 侧栏条目:默认显示主机名(name),副行 user@host:port;悬停出编辑/删除
|
|
3414
|
+
function entry(t) {
|
|
3415
|
+
const b = document.createElement("button");
|
|
3416
|
+
b.className = "target";
|
|
3417
|
+
b.dataset.name = t.name;
|
|
3418
|
+
const sub = `${t.user || ""}@${t.host || ""}${t.port ? ":" + t.port : ":22"}`;
|
|
3419
|
+
const label = t.name;
|
|
3420
|
+
b.innerHTML =
|
|
3421
|
+
`<span class="t-main"><span class="t-label">${esc(label)}</span>` +
|
|
3422
|
+
(sub.replace(/[@:]/g, "") ? `<span class="t-sub">${esc(sub)}</span>` : "") +
|
|
3423
|
+
"</span>" +
|
|
3424
|
+
'<span class="t-actions">' +
|
|
3425
|
+
'<button data-a="copy" title="复制为新的连接">⧉</button>' +
|
|
3426
|
+
'<button data-a="edit" title="编辑">✎</button>' +
|
|
3427
|
+
'<button data-a="del" title="删除">✕</button></span>';
|
|
3428
|
+
b.onclick = ev => {
|
|
3429
|
+
if (ev.target.closest(".t-actions")) return;
|
|
3430
|
+
openTab(t.name);
|
|
3431
|
+
};
|
|
3432
|
+
b.querySelector('[data-a="copy"]').onclick = ev => {
|
|
3433
|
+
ev.stopPropagation(); connForm({copy: t});
|
|
3434
|
+
};
|
|
3435
|
+
b.querySelector('[data-a="edit"]').onclick = ev => {
|
|
3436
|
+
ev.stopPropagation(); connForm({edit: t});
|
|
3437
|
+
};
|
|
3438
|
+
b.querySelector('[data-a="del"]').onclick = ev => {
|
|
3439
|
+
ev.stopPropagation(); connDelete(t);
|
|
3440
|
+
};
|
|
3441
|
+
return b;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
function localEntry() {
|
|
3445
|
+
const b = document.createElement("button");
|
|
3446
|
+
b.className = "target";
|
|
3447
|
+
b.dataset.name = "local";
|
|
3448
|
+
b.innerHTML =
|
|
3449
|
+
'<span class="t-main"><span class="t-label">local</span>' +
|
|
3450
|
+
'<span class="t-sub">本机终端</span></span>';
|
|
3451
|
+
b.onclick = () => openTab("local");
|
|
3452
|
+
return b;
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
// 连接表单:远程机器的新增与编辑;
|
|
3456
|
+
// 密码只进系统凭据库(服务端保证不落 SQLite)
|
|
3457
|
+
function connForm(opts = {}) {
|
|
3458
|
+
const edit = opts.edit || null;
|
|
3459
|
+
const copy = opts.copy || null; // 复制模式:预填源连接信息,保存新建
|
|
3460
|
+
const src = copy || edit; // 预填来源(复制=源连接,编辑=被编辑对象)
|
|
3461
|
+
const title = (copy ? "复制" : edit ? "编辑" : "添加") + "远程机器";
|
|
3462
|
+
// 复制默认名 = 原名 + 「 副本」:POST /api/saved 按 name upsert,同名保存会
|
|
3463
|
+
// 覆盖原连接而非新建,预加后缀保证落成独立新连接(用户可再改)
|
|
3464
|
+
const nameVal = copy ? ((copy.name || "").replace(/ 副本$/, "") + " 副本")
|
|
3465
|
+
: (edit ? edit.name : "");
|
|
3466
|
+
// 连接后命令集:仅远程机器表单。密码写 > @名字=密码,保存后服务端
|
|
3467
|
+
// 抽取入凭据库并把该行改写为 > @名字 回传(弹窗即关,无需回显刷新)
|
|
3468
|
+
const cmdRow = `
|
|
3469
|
+
<label>连接后命令</label>
|
|
3470
|
+
<textarea id="f-commands" rows="4" spellcheck="false"
|
|
3471
|
+
placeholder="连接成功后自动逐行执行。自动应答写 > @名字=密码(保存后转为引用)">${esc(src && src.commands ? src.commands : "")}</textarea>`;
|
|
3472
|
+
modalShell(title, `
|
|
3473
|
+
<div class="form-grid">
|
|
3474
|
+
<label>主机名</label>
|
|
3475
|
+
<input id="f-name" type="text" autocomplete="off"
|
|
3476
|
+
placeholder="如 web01,留空默认 user@IP" value="${esc(nameVal)}">
|
|
3477
|
+
<label>IP 地址</label>
|
|
3478
|
+
<input id="f-host" type="text" autocomplete="off"
|
|
3479
|
+
placeholder="如 192.168.1.10" value="${esc(src ? src.host : "")}">
|
|
3480
|
+
<label>端口</label>
|
|
3481
|
+
<input id="f-port" type="text" autocomplete="off"
|
|
3482
|
+
placeholder="默认 22" value="${src && src.port ? src.port : ""}">
|
|
3483
|
+
<label>用户名</label>
|
|
3484
|
+
<input id="f-user" type="text" autocomplete="off"
|
|
3485
|
+
placeholder="如 root" value="${src && src.user ? src.user : ""}">
|
|
3486
|
+
<label>密码</label>
|
|
3487
|
+
<input id="f-pass" type="password" autocomplete="new-password"
|
|
3488
|
+
placeholder="${copy || edit ? "留空则不修改" : "存入系统凭据库,不写数据库"}">
|
|
3489
|
+
${cmdRow}
|
|
3490
|
+
</div>`, [
|
|
3491
|
+
{label: "确定", primary: true, onClick: () => {
|
|
3492
|
+
const hostEl = document.getElementById("f-host");
|
|
3493
|
+
const host = hostEl.value.trim();
|
|
3494
|
+
if (!host) { // IP/地址必填:标红、不关闭弹窗
|
|
3495
|
+
hostEl.classList.add("invalid");
|
|
3496
|
+
hostEl.focus();
|
|
3497
|
+
return false;
|
|
3498
|
+
}
|
|
3499
|
+
const payload = {
|
|
3500
|
+
name: document.getElementById("f-name").value.trim(),
|
|
3501
|
+
host,
|
|
3502
|
+
port: document.getElementById("f-port").value.trim(),
|
|
3503
|
+
user: document.getElementById("f-user").value.trim(),
|
|
3504
|
+
password: document.getElementById("f-pass").value,
|
|
3505
|
+
};
|
|
3506
|
+
const cmdEl = document.getElementById("f-commands");
|
|
3507
|
+
if (cmdEl) payload.commands = cmdEl.value;
|
|
3508
|
+
const url = edit ? "/api/saved/" + encodeURIComponent(edit.name) : "/api/saved";
|
|
3509
|
+
authFetch(url, {method: edit ? "PUT" : "POST",
|
|
3510
|
+
body: JSON.stringify(payload)})
|
|
3511
|
+
.then(r => r.json().then(d => ({ok: r.ok, d}))
|
|
3512
|
+
.catch(() => ({ok: r.ok, d: {}})))
|
|
3513
|
+
.then(({ok, d}) => {
|
|
3514
|
+
if (!ok) { alert("保存失败:" + (d.detail || "请检查表单内容")); return; }
|
|
3515
|
+
loadTargets();
|
|
3516
|
+
if (d.warning) alert(d.warning);
|
|
3517
|
+
// 新增远程机器:保存成功后直接开 tab 尝试连接;复制不自动开——先落列表
|
|
3518
|
+
// 让用户确认/改完再连,避免误连出多个重复 tab
|
|
3519
|
+
if (!edit && !copy) openTab(d.name || payload.name || host);
|
|
3520
|
+
})
|
|
3521
|
+
.catch(() => alert("保存失败:网络错误"));
|
|
3522
|
+
return true;
|
|
3523
|
+
}},
|
|
3524
|
+
{label: "取消", onClick: () => {}},
|
|
3525
|
+
]);
|
|
3526
|
+
// 复制/编辑聚焦 IP 地址(最可能改动);新增聚焦主机名
|
|
3527
|
+
const first = document.getElementById(copy || edit ? "f-host" : "f-name");
|
|
3528
|
+
if (first) first.focus();
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
function connDelete(t) {
|
|
3532
|
+
const sub = `${t.user ? t.user + "@" : ""}${t.host || ""}${t.port ? ":" + t.port : ":22"}`;
|
|
3533
|
+
modalConfirm(`删除连接`,
|
|
3534
|
+
`确定删除 ${t.name}(${sub})吗?\n记住的密码将一并从系统凭据库清除。`,
|
|
3535
|
+
ok => {
|
|
3536
|
+
if (!ok) return;
|
|
3537
|
+
authFetch(`/api/saved/${encodeURIComponent(t.name)}`, {method: "DELETE"})
|
|
3538
|
+
.then(() => loadTargets());
|
|
3539
|
+
}, "删除", "取消");
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
// --- 全局事件 ---
|
|
3543
|
+
document.getElementById("btn-add-remote").onclick = () => connForm({});
|
|
3544
|
+
|
|
3545
|
+
document.getElementById("sidebar-search").addEventListener("input", ev => {
|
|
3546
|
+
searchQuery = ev.target.value.trim();
|
|
3547
|
+
renderTargets();
|
|
3548
|
+
});
|
|
3549
|
+
|
|
3550
|
+
document.getElementById("btn-toggle-sidebar").onclick = () => {
|
|
3551
|
+
document.body.classList.toggle("sidebar-collapsed");
|
|
3552
|
+
const s = sessions[activeTabId];
|
|
3553
|
+
if (s) setTimeout(() => s.fit(), 60);
|
|
3554
|
+
};
|
|
3555
|
+
|
|
3556
|
+
const showSidebar = document.getElementById("btn-show-sidebar");
|
|
3557
|
+
if (showSidebar) showSidebar.onclick = () => {
|
|
3558
|
+
document.body.classList.remove("sidebar-collapsed");
|
|
3559
|
+
document.getElementById("sidebar-search").focus();
|
|
3560
|
+
const s = sessions[activeTabId];
|
|
3561
|
+
if (s) setTimeout(() => s.fit(), 60);
|
|
3562
|
+
};
|
|
3563
|
+
|
|
3564
|
+
// 响应式侧栏:窄窗口收起侧栏让终端拿满宽度(CSS 在 ≤860px 把侧栏转浮层,
|
|
3565
|
+
// 收起后即可用顶栏按钮唤出)。只在「跨过阈值变窄」时收起,宽回去了也不
|
|
3566
|
+
// 自动展开——展开是用户的手势,不与之抢状态。
|
|
3567
|
+
const SIDEBAR_NARROW = 860;
|
|
3568
|
+
let wasNarrow = window.innerWidth <= SIDEBAR_NARROW;
|
|
3569
|
+
if (wasNarrow) document.body.classList.add("sidebar-collapsed");
|
|
3570
|
+
|
|
3571
|
+
let resizeTimer = null;
|
|
3572
|
+
window.addEventListener("resize", () => {
|
|
3573
|
+
clearTimeout(resizeTimer);
|
|
3574
|
+
resizeTimer = setTimeout(() => {
|
|
3575
|
+
const narrow = window.innerWidth <= SIDEBAR_NARROW;
|
|
3576
|
+
if (narrow && !wasNarrow) document.body.classList.add("sidebar-collapsed");
|
|
3577
|
+
wasNarrow = narrow;
|
|
3578
|
+
const s = sessions[activeTabId];
|
|
3579
|
+
if (s) { s.fit(); s._keepApprovalInView(); }
|
|
3580
|
+
}, 120);
|
|
3581
|
+
});
|
|
3582
|
+
|
|
3583
|
+
// 从其它窗口切回浏览器时恢复焦点:聚焦唯一终端,否则键盘输入会落到 body 上
|
|
3584
|
+
// 全部失效(用户切屏后「无法输入」)
|
|
3585
|
+
window.addEventListener("focus", () => {
|
|
3586
|
+
const s = sessions[activeTabId];
|
|
3587
|
+
if (s) s.focusCursor();
|
|
3588
|
+
});
|
|
3589
|
+
|
|
3590
|
+
loadTargets();
|
|
3591
|
+
|
|
3592
|
+
// Windows/Linux Ctrl+V 焦点抢救:焦点不在 xterm textarea 时(右键菜单点过后
|
|
3593
|
+
// 焦点落 body、点过边距/状态栏等),keydown 到不了 xterm,原生 paste 也无从
|
|
3594
|
+
// 发生——单靠 xterm 钩子放行救不了这种场景。这里把当前 tab 的终端同步拉回
|
|
3595
|
+
// 焦点,随后的默认 paste 动作即落进 xterm 的 textarea 走其粘贴监听。焦点本
|
|
3596
|
+
// 就在某个输入框(xterm 的 textarea、弹窗/审批编辑器等 UI 输入框)时不抢,
|
|
3597
|
+
// 各自保留原生粘贴语义。
|
|
3598
|
+
document.addEventListener("keydown", ev => {
|
|
3599
|
+
if (IS_APPLE || !ev.ctrlKey || ev.shiftKey || ev.altKey || ev.metaKey ||
|
|
3600
|
+
(ev.key !== "v" && ev.key !== "V" && ev.keyCode !== 86)) return;
|
|
3601
|
+
const ae = document.activeElement;
|
|
3602
|
+
if (ae && (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA" ||
|
|
3603
|
+
ae.isContentEditable)) return;
|
|
3604
|
+
const s = sessions[activeTabId];
|
|
3605
|
+
if (s && !s._dead) { try { s.term.focus(); } catch (e) {} }
|
|
3606
|
+
});
|
|
3607
|
+
|
|
3608
|
+
// 卡片 island 内的浏览器原生复制(选区在卡内按 Cmd/Ctrl+C / 右键复制):同样弹
|
|
3609
|
+
// 「已复制」。仅当选区锚点落在 .ot-card-host 里才弹——终端复制路径走 _copyText
|
|
3610
|
+
// 已有自己的提示,这里不会重复弹(xterm 选区不是 DOM 选区,卡外 copy 事件不命中)
|
|
3611
|
+
document.addEventListener("copy", ev => {
|
|
3612
|
+
const sel = window.getSelection();
|
|
3613
|
+
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
|
|
3614
|
+
const node = sel.anchorNode;
|
|
3615
|
+
const el = node && (node.nodeType === 1 ? node : node.parentElement);
|
|
3616
|
+
if (el && el.closest && el.closest(".ot-card-host")) {
|
|
3617
|
+
const s = sessions[activeTabId];
|
|
3618
|
+
if (s) s._toastCopied();
|
|
3619
|
+
}
|
|
3620
|
+
}, true);
|
|
3621
|
+
|
|
3622
|
+
// 全局快捷键:Ctrl+Shift+I 切换 seg(后端 hook 开关);审批卡快捷键路由
|
|
3623
|
+
document.addEventListener("keydown", ev => {
|
|
3624
|
+
const s = sessions[activeTabId];
|
|
3625
|
+
if (!s) return;
|
|
3626
|
+
if (ev.ctrlKey && ev.shiftKey && (ev.key === "i" || ev.key === "I")) {
|
|
3627
|
+
ev.preventDefault();
|
|
3628
|
+
s.setMode(s.mode === "ssh" ? "agent" : "ssh");
|
|
3629
|
+
return;
|
|
3630
|
+
}
|
|
3631
|
+
s.approvalKeys(ev);
|
|
3632
|
+
});
|