/* global module, setTimeout, clearTimeout */ function utf8ByteLength(text) { const value = String(text || ""); let total = 0; for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); if (code <= 0x7f) { total += 1; continue; } if (code <= 0x7ff) { total += 2; continue; } if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { const next = value.charCodeAt(index + 1); if (next >= 0xdc00 && next <= 0xdfff) { total += 4; index += 1; continue; } } total += 3; } return total; } /** * 统一规范终端渲染选项: * 目前页面层只暴露 `sendResize`,后续若增加其他布尔开关,也应在这里集中合并。 */ function mergeTerminalRenderOptions(base, incoming) { const previous = base && typeof base === "object" ? base : {}; const next = incoming && typeof incoming === "object" ? incoming : {}; return { sendResize: !!(previous.sendResize || next.sendResize) }; } function normalizeStdoutSample(sample) { const source = sample && typeof sample === "object" ? sample : {}; const text = String(source.text || ""); return { text, rawBytes: utf8ByteLength(text), appendStartedAt: Number(source.appendStartedAt) || 0, visibleBytes: Number(source.visibleBytes) || 0, visibleFrameCount: Number(source.visibleFrameCount) || 0 }; } function createPendingRequest(now) { return { options: mergeTerminalRenderOptions(), callbacks: [], stdoutSamples: [], requestedAt: now() }; } /** * 终端渲染调度器职责只有两件事: * 1. stdout 高频输出时,按一个很短的窗口合并成一轮真实渲染; * 2. 若上一轮渲染尚未完成,只保留“下一轮需要再跑一次”的脏标记,避免把 scroll-view 刷新堆成风暴。 * * 注意: * - 调度器不负责真正的布局/overlay 逻辑,页面层通过 `runRender` 注入; * - stdout 合批会把多段文本交给页面层一次性处理,再统一进入 layout/overlay。 */ function createTerminalRenderScheduler(options) { const config = options && typeof options === "object" ? options : {}; const now = typeof config.now === "function" ? config.now : () => Date.now(); const setTimer = typeof config.setTimer === "function" ? config.setTimer : setTimeout; const clearTimer = typeof config.clearTimer === "function" ? config.clearTimer : clearTimeout; const onError = typeof config.onError === "function" ? config.onError : null; const batchWindowMs = Number.isFinite(Number(config.batchWindowMs)) && Number(config.batchWindowMs) >= 0 ? Math.round(Number(config.batchWindowMs)) : 16; const runRender = typeof config.runRender === "function" ? config.runRender : null; if (!runRender) { throw new TypeError("terminal render scheduler 缺少 runRender"); } let inFlight = false; let stdoutTimer = null; let pendingRequest = null; let activeRequest = null; function reportError(error) { if (onError) { onError(error); return; } throw error; } function ensurePendingRequest() { if (!pendingRequest) { pendingRequest = createPendingRequest(now); } return pendingRequest; } function clearStdoutTimer() { if (!stdoutTimer) return; clearTimer(stdoutTimer); stdoutTimer = null; } function finalizeRequestCallbacks(request, result) { const callbacks = Array.isArray(request && request.callbacks) ? request.callbacks.slice() : []; callbacks.forEach((callback) => { if (typeof callback !== "function") return; try { callback(result, request); } catch (error) { reportError(error); } }); } function buildRequestSnapshot(request, timestamp) { if (!request || typeof request !== "object") { return null; } const samples = Array.isArray(request.stdoutSamples) ? request.stdoutSamples : []; return { reason: String(request.reason || ""), requestedAt: Number(request.requestedAt) || 0, startedAt: Number(request.startedAt) || 0, waitMs: timestamp && Number(request.requestedAt) ? Math.max(0, (Number(request.startedAt) || Number(timestamp)) - Number(request.requestedAt)) : 0, ageMs: timestamp && Number(request.startedAt) ? Math.max(0, Number(timestamp) - Number(request.startedAt)) : 0, stdoutSampleCount: samples.length, stdoutRawBytes: samples.reduce( (sum, sample) => sum + Math.max(0, Number(sample && sample.rawBytes) || 0), 0 ), stdoutVisibleBytes: samples.reduce( (sum, sample) => sum + Math.max(0, Number(sample && sample.visibleBytes) || 0), 0 ), callbackCount: Array.isArray(request.callbacks) ? request.callbacks.length : 0 }; } function startNextRun(reason) { if (inFlight || !pendingRequest) { return false; } clearStdoutTimer(); const request = pendingRequest; pendingRequest = null; request.reason = String(reason || ""); request.startedAt = now(); inFlight = true; activeRequest = request; try { runRender(request, (result) => { request.completedAt = now(); inFlight = false; activeRequest = null; finalizeRequestCallbacks(request, result); if (pendingRequest) { startNextRun(pendingRequest.stdoutSamples.length > 0 ? "pending_stdout" : "pending_immediate"); } }); } catch (error) { inFlight = false; activeRequest = null; reportError(error); if (pendingRequest) { startNextRun("recover_after_error"); } } return true; } function scheduleStdoutFlush() { if (inFlight || stdoutTimer || !pendingRequest) { return; } if (batchWindowMs <= 0) { startNextRun("stdout_immediate"); return; } stdoutTimer = setTimer(() => { stdoutTimer = null; startNextRun("stdout_batch"); }, batchWindowMs); } return { requestImmediate(options, callback) { const request = ensurePendingRequest(); request.options = mergeTerminalRenderOptions(request.options, options); if (typeof callback === "function") { request.callbacks.push(callback); } clearStdoutTimer(); if (!inFlight) { startNextRun("immediate"); } }, requestStdout(sample) { const request = ensurePendingRequest(); request.stdoutSamples.push(normalizeStdoutSample(sample)); scheduleStdoutFlush(); }, clearPending() { clearStdoutTimer(); pendingRequest = null; }, getSnapshot() { const timestamp = now(); return { inFlight, pending: buildRequestSnapshot(pendingRequest, timestamp), active: buildRequestSnapshot(activeRequest, timestamp) }; } }; } module.exports = { createTerminalRenderScheduler, mergeTerminalRenderOptions };