cc-viewer 1.6.311 → 1.6.313
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{App-T5FQTsBZ.js → App-DVWiEmB2.js} +1 -1
- package/dist/assets/{MdxEditorPanel-CEi81Xx8.js → MdxEditorPanel-DPZ3CV2s.js} +1 -1
- package/dist/assets/{Mobile-D0CPuF4p.js → Mobile-Dwue8DFB.js} +1 -1
- package/dist/assets/{index-DqUPEFNT.js → index-ByBJZi-l.js} +2 -2
- package/dist/assets/{seqResourceLoaders-Db3xRWgK.css → seqResourceLoaders-CX6xejM7.css} +1 -1
- package/dist/assets/seqResourceLoaders-CXTfIb_N.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/ansi-safe-slice.js +131 -0
- package/server/lib/pty-flood-coalescer.js +21 -1
- package/server/pty-manager.js +22 -49
- package/server/scratch-pty-manager.js +15 -26
- package/server/server.js +96 -34
- package/dist/assets/seqResourceLoaders-BE3I8wAc.js +0 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="/assets/index-
|
|
24
|
+
<script type="module" crossorigin src="/assets/index-ByBJZi-l.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="/assets/vendor-antd-Bur5ZxWE.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-Si44UqBp.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-Cco3AQJS.js">
|
package/package.json
CHANGED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI 转义序列安全边界工具(纯函数、零依赖,乱码残片不变量的语义基准)。
|
|
3
|
+
* 两个导出:
|
|
4
|
+
* findSafeSliceStart(buf, rawStart) —— 缓冲区"掐头"裁剪的安全起点(锚点扫描)
|
|
5
|
+
* splitTrailingIncomplete(buf) —— 批边界"截尾"缓带:[安全前段, 半截序列尾巴]
|
|
6
|
+
* 共同保证:xterm 收到的字节流中,任何删除/分批的续点都是合法解析起点,
|
|
7
|
+
* 半截序列永不以字面渲染。端到端验证见 test/terminal-pipeline-oracle.test.js。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* findSafeSliceStart:在输出缓冲区从头部截断时,找到安全的截断起点,
|
|
12
|
+
* 避免切片落在 ANSI 转义序列中间——丢掉 ESC[ 前缀后剩余字节
|
|
13
|
+
* 会被 xterm.js 当普通文本渲染(表现为 `[9m`、`?2026l`、`6;136;136m` 类乱码)。
|
|
14
|
+
*
|
|
15
|
+
* 算法:从 rawStart 向后扫描,锚到下一个无歧义的解析重启点:
|
|
16
|
+
* - 硬锚点 ESC (0x1b):从 ESC 起始永远是合法序列起点(CSI/SGR/OSC/DCS 通吃);
|
|
17
|
+
* 最坏情况锚到 OSC 终止符 ST(ESC \)自身,ground state 下是无害 no-op。
|
|
18
|
+
* - 软锚点 LF (0x0a):仅当窗口内无 ESC 时使用(纯文本流如 shell 日志),
|
|
19
|
+
* 返回 LF 之后的位置。不与 ESC 平级是因为 OSC payload 理论上可含 LF。
|
|
20
|
+
* - 都没有且窗口未覆盖尾部:返回 scanLimit(多丢 ≤scanWindow 字节,发生在
|
|
21
|
+
* 滚动缓冲头部,可忽略)。
|
|
22
|
+
* - 都没有且窗口覆盖尾部:回看一小窗判断 rawStart 是否落在某 CSI/OSC 内部
|
|
23
|
+
* (前方无 ESC/LF 意味着该序列的终止符只能在 rawStart 之后)——是则前跳到
|
|
24
|
+
* 终止符之后,否则返回 rawStart——绝不清空保留数据(洪泛限流的 last-wins
|
|
25
|
+
* 语义依赖这一点)。fallback 起点若落在 UTF-16 低代理上则 +1,避免孤儿化
|
|
26
|
+
* 代理对(高代理起点配对仍完整,无需处理)。
|
|
27
|
+
*
|
|
28
|
+
* 注意:只保 ANSI 序列边界,不保 DEC 2026 同步标记的配对——头部截断只可能
|
|
29
|
+
* 孤儿化 END(?2026l,xterm 下无害 no-op),不可能产生无 END 的 BEGIN;
|
|
30
|
+
* 跨 2026 块截断的配平由调用方负责(见 pty-flood-coalescer.js 的标记剥离)。
|
|
31
|
+
*/
|
|
32
|
+
// 前向锚点扫描窗:TUI 流 ESC 密集(几十字节内必中锚点),上限只在纯文本流生效——
|
|
33
|
+
// 多丢 ≤4KB 且发生在 ≥45KB 保留尾部的头部,不可感知。
|
|
34
|
+
const DEFAULT_SCAN_WINDOW = 4096;
|
|
35
|
+
// 回看判定窗:CSI 实际长度有界(最长常见真彩 SGR ≈20 字节),64 足以覆盖
|
|
36
|
+
// "rawStart 是否落在某序列内部"的判定;OSC 超长场景由前向 ESC 锚兜住。
|
|
37
|
+
const BACK_SCAN = 64;
|
|
38
|
+
// 缓带上限:正常序列远小于此;超限视为畸形流(永不终结的 OSC/DCS),
|
|
39
|
+
// 放弃缓带按原样发出,防半截尾巴无界滞留内存/延迟。
|
|
40
|
+
const DEFAULT_MAX_CARRY = 4096;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把 buf 切成 [可安全发出的前段, 尾部未终结的半截转义序列]。
|
|
44
|
+
* 用途:flushBatch 给每批输出包裹 DEC 2026 SYNC 标记——若批边界劈开一条序列,
|
|
45
|
+
* 注入的标记会吃掉它的 ESC,让后半段以字面渲染(`[9m`/`8;2;102m` 类残片的总根源)。
|
|
46
|
+
* 半截尾巴缓带到下一批(PTY 续写必然补全)即可保证每个被包裹的批序列完整。
|
|
47
|
+
* 尾部孤立高代理同样缓带(不劈 emoji 码点)。超 maxCarry 的悬挂(畸形流如
|
|
48
|
+
* 永不终结的 OSC)放弃缓带按原样发出,防止无界延迟/内存。
|
|
49
|
+
*/
|
|
50
|
+
export function splitTrailingIncomplete(buf, maxCarry = DEFAULT_MAX_CARRY) {
|
|
51
|
+
if (!buf) return ['', ''];
|
|
52
|
+
const k = buf.lastIndexOf('\x1b');
|
|
53
|
+
if (k !== -1 && buf.length - k <= maxCarry) {
|
|
54
|
+
const intro = k + 1 < buf.length ? buf.charCodeAt(k + 1) : -1;
|
|
55
|
+
let complete;
|
|
56
|
+
if (intro === -1) {
|
|
57
|
+
complete = false; // 裸 ESC 收尾
|
|
58
|
+
} else if (intro === 0x5b) { // CSI:任何 0x40-0x7e 终字节即终结
|
|
59
|
+
complete = false;
|
|
60
|
+
for (let j = k + 2; j < buf.length; j++) {
|
|
61
|
+
const c = buf.charCodeAt(j);
|
|
62
|
+
if (c >= 0x40 && c <= 0x7e) { complete = true; break; }
|
|
63
|
+
}
|
|
64
|
+
} else if (intro === 0x5d) { // OSC:k 是最后一个 ESC → ST 不可能,只看 BEL
|
|
65
|
+
complete = buf.indexOf('\x07', k + 2) !== -1;
|
|
66
|
+
} else if (intro === 0x50) { // DCS:ST 终结需要 ESC,而 k 已是最后一个
|
|
67
|
+
complete = false;
|
|
68
|
+
} else { // 短转义:ESC + 中间字节(0x20-0x2f)* + 终字节
|
|
69
|
+
let j = k + 1;
|
|
70
|
+
while (j < buf.length && buf.charCodeAt(j) >= 0x20 && buf.charCodeAt(j) <= 0x2f) j++;
|
|
71
|
+
complete = j < buf.length;
|
|
72
|
+
}
|
|
73
|
+
if (!complete) return [buf.slice(0, k), buf.slice(k)];
|
|
74
|
+
}
|
|
75
|
+
// 尾部孤立高代理:配对的低代理还在路上
|
|
76
|
+
const last = buf.charCodeAt(buf.length - 1);
|
|
77
|
+
if (last >= 0xd800 && last <= 0xdbff) return [buf.slice(0, -1), buf.slice(-1)];
|
|
78
|
+
return [buf, ''];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function findSafeSliceStart(buf, rawStart, scanWindow = DEFAULT_SCAN_WINDOW) {
|
|
82
|
+
if (rawStart <= 0) return 0;
|
|
83
|
+
if (rawStart >= buf.length) return buf.length;
|
|
84
|
+
const scanLimit = Math.min(rawStart + scanWindow, buf.length);
|
|
85
|
+
let afterLf = -1;
|
|
86
|
+
for (let i = rawStart; i < scanLimit; i++) {
|
|
87
|
+
const ch = buf.charCodeAt(i);
|
|
88
|
+
if (ch === 0x1b) return i;
|
|
89
|
+
if (ch === 0x0a && afterLf === -1) afterLf = i + 1;
|
|
90
|
+
}
|
|
91
|
+
if (afterLf !== -1) return afterLf;
|
|
92
|
+
const idx = scanLimit >= buf.length ? resolveInSequence(buf, rawStart) : scanLimit;
|
|
93
|
+
const c = buf.charCodeAt(idx);
|
|
94
|
+
return (c >= 0xdc00 && c <= 0xdfff) ? idx + 1 : idx;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 前向无任何锚点时的兜底:回看 BACK_SCAN 字节找最近的 ESC,判断 rawStart 是否
|
|
98
|
+
// 正落在它引导的 CSI/OSC 内部(引导符与 rawStart 之间无终止符),是则前跳过终止符。
|
|
99
|
+
// 注意调用前提:rawStart 之后整个 buf 内都没有 ESC(前向扫描已覆盖到结尾),
|
|
100
|
+
// 所以 OSC 只需考虑 BEL 终止形态(ESC\ 形态会被前向锚点拦下,到不了这里)。
|
|
101
|
+
function resolveInSequence(buf, rawStart) {
|
|
102
|
+
const backStop = Math.max(0, rawStart - BACK_SCAN);
|
|
103
|
+
for (let k = rawStart - 1; k >= backStop; k--) {
|
|
104
|
+
if (buf.charCodeAt(k) !== 0x1b) continue;
|
|
105
|
+
const intro = buf.charCodeAt(k + 1);
|
|
106
|
+
if (intro === 0x5b) { // CSI:终止符为 0x40-0x7e(引导符之后起算)
|
|
107
|
+
for (let j = k + 2; j < rawStart; j++) {
|
|
108
|
+
const cj = buf.charCodeAt(j);
|
|
109
|
+
if (cj >= 0x40 && cj <= 0x7e) return rawStart; // 序列已终结,rawStart 在纯文本里
|
|
110
|
+
}
|
|
111
|
+
for (let j = Math.max(rawStart, k + 2); j < buf.length; j++) {
|
|
112
|
+
const cj = buf.charCodeAt(j);
|
|
113
|
+
if (cj >= 0x40 && cj <= 0x7e) return j + 1;
|
|
114
|
+
}
|
|
115
|
+
// 序列到结尾仍未终结(还在被 PTY 续写):保留半截序列头,等续写补全;
|
|
116
|
+
// 若改为丢弃,下一 chunk 送来的序列尾会变成新的孤儿残片。
|
|
117
|
+
return k;
|
|
118
|
+
}
|
|
119
|
+
if (intro === 0x5d) { // OSC:BEL 终止
|
|
120
|
+
for (let j = k + 2; j < rawStart; j++) {
|
|
121
|
+
if (buf.charCodeAt(j) === 0x07) return rawStart;
|
|
122
|
+
}
|
|
123
|
+
for (let j = Math.max(rawStart, k + 2); j < buf.length; j++) {
|
|
124
|
+
if (buf.charCodeAt(j) === 0x07) return j + 1;
|
|
125
|
+
}
|
|
126
|
+
return k;
|
|
127
|
+
}
|
|
128
|
+
return rawStart; // 其他短转义(charset 切换等)≤3 字节,rawStart 已在其后
|
|
129
|
+
}
|
|
130
|
+
return rawStart; // 回看窗口内无 ESC:rawStart 在纯文本里
|
|
131
|
+
}
|
|
@@ -81,6 +81,12 @@ const DEFAULT_PT_COALESCE_MS = envIntAllowZero('CCV_FLOOD_PT_COALESCE_MS', 16);
|
|
|
81
81
|
* @param {(buf: string, rawStart: number) => number} opts.findSafeSliceStart - ANSI 安全截断(pty-manager 导出)
|
|
82
82
|
* @param {(buffered: number) => void} [opts.onFloodStart] - 进入限流态(observability 埋点)
|
|
83
83
|
* @param {() => void} [opts.onFloodEnd] - 回落直通态
|
|
84
|
+
* @param {(droppedChars: number) => void} [opts.onTruncate] - 本轮洪泛**实际丢过字节**且已回落直通时触发
|
|
85
|
+
* (紧随 onFloodEnd 之后,每轮洪泛至多一次,携带累计丢弃量)。安全切片只保证残片不上屏,
|
|
86
|
+
* 被截掉的中段对增量 TUI 流(macOS/Linux forkpty)不会自愈——调用方应在此用权威快照
|
|
87
|
+
* (getOutputBuffer → data-resync)对齐客户端画面。洪泛进行中不触发:中途 resync 快照
|
|
88
|
+
* 立刻过期且加重负载,回落时一次终态对齐即可。reset()/dispose() 清零累计(resync 路径
|
|
89
|
+
* 自身就是快照对齐,无需重复触发)。
|
|
84
90
|
* @param {number} [opts.flushMs]
|
|
85
91
|
* @param {number} [opts.floodThresholdBytesPerWin]
|
|
86
92
|
* @param {number} [opts.fallbackWins]
|
|
@@ -97,6 +103,7 @@ export function createFloodCoalescer({
|
|
|
97
103
|
findSafeSliceStart,
|
|
98
104
|
onFloodStart,
|
|
99
105
|
onFloodEnd,
|
|
106
|
+
onTruncate,
|
|
100
107
|
flushMs = DEFAULT_FLUSH_MS,
|
|
101
108
|
floodThresholdBytesPerWin = DEFAULT_FLOOD_THRESHOLD,
|
|
102
109
|
fallbackWins = DEFAULT_FALLBACK_WINS,
|
|
@@ -111,6 +118,7 @@ export function createFloodCoalescer({
|
|
|
111
118
|
let pending = '';
|
|
112
119
|
let winBytes = 0; // 当前桶累计字节(直通态由 offer 累计,限流态由 flush 结算)
|
|
113
120
|
let calmWins = 0; // 连续低于阈值的桶数
|
|
121
|
+
let droppedChars = 0; // 本轮洪泛累计实际丢弃的字符数(>0 时回落触发 onTruncate)
|
|
114
122
|
let flushTimer = null; // 限流态周期 flush;直通态下亦作为桶边界 timer(见 offer)
|
|
115
123
|
let ptBuffer = ''; // 直通态微合并缓冲(窗口开启期间到达的后续 chunk)
|
|
116
124
|
let ptTimer = null; // 直通态微合并窗口 timer(16ms),与 flushTimer(33ms 字节桶)并存、职责正交
|
|
@@ -157,7 +165,9 @@ export function createFloodCoalescer({
|
|
|
157
165
|
// 保证洪泛期送达客户端的字节率 ≤ flushBudgetBytes/flushMs,与前端消化速率同量级。
|
|
158
166
|
if (pending.length > flushBudgetBytes) {
|
|
159
167
|
const rawStart = pending.length - flushBudgetBytes;
|
|
160
|
-
|
|
168
|
+
const safeStart = findSafeSliceStart(pending, rawStart);
|
|
169
|
+
droppedChars += safeStart;
|
|
170
|
+
pending = pending.slice(safeStart);
|
|
161
171
|
}
|
|
162
172
|
const merged = SYNC_BEGIN + pending + SYNC_END;
|
|
163
173
|
pending = '';
|
|
@@ -179,6 +189,13 @@ export function createFloodCoalescer({
|
|
|
179
189
|
flooding = false;
|
|
180
190
|
calmWins = 0;
|
|
181
191
|
try { onFloodEnd?.(); } catch { }
|
|
192
|
+
// 本轮洪泛丢过字节 → 回落后通知一次终态对齐(见 opts.onTruncate doc)。
|
|
193
|
+
// 置零在调用**前**:回调内若同步 reset()/re-offer 不会重复触发。
|
|
194
|
+
if (droppedChars > 0) {
|
|
195
|
+
const dropped = droppedChars;
|
|
196
|
+
droppedChars = 0;
|
|
197
|
+
try { onTruncate?.(dropped); } catch { }
|
|
198
|
+
}
|
|
182
199
|
return; // 不再续约 timer,回直通
|
|
183
200
|
}
|
|
184
201
|
flushTimer = setTimer(onFloodTick, flushMs);
|
|
@@ -227,6 +244,7 @@ export function createFloodCoalescer({
|
|
|
227
244
|
if (pending.length > pendingCap) {
|
|
228
245
|
const rawStart = pending.length - trimTo;
|
|
229
246
|
const safeStart = findSafeSliceStart(pending, rawStart);
|
|
247
|
+
droppedChars += safeStart;
|
|
230
248
|
pending = pending.slice(safeStart);
|
|
231
249
|
}
|
|
232
250
|
},
|
|
@@ -239,6 +257,7 @@ export function createFloodCoalescer({
|
|
|
239
257
|
winBytes = 0;
|
|
240
258
|
calmWins = 0;
|
|
241
259
|
flooding = false;
|
|
260
|
+
droppedChars = 0; // resync 路径调用本方法,快照即对齐,不再补发 onTruncate
|
|
242
261
|
},
|
|
243
262
|
isFlooding() {
|
|
244
263
|
return flooding;
|
|
@@ -250,6 +269,7 @@ export function createFloodCoalescer({
|
|
|
250
269
|
stopPtTimer();
|
|
251
270
|
pending = '';
|
|
252
271
|
ptBuffer = '';
|
|
272
|
+
droppedChars = 0;
|
|
253
273
|
},
|
|
254
274
|
};
|
|
255
275
|
}
|
package/server/pty-manager.js
CHANGED
|
@@ -6,6 +6,7 @@ import { platform, arch, homedir } from 'node:os';
|
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import { prepareEmbeddedShellSpawn, stripClaudeNoFlickerUnlessOptedIn } from './lib/terminal-env.js';
|
|
8
8
|
import { killPtyTree } from './lib/term-signals.js';
|
|
9
|
+
import { findSafeSliceStart, splitTrailingIncomplete } from './lib/ansi-safe-slice.js';
|
|
9
10
|
|
|
10
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
12
|
const __dirname = dirname(__filename);
|
|
@@ -35,6 +36,9 @@ let _spawnInflight = null;
|
|
|
35
36
|
const PTY_COLS_MIN = 2, PTY_COLS_MAX = 1000;
|
|
36
37
|
const PTY_ROWS_MIN = 1, PTY_ROWS_MAX = 1000;
|
|
37
38
|
const MAX_BUFFER = 200000;
|
|
39
|
+
// 裁剪滞回:超 MAX_BUFFER 触发后一次裁到 TRIM_TO,而非每个 chunk 都裁到 MAX_BUFFER——
|
|
40
|
+
// 把 ~200KB slice 重分配的频率从每 chunk 一次降到每 ~20KB 新输出一次。
|
|
41
|
+
const BUFFER_TRIM_TO = 180000;
|
|
38
42
|
let batchBuffer = '';
|
|
39
43
|
let batchScheduled = false;
|
|
40
44
|
let _ptyImportForTests = null;
|
|
@@ -51,47 +55,9 @@ async function getPty() {
|
|
|
51
55
|
return ptyMod.default || ptyMod;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
* 策略:从截断点向后扫描,跳过可能被截断的不完整转义序列。
|
|
58
|
-
* 注意:只保 ANSI 序列边界,不保 DEC 2026 同步标记的配对——
|
|
59
|
-
* 跨 2026 块截断的配平由调用方负责(见 lib/pty-flood-coalescer.js)。
|
|
60
|
-
* export 供洪泛限流器复用同一截断语义。
|
|
61
|
-
*/
|
|
62
|
-
export function findSafeSliceStart(buf, rawStart) {
|
|
63
|
-
// 从 rawStart 开始,向后最多扫描 64 字节寻找安全起点
|
|
64
|
-
const scanLimit = Math.min(rawStart + 64, buf.length);
|
|
65
|
-
let i = rawStart;
|
|
66
|
-
while (i < scanLimit) {
|
|
67
|
-
const ch = buf.charCodeAt(i);
|
|
68
|
-
// 如果当前字符是 ESC (0x1b),可能是新转义序列的开头,
|
|
69
|
-
// 但也可能是被截断的序列的中间部分,跳过整个序列
|
|
70
|
-
if (ch === 0x1b) {
|
|
71
|
-
// 找到 ESC,向后寻找序列结束符(字母字符)
|
|
72
|
-
let j = i + 1;
|
|
73
|
-
while (j < scanLimit && !((buf.charCodeAt(j) >= 0x40 && buf.charCodeAt(j) <= 0x7e) && j > i + 1)) {
|
|
74
|
-
j++;
|
|
75
|
-
}
|
|
76
|
-
if (j < scanLimit) {
|
|
77
|
-
// 找到完整序列末尾,从下一个字符开始是安全的
|
|
78
|
-
return j + 1;
|
|
79
|
-
}
|
|
80
|
-
// 序列不完整,继续扫描
|
|
81
|
-
i = j;
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
// 如果字符是 CSI 参数字符 (0x30-0x3f) 或中间字符 (0x20-0x2f),
|
|
85
|
-
// 说明我们在转义序列中间,继续向后
|
|
86
|
-
if ((ch >= 0x20 && ch <= 0x3f)) {
|
|
87
|
-
i++;
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
// 普通可见字符或控制字符(非转义相关),这是安全位置
|
|
91
|
-
break;
|
|
92
|
-
}
|
|
93
|
-
return i < buf.length ? i : rawStart;
|
|
94
|
-
}
|
|
58
|
+
// ANSI 安全截断起点:实现迁至 lib/ansi-safe-slice.js(锚点扫描算法,见该文件 doc)。
|
|
59
|
+
// 保持从本模块 export——server.js 解构注入洪泛限流器、单测均从这里导入。
|
|
60
|
+
export { findSafeSliceStart };
|
|
95
61
|
|
|
96
62
|
// DEC Private Mode 2026 (Synchronized Output) markers.
|
|
97
63
|
// xterm.js 6.0+ 原生支持:收到 BEGIN 后缓存所有写入,收到 END 后一次性渲染,
|
|
@@ -99,11 +65,18 @@ export function findSafeSliceStart(buf, rawStart) {
|
|
|
99
65
|
const SYNC_BEGIN = '\x1b[?2026h';
|
|
100
66
|
const SYNC_END = '\x1b[?2026l';
|
|
101
67
|
|
|
102
|
-
function flushBatch() {
|
|
68
|
+
function flushBatch(force = false) {
|
|
103
69
|
batchScheduled = false;
|
|
104
70
|
if (!batchBuffer) return;
|
|
105
|
-
|
|
106
|
-
|
|
71
|
+
// 批边界半截序列缓带:每批都包 SYNC 标记,若批边界劈开一条转义序列,注入的标记
|
|
72
|
+
// 会吃掉它的 ESC、让后半段以字面渲染(`[9m`/`8;2;102m` 类残片的总根源)。半截尾巴
|
|
73
|
+
// 留到下一批(PTY 续写必然补全);force=true(进程退出)时不缓带,冲洗全部残余。
|
|
74
|
+
let safe = batchBuffer;
|
|
75
|
+
let carry = '';
|
|
76
|
+
if (!force) [safe, carry] = splitTrailingIncomplete(batchBuffer);
|
|
77
|
+
batchBuffer = carry;
|
|
78
|
+
if (!safe) return;
|
|
79
|
+
const chunk = SYNC_BEGIN + safe + SYNC_END;
|
|
107
80
|
for (const cb of dataListeners) {
|
|
108
81
|
try { cb(chunk); } catch { }
|
|
109
82
|
}
|
|
@@ -297,7 +270,7 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
297
270
|
ptyProcess.onData((data) => {
|
|
298
271
|
outputBuffer += data;
|
|
299
272
|
if (outputBuffer.length > MAX_BUFFER) {
|
|
300
|
-
const rawStart = outputBuffer.length -
|
|
273
|
+
const rawStart = outputBuffer.length - BUFFER_TRIM_TO;
|
|
301
274
|
const safeStart = findSafeSliceStart(outputBuffer, rawStart);
|
|
302
275
|
outputBuffer = outputBuffer.slice(safeStart);
|
|
303
276
|
}
|
|
@@ -309,7 +282,7 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
309
282
|
});
|
|
310
283
|
|
|
311
284
|
ptyProcess.onExit(({ exitCode }) => {
|
|
312
|
-
flushBatch();
|
|
285
|
+
flushBatch(true);
|
|
313
286
|
lastExitCode = exitCode;
|
|
314
287
|
ptyProcess = null;
|
|
315
288
|
ptyKind = null;
|
|
@@ -477,7 +450,7 @@ async function _spawnShellImpl() {
|
|
|
477
450
|
ptyProcess.onData((data) => {
|
|
478
451
|
outputBuffer += data;
|
|
479
452
|
if (outputBuffer.length > MAX_BUFFER) {
|
|
480
|
-
const rawStart = outputBuffer.length -
|
|
453
|
+
const rawStart = outputBuffer.length - BUFFER_TRIM_TO;
|
|
481
454
|
const safeStart = findSafeSliceStart(outputBuffer, rawStart);
|
|
482
455
|
outputBuffer = outputBuffer.slice(safeStart);
|
|
483
456
|
}
|
|
@@ -489,7 +462,7 @@ async function _spawnShellImpl() {
|
|
|
489
462
|
});
|
|
490
463
|
|
|
491
464
|
ptyProcess.onExit(({ exitCode }) => {
|
|
492
|
-
flushBatch();
|
|
465
|
+
flushBatch(true);
|
|
493
466
|
lastExitCode = exitCode;
|
|
494
467
|
ptyProcess = null;
|
|
495
468
|
ptyKind = null;
|
|
@@ -522,7 +495,7 @@ export function resizePty(cols, rows) {
|
|
|
522
495
|
|
|
523
496
|
export function killPty() {
|
|
524
497
|
if (ptyProcess) {
|
|
525
|
-
flushBatch();
|
|
498
|
+
flushBatch(true);
|
|
526
499
|
batchBuffer = '';
|
|
527
500
|
batchScheduled = false;
|
|
528
501
|
// Windows:node-pty 的 ConPTY kill 有已知同步挂起问题(microsoft/node-pty#454),
|
|
@@ -5,6 +5,7 @@ import { platform, arch, homedir } from 'node:os';
|
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { prepareEmbeddedShellSpawn } from './lib/terminal-env.js';
|
|
7
7
|
import { killPtyTree } from './lib/term-signals.js';
|
|
8
|
+
import { findSafeSliceStart, splitTrailingIncomplete } from './lib/ansi-safe-slice.js';
|
|
8
9
|
|
|
9
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
11
|
const __dirname = dirname(__filename);
|
|
@@ -21,6 +22,8 @@ const STARTUP_CWD = (() => {
|
|
|
21
22
|
})();
|
|
22
23
|
|
|
23
24
|
const MAX_BUFFER = 50000;
|
|
25
|
+
// 裁剪滞回:超 MAX_BUFFER 后一次裁到 TRIM_TO,降低每 chunk slice 重分配频率(同 pty-manager.js)
|
|
26
|
+
const BUFFER_TRIM_TO = 45000;
|
|
24
27
|
|
|
25
28
|
// id -> { ptyProcess, dataListeners, exitListeners, lastExitCode, outputBuffer, lastCols, lastRows, batchBuffer, batchScheduled }
|
|
26
29
|
const ptys = new Map();
|
|
@@ -71,31 +74,17 @@ function maybeReap(id, s) {
|
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
function
|
|
75
|
-
const scanLimit = Math.min(rawStart + 64, buf.length);
|
|
76
|
-
let i = rawStart;
|
|
77
|
-
while (i < scanLimit) {
|
|
78
|
-
const ch = buf.charCodeAt(i);
|
|
79
|
-
if (ch === 0x1b) {
|
|
80
|
-
let j = i + 1;
|
|
81
|
-
while (j < scanLimit && !((buf.charCodeAt(j) >= 0x40 && buf.charCodeAt(j) <= 0x7e) && j > i + 1)) {
|
|
82
|
-
j++;
|
|
83
|
-
}
|
|
84
|
-
if (j < scanLimit) return j + 1;
|
|
85
|
-
i = j;
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (ch >= 0x20 && ch <= 0x3f) { i++; continue; }
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
91
|
-
return i < buf.length ? i : rawStart;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function flushBatch(s) {
|
|
77
|
+
function flushBatch(s, force = false) {
|
|
95
78
|
s.batchScheduled = false;
|
|
96
79
|
if (!s.batchBuffer) return;
|
|
97
|
-
|
|
98
|
-
|
|
80
|
+
// 批边界半截序列缓带(同 pty-manager.flushBatch):scratch chunk 本身不包 SYNC 标记,
|
|
81
|
+
// 但洪泛限流器会按 flush 包裹——chunk 尾部序列完整才能保证 flush 边界不撕裂。
|
|
82
|
+
// force=true(进程退出)时不缓带,冲洗全部残余。
|
|
83
|
+
let chunk = s.batchBuffer;
|
|
84
|
+
let carry = '';
|
|
85
|
+
if (!force) [chunk, carry] = splitTrailingIncomplete(s.batchBuffer);
|
|
86
|
+
s.batchBuffer = carry;
|
|
87
|
+
if (!chunk) return;
|
|
99
88
|
// snapshot 防 listener 在迭代中卸载产生跳号
|
|
100
89
|
for (const cb of [...s.dataListeners]) {
|
|
101
90
|
try { cb(chunk); } catch { }
|
|
@@ -164,7 +153,7 @@ export async function spawnScratch(id) {
|
|
|
164
153
|
s.ptyProcess.onData((data) => {
|
|
165
154
|
s.outputBuffer += data;
|
|
166
155
|
if (s.outputBuffer.length > MAX_BUFFER) {
|
|
167
|
-
const rawStart = s.outputBuffer.length -
|
|
156
|
+
const rawStart = s.outputBuffer.length - BUFFER_TRIM_TO;
|
|
168
157
|
const safeStart = findSafeSliceStart(s.outputBuffer, rawStart);
|
|
169
158
|
s.outputBuffer = s.outputBuffer.slice(safeStart);
|
|
170
159
|
}
|
|
@@ -176,7 +165,7 @@ export async function spawnScratch(id) {
|
|
|
176
165
|
});
|
|
177
166
|
|
|
178
167
|
s.ptyProcess.onExit(({ exitCode }) => {
|
|
179
|
-
flushBatch(s);
|
|
168
|
+
flushBatch(s, true);
|
|
180
169
|
s.lastExitCode = exitCode;
|
|
181
170
|
s.ptyProcess = null;
|
|
182
171
|
for (const cb of [...s.exitListeners]) {
|
|
@@ -225,7 +214,7 @@ export function killScratch(id) {
|
|
|
225
214
|
const s = ptys.get(id);
|
|
226
215
|
if (!s) return;
|
|
227
216
|
if (s.ptyProcess) {
|
|
228
|
-
flushBatch(s);
|
|
217
|
+
flushBatch(s, true);
|
|
229
218
|
s.batchBuffer = '';
|
|
230
219
|
s.batchScheduled = false;
|
|
231
220
|
// 与 pty-manager.killPty 同款:win32 用 taskkill /T 收割 ConPTY 树,
|